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