From 8d0a946478598e284a820edf8b2e1071d11924bb Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Fri, 10 Jul 2026 12:00:33 +0200 Subject: [PATCH] test(categories): integration tests for layered category ordering (#782) Real-DB coverage: migration 158 backfill; global default reorder + a non-customised event following it; per-event override + isolation from other events; override accepts globals / rejects a foreign event's category; reset clears the override; create appends. --- .../integration/categoryReorder.test.js | 170 ++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 backend/__tests__/integration/categoryReorder.test.js diff --git a/backend/__tests__/integration/categoryReorder.test.js b/backend/__tests__/integration/categoryReorder.test.js new file mode 100644 index 00000000..4e180558 --- /dev/null +++ b/backend/__tests__/integration/categoryReorder.test.js @@ -0,0 +1,170 @@ +/** + * Layered per-event category ordering (#782). + * + * Two ordering layers, resolved per event: + * - GLOBAL default — photo_categories.display_order (migration 158), + * set via POST /reorder-global; applies everywhere. + * - PER-EVENT override — event_category_order (migration 159), set via + * POST /reorder; overrides the default for one gallery. + * - DELETE /reorder/:eventId clears an event's override. + * + * Verified against a real SQLite DB with the full core-migration set applied. + */ +const request = require('supertest'); +const { + bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken, buildRouteApp, +} = require('./helpers/crmDb'); + +jest.setTimeout(30000); + +describe('category ordering (#782)', () => { + let db; + let cleanup; + let token; + let app; + + beforeAll(async () => { + ({ db, cleanup } = await bootCrmDb()); + const { adminId } = await seedMinimal(db); + await assignAdminRole(db, adminId, 'super_admin'); + token = mintAdminToken(adminId); + app = buildRouteApp('/api/admin/categories', require('../../src/routes/adminCategories')); + }, 120000); + + afterAll(async () => { if (cleanup) await cleanup(); }); + + const auth = (r) => r.set('Authorization', `Bearer ${token}`); + + async function insertEvent(slug) { + await db('events').insert({ + event_type: 'wedding', password_hash: 'x', + expires_at: new Date(Date.now() + 9e9).toISOString(), + is_active: true, is_archived: false, slug, share_link: slug, + event_name: slug, event_date: '2026-01-01', + }); + return (await db('events').where({ slug }).first()).id; + } + + async function insertCat(name, { is_global = false, event_id = null, display_order = 0 } = {}) { + const res = await db('photo_categories').insert({ + name, + slug: name.toLowerCase().replace(/\s+/g, '-'), + is_global: is_global ? 1 : 0, + event_id, + display_order, + }).returning('id'); + return res[0]?.id ?? res[0]; + } + + const getEvent = (eventId) => auth(request(app).get(`/api/admin/categories/event/${eventId}`)).expect(200); + + describe('migration 158 backfill', () => { + it('seeds display_order from alphabetical order, scoped per event', async () => { + const eventId = await insertEvent('backfill-ev'); + await insertCat('Reception', { event_id: eventId }); + await insertCat('Ceremony', { event_id: eventId }); + await insertCat('Pre-Ceremony', { event_id: eventId }); + + // Re-run the migration: addColumn is guarded (no-op); the backfill loop + // re-runs and assigns per-scope alphabetical order — what an upgrade does. + await require('../../migrations/core/158_add_category_display_order').up(db); + + const evCats = await db('photo_categories').where({ event_id: eventId }).orderBy('display_order', 'asc'); + expect(evCats.map((c) => c.name)).toEqual(['Ceremony', 'Pre-Ceremony', 'Reception']); + expect(evCats.map((c) => c.display_order)).toEqual([1, 2, 3]); + }); + }); + + describe('global default order (POST /reorder-global)', () => { + it('reverses the global order and every non-customised event follows it', async () => { + const before = (await auth(request(app).get('/api/admin/categories/global')).expect(200)).body; + expect(before.length).toBeGreaterThan(1); + const reversedIds = before.map((c) => c.id).reverse(); + + const res = await auth(request(app).post('/api/admin/categories/reorder-global')) + .send({ orderedIds: reversedIds }) + .expect(200); + expect(res.body.map((c) => c.id)).toEqual(reversedIds); + + // A fresh event (no override) shows globals in the new global order. + const eventId = await insertEvent('follows-global'); + const globalsInEvent = (await getEvent(eventId)).body.filter((c) => c.is_global).map((c) => c.id); + expect(globalsInEvent).toEqual(reversedIds); + }); + }); + + describe('per-event override (POST /reorder)', () => { + it('pins a custom order for one event without affecting another', async () => { + const eventA = await insertEvent('override-a'); + const eventB = await insertEvent('override-b'); + const a1 = await insertCat('A-Ceremony', { event_id: eventA }); + const a2 = await insertCat('A-Reception', { event_id: eventA }); + + // Current resolved list for A (globals + A's two categories). + const listA = (await getEvent(eventA)).body; + // Put A-Reception first, then A-Ceremony, then the globals in their order. + const globalsA = listA.filter((c) => c.is_global).map((c) => c.id); + const desired = [a2, a1, ...globalsA]; + + const res = await auth(request(app).post('/api/admin/categories/reorder')) + .send({ event_id: eventA, orderedIds: desired }) + .expect(200); + expect(res.body.map((c) => c.id)).toEqual(desired); + // override_position is set on every row for a customised event. + expect(res.body.every((c) => c.override_position != null)).toBe(true); + + // Event B is untouched — no override, follows the global default. + const listB = (await getEvent(eventB)).body; + expect(listB.every((c) => c.override_position == null)).toBe(true); + }); + + it('accepts global ids but rejects another event’s category', async () => { + const eventId = await insertEvent('scope-ev'); + const own = await insertCat('Own', { event_id: eventId }); + const global = (await db('photo_categories').where('is_global', 1).first()).id; + const foreign = await insertCat('Foreign', { event_id: await insertEvent('other-ev') }); + + // A global id is allowed (globals can be arranged per event). + await auth(request(app).post('/api/admin/categories/reorder')) + .send({ event_id: eventId, orderedIds: [own, global] }) + .expect(200); + + // A foreign event's category is out of scope. + await auth(request(app).post('/api/admin/categories/reorder')) + .send({ event_id: eventId, orderedIds: [own, foreign] }) + .expect(400); + }); + }); + + describe('reset (DELETE /reorder/:eventId)', () => { + it('clears the override and reverts to the global default', async () => { + const eventId = await insertEvent('reset-ev'); + const c1 = await insertCat('R-One', { event_id: eventId }); + const list = (await getEvent(eventId)).body; + const globals = list.filter((c) => c.is_global).map((c) => c.id); + + await auth(request(app).post('/api/admin/categories/reorder')) + .send({ event_id: eventId, orderedIds: [c1, ...globals] }) + .expect(200); + expect((await getEvent(eventId)).body.some((c) => c.override_position != null)).toBe(true); + + const res = await auth(request(app).delete(`/api/admin/categories/reorder/${eventId}`)).expect(200); + expect(res.body.every((c) => c.override_position == null)).toBe(true); + expect(await db('event_category_order').where({ event_id: eventId }).first()).toBeUndefined(); + }); + }); + + describe('POST / (create) appends to the end of its scope', () => { + it('assigns display_order = max + 1 within the event', async () => { + const eventId = await insertEvent('append-ev'); + await insertCat('First', { event_id: eventId, display_order: 1 }); + await insertCat('Second', { event_id: eventId, display_order: 2 }); + + const res = await auth(request(app).post('/api/admin/categories')) + .send({ name: 'Third', is_global: false, event_id: eventId }) + .expect(200); + + expect(res.body.display_order).toBe(3); + }); + }); +});