Files
redefined-designs/backend/tests/integration/adminSettings.integration.test.ts
T
bermudalambandClaude Opus 5 22215f32eb feat(intake): settings for the submission ceiling (#227)
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>
2026-09-01 16:19:45 -05:00

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