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:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user