The model was going to be an environment variable, which meant a redeploy to change it. It is now an admin setting, so it can be changed from the Settings page like the cart expiry and the greeting. A dropdown validated on the server, not a free-text field. The API only rejects an unknown model at the point of use, so a typo would be stored happily and then fail on every submission, surfacing as drafts quietly not appearing rather than as an error anybody could act on. The PUT refuses anything outside the offered set, and getSettings falls back rather than handing on a value that is no longer offered — drafting with the default beats drafting with a model the API will refuse. One catalogue rather than two lists. The dropdown needs the models, costMicros needs their rates, and the price shown beside a model in Admin has to be the price it is actually billed at, which it cannot be if the two are maintained separately. Rates were confirmed against the pricing page rather than recalled: Sonnet 5 $2/$10, Opus 5 $5/$25, Haiku 4.5 $1/$5 per million tokens. The unknown-model fallback is deliberately the most expensive rate and never zero, because a budget that reads as unspent however much was spent is the one failure a spend guard cannot have. Adding a third setting type pushed getSettings past the cognitive complexity limit, so the per-type resolution moved out into one small function each — the same shape the definitions block above it already argues for. The exhaustive assertion in the GET test gained the new field rather than being loosened. It exists to catch a setting silently vanishing from the response, and that is worth more than not having to touch it here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
123 lines
4.6 KiB
TypeScript
123 lines
4.6 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'
|
|
});
|
|
});
|
|
});
|
|
|
|
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');
|
|
});
|
|
});
|