intakeNotifyEmail and intakeCeilingResetAt both document empty as their default and as a working configuration — no notification address, and no ceiling reset recorded. The validator refused every empty text value, so either could be set and then never removed through the admin at all; the only way back was a DELETE against admin_settings. An admin who turned intake notifications on could not turn them off. Whether empty is a mistake is a fact about the setting rather than about its type, so it is now declared on the setting, in the DEFINITIONS row that already carries its type and fallback. A new setting states it once, in the place someone adding one is already editing, and nothing else has to know. That is what makes this different from special-casing two names in the validator, which would have left the next such setting to rediscover the same bug. The blanket refusal stays the default, because for a setting with a non-empty fallback an empty value really is a mistake: an empty greeting format renders every greeting as nothing at all, which reads as a broken email rather than as something a person cleared. Both those cases keep their tests. Whitespace is normalised to empty rather than stored. Somebody clearing a field they cannot see the end of leaves spaces behind, and they meant cleared. The tests check that the clearing survives the request rather than only being echoed back — the last one sets a value, clears it, and then reads it again through GET, which is the assertion that would have caught this had it existed. Closes #280 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
174 lines
6.7 KiB
TypeScript
174 lines
6.7 KiB
TypeScript
import request from 'supertest';
|
|
import app from '../../src/app';
|
|
import { pool } from '../../src/db';
|
|
import { resetDb, closeDb } from './setup/testDb';
|
|
import { getSettings } from '../../src/adminSettings';
|
|
|
|
// resetDb only clears the stored email templates out of admin_settings — it
|
|
// matches `email\_%`. The settings themselves survive it, so without this a
|
|
// value written by one test is the baseline the next one reads. Cleared after
|
|
// the file too, so nothing leaks into the suites that run behind it.
|
|
beforeEach(async () => {
|
|
await resetDb();
|
|
await pool.query(`DELETE FROM admin_settings`);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await pool.query(`DELETE FROM admin_settings`);
|
|
await pool.end();
|
|
await closeDb();
|
|
});
|
|
|
|
describe('GET /api/admin/settings', () => {
|
|
it('answers with every setting, falling back for the ones never written', async () => {
|
|
const res = await request(app).get('/api/admin/settings');
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual({
|
|
cartExpiryHours: 24,
|
|
verifyTokenHours: 24,
|
|
passwordResetHours: 1,
|
|
greetingFormat: 'Hi {{firstName}},',
|
|
greetingFallback: 'Hi,',
|
|
draftingModel: 'claude-sonnet-5',
|
|
// Empty by default: nowhere to send the intake notification is a working
|
|
// configuration, and means simply do not send one (#224).
|
|
intakeNotifyEmail: '',
|
|
intakeDailyCeiling: 100,
|
|
intakeLinkAlertThreshold: 20,
|
|
intakeCeilingResetAt: ''
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('PUT /api/admin/settings', () => {
|
|
it('stores the lifetimes and reads them back', async () => {
|
|
const res = await request(app)
|
|
.put('/api/admin/settings')
|
|
.send({ verifyTokenHours: 2, passwordResetHours: 0.5 });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.verifyTokenHours).toBe(2);
|
|
expect(res.body.passwordResetHours).toBe(0.5);
|
|
// Read back through the accessor the senders use, not just the response.
|
|
expect((await getSettings()).verifyTokenHours).toBe(2);
|
|
});
|
|
|
|
// Partial rather than whole-object: a caller updating one field should not
|
|
// have to echo the others back to avoid clobbering them.
|
|
it('leaves settings it was not sent alone', async () => {
|
|
await request(app).put('/api/admin/settings').send({ cartExpiryHours: 6 });
|
|
await request(app).put('/api/admin/settings').send({ verifyTokenHours: 3 });
|
|
|
|
const { cartExpiryHours, verifyTokenHours } = await getSettings();
|
|
expect(cartExpiryHours).toBe(6);
|
|
expect(verifyTokenHours).toBe(3);
|
|
});
|
|
|
|
it('stores the greeting format and its fallback', async () => {
|
|
const res = await request(app)
|
|
.put('/api/admin/settings')
|
|
.send({ greetingFormat: 'Dear {{firstName}} {{lastName}}:', greetingFallback: 'Hello there,' });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.greetingFormat).toBe('Dear {{firstName}} {{lastName}}:');
|
|
expect(res.body.greetingFallback).toBe('Hello there,');
|
|
});
|
|
|
|
it.each([
|
|
['cartExpiryHours', 0],
|
|
['verifyTokenHours', -1],
|
|
['passwordResetHours', 'soon']
|
|
])('refuses %s of %p, naming the field', async (name, value) => {
|
|
const res = await request(app).put('/api/admin/settings').send({ [name]: value });
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toContain(name);
|
|
});
|
|
|
|
// An empty format would render every greeting as nothing at all, which reads
|
|
// as a broken email rather than a setting someone cleared.
|
|
it.each(['greetingFormat', 'greetingFallback'])('refuses an empty %s', async (name) => {
|
|
const res = await request(app).put('/api/admin/settings').send({ [name]: ' ' });
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toContain(name);
|
|
});
|
|
|
|
/**
|
|
* The other half of the same question (#280).
|
|
*
|
|
* These two settings document empty as their default and as a working
|
|
* configuration — no notification address, and no ceiling reset recorded. The
|
|
* blanket "text cannot be empty" rule meant an address could be set and then
|
|
* never removed through the admin at all, only by a DELETE against the table.
|
|
*/
|
|
it.each(['intakeNotifyEmail', 'intakeCeilingResetAt'])(
|
|
'lets %s be cleared, because empty is its documented default',
|
|
async (name) => {
|
|
const set = await request(app)
|
|
.put('/api/admin/settings')
|
|
.send({ [name]: name === 'intakeNotifyEmail' ? 'alerts@example.com' : '2026-09-01T00:00:00Z' });
|
|
expect(set.status).toBe(200);
|
|
expect(set.body[name]).not.toBe('');
|
|
|
|
const cleared = await request(app).put('/api/admin/settings').send({ [name]: '' });
|
|
|
|
expect(cleared.status).toBe(200);
|
|
expect(cleared.body[name]).toBe('');
|
|
}
|
|
);
|
|
|
|
// Whitespace is how a person clears a field they cannot see the end of, so it
|
|
// means cleared rather than being stored as spaces.
|
|
it('treats whitespace as cleared rather than storing it', async () => {
|
|
await request(app).put('/api/admin/settings').send({ intakeNotifyEmail: 'alerts@example.com' });
|
|
|
|
const res = await request(app).put('/api/admin/settings').send({ intakeNotifyEmail: ' ' });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.intakeNotifyEmail).toBe('');
|
|
});
|
|
|
|
// The clearing is real, not just echoed back in the response.
|
|
it('reads a cleared setting back as empty on a later request', async () => {
|
|
await request(app).put('/api/admin/settings').send({ intakeNotifyEmail: 'alerts@example.com' });
|
|
await request(app).put('/api/admin/settings').send({ intakeNotifyEmail: '' });
|
|
|
|
const res = await request(app).get('/api/admin/settings');
|
|
|
|
expect(res.body.intakeNotifyEmail).toBe('');
|
|
});
|
|
|
|
// Every field is validated before any is written, so a request that is part
|
|
// nonsense does not half-apply.
|
|
it('does not write anything when one field in the request is invalid', async () => {
|
|
await request(app).put('/api/admin/settings').send({ cartExpiryHours: 6 });
|
|
|
|
const res = await request(app)
|
|
.put('/api/admin/settings')
|
|
.send({ cartExpiryHours: 8, verifyTokenHours: -3 });
|
|
|
|
expect(res.status).toBe(400);
|
|
expect((await getSettings()).cartExpiryHours).toBe(6);
|
|
});
|
|
// #223. A model name outside the offered set is refused rather than stored.
|
|
// Stored, it would be accepted here and then fail on every submission,
|
|
// surfacing only as drafts quietly not appearing.
|
|
it('refuses a drafting model it does not offer', async () => {
|
|
const res = await request(app)
|
|
.put('/api/admin/settings')
|
|
.send({ draftingModel: 'claude-sonnet-5-typo' });
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toContain('draftingModel');
|
|
});
|
|
|
|
it('accepts a drafting model it does offer', async () => {
|
|
const res = await request(app).put('/api/admin/settings').send({ draftingModel: 'claude-opus-5' });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect((await getSettings()).draftingModel).toBe('claude-opus-5');
|
|
});
|
|
});
|