fix(events): return 409 instead of 500 when a slug is taken

Correction to the reported cause: both create paths already loop
`while (await db('events').where({ slug }).first())` before inserting, so a
sequential duplicate never 500s -- it gets -1 appended. The 500 is purely the
read-then-insert race: two concurrent creates for the same name+date both
clear the check and the loser's INSERT trips events_slug_unique.

isDuplicateSlugError(), built on the existing utils/dbErrors.isUniqueViolation,
is wired into the catch of POST / and POST /:id/duplicate ->
409 { code: 'EVENT_SLUG_TAKEN' }. The predicate is deliberately narrower than
isUniqueViolation: on PG it matches err.constraint, on SQLite the specific
"UNIQUE constraint failed: ... events.slug" text. A loose message test would
misfire because knex prefixes the whole INSERT -- which always names slug --
to err.message, and events has other unique columns (share_token).

PUT /:id cannot collide: slug is in IMMUTABLE_EVENT_COLUMNS. No other
adminEvents sub-router writes slug. CreateEventPage already toasts data.error,
so no frontend change is needed.

The test makes the race deterministic without timers: it hooks knex's `query`
event and injects the colliding row the instant the route issues its
slug-existence SELECT. The route then spends a full bcrypt hash before its own
INSERT, so the injected row always lands first.

Refs testplan REPORT.md B10.
This commit is contained in:
Paul Nothaft
2026-09-02 09:43:10 +02:00
parent da6e34d6a3
commit afc5779ce7
2 changed files with 74 additions and 0 deletions
@@ -117,6 +117,46 @@ describe('admin events CRUD endpoints (smoke)', () => {
expect(queued).toHaveLength(0);
});
it('409s (not 500) when the slug uniqueness race is lost', async () => {
// The route mints the slug with a read-then-insert, so two concurrent
// creates for the same name + date both clear the existence check and
// the loser's INSERT trips events_slug_unique. Reproduce it without a
// timer: slip the colliding row in the instant that existence SELECT is
// issued — the route then spends a bcrypt hash before its own INSERT.
const { slugify } = require('../../src/utils/slug');
const collidingSlug = `wedding-${slugify('Race Wedding')}-2026-09-02`;
let injected = null;
const onQuery = (q) => {
if (injected) return;
if (!/from\s+.?events.?\s+where\s+.?slug.?\s*=/i.test(q.sql)) return;
injected = insertEvent(db, adminId, { slug: collidingSlug, event_name: 'Race Wedding' });
};
db.on('query', onQuery);
try {
const res = await auth(request(app).post('/api/admin/events')).send({
event_type: 'wedding',
event_name: 'Race Wedding',
event_date: '2026-09-02',
customer_name: 'Client Person',
customer_email: '[email protected]',
admin_email: '[email protected]',
require_password: false,
is_draft: true,
});
expect(injected).not.toBeNull(); // the race was actually injected
await injected;
expect(res.status).toBe(409);
expect(res.body.code).toBe('EVENT_SLUG_TAKEN');
expect(res.body.error).toMatch(/already exists/i);
// Only the injected row survives — no half-created duplicate.
expect(await db('events').where({ slug: collidingSlug })).toHaveLength(1);
} finally {
db.removeListener('query', onQuery);
}
});
it('400s on an invalid event type', async () => {
const res = await auth(request(app).post('/api/admin/events')).send({
event_type: 'not-a-real-type',