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