diff --git a/server/tests/integration/days.test.ts b/server/tests/integration/days.test.ts index 4a47de768..850a720a9 100644 --- a/server/tests/integration/days.test.ts +++ b/server/tests/integration/days.test.ts @@ -6,6 +6,7 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vites import request from 'supertest'; import type { Application } from 'express'; import type { INestApplication } from '@nestjs/common'; +import { MikroORM } from '@mikro-orm/core'; // ───────────────────────────────────────────────────────────────────────────── // In-memory DB — schema applied in beforeAll after mocks register @@ -30,9 +31,32 @@ import { buildApp } from '../../src/bootstrap'; import { resetTestDb, resetRateLimits } from '../helpers/test-db'; import { createUser, createTrip, createDay, createPlace, addTripMember, createDayAccommodation, createReservation } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; +import { countRows, deleteRows, findRow, findRows, insertRow, updateRows } from '../helpers/factories/rows'; +import { setAppSetting } from '../helpers/factories/settings'; +import type { FactoryOrm } from '../helpers/factories/context'; +import { AppSettings } from '../../src/db/entities/AppSettings.entity'; +import { BudgetItems } from '../../src/db/entities/BudgetItems.entity'; +import { DayAccommodations } from '../../src/db/entities/DayAccommodations.entity'; +import { Days } from '../../src/db/entities/Days.entity'; +import { Reservations } from '../../src/db/entities/Reservations.entity'; +import { Trips } from '../../src/db/entities/Trips.entity'; let nestApp: INestApplication; let app: Application; +/** The app's own ORM, which the factories seed and read through. */ +const orm = (): FactoryOrm => nestApp.get(MikroORM); + +const dayCount = (tripId: number) => countRows(orm(), Days, { trip: tripId }); + +/** The trip's days in day order: id, number and date, as the reorder cases compare them. */ +const dayList = async (tripId: number) => + (await findRows(orm(), Days, { trip: tripId }, { day_number: 'asc' })).map((d) => ({ id: d.id, day_number: d.day_number, date: d.date })); + +/** The stay's check-in and check-out days. */ +const stayDays = async (id: number) => { + const stay = await findRow(orm(), DayAccommodations, { id }); + return { start_day_id: stay?.start_day_id, end_day_id: stay?.end_day_id }; +}; beforeAll(async () => { nestApp = await buildApp(); app = nestApp.getHttpAdapter().getInstance(); @@ -181,7 +205,7 @@ describe('Create day', () => { expect(res.status).toBe(400); expect(res.body).toEqual({ error: 'This trip has no dates. Add a day without a date instead.' }); - expect(testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id)).toEqual({ n: 1 }); + expect(await dayCount(trip.id)).toBe(1); }); it('DAY-013: dated next to a position is refused by the contract with 400 and adds nothing', async () => { @@ -195,8 +219,8 @@ describe('Create day', () => { expect(res.status).toBe(400); expect(JSON.stringify(res.body)).toContain('dated cannot be combined with date or position'); - expect(testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id)).toEqual({ n: 2 }); - expect(testDb.prepare('SELECT end_date FROM trips WHERE id = ?').get(trip.id)).toEqual({ end_date: '2026-08-02' }); + expect(await dayCount(trip.id)).toBe(2); + expect((await findRow(orm(), Trips, { id: trip.id }))?.end_date).toBe('2026-08-02'); }); }); @@ -307,8 +331,7 @@ describe('Delete day', () => { expect(res.status).toBe(200); expect(res.body.success).toBe(true); - const deleted = testDb.prepare('SELECT id FROM days WHERE id = ?').get(day.id); - expect(deleted).toBeUndefined(); + expect(await findRow(orm(), Days, { id: day.id })).toBeNull(); }); it('DELETE /api/trips/:tripId/days/:dayId returns 404 for unknown day', async () => { @@ -341,7 +364,7 @@ describe('Delete day', () => { it('DAY-008: deleting a dated day with no day left to take the last date ends the trip a day earlier', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Trip', start_date: '2026-08-01', end_date: '2026-08-03' }); - const first = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number LIMIT 1').get(trip.id) as { id: number }; + const [first] = await dayList(trip.id); const res = await request(app) .delete(`/api/trips/${trip.id}/days/${first.id}`) @@ -366,7 +389,7 @@ describe('Delete day', () => { expect(res.status).toBe(400); expect(res.body).toEqual({ error: 'A trip needs at least one day.' }); - expect(testDb.prepare('SELECT id FROM days WHERE id = ?').get(day.id)).toBeDefined(); + expect(await findRow(orm(), Days, { id: day.id })).not.toBeNull(); }); it('DAY-010: a member without day_edit gets 403 and the day stays', async () => { @@ -376,7 +399,7 @@ describe('Delete day', () => { addTripMember(testDb, trip.id, member.id); const day = createDay(testDb, trip.id); createDay(testDb, trip.id); - testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('perm_day_edit', 'trip_owner')").run(); + await setAppSetting(orm(), 'perm_day_edit', 'trip_owner'); const { invalidatePermissionsCache } = await import('../../src/nest/permissions/permissions-cache'); await invalidatePermissionsCache(); try { @@ -384,9 +407,9 @@ describe('Delete day', () => { .delete(`/api/trips/${trip.id}/days/${day.id}`) .set('Cookie', authCookie(member.id)); expect(res.status).toBe(403); - expect(testDb.prepare('SELECT id FROM days WHERE id = ?').get(day.id)).toBeDefined(); + expect(await findRow(orm(), Days, { id: day.id })).not.toBeNull(); } finally { - testDb.prepare("DELETE FROM app_settings WHERE key = 'perm_day_edit'").run(); + await deleteRows(orm(), AppSettings, { key: 'perm_day_edit' }); await invalidatePermissionsCache(); } }); @@ -459,9 +482,7 @@ describe('Accommodations', () => { const place = createPlace(testDb, trip.id, { name: 'Boutique Inn' }); // Seed accommodation directly - testDb.prepare( - 'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id) VALUES (?, ?, ?, ?)' - ).run(trip.id, place.id, day1.id, day2.id); + await insertRow(orm(), DayAccommodations, { trip: trip.id, place: place.id, startDay: day1.id, endDay: day2.id }); const res = await request(app) .get(`/api/trips/${trip.id}/accommodations`) @@ -509,8 +530,7 @@ describe('Accommodations', () => { expect(deleteRes.body.success).toBe(true); // Verify removed from DB - const row = testDb.prepare('SELECT id FROM day_accommodations WHERE id = ?').get(accommodationId); - expect(row).toBeUndefined(); + expect(await findRow(orm(), DayAccommodations, { id: accommodationId })).toBeNull(); }); it('ACCOM-003 — DELETE non-existent accommodation returns 404', async () => { @@ -540,12 +560,10 @@ describe('Accommodations', () => { expect(res.status).toBe(201); // Linked reservation should exist - const reservation = testDb.prepare( - 'SELECT * FROM reservations WHERE accommodation_id = ?' - ).get(res.body.accommodation.id) as any; - expect(reservation).toBeDefined(); - expect(reservation.type).toBe('hotel'); - expect(reservation.confirmation_number).toBe('CONF-XYZ'); + const reservation = await findRow(orm(), Reservations, { accommodation_id: String(res.body.accommodation.id) }); + expect(reservation).not.toBeNull(); + expect(reservation!.type).toBe('hotel'); + expect(reservation!.confirmation_number).toBe('CONF-XYZ'); }); it('ACCOM-004 — PUT /api/trips/:tripId/accommodations/:id updates the accommodation', async () => { @@ -601,20 +619,15 @@ describe('Accommodations', () => { .send({ place_id: place.id, start_day_id: day1.id, end_day_id: day2.id }); const accommodationId = createRes.body.accommodation.id; - const reservationBefore = testDb.prepare( - 'SELECT id FROM reservations WHERE accommodation_id = ?' - ).get(accommodationId) as any; - expect(reservationBefore).toBeDefined(); + const reservationBefore = await findRow(orm(), Reservations, { accommodation_id: String(accommodationId) }); + expect(reservationBefore).not.toBeNull(); const deleteRes = await request(app) .delete(`/api/trips/${trip.id}/accommodations/${accommodationId}`) .set('Cookie', authCookie(user.id)); expect(deleteRes.status).toBe(200); - const reservationAfter = testDb.prepare( - 'SELECT id FROM reservations WHERE id = ?' - ).get(reservationBefore.id); - expect(reservationAfter).toBeUndefined(); + expect(await findRow(orm(), Reservations, { id: reservationBefore!.id })).toBeNull(); }); it('ACCOM-006 — DELETE accommodation also removes its linked budget item (issue #933)', async () => { @@ -637,26 +650,19 @@ describe('Accommodations', () => { }); expect(createRes.status).toBe(201); - const accommodationId = testDb.prepare( - 'SELECT id FROM day_accommodations WHERE trip_id = ?' - ).get(trip.id) as any; - expect(accommodationId).toBeDefined(); + const accommodationId = await findRow(orm(), DayAccommodations, { trip: trip.id }); + expect(accommodationId).not.toBeNull(); - const budgetBefore = testDb.prepare( - 'SELECT id FROM budget_items WHERE trip_id = ?' - ).get(trip.id); - expect(budgetBefore).toBeDefined(); + const budgetBefore = await findRow(orm(), BudgetItems, { trip: trip.id }); + expect(budgetBefore).not.toBeNull(); // Delete via the accommodation endpoint (the primary bug path) const delRes = await request(app) - .delete(`/api/trips/${trip.id}/accommodations/${accommodationId.id}`) + .delete(`/api/trips/${trip.id}/accommodations/${accommodationId!.id}`) .set('Cookie', authCookie(user.id)); expect(delRes.status).toBe(200); - const budgetAfter = testDb.prepare( - 'SELECT id FROM budget_items WHERE trip_id = ?' - ).get(trip.id); - expect(budgetAfter).toBeUndefined(); + expect(await findRow(orm(), BudgetItems, { trip: trip.id })).toBeNull(); }); }); @@ -702,15 +708,15 @@ describe('A-H2 — DaysService trip id parsed once, threaded to every survivor ( .send({ title: 'Renamed' }); expect(res.status).toBe(404); expect(res.body).toEqual({ error: 'Day not found' }); - const row = testDb.prepare('SELECT title FROM days WHERE id = ?').get(day.id) as { title: string }; - expect(row.title).toBe('Original'); + const row = await findRow(orm(), Days, { id: day.id }); + expect(row?.title).toBe('Original'); }); it('POST /days by the hex-spelled trip id (append, no position) answers the guard\'s own 404 "Trip not found" and writes no day (M-1 narrowing, Task 9 fix round 2 — was a manufactured 500)', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Trip' }); const hexTripId = '0x' + trip.id.toString(16); - const before = (testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id) as { n: number }).n; + const before = await dayCount(trip.id); const res = await request(app) .post(`/api/trips/${hexTripId}/days`) @@ -718,7 +724,7 @@ describe('A-H2 — DaysService trip id parsed once, threaded to every survivor ( .send({}); expect(res.status).toBe(404); expect(res.body).toEqual({ error: 'Trip not found' }); - const after = (testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id) as { n: number }).n; + const after = await dayCount(trip.id); expect(after).toBe(before); }); @@ -726,7 +732,7 @@ describe('A-H2 — DaysService trip id parsed once, threaded to every survivor ( const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Trip', start_date: '2026-09-01', end_date: '2026-09-02' }); const hexTripId = '0x' + trip.id.toString(16); - const before = (testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id) as { n: number }).n; + const before = await dayCount(trip.id); const res = await request(app) .post(`/api/trips/${hexTripId}/days`) @@ -734,7 +740,7 @@ describe('A-H2 — DaysService trip id parsed once, threaded to every survivor ( .send({ position: 1 }); expect(res.status).toBe(404); expect(res.body).toEqual({ error: 'Trip not found' }); - const after = (testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id) as { n: number }).n; + const after = await dayCount(trip.id); expect(after).toBe(before); }); @@ -742,7 +748,7 @@ describe('A-H2 — DaysService trip id parsed once, threaded to every survivor ( const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Trip' }); const shapedTripId = `${trip.id}.0`; - const before = (testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id) as { n: number }).n; + const before = await dayCount(trip.id); const res = await request(app) .post(`/api/trips/${shapedTripId}/days`) @@ -750,7 +756,7 @@ describe('A-H2 — DaysService trip id parsed once, threaded to every survivor ( .send({}); expect(res.status).toBe(404); expect(res.body).toEqual({ error: 'Trip not found' }); - const after = (testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id) as { n: number }).n; + const after = await dayCount(trip.id); expect(after).toBe(before); }); @@ -758,7 +764,7 @@ describe('A-H2 — DaysService trip id parsed once, threaded to every survivor ( const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Trip', start_date: '2026-09-01', end_date: '2026-09-02' }); const shapedTripId = `${trip.id}.0`; - const before = (testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id) as { n: number }).n; + const before = await dayCount(trip.id); const res = await request(app) .post(`/api/trips/${shapedTripId}/days`) @@ -766,7 +772,7 @@ describe('A-H2 — DaysService trip id parsed once, threaded to every survivor ( .send({ position: 1 }); expect(res.status).toBe(404); expect(res.body).toEqual({ error: 'Trip not found' }); - const after = (testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id) as { n: number }).n; + const after = await dayCount(trip.id); expect(after).toBe(before); }); @@ -774,7 +780,7 @@ describe('A-H2 — DaysService trip id parsed once, threaded to every survivor ( const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Trip' }); const shapedTripId = `${trip.id}%20`; - const before = (testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id) as { n: number }).n; + const before = await dayCount(trip.id); const res = await request(app) .post(`/api/trips/${shapedTripId}/days`) @@ -782,7 +788,7 @@ describe('A-H2 — DaysService trip id parsed once, threaded to every survivor ( .send({}); expect(res.status).toBe(404); expect(res.body).toEqual({ error: 'Trip not found' }); - const after = (testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id) as { n: number }).n; + const after = await dayCount(trip.id); expect(after).toBe(before); }); @@ -790,7 +796,7 @@ describe('A-H2 — DaysService trip id parsed once, threaded to every survivor ( const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Trip', start_date: '2026-09-01', end_date: '2026-09-02' }); const shapedTripId = `${trip.id}%20`; - const before = (testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id) as { n: number }).n; + const before = await dayCount(trip.id); const res = await request(app) .post(`/api/trips/${shapedTripId}/days`) @@ -798,7 +804,7 @@ describe('A-H2 — DaysService trip id parsed once, threaded to every survivor ( .send({ position: 1 }); expect(res.status).toBe(404); expect(res.body).toEqual({ error: 'Trip not found' }); - const after = (testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id) as { n: number }).n; + const after = await dayCount(trip.id); expect(after).toBe(before); }); @@ -806,7 +812,7 @@ describe('A-H2 — DaysService trip id parsed once, threaded to every survivor ( const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Trip' }); const shapedTripId = `%2B${trip.id}`; - const before = (testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id) as { n: number }).n; + const before = await dayCount(trip.id); const res = await request(app) .post(`/api/trips/${shapedTripId}/days`) @@ -814,7 +820,7 @@ describe('A-H2 — DaysService trip id parsed once, threaded to every survivor ( .send({}); expect(res.status).toBe(404); expect(res.body).toEqual({ error: 'Trip not found' }); - const after = (testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id) as { n: number }).n; + const after = await dayCount(trip.id); expect(after).toBe(before); }); @@ -822,7 +828,7 @@ describe('A-H2 — DaysService trip id parsed once, threaded to every survivor ( const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Trip', start_date: '2026-09-01', end_date: '2026-09-02' }); const shapedTripId = `%2B${trip.id}`; - const before = (testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id) as { n: number }).n; + const before = await dayCount(trip.id); const res = await request(app) .post(`/api/trips/${shapedTripId}/days`) @@ -830,7 +836,7 @@ describe('A-H2 — DaysService trip id parsed once, threaded to every survivor ( .send({ position: 1 }); expect(res.status).toBe(404); expect(res.body).toEqual({ error: 'Trip not found' }); - const after = (testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id) as { n: number }).n; + const after = await dayCount(trip.id); expect(after).toBe(before); }); @@ -838,7 +844,7 @@ describe('A-H2 — DaysService trip id parsed once, threaded to every survivor ( const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Trip' }); const shapedTripId = `${trip.id}e0`; - const before = (testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id) as { n: number }).n; + const before = await dayCount(trip.id); const res = await request(app) .post(`/api/trips/${shapedTripId}/days`) @@ -846,7 +852,7 @@ describe('A-H2 — DaysService trip id parsed once, threaded to every survivor ( .send({}); expect(res.status).toBe(404); expect(res.body).toEqual({ error: 'Trip not found' }); - const after = (testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id) as { n: number }).n; + const after = await dayCount(trip.id); expect(after).toBe(before); }); @@ -854,7 +860,7 @@ describe('A-H2 — DaysService trip id parsed once, threaded to every survivor ( const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Trip', start_date: '2026-09-01', end_date: '2026-09-02' }); const shapedTripId = `${trip.id}e0`; - const before = (testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id) as { n: number }).n; + const before = await dayCount(trip.id); const res = await request(app) .post(`/api/trips/${shapedTripId}/days`) @@ -862,14 +868,14 @@ describe('A-H2 — DaysService trip id parsed once, threaded to every survivor ( .send({ position: 1 }); expect(res.status).toBe(404); expect(res.body).toEqual({ error: 'Trip not found' }); - const after = (testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id) as { n: number }).n; + const after = await dayCount(trip.id); expect(after).toBe(before); }); it('PUT /days/reorder by the hex-spelled trip id 400s "orderedIds must be a permutation…" and writes NOTHING — the live H2 bug: it used to 200 and invert a real accommodation', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Trip', start_date: '2026-06-01', end_date: '2026-06-03' }); - const days = testDb.prepare('SELECT id, day_number, date FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number; day_number: number; date: string }[]; + const days = await dayList(trip.id); expect(days).toHaveLength(3); const [d1, d2, d3] = days; const place = createPlace(testDb, trip.id, { name: 'Hotel' }); @@ -877,7 +883,7 @@ describe('A-H2 — DaysService trip id parsed once, threaded to every survivor ( // the shape the compiled-boot evidence used to catch reorder inverting. const stay = createDayAccommodation(testDb, trip.id, place.id, d1.id, d2.id); const reservation = createReservation(testDb, trip.id, { day_id: d1.id, title: 'Dinner' }); - testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?').run('2026-06-01T19:00:00', reservation.id); + await updateRows(orm(), Reservations, { id: reservation.id }, { reservation_time: '2026-06-01T19:00:00' }); const hexTripId = '0x' + trip.id.toString(16); const res = await request(app) @@ -888,20 +894,20 @@ describe('A-H2 — DaysService trip id parsed once, threaded to every survivor ( expect(res.body).toEqual({ error: 'orderedIds must be a permutation of the trip day ids.' }); // Nothing renumbered. - const after = testDb.prepare('SELECT id, day_number, date FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number; day_number: number; date: string }[]; + const after = await dayList(trip.id); expect(after).toEqual(days); // The stay is still d1 -> d2, never inverted. - const stayAfter = testDb.prepare('SELECT start_day_id, end_day_id FROM day_accommodations WHERE id = ?').get(stay.id) as { start_day_id: number; end_day_id: number }; + const stayAfter = await stayDays(stay.id); expect(stayAfter).toEqual({ start_day_id: d1.id, end_day_id: d2.id }); // The reservation's stamped time is untouched. - const resAfter = testDb.prepare('SELECT reservation_time FROM reservations WHERE id = ?').get(reservation.id) as { reservation_time: string }; - expect(resAfter.reservation_time).toBe('2026-06-01T19:00:00'); + const resAfter = await findRow(orm(), Reservations, { id: reservation.id }); + expect(resAfter?.reservation_time).toBe('2026-06-01T19:00:00'); }); it('PUT /days/reorder with the REAL numeric trip id still works and can still be legitimately rejected for inverting a stay (unchanged, base 400) — L-5: zero days rows changed, not only the stay', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Trip', start_date: '2026-06-01', end_date: '2026-06-03' }); - const days = testDb.prepare('SELECT id, day_number, date FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number; day_number: number; date: string }[]; + const days = await dayList(trip.id); const [d1, d2, d3] = days; const place = createPlace(testDb, trip.id, { name: 'Hotel' }); const stay = createDayAccommodation(testDb, trip.id, place.id, d1.id, d3.id); @@ -912,10 +918,10 @@ describe('A-H2 — DaysService trip id parsed once, threaded to every survivor ( .send({ orderedIds: [d3.id, d2.id, d1.id] }); expect(res.status).toBe(400); expect(res.body).toEqual({ error: 'This move would make an accommodation end before it starts.' }); - const stayAfter = testDb.prepare('SELECT start_day_id, end_day_id FROM day_accommodations WHERE id = ?').get(stay.id) as { start_day_id: number; end_day_id: number }; + const stayAfter = await stayDays(stay.id); expect(stayAfter).toEqual({ start_day_id: d1.id, end_day_id: d3.id }); // L-5: the rollback holds on every day row, not only the stay — zero rows changed. - const daysAfter = testDb.prepare('SELECT id, day_number, date FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number; day_number: number; date: string }[]; + const daysAfter = await dayList(trip.id); expect(daysAfter).toEqual(days); }); }); diff --git a/server/tests/unit/nest/oidc.service.test.ts b/server/tests/unit/nest/oidc.service.test.ts index 2d0360975..26859c29e 100644 --- a/server/tests/unit/nest/oidc.service.test.ts +++ b/server/tests/unit/nest/oidc.service.test.ts @@ -19,21 +19,8 @@ vi.mock('../../../src/db/database', async () => { const { createSnapshotTestDb } = await import('../../helpers/db-mock'); const db = createSnapshotTestDb(); - const mock = { - db, - closeDb: () => {}, - reinitialize: () => {}, - getPlaceWithTags: () => null, - canAccessTrip: (tripId: any, userId: number) => - db.prepare(` - SELECT t.id, t.user_id FROM trips t - LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? - WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL) - `).get(userId, tripId, userId), - isOwner: (tripId: any, userId: number) => - !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId), - }; - return mock; + // Trip access reads through TripsRepository now; the module only hands out the handle. + return { db, closeDb: () => {}, reinitialize: () => {} }; }); @@ -116,6 +103,34 @@ import { createTestTripMembersRepo, } from '../../helpers/test-uow'; import { budgetRepoArgs } from '../../helpers/budget-repos'; +import { findRow, updateRows } from '../../helpers/factories/rows'; +import { readUser } from '../../helpers/factories/users'; +import { setAppSetting } from '../../helpers/factories/settings'; +import { makeInviteToken } from '../../helpers/factories/tokens'; +import { InviteTokens } from '../../../src/db/entities/InviteTokens.entity'; +import { TripMembers } from '../../../src/db/entities/TripMembers.entity'; +import { Users } from '../../../src/db/entities/Users.entity'; + +const orm = () => sharedTestOrm(testDb); + +/** Writes columns of the user row directly, the state a login starts from. */ +async function setUserColumns( + userId: number, + data: { oidc_sub?: string | null; oidc_issuer?: string | null; avatar?: string | null; updated_at?: string }, +): Promise { + await updateRows(await orm(), Users, { id: userId }, data); +} + +async function userByEmail(email: string) { + return findRow(await orm(), Users, { email }); +} + +/** How often the invite was redeemed; fails the case when it is gone. */ +async function usedCount(token: string): Promise { + const row = await findRow(await orm(), InviteTokens, { token }); + if (!row) throw new Error(`no invite ${token}`); + return row.used_count; +} import { createTestShareTokensRepo, createTestPluginsRepo, createTestPluginUserErasureQueueRepo } from '../../helpers/share-repos'; import { createTestBudgetItemsRepo } from '../../helpers/files-repos'; import { createTestJourneysRepo, createTestJourneyEntriesRepo, createTestJourneyContributorsRepo } from '../../helpers/journey-repos'; @@ -520,8 +535,7 @@ describe('findOrCreateUser', () => { it('OIDC-SVC-020: finds existing user by oidc_sub', async () => { const { user } = createUser(testDb, { email: 'alice@example.com' }); // Link the sub manually - testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?') - .run('sub-alice-123', MOCK_CONFIG.issuer, user.id); + await setUserColumns(user.id, { oidc_sub: 'sub-alice-123', oidc_issuer: MOCK_CONFIG.issuer }); const result = await svc.findOrCreateUser( { sub: 'sub-alice-123', email: 'alice@example.com', name: 'Alice' }, @@ -548,8 +562,7 @@ describe('findOrCreateUser', () => { MOCK_CONFIG ); expect('user' in result).toBe(true); - const newUser = testDb.prepare("SELECT * FROM users WHERE email = 'newuser@example.com'").get(); - expect(newUser).toBeDefined(); + expect(await userByEmail('newuser@example.com')).not.toBeNull(); }); it('OIDC-SVC-023: first user gets admin role', async () => { @@ -595,7 +608,7 @@ describe('findOrCreateUser', () => { it('OIDC-SVC-022e: a later login never renames the account to the claim', async () => { const { user } = createUser(testDb, { email: 'kept@example.com', username: 'chosen' }); - testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?').run('sub-kept', MOCK_CONFIG.issuer, user.id); + await setUserColumns(user.id, { oidc_sub: 'sub-kept', oidc_issuer: MOCK_CONFIG.issuer }); process.env.OIDC_USERNAME_CLAIM = 'preferred_username'; const result = await svc.findOrCreateUser( { sub: 'sub-kept', email: 'kept@example.com', name: 'Kept', preferred_username: 'idp-name' }, @@ -606,7 +619,7 @@ describe('findOrCreateUser', () => { it('OIDC-SVC-024: returns registration_disabled error when registration is off', async () => { createUser(testDb, { email: 'existing@example.com' }); - testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run(); + await setAppSetting(await orm(), 'allow_registration', 'false'); const result = await svc.findOrCreateUser( { sub: 'sub-blocked', email: 'blocked@example.com', name: 'Blocked' }, @@ -619,20 +632,20 @@ describe('findOrCreateUser', () => { it('OIDC-SVC-025: links oidc_sub when existing user has none (verified email)', async () => { const { user } = createUser(testDb, { email: 'charlie@example.com' }); // Ensure no oidc_sub set - testDb.prepare('UPDATE users SET oidc_sub = NULL, oidc_issuer = NULL WHERE id = ?').run(user.id); + await setUserColumns(user.id, { oidc_sub: null, oidc_issuer: null }); await svc.findOrCreateUser( { sub: 'sub-charlie-linked', email: 'charlie@example.com', name: 'Charlie', email_verified: true }, MOCK_CONFIG ); - const updated = testDb.prepare('SELECT oidc_sub FROM users WHERE id = ?').get(user.id) as any; + const updated = await readUser(await orm(), user.id); expect(updated.oidc_sub).toBe('sub-charlie-linked'); }); it('OIDC-SVC-025b: refuses to link an unverified email to an existing local account', async () => { const { user } = createUser(testDb, { email: 'dora@example.com' }); - testDb.prepare('UPDATE users SET oidc_sub = NULL, oidc_issuer = NULL WHERE id = ?').run(user.id); + await setUserColumns(user.id, { oidc_sub: null, oidc_issuer: null }); // No email_verified claim — an IdP that lets users set arbitrary emails must // not be able to take over a pre-existing password account. @@ -643,15 +656,14 @@ describe('findOrCreateUser', () => { expect('error' in result).toBe(true); expect((result as { error: string }).error).toBe('email_not_verified'); - const updated = testDb.prepare('SELECT oidc_sub FROM users WHERE id = ?').get(user.id) as any; + const updated = await readUser(await orm(), user.id); expect(updated.oidc_sub).toBeNull(); // account not linked / not hijacked }); it('OIDC-SVC-026: existing user role is updated when OIDC claim mapping changes it', async () => { const { user } = createUser(testDb, { email: 'diana@example.com', role: 'user' }); // Link oidc_sub manually so the user is found by sub lookup - testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?') - .run('sub-diana-role', MOCK_CONFIG.issuer, user.id); + await setUserColumns(user.id, { oidc_sub: 'sub-diana-role', oidc_issuer: MOCK_CONFIG.issuer }); process.env.OIDC_ADMIN_VALUE = 'admins'; @@ -663,15 +675,12 @@ describe('findOrCreateUser', () => { expect('user' in result).toBe(true); expect((result as { user: any }).user.role).toBe('admin'); - const dbUser = testDb.prepare('SELECT role FROM users WHERE id = ?').get(user.id) as any; - expect(dbUser.role).toBe('admin'); + expect((await readUser(await orm(), user.id)).role).toBe('admin'); }); it('OIDC-SVC-027: new user with valid invite token increments used_count', async () => { const { user: creator } = createUser(testDb, { email: 'creator@example.com' }); - testDb.prepare( - "INSERT INTO invite_tokens (token, max_uses, used_count, created_by) VALUES ('tok-valid', 5, 0, ?)" - ).run(creator.id); + await makeInviteToken(await orm(), creator.id, { token: 'tok-valid', max_uses: 5, used_count: 0 }); const result = await svc.findOrCreateUser( { sub: 'sub-invite-user', email: 'invitee@example.com', name: 'Invitee' }, @@ -681,15 +690,12 @@ describe('findOrCreateUser', () => { expect('user' in result).toBe(true); - const token = testDb.prepare("SELECT used_count FROM invite_tokens WHERE token = 'tok-valid'").get() as any; - expect(token.used_count).toBe(1); + expect(await usedCount('tok-valid')).toBe(1); }); it('OIDC-SVC-028: new user with expired invite token is created but invite is ignored', async () => { const { user: creator } = createUser(testDb, { email: 'creator2@example.com' }); - testDb.prepare( - "INSERT INTO invite_tokens (token, max_uses, used_count, expires_at, created_by) VALUES ('tok-expired', 5, 0, '2000-01-01T00:00:00.000Z', ?)" - ).run(creator.id); + await makeInviteToken(await orm(), creator.id, { token: 'tok-expired', max_uses: 5, used_count: 0, expires_at: '2000-01-01T00:00:00.000Z' }); const result = await svc.findOrCreateUser( { sub: 'sub-expired-invite', email: 'expired-invitee@example.com', name: 'ExpiredInvitee' }, @@ -699,19 +705,15 @@ describe('findOrCreateUser', () => { // User is still created because open registration is allowed expect('user' in result).toBe(true); - const newUser = testDb.prepare("SELECT id FROM users WHERE email = 'expired-invitee@example.com'").get(); - expect(newUser).toBeDefined(); + expect(await userByEmail('expired-invitee@example.com')).not.toBeNull(); // Invite used_count must remain 0 (token was treated as invalid) - const token = testDb.prepare("SELECT used_count FROM invite_tokens WHERE token = 'tok-expired'").get() as any; - expect(token.used_count).toBe(0); + expect(await usedCount('tok-expired')).toBe(0); }); it('OIDC-SVC-029: new user with max_uses exceeded invite token is created but invite is ignored', async () => { const { user: creator } = createUser(testDb, { email: 'creator3@example.com' }); - testDb.prepare( - "INSERT INTO invite_tokens (token, max_uses, used_count, created_by) VALUES ('tok-full', 1, 1, ?)" - ).run(creator.id); + await makeInviteToken(await orm(), creator.id, { token: 'tok-full', max_uses: 1, used_count: 1 }); const result = await svc.findOrCreateUser( { sub: 'sub-full-invite', email: 'full-invitee@example.com', name: 'FullInvitee' }, @@ -721,12 +723,10 @@ describe('findOrCreateUser', () => { // User is still created because open registration is allowed expect('user' in result).toBe(true); - const newUser = testDb.prepare("SELECT id FROM users WHERE email = 'full-invitee@example.com'").get(); - expect(newUser).toBeDefined(); + expect(await userByEmail('full-invitee@example.com')).not.toBeNull(); // Invite used_count must remain 1 (token was treated as invalid) - const token = testDb.prepare("SELECT used_count FROM invite_tokens WHERE token = 'tok-full'").get() as any; - expect(token.used_count).toBe(1); + expect(await usedCount('tok-full')).toBe(1); }); // ── OIDC picture claim → avatar (#1399) ────────────────────────────────── @@ -737,8 +737,7 @@ describe('findOrCreateUser', () => { MOCK_CONFIG ); expect('user' in result).toBe(true); - const row = testDb.prepare("SELECT avatar FROM users WHERE email = 'pic1@example.com'").get() as any; - expect(row.avatar).toBe('https://idp.example.com/u/pic1.png'); + expect((await userByEmail('pic1@example.com'))?.avatar).toBe('https://idp.example.com/u/pic1.png'); }); it('OIDC-SVC-041: new user with a non-https picture claim stores no avatar', async () => { @@ -746,43 +745,41 @@ describe('findOrCreateUser', () => { { sub: 'sub-pic-2', email: 'pic2@example.com', name: 'Pic Two', picture: 'http://idp.example.com/u/pic2.png' }, MOCK_CONFIG ); - const row = testDb.prepare("SELECT avatar FROM users WHERE email = 'pic2@example.com'").get() as any; - expect(row.avatar).toBeNull(); + const row = await userByEmail('pic2@example.com'); + expect(row).not.toBeNull(); + expect(row!.avatar).toBeNull(); }); it('OIDC-SVC-042: existing user with no avatar gets the OIDC picture', async () => { const { user } = createUser(testDb, { email: 'pic3@example.com' }); - testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ?, avatar = NULL WHERE id = ?') - .run('sub-pic-3', MOCK_CONFIG.issuer, user.id); + await setUserColumns(user.id, { oidc_sub: 'sub-pic-3', oidc_issuer: MOCK_CONFIG.issuer, avatar: null }); await svc.findOrCreateUser( { sub: 'sub-pic-3', email: 'pic3@example.com', name: 'Pic Three', picture: 'https://idp.example.com/u/pic3.png' }, MOCK_CONFIG ); - const row = testDb.prepare('SELECT avatar FROM users WHERE id = ?').get(user.id) as any; + const row = await readUser(await orm(), user.id); expect(row.avatar).toBe('https://idp.example.com/u/pic3.png'); }); it('OIDC-SVC-043: a custom uploaded avatar is never overwritten by the OIDC picture', async () => { const { user } = createUser(testDb, { email: 'pic4@example.com' }); - testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ?, avatar = ? WHERE id = ?') - .run('sub-pic-4', MOCK_CONFIG.issuer, 'uploaded-abc.jpg', user.id); + await setUserColumns(user.id, { oidc_sub: 'sub-pic-4', oidc_issuer: MOCK_CONFIG.issuer, avatar: 'uploaded-abc.jpg' }); await svc.findOrCreateUser( { sub: 'sub-pic-4', email: 'pic4@example.com', name: 'Pic Four', picture: 'https://idp.example.com/u/pic4.png' }, MOCK_CONFIG ); - const row = testDb.prepare('SELECT avatar FROM users WHERE id = ?').get(user.id) as any; + const row = await readUser(await orm(), user.id); expect(row.avatar).toBe('uploaded-abc.jpg'); }); it('OIDC-SVC-044: a previously stored OIDC picture URL is refreshed on next login', async () => { const { user } = createUser(testDb, { email: 'pic5@example.com' }); - testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ?, avatar = ? WHERE id = ?') - .run('sub-pic-5', MOCK_CONFIG.issuer, 'https://idp.example.com/u/old.png', user.id); + await setUserColumns(user.id, { oidc_sub: 'sub-pic-5', oidc_issuer: MOCK_CONFIG.issuer, avatar: 'https://idp.example.com/u/old.png' }); await svc.findOrCreateUser( { sub: 'sub-pic-5', email: 'pic5@example.com', name: 'Pic Five', picture: 'https://idp.example.com/u/new.png' }, MOCK_CONFIG ); - const row = testDb.prepare('SELECT avatar FROM users WHERE id = ?').get(user.id) as any; + const row = await readUser(await orm(), user.id); expect(row.avatar).toBe('https://idp.example.com/u/new.png'); }); @@ -792,15 +789,14 @@ describe('findOrCreateUser', () => { // a sub/issuer pair pinning the account to a provider that is gone. it('OIDC-SVC-060: a provider without a picture claim clears the previous provider avatar', async () => { const { user } = createUser(testDb, { email: 'switch1@example.com' }); - testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ?, avatar = ? WHERE id = ?') - .run('sub-old-1', 'https://old-idp.example.com', 'https://old-idp.example.com/u/me.png', user.id); + await setUserColumns(user.id, { oidc_sub: 'sub-old-1', oidc_issuer: 'https://old-idp.example.com', avatar: 'https://old-idp.example.com/u/me.png' }); await svc.findOrCreateUser( { sub: 'sub-new-1', email: 'switch1@example.com', name: 'Switcher', email_verified: true }, { ...MOCK_CONFIG, issuer: 'https://new-idp.example.com' }, ); - const row = testDb.prepare('SELECT avatar FROM users WHERE id = ?').get(user.id) as any; + const row = await readUser(await orm(), user.id); expect(row.avatar).toBeNull(); }); @@ -824,29 +820,27 @@ describe('findOrCreateUser', () => { it('OIDC-SVC-061: an uploaded avatar survives the same switch', async () => { const { user } = createUser(testDb, { email: 'switch2@example.com' }); // A local upload is a bare filename, not a URL, and belongs to the user. - testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ?, avatar = ? WHERE id = ?') - .run('sub-old-2', 'https://old-idp.example.com', 'uploaded-abc.jpg', user.id); + await setUserColumns(user.id, { oidc_sub: 'sub-old-2', oidc_issuer: 'https://old-idp.example.com', avatar: 'uploaded-abc.jpg' }); await svc.findOrCreateUser( { sub: 'sub-new-2', email: 'switch2@example.com', name: 'Switcher', email_verified: true }, { ...MOCK_CONFIG, issuer: 'https://new-idp.example.com' }, ); - const row = testDb.prepare('SELECT avatar FROM users WHERE id = ?').get(user.id) as any; + const row = await readUser(await orm(), user.id); expect(row.avatar).toBe('uploaded-abc.jpg'); }); it('OIDC-SVC-062: the account is relinked to the new provider sub and issuer', async () => { const { user } = createUser(testDb, { email: 'switch3@example.com' }); - testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?') - .run('sub-old-3', 'https://old-idp.example.com', user.id); + await setUserColumns(user.id, { oidc_sub: 'sub-old-3', oidc_issuer: 'https://old-idp.example.com' }); await svc.findOrCreateUser( { sub: 'sub-new-3', email: 'switch3@example.com', name: 'Switcher', email_verified: true }, { ...MOCK_CONFIG, issuer: 'https://new-idp.example.com' }, ); - const row = testDb.prepare('SELECT oidc_sub, oidc_issuer FROM users WHERE id = ?').get(user.id) as any; + const row = await readUser(await orm(), user.id); expect(row.oidc_sub).toBe('sub-new-3'); expect(row.oidc_issuer).toBe('https://new-idp.example.com'); }); @@ -858,13 +852,14 @@ describe('findOrCreateUser', () => { // exists to foreclose (task-1-fix-report.md). it('OIDC-SVC-090: identity linking (O5) leaves the rest of the users row byte-identical', async () => { const { user } = createUser(testDb, { email: 'linkrow@example.com', username: 'linkrowuser' }); - testDb.prepare('UPDATE users SET oidc_sub = NULL, oidc_issuer = NULL WHERE id = ?').run(user.id); + await setUserColumns(user.id, { oidc_sub: null, oidc_issuer: null }); // Task 6 review L1 / Task 7 review, Task 6 L1: pin updated_at to a fixed // past value before the snapshot so a future `linkOidcIdentity` that // starts stamping updated_at (a CURRENT_TIMESTAMP drift) makes this test // fail loudly, instead of the before/after snapshot racing the mutation // inside the same second and passing by accident. - testDb.prepare("UPDATE users SET updated_at = '2020-01-01 00:00:00' WHERE id = ?").run(user.id); + await setUserColumns(user.id, { updated_at: '2020-01-01 00:00:00' }); + // test-sql-allow: the whole row as SELECT * returns it, every column the table has, is what must not move. const before = testDb.prepare('SELECT * FROM users WHERE id = ?').get(user.id) as Record; await svc.findOrCreateUser( @@ -872,16 +867,17 @@ describe('findOrCreateUser', () => { MOCK_CONFIG, ); + // test-sql-allow: the whole row as SELECT * returns it, every column the table has, is what must not move. const after = testDb.prepare('SELECT * FROM users WHERE id = ?').get(user.id) as Record; expect(after).toEqual({ ...before, oidc_sub: 'sub-linkrow', oidc_issuer: MOCK_CONFIG.issuer }); }); it('OIDC-SVC-091: the provider-switch branch (O6) leaves the rest of the users row byte-identical', async () => { const { user } = createUser(testDb, { email: 'switchrow@example.com', username: 'switchrowuser' }); - testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?') - .run('sub-old-row', 'https://old-idp.example.com', user.id); + await setUserColumns(user.id, { oidc_sub: 'sub-old-row', oidc_issuer: 'https://old-idp.example.com' }); // Task 6 review L1 / Task 7 review, Task 6 L1: see OIDC-SVC-090's comment. - testDb.prepare("UPDATE users SET updated_at = '2020-01-01 00:00:00' WHERE id = ?").run(user.id); + await setUserColumns(user.id, { updated_at: '2020-01-01 00:00:00' }); + // test-sql-allow: the whole row as SELECT * returns it, every column the table has, is what must not move. const before = testDb.prepare('SELECT * FROM users WHERE id = ?').get(user.id) as Record; await svc.findOrCreateUser( @@ -889,6 +885,7 @@ describe('findOrCreateUser', () => { { ...MOCK_CONFIG, issuer: 'https://new-idp-row.example.com' }, ); + // test-sql-allow: the whole row as SELECT * returns it, every column the table has, is what must not move. const after = testDb.prepare('SELECT * FROM users WHERE id = ?').get(user.id) as Record; expect(after).toEqual({ ...before, oidc_sub: 'sub-new-row', oidc_issuer: 'https://new-idp-row.example.com' }); }); @@ -902,9 +899,7 @@ describe('findOrCreateUser', () => { // serialization, one winner. it('OIDC-SVC-092: two concurrent SSO registrations racing the same one-use invite — the loser gets registration_disabled, byte-identical to the legacy invite_exhausted outcome', async () => { const { user: creator } = createUser(testDb, { email: 'racecreator@example.com' }); - testDb.prepare( - "INSERT INTO invite_tokens (token, max_uses, used_count, created_by) VALUES ('tok-race', 1, 0, ?)" - ).run(creator.id); + await makeInviteToken(await orm(), creator.id, { token: 'tok-race', max_uses: 1, used_count: 0 }); const [first, second] = await Promise.all([ svc.findOrCreateUser({ sub: 'sub-race-1', email: 'racer1@example.com', name: 'Racer One' }, MOCK_CONFIG, 'tok-race'), @@ -918,8 +913,7 @@ describe('findOrCreateUser', () => { expect(losers).toHaveLength(1); expect(losers[0]!.error).toBe('registration_disabled'); - const token = testDb.prepare("SELECT used_count FROM invite_tokens WHERE token = 'tok-race'").get() as { used_count: number }; - expect(token.used_count).toBe(1); + expect(await usedCount('tok-race')).toBe(1); }); it('OIDC-SVC-053: returns no_email when the email claim is missing (no throw)', async () => { @@ -945,9 +939,7 @@ describe('findOrCreateUser', () => { it('OIDC-SVC-045: a trip-bound invite auto-adds the new SSO user as a trip member (#1402)', async () => { const { user: admin } = createUser(testDb, { role: 'admin' }); const trip = createTrip(testDb, admin.id); - testDb.prepare( - 'INSERT INTO invite_tokens (token, max_uses, used_count, expires_at, created_by, trip_id) VALUES (?, 5, 0, NULL, ?, ?)' - ).run('inv-trip-join', admin.id, trip.id); + await makeInviteToken(await orm(), admin.id, { token: 'inv-trip-join', max_uses: 5, used_count: 0, expires_at: null, trip: trip.id }); const result = await svc.findOrCreateUser( { sub: 'sub-trip-join', email: 'joiner@example.com', name: 'Joiner' }, @@ -956,8 +948,7 @@ describe('findOrCreateUser', () => { ); expect('user' in result).toBe(true); const uid = (result as { user: any }).user.id; - const member = testDb.prepare('SELECT * FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, uid); - expect(member).toBeTruthy(); + expect(await findRow(await orm(), TripMembers, { trip: trip.id, user: uid })).toBeTruthy(); }); }); @@ -966,19 +957,19 @@ describe('findOrCreateUser', () => { describe('findOrCreateUser role mapping', () => { type RoleResult = { user: any; roleChange?: { from: string; to: string; claim: string } }; - function ssoUser(email: string, sub: string, role: 'admin' | 'user') { + async function ssoUser(email: string, sub: string, role: 'admin' | 'user') { const { user } = createUser(testDb, { email, role }); - testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?').run(sub, MOCK_CONFIG.issuer, user.id); + await setUserColumns(user.id, { oidc_sub: sub, oidc_issuer: MOCK_CONFIG.issuer }); return user; } - const storedRole = (id: number) => (testDb.prepare('SELECT role FROM users WHERE id = ?').get(id) as { role: string }).role; + const storedRole = async (id: number) => (await readUser(await orm(), id)).role; it('OIDC-SVC-063: an admin keeps the role when the configured claim is absent from the payload', async () => { process.env.OIDC_ADMIN_VALUE = 'trek-admins'; process.env.OIDC_ADMIN_CLAIM = 'entitlements'; // A second admin row, so the #1274 last-admin guard cannot be what saves them. createUser(testDb, { email: 'bootstrap@example.com', role: 'admin' }); - const user = ssoUser('sso-admin@example.com', 'sub-keep-admin', 'admin'); + const user = await ssoUser('sso-admin@example.com', 'sub-keep-admin', 'admin'); const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); try { @@ -988,7 +979,7 @@ describe('findOrCreateUser role mapping', () => { )) as RoleResult; expect(result.user.role).toBe('admin'); - expect(storedRole(user.id)).toBe('admin'); + expect(await storedRole(user.id)).toBe('admin'); expect(result.roleChange).toBeUndefined(); } finally { warn.mockRestore(); @@ -998,7 +989,7 @@ describe('findOrCreateUser role mapping', () => { it('OIDC-SVC-064: an absent claim does not promote a plain user either', async () => { process.env.OIDC_ADMIN_VALUE = 'trek-admins'; process.env.OIDC_ADMIN_CLAIM = 'entitlements'; - const user = ssoUser('plain@example.com', 'sub-keep-user', 'user'); + const user = await ssoUser('plain@example.com', 'sub-keep-user', 'user'); const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); try { @@ -1008,7 +999,7 @@ describe('findOrCreateUser role mapping', () => { )) as RoleResult; expect(result.user.role).toBe('user'); - expect(storedRole(user.id)).toBe('user'); + expect(await storedRole(user.id)).toBe('user'); expect(result.roleChange).toBeUndefined(); } finally { warn.mockRestore(); @@ -1018,14 +1009,14 @@ describe('findOrCreateUser role mapping', () => { it('OIDC-SVC-065: a custom claim that does arrive still promotes, and reports the change', async () => { process.env.OIDC_ADMIN_VALUE = 'trek-admins'; process.env.OIDC_ADMIN_CLAIM = 'entitlements'; - const user = ssoUser('promote@example.com', 'sub-promote', 'user'); + const user = await ssoUser('promote@example.com', 'sub-promote', 'user'); const result = (await svc.findOrCreateUser( { sub: 'sub-promote', email: 'promote@example.com', entitlements: ['trek-users', 'trek-admins'] }, MOCK_CONFIG )) as RoleResult; - expect(storedRole(user.id)).toBe('admin'); + expect(await storedRole(user.id)).toBe('admin'); expect(result.roleChange).toEqual({ from: 'user', to: 'admin', claim: 'entitlements' }); }); @@ -1033,21 +1024,21 @@ describe('findOrCreateUser role mapping', () => { process.env.OIDC_ADMIN_VALUE = 'trek-admins'; process.env.OIDC_ADMIN_CLAIM = 'entitlements'; createUser(testDb, { email: 'bootstrap2@example.com', role: 'admin' }); - const user = ssoUser('demote@example.com', 'sub-demote', 'admin'); + const user = await ssoUser('demote@example.com', 'sub-demote', 'admin'); const result = (await svc.findOrCreateUser( { sub: 'sub-demote', email: 'demote@example.com', entitlements: ['trek-users'] }, MOCK_CONFIG )) as RoleResult; - expect(storedRole(user.id)).toBe('user'); + expect(await storedRole(user.id)).toBe('user'); expect(result.roleChange).toEqual({ from: 'admin', to: 'user', claim: 'entitlements' }); }); it('OIDC-SVC-067: the only admin is still kept when the claim arrives without the admin value (#1274)', async () => { process.env.OIDC_ADMIN_VALUE = 'trek-admins'; process.env.OIDC_ADMIN_CLAIM = 'entitlements'; - const user = ssoUser('lonely@example.com', 'sub-lonely', 'admin'); + const user = await ssoUser('lonely@example.com', 'sub-lonely', 'admin'); const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); try { @@ -1056,7 +1047,7 @@ describe('findOrCreateUser role mapping', () => { MOCK_CONFIG )) as RoleResult; - expect(storedRole(user.id)).toBe('admin'); + expect(await storedRole(user.id)).toBe('admin'); expect(result.roleChange).toBeUndefined(); expect(warn.mock.calls[0][0]).toContain('only admin'); } finally { @@ -1067,7 +1058,7 @@ describe('findOrCreateUser role mapping', () => { it('OIDC-SVC-068: for a plain user the absent-claim warning names the claim and the keys that arrived, never their values, and fires once', async () => { process.env.OIDC_ADMIN_VALUE = 'trek-admins'; process.env.OIDC_ADMIN_CLAIM = 'trek_roles'; - ssoUser('warned@example.com', 'sub-warned', 'user'); + await ssoUser('warned@example.com', 'sub-warned', 'user'); const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); // The warning is deduped per claim name for the life of the process, so this // case needs an instance that has not seen the claim yet. @@ -1103,7 +1094,7 @@ describe('findOrCreateUser role mapping', () => { MOCK_CONFIG )) as RoleResult; - expect(storedRole(result.user.id)).toBe('user'); + expect(await storedRole(result.user.id)).toBe('user'); expect(result.roleChange).toBeUndefined(); expect(warn.mock.calls[0][0]).toContain('during registration'); } finally { @@ -1114,14 +1105,14 @@ describe('findOrCreateUser role mapping', () => { it('OIDC-SVC-070: a claim that already agrees with the stored role reports no change', async () => { process.env.OIDC_ADMIN_VALUE = 'trek-admins'; process.env.OIDC_ADMIN_CLAIM = 'entitlements'; - const user = ssoUser('steady@example.com', 'sub-steady', 'admin'); + const user = await ssoUser('steady@example.com', 'sub-steady', 'admin'); const result = (await svc.findOrCreateUser( { sub: 'sub-steady', email: 'steady@example.com', entitlements: ['trek-admins'] }, MOCK_CONFIG )) as RoleResult; - expect(storedRole(user.id)).toBe('admin'); + expect(await storedRole(user.id)).toBe('admin'); expect(result.roleChange).toBeUndefined(); }); @@ -1142,7 +1133,7 @@ describe('findOrCreateUser role mapping', () => { process.env.OIDC_ADMIN_VALUE = 'trek-admins'; process.env.OIDC_ADMIN_CLAIM = 'kept_admin_roles'; createUser(testDb, { email: 'bootstrap3@example.com', role: 'admin' }); - const user = ssoUser('kept@example.com', 'sub-kept-admin', 'admin'); + const user = await ssoUser('kept@example.com', 'sub-kept-admin', 'admin'); const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); try { @@ -1161,7 +1152,7 @@ describe('findOrCreateUser role mapping', () => { expect(line).not.toContain('authentik Admins'); expect(line).not.toContain('kept@example.com'); } - expect(storedRole(user.id)).toBe('admin'); + expect(await storedRole(user.id)).toBe('admin'); } finally { warn.mockRestore(); } @@ -1171,8 +1162,8 @@ describe('findOrCreateUser role mapping', () => { process.env.OIDC_ADMIN_VALUE = 'trek-admins'; process.env.OIDC_ADMIN_CLAIM = 'shared_roles'; createUser(testDb, { email: 'bootstrap4@example.com', role: 'admin' }); - ssoUser('shared-admin@example.com', 'sub-shared-admin', 'admin'); - ssoUser('shared-plain@example.com', 'sub-shared-plain', 'user'); + await ssoUser('shared-admin@example.com', 'sub-shared-admin', 'admin'); + await ssoUser('shared-plain@example.com', 'sub-shared-plain', 'user'); const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); const fresh = new OidcService(auth, membership, await createTestUnitOfWork(testDb), await createTestUsersRepo(testDb), await createTestInviteTokensRepo(testDb), await createTestAppSettingsRepo(testDb)); diff --git a/server/tests/unit/nest/share.service.test.ts b/server/tests/unit/nest/share.service.test.ts index db9300273..1fd1b2924 100644 --- a/server/tests/unit/nest/share.service.test.ts +++ b/server/tests/unit/nest/share.service.test.ts @@ -12,24 +12,10 @@ import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/plac // ── DB setup ────────────────────────────────────────────────────────────────── vi.mock('../../../src/db/database', async () => { - const { createSnapshotTestDb } = await import('../../helpers/db-mock'); const db = createSnapshotTestDb(); - const mock = { - db, - closeDb: () => {}, - reinitialize: () => {}, - getPlaceWithTags: () => null, - canAccessTrip: (tripId: any, userId: number) => - db.prepare(` - SELECT t.id, t.user_id FROM trips t - LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? - WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL) - `).get(userId, tripId, userId), - isOwner: (tripId: any, userId: number) => - !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId), - }; - return mock; + // Trip access reads through TripsRepository now; the module only hands out the handle. + return { db, closeDb: () => {}, reinitialize: () => {} }; }); @@ -60,6 +46,20 @@ import type { User } from '../../../src/types'; import { sharedTestOrm, createTestUnitOfWork, createTestAppSettingsRepo, createTestSettingsRepo, createTestTagsRepo, createTestPlaceRatingsRepo, createTestAssignmentParticipantsRepo } from '../../helpers/test-uow'; import { shareServiceRepoArgs } from '../../helpers/share-repos'; import type { TestOrm } from '../../helpers/test-orm'; +import type { EntityData, RequiredEntityData } from '@mikro-orm/core'; +import { deleteRows, findRow, insertRow, updateRows } from '../../helpers/factories/rows'; +import { setAppSetting, setUserSetting } from '../../helpers/factories/settings'; +import { BudgetItems } from '../../../src/db/entities/BudgetItems.entity'; +import { CollabMessages } from '../../../src/db/entities/CollabMessages.entity'; +import { DayAccommodations } from '../../../src/db/entities/DayAccommodations.entity'; +import { DayAssignments } from '../../../src/db/entities/DayAssignments.entity'; +import { PackingItems } from '../../../src/db/entities/PackingItems.entity'; +import { Places } from '../../../src/db/entities/Places.entity'; +import { ReservationDayPositions } from '../../../src/db/entities/ReservationDayPositions.entity'; +import { ReservationEndpoints } from '../../../src/db/entities/ReservationEndpoints.entity'; +import { Reservations } from '../../../src/db/entities/Reservations.entity'; +import { ShareTokens } from '../../../src/db/entities/ShareTokens.entity'; +import { Trips } from '../../../src/db/entities/Trips.entity'; let svc: ShareService; let t: TestOrm; @@ -92,7 +92,12 @@ afterAll(async () => { }); function shareRow(tripId: number | string) { - return testDb.prepare('SELECT * FROM share_tokens WHERE trip_id = ?').get(tripId) as any; + return findRow(t, ShareTokens, { trip: Number(tripId) }); +} + +/** A reservation on the trip with exactly the given columns; returns its id. */ +function addReservation(tripId: number, columns: EntityData): Promise { + return insertRow(t, Reservations, { trip: tripId, ...columns } as RequiredEntityData); } /** Owner + trip + a share link with all five flags on unless overridden. */ @@ -142,7 +147,7 @@ describe('createOrUpdate', () => { const result = await svc.createOrUpdate(String(trip.id), user.id, {}); expect(result.created).toBe(true); expect(result.token).toMatch(/^[A-Za-z0-9_-]+$/); // base64url - const row = shareRow(trip.id); + const row = (await shareRow(trip.id))!; expect(row.token).toBe(result.token); expect(row.created_by).toBe(user.id); expect([row.share_map, row.share_bookings, row.share_packing, row.share_budget, row.share_collab]) @@ -159,7 +164,7 @@ describe('createOrUpdate', () => { await svc.createOrUpdate(String(trip.id), user.id, { share_map: false, share_bookings: false, share_packing: true, share_budget: true, share_collab: true, }); - const row = shareRow(trip.id); + const row = (await shareRow(trip.id))!; expect([row.share_map, row.share_bookings, row.share_packing, row.share_budget, row.share_collab]) .toEqual([0, 0, 1, 1, 1]); }); @@ -170,7 +175,7 @@ describe('createOrUpdate', () => { const first = await svc.createOrUpdate(String(trip.id), user.id, {}); const second = await svc.createOrUpdate(String(trip.id), user.id, { share_budget: true }); expect(second).toEqual({ token: first.token, created: false }); - expect(shareRow(trip.id).share_budget).toBe(1); + expect((await shareRow(trip.id))!.share_budget).toBe(1); }); it('SHARE-SVC-006: the update path re-applies the defaults for omitted flags and renews the 90-day expiry', async () => { @@ -179,10 +184,10 @@ describe('createOrUpdate', () => { await svc.createOrUpdate(String(trip.id), user.id, { share_packing: true }); // Simulate a legacy pre-TTL row (NULL expiry): an explicit update moves it // onto the 90-day clock. - testDb.prepare('UPDATE share_tokens SET expires_at = NULL WHERE trip_id = ?').run(trip.id); + await updateRows(t, ShareTokens, { trip: trip.id }, { expires_at: null }); const before = Date.now(); await svc.createOrUpdate(String(trip.id), user.id, {}); - const row = shareRow(trip.id); + const row = (await shareRow(trip.id))!; // Omitted share_packing fell back to its default (off) — destructuring // defaults apply on every call, not only on create. expect(row.share_packing).toBe(0); @@ -225,7 +230,7 @@ describe('get and remove', () => { const trip = createTrip(testDb, user.id); await svc.createOrUpdate(String(trip.id), user.id, {}); await svc.remove(String(trip.id)); - expect(shareRow(trip.id)).toBeUndefined(); + expect(await shareRow(trip.id)).toBeNull(); await expect(svc.remove(String(trip.id))).resolves.toBeUndefined(); }); }); @@ -239,9 +244,9 @@ describe('getSharedTripData', () => { it('SHARE-SVC-011: returns null for an expired token but honours NULL expiry (legacy rows)', async () => { const { trip, token } = await seedSharedTrip(); - testDb.prepare('UPDATE share_tokens SET expires_at = ? WHERE trip_id = ?').run('2020-01-01T00:00:00.000Z', trip.id); + await updateRows(t, ShareTokens, { trip: trip.id }, { expires_at: '2020-01-01T00:00:00.000Z' }); expect(await svc.getSharedTripData(token)).toBeNull(); - testDb.prepare('UPDATE share_tokens SET expires_at = NULL WHERE trip_id = ?').run(trip.id); + await updateRows(t, ShareTokens, { trip: trip.id }, { expires_at: null }); expect(await svc.getSharedTripData(token)).not.toBeNull(); }); @@ -257,7 +262,7 @@ describe('getSharedTripData', () => { it('SHARE-SVC-040 (R5, revoked): a deleted token returns null — distinct from merely unknown', async () => { const { trip, token } = await seedSharedTrip(); expect(await svc.getSharedTripData(token)).not.toBeNull(); - testDb.prepare('DELETE FROM share_tokens WHERE trip_id = ?').run(trip.id); + await deleteRows(t, ShareTokens, { trip: trip.id }); expect(await svc.getSharedTripData(token)).toBeNull(); }); @@ -278,7 +283,7 @@ describe('getSharedTripData', () => { // The token normally cascades away with its trip; orphan it deliberately // to pin the defensive `if (!trip) return null` branch. testDb.exec('PRAGMA foreign_keys = OFF'); - testDb.prepare('DELETE FROM trips WHERE id = ?').run(trip.id); + await deleteRows(t, Trips, { id: trip.id }); testDb.exec('PRAGMA foreign_keys = ON'); expect(await svc.getSharedTripData(token)).toBeNull(); }); @@ -314,12 +319,12 @@ describe('getSharedTripData', () => { const { trip, token } = await seedSharedTrip(); const day = createDay(testDb, trip.id); const place = createPlace(testDb, trip.id); - testDb.prepare('UPDATE places SET place_time = ?, end_time = ? WHERE id = ?').run('09:00', '10:00', place.id); + await updateRows(t, Places, { id: place.id }, { place_time: '09:00', end_time: '10:00' }); const a = createDayAssignment(testDb, day.id, place.id); let entry = (await svc.getSharedTripData(token))!.assignments[day.id][0]; expect(entry.place.place_time).toBe('09:00'); expect(entry.place.end_time).toBe('10:00'); - testDb.prepare('UPDATE day_assignments SET assignment_time = ?, assignment_end_time = ? WHERE id = ?').run('14:00', '15:00', a.id); + await updateRows(t, DayAssignments, { id: a.id }, { assignment_time: '14:00', assignment_end_time: '15:00' }); entry = (await svc.getSharedTripData(token))!.assignments[day.id][0]; expect(entry.place.place_time).toBe('14:00'); expect(entry.place.end_time).toBe('15:00'); @@ -330,12 +335,8 @@ describe('getSharedTripData', () => { const day = createDay(testDb, trip.id); const p1 = createPlace(testDb, trip.id, { name: 'First' }); const p2 = createPlace(testDb, trip.id, { name: 'Second' }); - testDb.prepare( - "INSERT INTO day_assignments (day_id, place_id, order_index, created_at) VALUES (?, ?, 0, '2025-01-01T11:00:00')" - ).run(day.id, p2.id); - testDb.prepare( - "INSERT INTO day_assignments (day_id, place_id, order_index, created_at) VALUES (?, ?, 0, '2025-01-01T10:00:00')" - ).run(day.id, p1.id); + await insertRow(t, DayAssignments, { day: day.id, place: p2.id, order_index: 0, created_at: '2025-01-01T11:00:00' }); + await insertRow(t, DayAssignments, { day: day.id, place: p1.id, order_index: 0, created_at: '2025-01-01T10:00:00' }); const entries = (await svc.getSharedTripData(token))!.assignments[day.id]; expect(entries.map((e: any) => e.place.name)).toEqual(['First', 'Second']); }); @@ -343,29 +344,28 @@ describe('getSharedTripData', () => { it('SHARE-SVC-016: attaches reservation day_positions, null when a reservation has none', async () => { const { trip, token } = await seedSharedTrip(); const day = createDay(testDb, trip.id); - const r1 = testDb.prepare("INSERT INTO reservations (trip_id, title, type) VALUES (?, 'Flight', 'flight')").run(trip.id); - const r2 = testDb.prepare("INSERT INTO reservations (trip_id, title, type) VALUES (?, 'Hotel', 'hotel')").run(trip.id); - testDb.prepare('INSERT INTO reservation_day_positions (reservation_id, day_id, position) VALUES (?, ?, 3)') - .run(r1.lastInsertRowid, day.id); + const r1 = await addReservation(trip.id, { title: 'Flight', type: 'flight' }); + const r2 = await addReservation(trip.id, { title: 'Hotel', type: 'hotel' }); + await insertRow(t, ReservationDayPositions, { reservation: r1, day: day.id, position: 3 }); const reservations = (await svc.getSharedTripData(token))!.reservations; - const flight = reservations.find((r: any) => r.id === r1.lastInsertRowid); - const hotel = reservations.find((r: any) => r.id === r2.lastInsertRowid); + const flight = reservations.find((r: any) => r.id === r1); + const hotel = reservations.find((r: any) => r.id === r2); expect(flight.day_positions).toEqual({ [day.id]: 3 }); expect(hotel.day_positions).toBeNull(); }); it('SHARE-SVC-017: excludes private packing items from the public payload (#858)', async () => { const { user, trip, token } = await seedSharedTrip(); - testDb.prepare("INSERT INTO packing_items (trip_id, name, is_private, owner_id) VALUES (?, 'Common', 0, ?)").run(trip.id, user.id); - testDb.prepare("INSERT INTO packing_items (trip_id, name, is_private, owner_id) VALUES (?, 'Secret', 1, ?)").run(trip.id, user.id); + await insertRow(t, PackingItems, { trip: trip.id, name: 'Common', is_private: 0, owner: user.id }); + await insertRow(t, PackingItems, { trip: trip.id, name: 'Secret', is_private: 1, owner: user.id }); const packing = (await svc.getSharedTripData(token))!.packing; expect(packing.map((p: any) => p.name)).toEqual(['Common']); }); it('SHARE-SVC-018: includes non-deleted collab messages only when share_collab is on', async () => { const { user, trip, token } = await seedSharedTrip(); - testDb.prepare("INSERT INTO collab_messages (trip_id, user_id, text, deleted) VALUES (?, ?, 'Hello', 0)").run(trip.id, user.id); - testDb.prepare("INSERT INTO collab_messages (trip_id, user_id, text, deleted) VALUES (?, ?, 'Gone', 1)").run(trip.id, user.id); + await insertRow(t, CollabMessages, { trip: trip.id, user: user.id, text: 'Hello', deleted: 0 }); + await insertRow(t, CollabMessages, { trip: trip.id, user: user.id, text: 'Gone', deleted: 1 }); const collab = (await svc.getSharedTripData(token))!.collab; expect(collab).toHaveLength(1); expect(collab[0]).toEqual(expect.objectContaining({ text: 'Hello', username: user.username })); @@ -381,9 +381,9 @@ describe('getSharedTripData', () => { const day = createDay(testDb, trip.id); const place = createPlace(testDb, trip.id); createDayAssignment(testDb, day.id, place.id); - testDb.prepare("INSERT INTO reservations (trip_id, title, type) VALUES (?, 'Flight', 'flight')").run(trip.id); - testDb.prepare("INSERT INTO packing_items (trip_id, name) VALUES (?, 'Socks')").run(trip.id); - testDb.prepare("INSERT INTO budget_items (trip_id, name, category, total_price) VALUES (?, 'Food', 'food', 10)").run(trip.id); + await addReservation(trip.id, { title: 'Flight', type: 'flight' }); + await insertRow(t, PackingItems, { trip: trip.id, name: 'Socks' }); + await insertRow(t, BudgetItems, { trip: trip.id, name: 'Food', category: 'food', total_price: 10 }); const data = (await svc.getSharedTripData(token))!; expect(data.days).toEqual([]); expect(data.assignments).toEqual({}); @@ -404,7 +404,7 @@ describe('getSharedTripData', () => { const stay = createDayAccommodation(testDb, trip.id, hotel.id, day.id, day.id); createDayAssignment(testDb, day.id, museum.id); const stayStop = createDayAssignment(testDb, day.id, hotel.id); - testDb.prepare('UPDATE day_assignments SET accommodation_id = ? WHERE id = ?').run(stay.id, stayStop.id); + await updateRows(t, DayAssignments, { id: stayStop.id }, { accommodation_id: stay.id }); createDayNote(testDb, day.id, trip.id); for (const type of ['flight', 'hotel', 'restaurant', 'tour']) createReservation(testDb, trip.id, { type, title: type }); @@ -420,7 +420,7 @@ describe('getSharedTripData', () => { const { trip, token } = await seedSharedTrip({ share_hide_images: true }); const day = createDay(testDb, trip.id); const place = createPlace(testDb, trip.id, { name: 'Louvre' }); - testDb.prepare("UPDATE places SET image_url = '/api/maps/place-photo/abc/bytes' WHERE id = ?").run(place.id); + await updateRows(t, Places, { id: place.id }, { image_url: '/api/maps/place-photo/abc/bytes' }); createDayAssignment(testDb, day.id, place.id); const data = (await svc.getSharedTripData(token))!; @@ -433,9 +433,9 @@ describe('getSharedTripData', () => { it('SHARE-SVC-020: baseCurrency falls back trip currency → EUR, with the owner default_currency winning (#1361)', async () => { const { user, trip, token } = await seedSharedTrip(); expect((await svc.getSharedTripData(token))!.baseCurrency).toBe('EUR'); - testDb.prepare('UPDATE trips SET currency = ? WHERE id = ?').run('USD', trip.id); + await updateRows(t, Trips, { id: trip.id }, { currency: 'USD' }); expect((await svc.getSharedTripData(token))!.baseCurrency).toBe('USD'); - testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'default_currency', ' CHF ')").run(user.id); + await setUserSetting(t, user.id, 'default_currency', ' CHF '); expect((await svc.getSharedTripData(token))!.baseCurrency).toBe('CHF'); }); @@ -444,8 +444,8 @@ describe('getSharedTripData', () => { const proxied = createPlace(testDb, trip.id, { name: 'Proxied' }); const uploaded = createPlace(testDb, trip.id, { name: 'Uploaded' }); createPlace(testDb, trip.id, { name: 'Bare' }); - testDb.prepare('UPDATE places SET image_url = ? WHERE id = ?').run('/api/maps/place-photo/ChIJabc/bytes', proxied.id); - testDb.prepare('UPDATE places SET image_url = ? WHERE id = ?').run('/uploads/pic.jpg', uploaded.id); + await updateRows(t, Places, { id: proxied.id }, { image_url: '/api/maps/place-photo/ChIJabc/bytes' }); + await updateRows(t, Places, { id: uploaded.id }, { image_url: '/uploads/pic.jpg' }); const byName = Object.fromEntries( (await svc.getSharedTripData(token))!.places.map((p: any) => [p.name, p.image_url]), ); @@ -457,18 +457,16 @@ describe('getSharedTripData', () => { it('SHARE-SVC-027: cartoApiKey resolves owner setting → admin instance default → empty (#2054)', async () => { const { user, token } = await seedSharedTrip(); expect((await svc.getSharedTripData(token))!.cartoApiKey).toBe(''); - testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('default_user_setting_carto_api_key', 'instance-key')").run(); + await setAppSetting(t, 'default_user_setting_carto_api_key', 'instance-key'); expect((await svc.getSharedTripData(token))!.cartoApiKey).toBe('instance-key'); - testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'carto_api_key', ' owner-key ')").run(user.id); + await setUserSetting(t, user.id, 'carto_api_key', ' owner-key '); expect((await svc.getSharedTripData(token))!.cartoApiKey).toBe('owner-key'); }); it('SHARE-SVC-029: staged bookings stay out of the public payload, confirmation number included', async () => { const { trip, token } = await seedSharedTrip(); - testDb.prepare(`INSERT INTO reservations (trip_id, title, type, status, confirmation_number, ingest_state) - VALUES (?, 'Parked Flight', 'flight', 'confirmed', 'SECRET1', 'staged')`).run(trip.id); - testDb.prepare(`INSERT INTO reservations (trip_id, title, type, status, confirmation_number) - VALUES (?, 'Booked Flight', 'flight', 'confirmed', 'OPEN1')`).run(trip.id); + await addReservation(trip.id, { title: 'Parked Flight', type: 'flight', status: 'confirmed', confirmation_number: 'SECRET1', ingest_state: 'staged' }); + await addReservation(trip.id, { title: 'Booked Flight', type: 'flight', status: 'confirmed', confirmation_number: 'OPEN1' }); const data = (await svc.getSharedTripData(token))!; @@ -480,8 +478,7 @@ describe('getSharedTripData', () => { it('SHARE-SVC-030: every booking that predates the column stays in the payload', async () => { const { trip, token } = await seedSharedTrip(); for (let i = 0; i < 5; i++) { - testDb.prepare(`INSERT INTO reservations (trip_id, title, type, status) - VALUES (?, ?, 'flight', 'confirmed')`).run(trip.id, `Booking ${i}`); + await addReservation(trip.id, { title: `Booking ${i}`, type: 'flight', status: 'confirmed' }); } expect(((await svc.getSharedTripData(token))!.reservations as any[])).toHaveLength(5); @@ -491,14 +488,18 @@ describe('getSharedTripData', () => { const { trip, token } = await seedSharedTrip(); const place = createPlace(testDb, trip.id, { name: 'Hotel Bellevue' }); const day = createDay(testDb, trip.id, { date: '2026-09-01' }); - const stay = (): number => testDb.prepare(` - INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id) VALUES (?, ?, ?, ?) - `).run(trip.id, place.id, day.id, day.id).lastInsertRowid as number; + const stay = (): Promise => + insertRow(t, DayAccommodations, { trip: trip.id, place: place.id, startDay: day.id, endDay: day.id }); - const stagedStay = stay(); - testDb.prepare(`INSERT INTO reservations (trip_id, title, type, status, accommodation_id, ingest_state) - VALUES (?, 'Parked Hotel', 'hotel', 'confirmed', ?, 'staged')`).run(trip.id, String(stagedStay)); - const unlinkedStay = stay(); + const stagedStay = await stay(); + await addReservation(trip.id, { + title: 'Parked Hotel', + type: 'hotel', + status: 'confirmed', + accommodation_id: String(stagedStay), + ingest_state: 'staged', + }); + const unlinkedStay = await stay(); const rows = (await svc.getSharedTripData(token))!.accommodations as any[]; @@ -511,10 +512,18 @@ describe('getSharedTripData', () => { describe('getSharedTripData redaction (#2320)', () => { it('SHARE-SVC-032: a booking travels without its confirmation, import trail or travellers', async () => { const { trip, token } = await seedSharedTrip(); - testDb.prepare(`INSERT INTO reservations - (trip_id, title, type, status, confirmation_number, notes, url, external_source, external_id, sync_enabled, needs_review) - VALUES (?, 'Night train', 'train', 'confirmed', 'PNR9XY', 'Bring the tickets', 'https://bahn.example/booking/1', 'kitinerary', 'ext-42', 1, 0)`) - .run(trip.id); + await addReservation(trip.id, { + title: 'Night train', + type: 'train', + status: 'confirmed', + confirmation_number: 'PNR9XY', + notes: 'Bring the tickets', + url: 'https://bahn.example/booking/1', + external_source: 'kitinerary', + external_id: 'ext-42', + sync_enabled: 1, + needs_review: 0, + }); const [row] = (await svc.getSharedTripData(token))!.reservations as any[]; @@ -538,8 +547,7 @@ describe('getSharedTripData redaction (#2320)', () => { { from: 'BER', to: 'JFK', airline: 'LH', flight_number: 'LH400', dep_time: '11:00', arr_time: '14:10', confirmation_number: 'LEG2' }, ], }); - testDb.prepare(`INSERT INTO reservations (trip_id, title, type, status, metadata) - VALUES (?, 'To New York', 'flight', 'confirmed', ?)`).run(trip.id, metadata); + await addReservation(trip.id, { title: 'To New York', type: 'flight', status: 'confirmed', metadata }); const data = (await svc.getSharedTripData(token))!; const [row] = data.reservations as any[]; @@ -560,12 +568,9 @@ describe('getSharedTripData redaction (#2320)', () => { it('SHARE-SVC-034: a booking carries its ordered stops', async () => { const { trip, token } = await seedSharedTrip(); - const id = testDb.prepare(`INSERT INTO reservations (trip_id, title, type, status) - VALUES (?, 'Coach', 'bus', 'confirmed')`).run(trip.id).lastInsertRowid; - const ins = testDb.prepare(`INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng) - VALUES (?, ?, ?, ?, ?, ?, ?)`); - ins.run(id, 'to', 1, 'Lyon', null, 45.76, 4.84); - ins.run(id, 'from', 0, 'Paris', 'PAR', 48.85, 2.35); + const id = await addReservation(trip.id, { title: 'Coach', type: 'bus', status: 'confirmed' }); + await insertRow(t, ReservationEndpoints, { reservation: id, role: 'to', sequence: 1, name: 'Lyon', code: null, lat: 45.76, lng: 4.84 }); + await insertRow(t, ReservationEndpoints, { reservation: id, role: 'from', sequence: 0, name: 'Paris', code: 'PAR', lat: 48.85, lng: 2.35 }); const [row] = (await svc.getSharedTripData(token))!.reservations as any[]; @@ -579,10 +584,10 @@ describe('getSharedTripData redaction (#2320)', () => { const { trip, token } = await seedSharedTrip(); const day = createDay(testDb, trip.id, { date: '2026-09-01' }); for (const [title, url] of [['ok', 'https://example.com/x'], ['js', 'javascript:alert(1)'], ['data', 'data:text/html,hi'], ['junk', 'not a url'], ['blank', ' ']]) { - testDb.prepare(`INSERT INTO reservations (trip_id, title, type, status, url) VALUES (?, ?, 'other', 'confirmed', ?)`).run(trip.id, title, url); + await addReservation(trip.id, { title, type: 'other', status: 'confirmed', url }); } const place = createPlace(testDb, trip.id, { name: 'Museum' }); - testDb.prepare('UPDATE places SET website = ?, phone = ? WHERE id = ?').run('javascript:alert(2)', '+43 1 234', place.id); + await updateRows(t, Places, { id: place.id }, { website: 'javascript:alert(2)', phone: '+43 1 234' }); createDayAssignment(testDb, day.id, place.id, {}); const data = (await svc.getSharedTripData(token))!; @@ -597,8 +602,16 @@ describe('getSharedTripData redaction (#2320)', () => { const { trip, token } = await seedSharedTrip(); const day = createDay(testDb, trip.id, { date: '2026-09-01' }); const place = createPlace(testDb, trip.id, { name: 'Louvre' }); - testDb.prepare(`UPDATE places SET description = 'Big museum', address = 'Rue de Rivoli', notes = 'Skip the pyramid queue', - duration_minutes = 180, website = 'https://louvre.fr', phone = '+33 1', reservation_notes = 'Booked under Ada', google_place_id = 'ChIJ123' WHERE id = ?`).run(place.id); + await updateRows(t, Places, { id: place.id }, { + description: 'Big museum', + address: 'Rue de Rivoli', + notes: 'Skip the pyramid queue', + duration_minutes: 180, + website: 'https://louvre.fr', + phone: '+33 1', + reservation_notes: 'Booked under Ada', + google_place_id: 'ChIJ123', + }); createDayAssignment(testDb, day.id, place.id, { notes: 'go early' }); const data = (await svc.getSharedTripData(token))!; @@ -619,16 +632,16 @@ describe('getSharedTripData redaction (#2320)', () => { it('SHARE-SVC-046 (SH12 full-key parity): every allow-listed column of a fully-populated place reaches the public payload, nothing else does', async () => { const { trip, token } = await seedSharedTrip(); const place = createPlace(testDb, trip.id, { name: 'Fully Populated' }); - testDb.prepare(`UPDATE places SET - description = 'A description', address = '1 Rue Test', price = 12.5, currency = 'EUR', - place_time = '09:00', end_time = '10:00', duration_minutes = 60, notes = 'A public note', - image_url = '/uploads/pic.jpg', website = 'https://example.com', phone = '+33 1 23', - transport_mode = 'walk', - reservation_status = 'confirmed', reservation_notes = 'Private booking note', reservation_datetime = '2026-01-01T09:00', - google_place_id = 'ChIJ-private', google_ftid = 'ftid-private', osm_id = 'osm-private', amap_poi_id = 'amap-private', - route_geometry = '{"type":"LineString"}', route_color = '#ff0000', stop_type = 'hotel', fill_percent = 42, - source = 'import' - WHERE id = ?`).run(place.id); + await updateRows(t, Places, { id: place.id }, { + description: 'A description', address: '1 Rue Test', price: 12.5, currency: 'EUR', + place_time: '09:00', end_time: '10:00', duration_minutes: 60, notes: 'A public note', + image_url: '/uploads/pic.jpg', website: 'https://example.com', phone: '+33 1 23', + transport_mode: 'walk', + reservation_status: 'confirmed', reservation_notes: 'Private booking note', reservation_datetime: '2026-01-01T09:00', + google_place_id: 'ChIJ-private', google_ftid: 'ftid-private', osm_id: 'osm-private', amap_poi_id: 'amap-private', + route_geometry: '{"type":"LineString"}', route_color: '#ff0000', stop_type: 'hotel', fill_percent: 42, + source: 'import', + }); const data = (await svc.getSharedTripData(token))!; const pool = (data.places as any[])[0]; @@ -659,8 +672,16 @@ describe('getSharedTripData redaction (#2320)', () => { const { trip, token } = await seedSharedTrip(); const place = createPlace(testDb, trip.id, { name: 'Hotel' }); const day = createDay(testDb, trip.id, { date: '2026-09-01' }); - testDb.prepare(`INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes) - VALUES (?, ?, ?, ?, '15:00', '11:00', 'HOTEL-777', 'Ask for a quiet room')`).run(trip.id, place.id, day.id, day.id); + await insertRow(t, DayAccommodations, { + trip: trip.id, + place: place.id, + startDay: day.id, + endDay: day.id, + check_in: '15:00', + check_out: '11:00', + confirmation: 'HOTEL-777', + notes: 'Ask for a quiet room', + }); const data = (await svc.getSharedTripData(token))!; const [stay] = data.accommodations as any[]; @@ -685,7 +706,7 @@ describe('getSharedPlacePhotoKey', () => { it('SHARE-SVC-022: returns null for an unknown or expired token', async () => { expect(await svc.getSharedPlacePhotoKey('nope', 'ChIJabc')).toBeNull(); const { trip, token } = await seedSharedTrip(); - testDb.prepare('UPDATE share_tokens SET expires_at = ? WHERE trip_id = ?').run('2020-01-01T00:00:00.000Z', trip.id); + await updateRows(t, ShareTokens, { trip: trip.id }, { expires_at: '2020-01-01T00:00:00.000Z' }); expect(await svc.getSharedPlacePhotoKey(token, 'ChIJabc')).toBeNull(); expect(serveKey).not.toHaveBeenCalled(); }); @@ -698,9 +719,9 @@ describe('getSharedPlacePhotoKey', () => { it('SHARE-SVC-043 (R5, revoked): a deleted token returns null — distinct from merely unknown', async () => { const { trip, token } = await seedSharedTrip(); const place = createPlace(testDb, trip.id); - testDb.prepare('UPDATE places SET image_url = ? WHERE id = ?').run('/api/maps/place-photo/ChIJabc/bytes', place.id); + await updateRows(t, Places, { id: place.id }, { image_url: '/api/maps/place-photo/ChIJabc/bytes' }); expect(await svc.getSharedPlacePhotoKey(token, 'ChIJabc')).not.toBeNull(); - testDb.prepare('DELETE FROM share_tokens WHERE trip_id = ?').run(trip.id); + await deleteRows(t, ShareTokens, { trip: trip.id }); expect(await svc.getSharedPlacePhotoKey(token, 'ChIJabc')).toBeNull(); }); @@ -719,7 +740,7 @@ describe('getSharedPlacePhotoKey', () => { it('SHARE-SVC-023: returns null when the owner disabled the map section', async () => { const { trip, token } = await seedSharedTrip({ share_map: false }); const place = createPlace(testDb, trip.id); - testDb.prepare('UPDATE places SET image_url = ? WHERE id = ?').run('/api/maps/place-photo/ChIJabc/bytes', place.id); + await updateRows(t, Places, { id: place.id }, { image_url: '/api/maps/place-photo/ChIJabc/bytes' }); expect(await svc.getSharedPlacePhotoKey(token, 'ChIJabc')).toBeNull(); expect(serveKey).not.toHaveBeenCalled(); }); @@ -735,8 +756,7 @@ describe('getSharedPlacePhotoKey', () => { const place = createPlace(testDb, trip.id); // Wikimedia pseudo-IDs contain characters that must round-trip encoded. const placeId = 'coords:48.8,2.3'; - testDb.prepare('UPDATE places SET image_url = ? WHERE id = ?') - .run(`/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`, place.id); + await updateRows(t, Places, { id: place.id }, { image_url: `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes` }); serveKey.mockReturnValue('abc.jpg'); expect(await svc.getSharedPlacePhotoKey(token, placeId)).toBe('abc.jpg'); expect(serveKey).toHaveBeenCalledWith(placeId);