Files
redefined-designs/backend/tests/integration/adminSettings.integration.test.ts
T
bermudalambandClaude Opus 5 be6800181d
Linting / lint (pull_request) Successful in 8m44s
SonarQube Analysis / sonarqube (pull_request) Successful in 40m1s
fix(admin): let a setting whose default is empty actually be cleared (#280)
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>
2026-09-03 16:51:32 -05:00

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');
});
});