A count type beside the existing hours, text and choice readers. resolveHours would have worked — it is parseFloat with a positive guard — but calling a submission ceiling an "hours" setting is a lie in the type name that every later reader has to decode. Whole numbers only, so a ceiling of 12.5 is a typo rather than a preference, and a malformed value falls back rather than yielding a NaN that compares false against everything and silently disables the limit. resetDb is widened to clear intake_ settings as well as email_ ones. It deliberately does not truncate admin_settings, so a ceiling of 1 left behind by one suite would make every later suite's submissions refuse with a 503, in files that never mention a ceiling. The comment there already records that exact failure happening once with an email template subject, which reached the favorite-alert tests and failed five of them somewhere else entirely. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
129 lines
4.9 KiB
TypeScript
129 lines
4.9 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);
|
|
});
|
|
|
|
// 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');
|
|
});
|
|
});
|