diff --git a/server/tests/unit/db/repositories/DayAssignments.repository.test.ts b/server/tests/unit/db/repositories/DayAssignments.repository.test.ts index 120c089f7..0cc01427d 100644 --- a/server/tests/unit/db/repositories/DayAssignments.repository.test.ts +++ b/server/tests/unit/db/repositories/DayAssignments.repository.test.ts @@ -13,6 +13,10 @@ import { DayAssignments } from '../../../../src/db/entities/DayAssignments.entit import type { DayAssignmentsRepository } from '../../../../src/db/repositories/DayAssignments.repository'; import { UnitOfWork } from '../../../../src/nest/database/unit-of-work'; import { withRequestContext } from '../../../../src/nest/database/request-context'; +import type { DayAssignmentRow } from '../../../helpers/factories/itinerary'; +import { findRow, findRows, insertRow, updateRows } from '../../../helpers/factories/rows'; +import { DayAccommodations } from '../../../../src/db/entities/DayAccommodations.entity'; +import { Places } from '../../../../src/db/entities/Places.entity'; const testDb = createSnapshotTestDb(); let t: TestOrm; @@ -27,8 +31,21 @@ beforeAll(async () => { beforeEach(() => { resetTestDb(testDb); t.clear(); }); afterAll(async () => { await t.close(); testDb.close(); }); +/** The named columns of the assignment, or undefined once it is gone: what a `SELECT FROM day_assignments` read back. */ +async function assignmentCols(id: number, ...cols: K[]): Promise | undefined> { + const row = await findRow(t, DayAssignments, { id }); + if (!row) return undefined; + return Object.fromEntries(cols.map((c) => [c, row[c]])) as Pick; +} + +/** The assignment's order_index. */ +async function orderOf(id: number) { + return (await findRow(t, DayAssignments, { id }))!.order_index; +} + /** The DY1/DY3/AS1/AS3 statement, run raw — the parity oracle every assertion below is checked against. */ function legacyProjectionRow(id: number): unknown { + // test-sql-allow: the legacy DY1/DY3/AS1/AS3 statement is the parity oracle the repository is compared against. return testDb.prepare(` SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description, p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency, @@ -101,8 +118,8 @@ describe('DayAssignmentsRepository — the DY1/DY3/AS1/AS3 projection', () => { const day = createDay(testDb, trip.id); const tourPlace = createPlace(testDb, trip.id, { name: 'Ridge walk' }); const trackPlace = createPlace(testDb, trip.id, { name: 'Imported track' }); - testDb.prepare('UPDATE places SET route_geometry = ? WHERE id = ?').run('[[47,11],[47.1,11.1]]', tourPlace.id); - testDb.prepare('UPDATE places SET route_geometry = ? WHERE id = ?').run('[[1,2],[3,4]]', trackPlace.id); + await updateRows(t, Places, { id: tourPlace.id }, { route_geometry: '[[47,11],[47.1,11.1]]' }); + await updateRows(t, Places, { id: trackPlace.id }, { route_geometry: '[[1,2],[3,4]]' }); createTour(testDb, tourPlace.id); const tour = createDayAssignment(testDb, day.id, tourPlace.id, { order_index: 0 }); const track = createDayAssignment(testDb, day.id, trackPlace.id, { order_index: 1 }); @@ -124,9 +141,9 @@ describe('DayAssignmentsRepository — the DY1/DY3/AS1/AS3 projection', () => { const trip = createTrip(testDb, user.id); 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 withOwnTime = createDayAssignment(testDb, day.id, place.id); - testDb.prepare('UPDATE day_assignments SET assignment_time = ?, assignment_end_time = ? WHERE id = ?').run('14:00', '15:00', withOwnTime.id); + await updateRows(t, DayAssignments, { id: withOwnTime.id }, { assignment_time: '14:00', assignment_end_time: '15:00' }); const withoutOwnTime = createDayAssignment(testDb, day.id, place.id); const rows = await assignments.listForDay(day.id); @@ -143,7 +160,7 @@ describe('DayAssignmentsRepository — the DY1/DY3/AS1/AS3 projection', () => { const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id); const place = createPlace(testDb, trip.id); - testDb.prepare('UPDATE places SET category_id = NULL WHERE id = ?').run(place.id); + await updateRows(t, Places, { id: place.id }, { category: null }); const assignment = createDayAssignment(testDb, day.id, place.id); const [row] = await assignments.listForDay(day.id); @@ -204,6 +221,7 @@ describe('DayAssignmentsRepository — the DY1/DY3/AS1/AS3 projection', () => { const place = createPlace(testDb, trip.id, { name: 'Old Name' }); const assignment = createDayAssignment(testDb, day.id, place.id); await t.repo(DayAssignments).find({}, { disableIdentityMap: false }); // populate the identity map with the managed assignment entity + // test-sql-allow: the out-of-band write this case is about has to bypass every EntityManager, the one under test included. testDb.prepare('UPDATE places SET name = ? WHERE id = ?').run('Renamed', place.id); const [row] = await assignments.listForDay(day.id); @@ -247,10 +265,10 @@ describe('DayAssignmentsRepository — AC20/AC21 (the stop a booking owns, carri await assignments.relocate(stop.id, toDay.id, stopPlace.id, 6); - const read = testDb.prepare('SELECT day_id, place_id, order_index FROM day_assignments WHERE id = ?'); - expect(read.get(stop.id)).toEqual({ day_id: toDay.id, place_id: stopPlace.id, order_index: 6 }); - expect(read.get(neighbour.id)).toEqual({ day_id: fromDay.id, place_id: otherPlace.id, order_index: 1 }); - expect(read.get(existing.id)).toEqual({ day_id: toDay.id, place_id: otherPlace.id, order_index: 5 }); + const read = (id: number) => assignmentCols(id, 'day_id', 'place_id', 'order_index'); + expect(await read(stop.id)).toEqual({ day_id: toDay.id, place_id: stopPlace.id, order_index: 6 }); + expect(await read(neighbour.id)).toEqual({ day_id: fromDay.id, place_id: otherPlace.id, order_index: 1 }); + expect(await read(existing.id)).toEqual({ day_id: toDay.id, place_id: otherPlace.id, order_index: 5 }); }); }); @@ -287,9 +305,10 @@ describe('DayAssignmentsRepository — AS4/AS9/AS12 existence + trip-scoped read const day = createDay(testDb, trip.id); const place = createPlace(testDb, trip.id); const a = createDayAssignment(testDb, day.id, place.id, { order_index: 3 }); - testDb.prepare('UPDATE day_assignments SET assignment_time = ?, notes = ? WHERE id = ?').run('09:00', 'a note', a.id); + await updateRows(t, DayAssignments, { id: a.id }, { assignment_time: '09:00', notes: 'a note' }); const row = await assignments.findInTrip(a.id, trip.id); + // test-sql-allow: the legacy AS12 statement is the parity oracle the repository is compared against. const legacy = testDb.prepare(`SELECT da.* FROM day_assignments da JOIN days d ON da.day_id = d.id WHERE da.id = ? AND d.trip_id = ?`).get(a.id, trip.id); expect(row).toStrictEqual(legacy); expect(row).toMatchObject({ id: a.id, day_id: day.id, order_index: 3, assignment_time: '09:00', notes: 'a note' }); @@ -323,11 +342,10 @@ describe('DayAssignmentsRepository — AS6/AS7/AS8 (createAssignment)', () => { await withRequestContext(t.orm, async () => { await assignments.shiftOrderFrom(day.id, 1); }); - const order = (id: number) => (testDb.prepare('SELECT order_index FROM day_assignments WHERE id = ?').get(id) as { order_index: number }).order_index; - expect(order(before.id)).toBe(0); - expect(order(at.id)).toBe(2); - expect(order(after.id)).toBe(3); - expect(order(foreign.id)).toBe(1); + expect(await orderOf(before.id)).toBe(0); + expect(await orderOf(at.id)).toBe(2); + expect(await orderOf(after.id)).toBe(3); + expect(await orderOf(foreign.id)).toBe(1); }); it('ASSIGNREPO-006 (AS8, insertAssignment): writes every column and returns the inserted id, notes/accommodation_id nullable', async () => { @@ -340,13 +358,13 @@ describe('DayAssignmentsRepository — AS6/AS7/AS8 (createAssignment)', () => { day_id: day.id, place_id: place.id, order_index: 2, notes: null, accommodation_id: null, })); expect(typeof id).toBe('number'); - const row = testDb.prepare('SELECT day_id, place_id, order_index, notes, accommodation_id FROM day_assignments WHERE id = ?').get(id); + const row = await assignmentCols(id, 'day_id', 'place_id', 'order_index', 'notes', 'accommodation_id'); expect(row).toEqual({ day_id: day.id, place_id: place.id, order_index: 2, notes: null, accommodation_id: null }); const id2 = await withRequestContext(t.orm, () => assignments.insertAssignment({ day_id: day.id, place_id: place.id, order_index: 0, notes: 'skip the line', accommodation_id: 42, })); - const row2 = testDb.prepare('SELECT notes, accommodation_id FROM day_assignments WHERE id = ?').get(id2); + const row2 = await assignmentCols(id2, 'notes', 'accommodation_id'); expect(row2).toEqual({ notes: 'skip the line', accommodation_id: 42 }); }); }); @@ -359,7 +377,7 @@ describe('DayAssignmentsRepository — AS10/AS11/AS13/AS14/AS19 (delete / order const place = createPlace(testDb, trip.id); const a = createDayAssignment(testDb, day.id, place.id); await withRequestContext(t.orm, () => assignments.deleteById(a.id)); - expect(testDb.prepare('SELECT id FROM day_assignments WHERE id = ?').get(a.id)).toBeUndefined(); + expect(await findRow(t, DayAssignments, { id: a.id })).toBeNull(); }); it('ASSIGNREPO-008 (AS11, setOrderIndex day-scoped): writes only the row matching BOTH id and day_id', async () => { @@ -375,9 +393,8 @@ describe('DayAssignmentsRepository — AS10/AS11/AS13/AS14/AS19 (delete / order // A day_id mismatch (this id does not sit on otherDay) writes nothing. await withRequestContext(t.orm, () => assignments.setOrderIndex(foreign.id, day.id, 9)); - const order = (id: number) => (testDb.prepare('SELECT order_index FROM day_assignments WHERE id = ?').get(id) as { order_index: number }).order_index; - expect(order(a.id)).toBe(7); - expect(order(foreign.id)).toBe(0); + expect(await orderOf(a.id)).toBe(7); + expect(await orderOf(foreign.id)).toBe(0); }); it('ASSIGNREPO-009 (AS19, setOrderIndex unscoped): day_id undefined writes by id alone', async () => { @@ -387,7 +404,7 @@ describe('DayAssignmentsRepository — AS10/AS11/AS13/AS14/AS19 (delete / order const place = createPlace(testDb, trip.id); const a = createDayAssignment(testDb, day.id, place.id, { order_index: 0 }); await withRequestContext(t.orm, () => assignments.setOrderIndex(a.id, undefined, 3)); - expect((testDb.prepare('SELECT order_index FROM day_assignments WHERE id = ?').get(a.id) as { order_index: number }).order_index).toBe(3); + expect(await orderOf(a.id)).toBe(3); }); it('ASSIGNREPO-010 (AS13, getDayId): the assignment\'s day_id, undefined for a missing id', async () => { @@ -418,7 +435,7 @@ describe('DayAssignmentsRepository — AS10/AS11/AS13/AS14/AS19 (delete / order const place = createPlace(testDb, trip.id); const a = createDayAssignment(testDb, day.id, place.id, { order_index: 0 }); await withRequestContext(t.orm, () => assignments.moveToDay(a.id, target.id, 4)); - const row = testDb.prepare('SELECT day_id, order_index FROM day_assignments WHERE id = ?').get(a.id); + const row = await assignmentCols(a.id, 'day_id', 'order_index'); expect(row).toEqual({ day_id: target.id, order_index: 4 }); }); @@ -446,7 +463,7 @@ describe('DayAssignmentsRepository — AS10/AS11/AS13/AS14/AS19 (delete / order } catch (e) { caught = e; } expect((caught as Error).message).toBe('force rollback'); - const row = testDb.prepare('SELECT day_id, order_index FROM day_assignments WHERE id = ?').get(a.id); + const row = await assignmentCols(a.id, 'day_id', 'order_index'); expect(row).toEqual({ day_id: day.id, order_index: 0 }); }); }); @@ -457,11 +474,12 @@ describe('DayAssignmentsRepository.effectiveStart (AS16, Kysely)', () => { const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id); const place = createPlace(testDb, trip.id); - testDb.prepare('UPDATE places SET place_time = ? WHERE id = ?').run('08:00', place.id); + await updateRows(t, Places, { id: place.id }, { place_time: '08:00' }); const a = createDayAssignment(testDb, day.id, place.id); - testDb.prepare('UPDATE day_assignments SET assignment_time = ? WHERE id = ?').run('14:00', a.id); + await updateRows(t, DayAssignments, { id: a.id }, { assignment_time: '14:00' }); const row = await withRequestContext(t.orm, () => assignments.effectiveStart(a.id)); + // test-sql-allow: the legacy AS16 three-way COALESCE is the parity oracle the repository is compared against. const legacy = testDb.prepare(` SELECT da.day_id, COALESCE(da.assignment_time, p.place_time, acc.check_in) AS start FROM day_assignments da JOIN places p ON da.place_id = p.id @@ -477,7 +495,7 @@ describe('DayAssignmentsRepository.effectiveStart (AS16, Kysely)', () => { const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id); const place = createPlace(testDb, trip.id); - testDb.prepare('UPDATE places SET place_time = ? WHERE id = ?').run('08:00', place.id); + await updateRows(t, Places, { id: place.id }, { place_time: '08:00' }); const a = createDayAssignment(testDb, day.id, place.id); expect(await withRequestContext(t.orm, () => assignments.effectiveStart(a.id))).toEqual({ day_id: day.id, start: '08:00' }); }); @@ -488,10 +506,8 @@ describe('DayAssignmentsRepository.effectiveStart (AS16, Kysely)', () => { const day = createDay(testDb, trip.id); const hotel = createPlace(testDb, trip.id); const night = createDayAssignment(testDb, day.id, hotel.id); - const stay = testDb.prepare( - "INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in) VALUES (?, ?, ?, ?, '17:00')", - ).run(trip.id, hotel.id, day.id, day.id); - testDb.prepare('UPDATE day_assignments SET accommodation_id = ? WHERE id = ?').run(Number(stay.lastInsertRowid), night.id); + const stayId = await insertRow(t, DayAccommodations, { trip: trip.id, place: hotel.id, startDay: day.id, endDay: day.id, check_in: '17:00' }); + await updateRows(t, DayAssignments, { id: night.id }, { accommodation_id: stayId }); expect(await withRequestContext(t.orm, () => assignments.effectiveStart(night.id))).toEqual({ day_id: day.id, start: '17:00' }); }); @@ -517,12 +533,13 @@ describe('DayAssignmentsRepository.listForTimeSort (AS18, Kysely)', () => { const day = createDay(testDb, trip.id); const located = createPlace(testDb, trip.id, { name: 'Has coords' }); const unlocated = createPlace(testDb, trip.id, { name: 'No coords' }); - testDb.prepare('UPDATE places SET lat = NULL, lng = NULL WHERE id = ?').run(unlocated.id); + await updateRows(t, Places, { id: unlocated.id }, { lat: null, lng: null }); const a1 = createDayAssignment(testDb, day.id, located.id, { order_index: 1 }); const a2 = createDayAssignment(testDb, day.id, unlocated.id, { order_index: 0 }); - testDb.prepare('UPDATE day_assignments SET assignment_time = ? WHERE id = ?').run('09:00', a1.id); + await updateRows(t, DayAssignments, { id: a1.id }, { assignment_time: '09:00' }); const rows = await withRequestContext(t.orm, () => assignments.listForTimeSort(day.id)); + // test-sql-allow: the legacy AS18 statement is the parity oracle the repository is compared against. const legacy = testDb.prepare(` SELECT da.id, da.order_index, COALESCE(da.assignment_time, p.place_time, acc.check_in) as effective_time, (p.lat IS NOT NULL AND p.lng IS NOT NULL) as located @@ -554,18 +571,19 @@ describe('DayAssignmentsRepository.listSeatRows (AC7, Kysely)', () => { const museum = createPlace(testDb, trip.id, { name: 'Museum' }); const cafe = createPlace(testDb, trip.id, { name: 'Cafe' }); const unlocated = createPlace(testDb, trip.id, { name: 'No coords' }); - testDb.prepare('UPDATE places SET place_time = ? WHERE id = ?').run('08:00', hotel.id); - testDb.prepare('UPDATE places SET place_time = ? WHERE id = ?').run('11:00', cafe.id); - testDb.prepare('UPDATE places SET lat = NULL, lng = NULL WHERE id = ?').run(unlocated.id); + await updateRows(t, Places, { id: hotel.id }, { place_time: '08:00' }); + await updateRows(t, Places, { id: cafe.id }, { place_time: '11:00' }); + await updateRows(t, Places, { id: unlocated.id }, { lat: null, lng: null }); const night = createDayAssignment(testDb, day.id, hotel.id, { order_index: 3 }); const ownTime = createDayAssignment(testDb, day.id, museum.id, { order_index: 1 }); const placeTime = createDayAssignment(testDb, day.id, cafe.id, { order_index: 0 }); const noCoords = createDayAssignment(testDb, day.id, unlocated.id, { order_index: 2 }); const stay = createDayAccommodation(testDb, trip.id, hotel.id, day.id, day.id, { check_in: '17:00' }); - testDb.prepare('UPDATE day_assignments SET accommodation_id = ? WHERE id = ?').run(stay.id, night.id); - testDb.prepare('UPDATE day_assignments SET assignment_time = ? WHERE id = ?').run('09:00', ownTime.id); + await updateRows(t, DayAssignments, { id: night.id }, { accommodation_id: stay.id }); + await updateRows(t, DayAssignments, { id: ownTime.id }, { assignment_time: '09:00' }); const rows = await withRequestContext(t.orm, () => assignments.listSeatRows(day.id)); + // test-sql-allow: the legacy AC7 statement is the parity oracle the repository is compared against. const legacy = testDb.prepare(` SELECT da.id, da.order_index, CASE WHEN other.id IS NULL THEN COALESCE(da.assignment_time, p.place_time) ELSE other.check_in END AS at, @@ -593,10 +611,10 @@ describe('DayAssignmentsRepository — AS17/AS24/AS25/AS26/AS27/AS32/AS33/AS34 ( const place = createPlace(testDb, trip.id); const a = createDayAssignment(testDb, day.id, place.id); await withRequestContext(t.orm, () => assignments.setTimes(a.id, '09:00', '10:30')); - expect(testDb.prepare('SELECT assignment_time, assignment_end_time FROM day_assignments WHERE id = ?').get(a.id)) + expect(await assignmentCols(a.id, 'assignment_time', 'assignment_end_time')) .toEqual({ assignment_time: '09:00', assignment_end_time: '10:30' }); await withRequestContext(t.orm, () => assignments.setTimes(a.id, null, null)); - expect(testDb.prepare('SELECT assignment_time, assignment_end_time FROM day_assignments WHERE id = ?').get(a.id)) + expect(await assignmentCols(a.id, 'assignment_time', 'assignment_end_time')) .toEqual({ assignment_time: null, assignment_end_time: null }); }); @@ -621,7 +639,7 @@ describe('DayAssignmentsRepository — AS17/AS24/AS25/AS26/AS27/AS32/AS33/AS34 ( } catch (e) { caught = e; } expect((caught as Error).message).toBe('force rollback'); - expect(testDb.prepare('SELECT assignment_time, assignment_end_time FROM day_assignments WHERE id = ?').get(a.id)) + expect(await assignmentCols(a.id, 'assignment_time', 'assignment_end_time')) .toEqual({ assignment_time: null, assignment_end_time: null }); }); @@ -632,7 +650,7 @@ describe('DayAssignmentsRepository — AS17/AS24/AS25/AS26/AS27/AS32/AS33/AS34 ( const place = createPlace(testDb, trip.id); const a = createDayAssignment(testDb, day.id, place.id); await withRequestContext(t.orm, () => assignments.setEndDay(a.id, 1)); - expect((testDb.prepare('SELECT end_day FROM day_assignments WHERE id = ?').get(a.id) as { end_day: number }).end_day).toBe(1); + expect((await findRow(t, DayAssignments, { id: a.id }))!.end_day).toBe(1); }); it('ASSIGNREPO-021 (AS25, setNotes): null-clearable', async () => { @@ -642,9 +660,9 @@ describe('DayAssignmentsRepository — AS17/AS24/AS25/AS26/AS27/AS32/AS33/AS34 ( const place = createPlace(testDb, trip.id); const a = createDayAssignment(testDb, day.id, place.id); await withRequestContext(t.orm, () => assignments.setNotes(a.id, 'a note')); - expect((testDb.prepare('SELECT notes FROM day_assignments WHERE id = ?').get(a.id) as { notes: string | null }).notes).toBe('a note'); + expect((await findRow(t, DayAssignments, { id: a.id }))!.notes).toBe('a note'); await withRequestContext(t.orm, () => assignments.setNotes(a.id, null)); - expect((testDb.prepare('SELECT notes FROM day_assignments WHERE id = ?').get(a.id) as { notes: string | null }).notes).toBeNull(); + expect((await findRow(t, DayAssignments, { id: a.id }))!.notes).toBeNull(); }); it('ASSIGNREPO-022 (AS26/AS27, setLegMode/setIncomingLegMode): each writes its own column', async () => { @@ -655,10 +673,10 @@ describe('DayAssignmentsRepository — AS17/AS24/AS25/AS26/AS27/AS32/AS33/AS34 ( const a = createDayAssignment(testDb, day.id, place.id); await withRequestContext(t.orm, () => assignments.setLegMode(a.id, 'cycling')); await withRequestContext(t.orm, () => assignments.setIncomingLegMode(a.id, 'walking')); - expect(testDb.prepare('SELECT leg_transport_mode, incoming_leg_transport_mode FROM day_assignments WHERE id = ?').get(a.id)) + expect(await assignmentCols(a.id, 'leg_transport_mode', 'incoming_leg_transport_mode')) .toEqual({ leg_transport_mode: 'cycling', incoming_leg_transport_mode: 'walking' }); await withRequestContext(t.orm, () => assignments.setLegMode(a.id, null)); - expect((testDb.prepare('SELECT leg_transport_mode FROM day_assignments WHERE id = ?').get(a.id) as { leg_transport_mode: string | null }).leg_transport_mode).toBeNull(); + expect((await findRow(t, DayAssignments, { id: a.id }))!.leg_transport_mode).toBeNull(); }); it('ASSIGNREPO-039 (AS32, listIdsToClear): exactly the day\'s ids, [] for an empty day', async () => { @@ -685,8 +703,8 @@ describe('DayAssignmentsRepository — AS17/AS24/AS25/AS26/AS27/AS32/AS33/AS34 ( createDayAssignment(testDb, day.id, place.id); const kept = createDayAssignment(testDb, otherDay.id, place.id); await withRequestContext(t.orm, () => assignments.deleteForDay(day.id)); - expect(testDb.prepare('SELECT id FROM day_assignments WHERE day_id = ?').all(day.id)).toEqual([]); - expect(testDb.prepare('SELECT id FROM day_assignments WHERE day_id = ?').all(otherDay.id)).toEqual([{ id: kept.id }]); + expect(await findRows(t, DayAssignments, { day: day.id })).toEqual([]); + expect((await findRows(t, DayAssignments, { day: otherDay.id }, { id: 'asc' })).map((r) => ({ id: r.id }))).toEqual([{ id: kept.id }]); }); it('ASSIGNREPO-041 (AS34, setRouteExcluded): writes 1 and then 0 on exactly the given row', async () => { @@ -696,13 +714,13 @@ describe('DayAssignmentsRepository — AS17/AS24/AS25/AS26/AS27/AS32/AS33/AS34 ( const place = createPlace(testDb, trip.id); const a = createDayAssignment(testDb, day.id, place.id); const other = createDayAssignment(testDb, day.id, place.id); - const routeExcluded = (id: number) => (testDb.prepare('SELECT route_excluded FROM day_assignments WHERE id = ?').get(id) as { route_excluded: number }).route_excluded; + const routeExcluded = async (id: number) => (await findRow(t, DayAssignments, { id }))!.route_excluded; await withRequestContext(t.orm, () => assignments.setRouteExcluded(a.id, 1)); - expect(routeExcluded(a.id)).toBe(1); - expect(routeExcluded(other.id)).toBe(0); + expect(await routeExcluded(a.id)).toBe(1); + expect(await routeExcluded(other.id)).toBe(0); await withRequestContext(t.orm, () => assignments.setRouteExcluded(a.id, 0)); - expect(routeExcluded(a.id)).toBe(0); - expect(routeExcluded(other.id)).toBe(0); + expect(await routeExcluded(a.id)).toBe(0); + expect(await routeExcluded(other.id)).toBe(0); }); }); @@ -735,7 +753,7 @@ describe('DayAssignmentsRepository.listItineraryForGpx (PL30)', () => { const day1 = createDay(testDb, trip.id, { day_number: 1, date: '2026-01-01', title: 'Day One' }); const day2 = createDay(testDb, trip.id, { day_number: 2, date: '2026-01-02' }); const geo = createPlace(testDb, trip.id, { name: 'Has Coords', lat: 1, lng: 2 }); - const noGeo = testDb.prepare('INSERT INTO places (trip_id, name, lat, lng) VALUES (?, ?, NULL, NULL)').run(trip.id, 'No Coords').lastInsertRowid as number; + const noGeo = await insertRow(t, Places, { trip: trip.id, name: 'No Coords', lat: null, lng: null }); const secondStop = createPlace(testDb, trip.id, { name: 'Second Stop', lat: 3, lng: 4 }); // day2's stop first (order_index 0), day1's stop second — proves the // ORDER BY is day_number then order_index, not insertion/id order. @@ -754,7 +772,7 @@ describe('DayAssignmentsRepository.listItineraryForGpx (PL30)', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id); - const noGeo = testDb.prepare('INSERT INTO places (trip_id, name, lat, lng) VALUES (?, ?, NULL, NULL)').run(trip.id, 'No Coords').lastInsertRowid as number; + const noGeo = await insertRow(t, Places, { trip: trip.id, name: 'No Coords', lat: null, lng: null }); createDayAssignment(testDb, day.id, noGeo); expect(await assignments.listItineraryForGpx(trip.id)).toEqual([]); }); @@ -788,34 +806,29 @@ describe('DayAssignmentsRepository.listAllForTrip (TP48)', () => { const bare = createPlace(testDb, trip.id, { name: 'Stop B' }); const accommodation = createDayAccommodation(testDb, trip.id, place.id, day.id, day.id); - const insert = testDb.prepare(` - INSERT INTO day_assignments ( - day_id, place_id, order_index, notes, reservation_status, reservation_notes, - reservation_datetime, created_at, assignment_time, assignment_end_time, - leg_transport_mode, incoming_leg_transport_mode, end_day, accommodation_id - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `); - // Row A: every nullable column non-null, a unicode string and a // '007'-style digit string among the values. - const a1 = insert.run( - day.id, place.id, 5, 'Notes — 日本 ☕️ 007', 'confirmed', 'resv notes 007', - '2026-01-01T10:00:00Z', '2026-01-01 09:00:00', '10:00', '11:00', - 'walking', 'driving', 1, accommodation.id, - ).lastInsertRowid as number; + const a1 = await insertRow(t, DayAssignments, { + day: day.id, place: place.id, order_index: 5, notes: 'Notes — 日本 ☕️ 007', reservation_status: 'confirmed', + reservation_notes: 'resv notes 007', reservation_datetime: '2026-01-01T10:00:00Z', created_at: '2026-01-01 09:00:00', + assignment_time: '10:00', assignment_end_time: '11:00', leg_transport_mode: 'walking', + incoming_leg_transport_mode: 'driving', end_day: 1, accommodation_id: accommodation.id, + }); // Row B: every nullable column NULL (end_day is NOT NULL, given 0). - const a2 = insert.run( - day.id, bare.id, null, null, null, null, - null, null, null, null, - null, null, 0, null, - ).lastInsertRowid as number; + const a2 = await insertRow(t, DayAssignments, { + day: day.id, place: bare.id, order_index: null, notes: null, reservation_status: null, + reservation_notes: null, reservation_datetime: null, created_at: null, assignment_time: null, + assignment_end_time: null, leg_transport_mode: null, incoming_leg_transport_mode: null, end_day: 0, + accommodation_id: null, + }); const otherDay = createDay(testDb, other.id); const otherPlace = createPlace(testDb, other.id); createDayAssignment(testDb, otherDay.id, otherPlace.id); const rows = await assignments.listAllForTrip(trip.id); + // test-sql-allow: the legacy TP48 statement is the parity oracle the repository is compared against. const legacy = testDb.prepare('SELECT da.* FROM day_assignments da JOIN days d ON d.id = da.day_id WHERE d.trip_id = ?').all(trip.id); expect(rows).toEqual(legacy); expect(rows.map((r) => r.id)).toEqual([a1, a2]); @@ -841,11 +854,10 @@ describe('DayAssignmentsRepository.insertAssignmentCopy (TP49)', () => { assignment_time: '10:00', assignment_end_time: '11:00', end_day: 0, }); - const row = testDb.prepare(` - SELECT day_id, place_id, order_index, notes, reservation_status, reservation_notes, - reservation_datetime, assignment_time, assignment_end_time, end_day, accommodation_id - FROM day_assignments WHERE id = ? - `).get(newId); + const row = await assignmentCols( + newId, 'day_id', 'place_id', 'order_index', 'notes', 'reservation_status', 'reservation_notes', + 'reservation_datetime', 'assignment_time', 'assignment_end_time', 'end_day', 'accommodation_id', + ); expect(row).toEqual({ day_id: day.id, place_id: place.id, order_index: 2, notes: 'n', reservation_status: 'booked', reservation_notes: 'rn', reservation_datetime: '2026-01-01T10:00', @@ -866,8 +878,8 @@ describe('DayAssignmentsRepository.setAccommodation (TP57)', () => { await assignments.setAccommodation(assignment.id, accom.id); - expect((testDb.prepare('SELECT accommodation_id FROM day_assignments WHERE id = ?').get(assignment.id) as { accommodation_id: number }).accommodation_id).toBe(accom.id); - expect((testDb.prepare('SELECT accommodation_id FROM day_assignments WHERE id = ?').get(other.id) as { accommodation_id: number | null }).accommodation_id).toBeNull(); + expect((await findRow(t, DayAssignments, { id: assignment.id }))!.accommodation_id).toBe(accom.id); + expect((await findRow(t, DayAssignments, { id: other.id }))!.accommodation_id).toBeNull(); }); }); @@ -903,10 +915,10 @@ describe('DayAssignmentsRepository.listRoadtripVisits (RPL2, roadtrip-plan.servi const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id); const place = createPlace(testDb, trip.id, { name: 'Museum' }); - testDb.prepare('UPDATE places SET place_time = ?, end_time = ?, duration_minutes = ?, stop_type = ?, fill_percent = ? WHERE id = ?') - .run('09:00', '11:00', 90, null, null, place.id); + await updateRows(t, Places, { id: place.id }, { place_time: '09:00', end_time: '11:00', duration_minutes: 90, stop_type: null, fill_percent: null }); createDayAssignment(testDb, day.id, place.id); + // test-sql-allow: the legacy RPL2 statement is the parity oracle the repository is compared against. const legacy = testDb.prepare(LEGACY_LIST_ROADTRIP_VISITS).all(trip.id); const typed = await assignments.listRoadtripVisits(trip.id); expect(typed).toEqual(legacy); @@ -919,13 +931,14 @@ describe('DayAssignmentsRepository.listRoadtripVisits (RPL2, roadtrip-plan.servi const day = createDay(testDb, trip.id, { day_number: 1 }); const checkoutDay = createDay(testDb, trip.id, { day_number: 2 }); const place = createPlace(testDb, trip.id, { name: 'Grand Hotel' }); - testDb.prepare('UPDATE places SET place_time = ?, end_time = ?, duration_minutes = ?, stop_type = ?, fill_percent = ? WHERE id = ?') - .run('08:00', '10:00', 45, 'lodging', 80, place.id); + await updateRows(t, Places, { id: place.id }, { place_time: '08:00', end_time: '10:00', duration_minutes: 45, stop_type: 'lodging', fill_percent: 80 }); const stay = createDayAccommodation(testDb, trip.id, place.id, day.id, checkoutDay.id, { check_in: '15:00', check_out: '11:00' }); const assignment = createDayAssignment(testDb, day.id, place.id); - testDb.prepare('UPDATE day_assignments SET assignment_time = ?, assignment_end_time = ?, leg_transport_mode = ?, incoming_leg_transport_mode = ?, end_day = ? WHERE id = ?') - .run('14:00', '14:30', 'driving', 'walking', 1, assignment.id); + await updateRows(t, DayAssignments, { id: assignment.id }, { + assignment_time: '14:00', assignment_end_time: '14:30', leg_transport_mode: 'driving', incoming_leg_transport_mode: 'walking', end_day: 1, + }); + // test-sql-allow: the legacy RPL2 statement is the parity oracle the repository is compared against. const legacy = testDb.prepare(LEGACY_LIST_ROADTRIP_VISITS).all(trip.id); const typed = await assignments.listRoadtripVisits(trip.id); expect(typed).toEqual(legacy); @@ -956,6 +969,7 @@ describe('DayAssignmentsRepository.listRoadtripVisits (RPL2, roadtrip-plan.servi function legacyPublicForShare(dayIds: number[]): unknown { if (dayIds.length === 0) return []; const placeholders = dayIds.map(() => '?').join(','); + // test-sql-allow: the legacy SH9 statement is the parity oracle the repository is compared against. return testDb.prepare(` SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description, p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency, @@ -982,25 +996,25 @@ describe('DayAssignmentsRepository.listPublicForShare (SH9, share.service.ts)', const category = createCategory(testDb, { name: 'Museum', color: '#111111', icon: '🏛️' }); const placeFull = createPlace(testDb, trip.id, { name: 'Louvre', lat: 48.86, lng: 2.34, category_id: category.id, description: 'Art museum' }); - testDb.prepare(` - UPDATE places SET address = ?, price = ?, currency = ?, place_time = ?, end_time = ?, - duration_minutes = ?, notes = ?, image_url = ?, transport_mode = ?, website = ?, phone = ?, - google_place_id = 'ChIJ123', google_ftid = 'ftid1', osm_id = 'osm1', amap_poi_id = 'amap1', - stop_type = 'lodging', fill_percent = 80 - WHERE id = ?`).run('Rue de Rivoli', 17.5, 'EUR', '09:00', '11:00', 120, 'bring ID', 'https://img/louvre.jpg', 'walking', 'https://louvre.fr', '+33140205050', placeFull.id); + await updateRows(t, Places, { id: placeFull.id }, { + address: 'Rue de Rivoli', price: 17.5, currency: 'EUR', place_time: '09:00', end_time: '11:00', + duration_minutes: 120, notes: 'bring ID', image_url: 'https://img/louvre.jpg', transport_mode: 'walking', + website: 'https://louvre.fr', phone: '+33140205050', google_place_id: 'ChIJ123', google_ftid: 'ftid1', + osm_id: 'osm1', amap_poi_id: 'amap1', stop_type: 'lodging', fill_percent: 80, + }); const assignmentOnFullPlace = createDayAssignment(testDb, day1.id, placeFull.id, { order_index: 2 }); - testDb.prepare(` - UPDATE day_assignments SET assignment_time = ?, assignment_end_time = ?, reservation_status = 'confirmed', - reservation_notes = 'window seat', reservation_datetime = '2026-09-01T09:00:00.000Z', - leg_transport_mode = 'driving', incoming_leg_transport_mode = 'walking', accommodation_id = NULL - WHERE id = ?`).run('14:00', '14:30', assignmentOnFullPlace.id); + await updateRows(t, DayAssignments, { id: assignmentOnFullPlace.id }, { + assignment_time: '14:00', assignment_end_time: '14:30', reservation_status: 'confirmed', + reservation_notes: 'window seat', reservation_datetime: '2026-09-01T09:00:00.000Z', + leg_transport_mode: 'driving', incoming_leg_transport_mode: 'walking', accommodation_id: null, + }); const placeBare = createPlace(testDb, trip.id, { name: 'Unnamed spot' }); - testDb.prepare(` - UPDATE places SET category_id = NULL, lat = NULL, lng = NULL, address = NULL, price = NULL, - currency = NULL, place_time = NULL, end_time = NULL, duration_minutes = NULL, notes = NULL, - image_url = NULL, transport_mode = NULL, website = NULL, phone = NULL, description = NULL - WHERE id = ?`).run(placeBare.id); + await updateRows(t, Places, { id: placeBare.id }, { + category: null, lat: null, lng: null, address: null, price: null, currency: null, place_time: null, + end_time: null, duration_minutes: null, notes: null, image_url: null, transport_mode: null, + website: null, phone: null, description: null, + }); const assignmentOnBarePlace = createDayAssignment(testDb, day2.id, placeBare.id, { order_index: 1 }); // A second assignment on day1, ordered before both of the above by order_index. diff --git a/server/tests/unit/db/repositories/DayNotes.repository.test.ts b/server/tests/unit/db/repositories/DayNotes.repository.test.ts index b44a0a602..3195441c4 100644 --- a/server/tests/unit/db/repositories/DayNotes.repository.test.ts +++ b/server/tests/unit/db/repositories/DayNotes.repository.test.ts @@ -6,6 +6,7 @@ import { createDay, createDayNote, createTrip, createUser } from '../../../helpe import { DayNotes } from '../../../../src/db/entities/DayNotes.entity'; import type { DayNotesRepository } from '../../../../src/db/repositories/DayNotes.repository'; import { DB_TIMESTAMP_RE } from '../../../../src/db/types'; +import { findRow, insertRow } from '../../../helpers/factories/rows'; const testDb = createSnapshotTestDb(); let t: TestOrm; @@ -24,6 +25,7 @@ describe('DayNotesRepository timestamps', () => { const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id); const row = await notes.createNote({ day_id: day.id, trip_id: trip.id, text: 'Lunch', time: null, icon: '📝', sort_order: 0, color: null }); + // test-sql-allow: typeof() reports the SQLite storage class of the stored value, which no entity maps. const stored = testDb.prepare('SELECT created_at, typeof(created_at) AS kind FROM day_notes WHERE id = ?').get(row.id) as { created_at: string; kind: string }; expect(stored.kind).toBe('text'); expect(stored.created_at).toMatch(DB_TIMESTAMP_RE); @@ -35,6 +37,7 @@ describe('DayNotesRepository timestamps', () => { const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id); const row = await notes.createNote({ day_id: day.id, trip_id: trip.id, text: 'Lunch', time: null, icon: '📝', sort_order: 0, color: null }); + // test-sql-allow: the raw full row is the parity oracle the repository's return value is compared against. expect(row).toStrictEqual(testDb.prepare('SELECT * FROM day_notes WHERE id = ?').get(row.id)); expect(row.icon).toBe('📝'); expect(row.sort_order).toBe(0); @@ -45,6 +48,7 @@ describe('DayNotesRepository timestamps', () => { const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id); const row = await notes.createNote({ day_id: day.id, trip_id: trip.id, text: 'Lunch', time: null, icon: '📝', sort_order: 0, color: null }); + // test-sql-allow: the point is that SQLite's own date() parses the stored value, so the read has to be SQL. const d = testDb.prepare('SELECT date(created_at) AS d FROM day_notes WHERE id = ?').get(row.id) as { d: string }; expect(d.d).toMatch(/^\d{4}-\d{2}-\d{2}$/); }); @@ -115,6 +119,7 @@ describe('DayNotesRepository.listByDayIds (DY4)', () => { const day = createDay(testDb, trip.id); const note = createDayNote(testDb, day.id, trip.id, { text: 'Old text' }); await t.repo(DayNotes).find({}, { disableIdentityMap: false }); // populate the identity map with the managed note entity + // test-sql-allow: the out-of-band write this case is about has to bypass every EntityManager, the one under test included. testDb.prepare('UPDATE day_notes SET text = ? WHERE id = ?').run('Fresh text', note.id); const rows = await notes.listByDayIds([day.id]); @@ -137,17 +142,21 @@ describe('DayNotesRepository.listByTrip (TP70)', () => { const other = createTrip(testDb, user.id); const day = createDay(testDb, trip.id); - const insert = testDb.prepare('INSERT INTO day_notes (day_id, trip_id, text, time, icon, sort_order, color) VALUES (?, ?, ?, ?, ?, ?, ?)'); // Row A: every nullable column non-null, a unicode string and a // '007'-style digit string among the values. - const n1 = insert.run(day.id, trip.id, 'Keep — 日本 ☕️ 007', '09:00', '🗒️', 1, '#00ff00').lastInsertRowid as number; + const n1 = await insertRow(t, DayNotes, { + day: day.id, trip: trip.id, text: 'Keep — 日本 ☕️ 007', time: '09:00', icon: '🗒️', sort_order: 1, color: '#00ff00', + }); // Row B: every nullable column NULL. - const n2 = insert.run(day.id, trip.id, 'Bare note', null, null, null, null).lastInsertRowid as number; + const n2 = await insertRow(t, DayNotes, { + day: day.id, trip: trip.id, text: 'Bare note', time: null, icon: null, sort_order: null, color: null, + }); const otherDay = createDay(testDb, other.id); createDayNote(testDb, otherDay.id, other.id, { text: 'Not this trip' }); const rows = await notes.listByTrip(trip.id); + // test-sql-allow: the legacy statement is the parity oracle the repository is compared against. const legacy = testDb.prepare('SELECT * FROM day_notes WHERE trip_id = ?').all(trip.id); expect(rows).toEqual(legacy); expect(rows.map((r) => r.id)).toEqual([n1, n2]); @@ -170,8 +179,9 @@ describe('DayNotesRepository.insertNoteCopy (TP71)', () => { day_id: day.id, trip_id: trip.id, text: 'Copied note', time: '09:00', icon: '🎒', sort_order: 5, }); - const row = testDb.prepare('SELECT day_id, trip_id, text, time, icon, sort_order, color FROM day_notes WHERE id = ?').get(newId); - expect(row).toEqual({ day_id: day.id, trip_id: trip.id, text: 'Copied note', time: '09:00', icon: '🎒', sort_order: 5, color: null }); + const { day_id, trip_id, text, time, icon, sort_order, color } = (await findRow(t, DayNotes, { id: newId }))!; + expect({ day_id, trip_id, text, time, icon, sort_order, color }) + .toEqual({ day_id: day.id, trip_id: trip.id, text: 'Copied note', time: '09:00', icon: '🎒', sort_order: 5, color: null }); }); }); @@ -185,6 +195,7 @@ describe('DayNotesRepository.listByDayAndTrip (Plan 4 Task 1, day-notes.service. const first = createDayNote(testDb, day.id, trip.id, { sort_order: 1 }); createDayNote(testDb, otherDay.id, trip.id); + // test-sql-allow: the legacy statement is the parity oracle the repository is compared against. const legacy = testDb.prepare('SELECT * FROM day_notes WHERE day_id = ? AND trip_id = ? ORDER BY sort_order ASC, created_at ASC').all(day.id, trip.id); const rows = await notes.listByDayAndTrip(day.id, trip.id); expect(rows).toEqual(legacy); @@ -223,7 +234,8 @@ describe('DayNotesRepository.updateNote (Plan 4 Task 1, day-notes.service.ts::up const updated = await notes.updateNote(note.id, { text: 'Dinner', time: '19:00', icon: '🍣', sort_order: 3, color: '#2563eb' }); expect(updated).toMatchObject({ id: note.id, text: 'Dinner', time: '19:00', icon: '🍣', sort_order: 3, color: '#2563eb' }); - expect(testDb.prepare('SELECT text, time, icon, sort_order, color FROM day_notes WHERE id = ?').get(note.id)) + const stored = (await findRow(t, DayNotes, { id: note.id }))!; + expect({ text: stored.text, time: stored.time, icon: stored.icon, sort_order: stored.sort_order, color: stored.color }) .toEqual({ text: 'Dinner', time: '19:00', icon: '🍣', sort_order: 3, color: '#2563eb' }); }); @@ -241,7 +253,7 @@ describe('DayNotesRepository.deleteById (Plan 4 Task 1, day-notes.service.ts::re await notes.deleteById(note.id); - expect(testDb.prepare('SELECT id FROM day_notes WHERE id = ?').get(note.id)).toBeUndefined(); + expect(await findRow(t, DayNotes, { id: note.id })).toBeNull(); }); it('NOTEREPO-018: deleting an id that does not exist is a no-op, not an error', async () => { diff --git a/server/tests/unit/db/repositories/DocumentProviders.repository.test.ts b/server/tests/unit/db/repositories/DocumentProviders.repository.test.ts index 20699b334..fa163bd2a 100644 --- a/server/tests/unit/db/repositories/DocumentProviders.repository.test.ts +++ b/server/tests/unit/db/repositories/DocumentProviders.repository.test.ts @@ -4,6 +4,7 @@ import { resetTestDb } from '../../../helpers/test-db'; import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; import { DocumentProviders } from '../../../../src/db/entities/DocumentProviders.entity'; import type { DocumentProvidersRepository } from '../../../../src/db/repositories/DocumentProviders.repository'; +import { insertRow } from '../../../helpers/factories/rows'; const testDb = createSnapshotTestDb(); let t: TestOrm; @@ -26,17 +27,17 @@ afterAll(async () => { testDb.close(); }); -function insertProvider(row: { +async function insertProvider(row: { id: string; name: string; description?: string | null; icon?: string | null; enabled: 0 | 1; sort_order?: number; -}): void { - testDb - .prepare('INSERT INTO document_providers (id, name, description, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?)') - .run(row.id, row.name, row.description ?? null, row.icon ?? null, row.enabled, row.sort_order ?? 0); +}): Promise { + await insertRow(t, DocumentProviders, { + id: row.id, name: row.name, description: row.description ?? null, icon: row.icon ?? null, enabled: row.enabled, sort_order: row.sort_order ?? 0, + }); } // Plan 3i Task 4 fix wave (should-land 5): AD29 (listAllOrdered) and AD32 @@ -47,9 +48,10 @@ function insertProvider(row: { // docstring names. describe('DocumentProvidersRepository — admin (AD29/AD32) read methods, full-key parity', () => { it('DOCPROVREPO-001 (AD29): listAllOrdered matches SELECT * FROM document_providers ORDER BY sort_order, id — unfiltered, including a disabled provider', async () => { - insertProvider({ id: 'paperless', name: 'Paperless', description: 'Self-hosted document management', icon: 'FileText', enabled: 0, sort_order: 1 }); - insertProvider({ id: 'papra', name: 'Papra', enabled: 1, sort_order: 0 }); + await insertProvider({ id: 'paperless', name: 'Paperless', description: 'Self-hosted document management', icon: 'FileText', enabled: 0, sort_order: 1 }); + await insertProvider({ id: 'papra', name: 'Papra', enabled: 1, sort_order: 0 }); + // test-sql-allow: the legacy AD29 statement is the parity oracle the repository is compared against. const legacy = testDb.prepare('SELECT * FROM document_providers ORDER BY sort_order, id').all(); const rows = await documentProviders.listAllOrdered(); expect(rows.map((r) => r.id)).toEqual(['papra', 'paperless']); // includes the disabled one, unlike listEnabledCatalog @@ -64,10 +66,11 @@ describe('DocumentProvidersRepository — admin (AD29/AD32) read methods, full-k }); it('DOCPROVREPO-003 (AD32): findById matches SELECT * FROM document_providers WHERE id = ?, on both a pre-write read and a post-write re-select', async () => { - insertProvider({ id: 'paperless', name: 'Paperless', description: 'Self-hosted document management', icon: 'FileText', enabled: 0, sort_order: 3 }); + await insertProvider({ id: 'paperless', name: 'Paperless', description: 'Self-hosted document management', icon: 'FileText', enabled: 0, sort_order: 3 }); const preWrite = await documentProviders.findById('paperless'); expect(preWrite).toEqual({ id: 'paperless', name: 'Paperless', description: 'Self-hosted document management', icon: 'FileText', enabled: 0, sort_order: 3 }); + // test-sql-allow: the out-of-band write this case is about has to bypass every EntityManager, the one under test included. testDb.prepare('UPDATE document_providers SET enabled = 1 WHERE id = ?').run('paperless'); const postWrite = await documentProviders.findById('paperless'); expect(postWrite?.enabled).toBe(1); diff --git a/server/tests/unit/db/repositories/DocumentSyncItems.repository.test.ts b/server/tests/unit/db/repositories/DocumentSyncItems.repository.test.ts index 19ca6da29..d19fb94ab 100644 --- a/server/tests/unit/db/repositories/DocumentSyncItems.repository.test.ts +++ b/server/tests/unit/db/repositories/DocumentSyncItems.repository.test.ts @@ -13,6 +13,10 @@ import { createTrip, createUser } from '../../../helpers/factories'; import { DocumentSyncItems } from '../../../../src/db/entities/DocumentSyncItems.entity'; import type { DocumentSyncItemsRepository } from '../../../../src/db/repositories/DocumentSyncItems.repository'; import { currentTimestampKysely } from '../../../../src/db/dialect/kysely-functions'; +import { findRow, findRows, insertRow, updateRows } from '../../../helpers/factories/rows'; +import { DocumentConnections } from '../../../../src/db/entities/DocumentConnections.entity'; +import { TripDocumentLinks } from '../../../../src/db/entities/TripDocumentLinks.entity'; +import { TripFiles } from '../../../../src/db/entities/TripFiles.entity'; const testDb = createSnapshotTestDb(); let t: TestOrm; @@ -25,22 +29,22 @@ beforeAll(async () => { beforeEach(() => { resetTestDb(testDb); t.clear(); }); afterAll(async () => { await t.close(); testDb.close(); }); -function fixture() { +async function fixture() { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const connInfo = testDb - .prepare( - `INSERT INTO document_connections (trip_id, provider_id, owner_user_id, base_url, secrets, settings) - VALUES (?, 'paperless', ?, 'https://paperless.example.com', NULL, '{}')`, - ) - .run(trip.id, user.id); - const linkInfo = testDb - .prepare( - `INSERT INTO trip_document_links (trip_id, connection_id, provider_id, remote_scope_key, remote_label, direction, delete_policy, conflict_policy, sync_enabled, created_by) - VALUES (?, ?, 'paperless', 'tag:1', 'Japan', 'both', 'unlink', 'manual', 1, ?)`, - ) - .run(trip.id, connInfo.lastInsertRowid, user.id); - return { user, trip, linkId: Number(linkInfo.lastInsertRowid) }; + const connectionId = await insertRow(t, DocumentConnections, { + trip: trip.id, provider: 'paperless', ownerUser: user.id, base_url: 'https://paperless.example.com', secrets: null, settings: '{}', + }); + const linkId = await insertRow(t, TripDocumentLinks, { + trip: trip.id, connection: connectionId, provider_id: 'paperless', remote_scope_key: 'tag:1', remote_label: 'Japan', + direction: 'both', delete_policy: 'unlink', conflict_policy: 'manual', sync_enabled: 1, createdByRef: user.id, + }); + return { user, trip, linkId }; +} + +/** The sync item as it is now; fails the test when it is gone. */ +async function itemRow(id: number) { + return (await findRow(t, DocumentSyncItems, { id }))!; } function insertData(linkId: number, tripId: number, over: Partial[0]> = {}) { @@ -121,17 +125,17 @@ describe('DocumentSyncItemsRepository', () => { * remote_id per link" post-conversion by upserting (not duplicating). */ it('DS24REPO-002 (R3 mutation proof): a colliding (link_id, remote_id) pair upserts in place rather than duplicating', async () => { - const { linkId, trip } = fixture(); + const { linkId, trip } = await fixture(); await repo.insertOrUpsertOnConflict(insertData(linkId, trip.id, { remote_id: 'r1', trek_doc_uid: 'uid-a', state: 'synced', remote_version: 'v1' })); await repo.insertOrUpsertOnConflict(insertData(linkId, trip.id, { remote_id: 'r2', trek_doc_uid: 'uid-b', state: 'synced', remote_version: 'v1' })); - const afterTwo = testDb.prepare('SELECT id, remote_id, remote_version FROM document_sync_items WHERE link_id = ? ORDER BY id').all(linkId) as Array<{ id: number; remote_id: string; remote_version: string }>; + const afterTwo = await findRows(t, DocumentSyncItems, { link: linkId }, { id: 'asc' }); expect(afterTwo).toHaveLength(2); const r1Id = afterTwo.find((r) => r.remote_id === 'r1')!.id; // The colliding insert: same link_id + remote_id='r1' as the first row. await repo.insertOrUpsertOnConflict(insertData(linkId, trip.id, { remote_id: 'r1', trek_doc_uid: 'uid-c', state: 'conflict', remote_version: 'v2' })); - const afterThree = testDb.prepare('SELECT id, remote_id, remote_version, state FROM document_sync_items WHERE link_id = ? ORDER BY id').all(linkId) as Array<{ id: number; remote_id: string; remote_version: string; state: string }>; + const afterThree = await findRows(t, DocumentSyncItems, { link: linkId }, { id: 'asc' }); // Still exactly two rows — the third insert upserted onto the first, not a third row. expect(afterThree).toHaveLength(2); const upserted = afterThree.find((r) => r.id === r1Id)!; @@ -141,10 +145,10 @@ describe('DocumentSyncItemsRepository', () => { }); it('DS24REPO-003: two rows with NULL remote_id under the same link never collide (the partial index only constrains non-NULL)', async () => { - const { linkId, trip } = fixture(); + const { linkId, trip } = await fixture(); await repo.insertOrUpsertOnConflict(insertData(linkId, trip.id, { remote_id: null, trek_doc_uid: 'uid-x' })); await repo.insertOrUpsertOnConflict(insertData(linkId, trip.id, { remote_id: null, trek_doc_uid: 'uid-y' })); - const rows = testDb.prepare('SELECT id FROM document_sync_items WHERE link_id = ?').all(linkId); + const rows = await findRows(t, DocumentSyncItems, { link: linkId }); expect(rows).toHaveLength(2); }); }); @@ -156,7 +160,7 @@ describe('DocumentSyncItemsRepository', () => { remote_id: 'r1', file_id: null, remote_name: 'a.pdf', remote_version: 'v1', remote_size: 512, remote_modified_at: '2026-09-01T08:00:00Z', content_sha256: 'old-hash', pushed_sha256: 'old-pushed', }), - ).then(() => (testDb.prepare('SELECT id FROM document_sync_items WHERE link_id = ? AND remote_id = ?').get(linkId, 'r1') as { id: number }).id); + ).then(async () => (await findRow(t, DocumentSyncItems, { link: linkId, remote_id: 'r1' }))!.id); return id; } @@ -169,11 +173,9 @@ describe('DocumentSyncItemsRepository', () => { * genuinely proving "kept", not merely "never changed". */ it('DS23REPO-001 (R3 parity): a non-null new value overwrites every COALESCE column; a null one keeps the existing value', async () => { - const { linkId, trip } = fixture(); + const { linkId, trip } = await fixture(); const itemId = await seedRow(linkId, trip.id); - const newFileId = Number( - testDb.prepare('INSERT INTO trip_files (trip_id, filename, original_name) VALUES (?, ?, ?)').run(trip.id, 'stored.pdf', 'b.pdf').lastInsertRowid, - ); + const newFileId = await insertRow(t, TripFiles, { trip: trip.id, filename: 'stored.pdf', original_name: 'b.pdf' }); // Branch 1: every COALESCE column gets a genuinely new, non-null value. await repo.recordAttempt(itemId, { @@ -181,7 +183,7 @@ describe('DocumentSyncItemsRepository', () => { remote_name: 'b.pdf', remote_size: 2048, remote_modified_at: '2026-09-18T10:00:00Z', content_sha256: 'new-hash', pushed_sha256: 'new-pushed', attempts: 0, next_attempt_after_seconds: null, }); - let row = testDb.prepare('SELECT * FROM document_sync_items WHERE id = ?').get(itemId) as Record; + let row = await itemRow(itemId); expect(row).toMatchObject({ file_id: newFileId, remote_id: 'r1-new', remote_version: 'v2', remote_name: 'b.pdf', remote_size: 2048, remote_modified_at: '2026-09-18T10:00:00Z', content_sha256: 'new-hash', pushed_sha256: 'new-pushed', @@ -193,7 +195,7 @@ describe('DocumentSyncItemsRepository', () => { remote_name: null, remote_size: null, remote_modified_at: null, content_sha256: null, pushed_sha256: null, attempts: 0, next_attempt_after_seconds: null, }); - row = testDb.prepare('SELECT * FROM document_sync_items WHERE id = ?').get(itemId) as Record; + row = await itemRow(itemId); expect(row).toMatchObject({ file_id: newFileId, remote_id: 'r1-new', remote_version: 'v2', remote_name: 'b.pdf', remote_size: 2048, remote_modified_at: '2026-09-18T10:00:00Z', content_sha256: 'new-hash', pushed_sha256: 'new-pushed', @@ -201,7 +203,7 @@ describe('DocumentSyncItemsRepository', () => { }); it('DS23REPO-002: next_attempt_after_seconds shifts the DB clock forward by the given second count; null leaves it NULL', async () => { - const { linkId, trip } = fixture(); + const { linkId, trip } = await fixture(); const itemId = await seedRow(linkId, trip.id); await repo.recordAttempt(itemId, { @@ -209,37 +211,38 @@ describe('DocumentSyncItemsRepository', () => { remote_name: null, remote_size: null, remote_modified_at: null, content_sha256: null, pushed_sha256: null, attempts: 1, next_attempt_after_seconds: 300, }); - const row = testDb.prepare("SELECT next_attempt_at, datetime('now', '+300 seconds') AS expected FROM document_sync_items WHERE id = ?").get(itemId) as { next_attempt_at: string; expected: string }; - expect(row.next_attempt_at).not.toBeNull(); - expect(Math.abs(new Date(`${row.next_attempt_at.replace(' ', 'T')}Z`).getTime() - new Date(`${row.expected.replace(' ', 'T')}Z`).getTime())).toBeLessThan(5000); + const nextAttemptAt = (await itemRow(itemId)).next_attempt_at; + const expected = Date.now() + 300_000; + expect(nextAttemptAt).not.toBeNull(); + expect(Math.abs(new Date(`${nextAttemptAt!.replace(' ', 'T')}Z`).getTime() - expected)).toBeLessThan(5000); await repo.recordAttempt(itemId, { state: 'synced', error_code: null, file_id: null, remote_id: null, remote_version: null, remote_name: null, remote_size: null, remote_modified_at: null, content_sha256: null, pushed_sha256: null, attempts: 0, next_attempt_after_seconds: null, }); - const cleared = testDb.prepare('SELECT next_attempt_at FROM document_sync_items WHERE id = ?').get(itemId) as { next_attempt_at: string | null }; + const cleared = await itemRow(itemId); expect(cleared.next_attempt_at).toBeNull(); }); it('DS23REPO-003: synced_at is stamped only when state is synced, and left alone otherwise', async () => { - const { linkId, trip } = fixture(); + const { linkId, trip } = await fixture(); const itemId = await seedRow(linkId, trip.id); - testDb.prepare("UPDATE document_sync_items SET synced_at = '2020-01-01 00:00:00' WHERE id = ?").run(itemId); + await updateRows(t, DocumentSyncItems, { id: itemId }, { synced_at: '2020-01-01 00:00:00' }); await repo.recordAttempt(itemId, { state: 'error', error_code: 'timeout', file_id: null, remote_id: null, remote_version: null, remote_name: null, remote_size: null, remote_modified_at: null, content_sha256: null, pushed_sha256: null, attempts: 1, next_attempt_after_seconds: null, }); - expect((testDb.prepare('SELECT synced_at FROM document_sync_items WHERE id = ?').get(itemId) as { synced_at: string }).synced_at).toBe('2020-01-01 00:00:00'); + expect((await itemRow(itemId)).synced_at).toBe('2020-01-01 00:00:00'); await repo.recordAttempt(itemId, { state: 'synced', error_code: null, file_id: null, remote_id: null, remote_version: null, remote_name: null, remote_size: null, remote_modified_at: null, content_sha256: null, pushed_sha256: null, attempts: 0, next_attempt_after_seconds: null, }); - expect((testDb.prepare('SELECT synced_at FROM document_sync_items WHERE id = ?').get(itemId) as { synced_at: string }).synced_at).not.toBe('2020-01-01 00:00:00'); + expect((await itemRow(itemId)).synced_at).not.toBe('2020-01-01 00:00:00'); }); }); }); diff --git a/server/tests/unit/db/repositories/GooglePlacePhotoMeta.repository.test.ts b/server/tests/unit/db/repositories/GooglePlacePhotoMeta.repository.test.ts index d92057ea3..558f3b004 100644 --- a/server/tests/unit/db/repositories/GooglePlacePhotoMeta.repository.test.ts +++ b/server/tests/unit/db/repositories/GooglePlacePhotoMeta.repository.test.ts @@ -10,6 +10,7 @@ import { resetTestDb } from '../../../helpers/test-db'; import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; import { GooglePlacePhotoMeta } from '../../../../src/db/entities/GooglePlacePhotoMeta.entity'; import type { GooglePlacePhotoMetaRepository } from '../../../../src/db/repositories/GooglePlacePhotoMeta.repository'; +import { countRows, findRow, insertRow } from '../../../helpers/factories/rows'; const testDb = createSnapshotTestDb(); let t: TestOrm; @@ -22,18 +23,22 @@ beforeAll(async () => { beforeEach(() => { resetTestDb(testDb); t.clear(); }); afterAll(async () => { await t.close(); testDb.close(); }); -function rawRow(placeId: string): unknown { - return testDb.prepare('SELECT * FROM google_place_photo_meta WHERE place_id = ?').get(placeId); +function storedRow(placeId: string) { + return findRow(t, GooglePlacePhotoMeta, { place_id: placeId }); +} + +function seedMeta(placeId: string, attribution: string | null, fetchedAt: number, errorAt: number | null) { + return insertRow(t, GooglePlacePhotoMeta, { place_id: placeId, attribution, fetched_at: fetchedAt, error_at: errorAt }); } describe('GooglePlacePhotoMetaRepository.findLive / findErrored', () => { it('GPPMREPO-001: findLive returns the attribution only when error_at is NULL', async () => { - testDb.prepare('INSERT INTO google_place_photo_meta (place_id, attribution, fetched_at, error_at) VALUES (?, ?, ?, NULL)').run('p1', 'Some Author', 1000); + await seedMeta('p1', 'Some Author', 1000, null); expect(await meta.findLive('p1')).toEqual({ attribution: 'Some Author' }); }); it('GPPMREPO-002: findLive returns null when error_at is set, even with an attribution present', async () => { - testDb.prepare('INSERT INTO google_place_photo_meta (place_id, attribution, fetched_at, error_at) VALUES (?, NULL, ?, ?)').run('p1', 1000, 2000); + await seedMeta('p1', null, 1000, 2000); expect(await meta.findLive('p1')).toBeNull(); }); @@ -43,26 +48,26 @@ describe('GooglePlacePhotoMetaRepository.findLive / findErrored', () => { it('GPPMREPO-004: works with coords:lat:lng pseudo-ids (TEXT key, colons and dots included)', async () => { const pseudoId = 'coords:48.8566:2.3522'; - testDb.prepare('INSERT INTO google_place_photo_meta (place_id, attribution, fetched_at, error_at) VALUES (?, NULL, ?, NULL)').run(pseudoId, 1000); + await seedMeta(pseudoId, null, 1000, null); expect(await meta.findLive(pseudoId)).toEqual({ attribution: null }); }); it('GPPMREPO-005: findErrored returns {error_at} only when error_at IS NOT NULL', async () => { - testDb.prepare('INSERT INTO google_place_photo_meta (place_id, attribution, fetched_at, error_at) VALUES (?, NULL, ?, ?)').run('p1', 1000, 5000); + await seedMeta('p1', null, 1000, 5000); expect(await meta.findErrored('p1')).toEqual({ error_at: 5000 }); }); it('GPPMREPO-006: findErrored returns null when error_at is NULL', async () => { - testDb.prepare('INSERT INTO google_place_photo_meta (place_id, attribution, fetched_at, error_at) VALUES (?, ?, ?, NULL)').run('p1', 'A', 1000); + await seedMeta('p1', 'A', 1000, null); expect(await meta.findErrored('p1')).toBeNull(); }); }); describe('GooglePlacePhotoMetaRepository.deleteByPlaceId', () => { it('GPPMREPO-007: deletes the row for the given place_id', async () => { - testDb.prepare('INSERT INTO google_place_photo_meta (place_id, attribution, fetched_at, error_at) VALUES (?, NULL, ?, NULL)').run('p1', 1000); + await seedMeta('p1', null, 1000, null); await meta.deleteByPlaceId('p1'); - expect(rawRow('p1')).toBeUndefined(); + expect(await storedRow('p1')).toBeNull(); }); it('GPPMREPO-008: deleting a missing place_id does not throw', async () => { @@ -73,12 +78,12 @@ describe('GooglePlacePhotoMetaRepository.deleteByPlaceId', () => { describe('GooglePlacePhotoMetaRepository.upsertError / upsertPhoto — INSERT OR REPLACE parity', () => { it('GPPMREPO-009: upsertError writes NULL attribution and matching fetched_at/error_at on a fresh row', async () => { await meta.upsertError('p1', 12345); - expect(rawRow('p1')).toMatchObject({ place_id: 'p1', attribution: null, fetched_at: 12345, error_at: 12345 }); + expect(await storedRow('p1')).toMatchObject({ place_id: 'p1', attribution: null, fetched_at: 12345, error_at: 12345 }); }); it('GPPMREPO-010: upsertPhoto writes the attribution with a NULL error_at on a fresh row', async () => { await meta.upsertPhoto('p1', 'Some Author · CC BY 2.0', 999); - expect(rawRow('p1')).toMatchObject({ place_id: 'p1', attribution: 'Some Author · CC BY 2.0', fetched_at: 999, error_at: null }); + expect(await storedRow('p1')).toMatchObject({ place_id: 'p1', attribution: 'Some Author · CC BY 2.0', fetched_at: 999, error_at: null }); }); // PP4 forces attribution NULL; PP5 forces error_at NULL — same PK, mirror @@ -86,23 +91,23 @@ describe('GooglePlacePhotoMetaRepository.upsertError / upsertPhoto — INSERT OR // row after each (Task 1 brief's explicit test requirement). it('GPPMREPO-011: put then markError then put again — each call overwrites the row exactly (INSERT OR REPLACE semantics via em.upsert merge)', async () => { await meta.upsertPhoto('p1', 'Author A', 100); - expect(rawRow('p1')).toMatchObject({ attribution: 'Author A', fetched_at: 100, error_at: null }); + expect(await storedRow('p1')).toMatchObject({ attribution: 'Author A', fetched_at: 100, error_at: null }); await meta.upsertError('p1', 200); - expect(rawRow('p1')).toMatchObject({ attribution: null, fetched_at: 200, error_at: 200 }); + expect(await storedRow('p1')).toMatchObject({ attribution: null, fetched_at: 200, error_at: 200 }); await meta.upsertPhoto('p1', 'Author B', 300); - expect(rawRow('p1')).toMatchObject({ attribution: 'Author B', fetched_at: 300, error_at: null }); + expect(await storedRow('p1')).toMatchObject({ attribution: 'Author B', fetched_at: 300, error_at: null }); // Only one row exists for this PK throughout — no duplicate insert. - expect(testDb.prepare('SELECT COUNT(*) as c FROM google_place_photo_meta WHERE place_id = ?').get('p1')).toEqual({ c: 1 }); + expect(await countRows(t, GooglePlacePhotoMeta, { place_id: 'p1' })).toBe(1); }); }); describe('GooglePlacePhotoMetaRepository.listPlaceIds', () => { it('GPPMREPO-012: lists every place_id, full table scan', async () => { - testDb.prepare('INSERT INTO google_place_photo_meta (place_id, fetched_at) VALUES (?, ?)').run('p1', 1); - testDb.prepare('INSERT INTO google_place_photo_meta (place_id, fetched_at) VALUES (?, ?)').run('p2', 2); + await insertRow(t, GooglePlacePhotoMeta, { place_id: 'p1', fetched_at: 1 }); + await insertRow(t, GooglePlacePhotoMeta, { place_id: 'p2', fetched_at: 2 }); expect((await meta.listPlaceIds()).sort()).toEqual(['p1', 'p2']); }); @@ -118,12 +123,13 @@ describe('GooglePlacePhotoMetaRepository.listPlaceIds', () => { // non-PK-only `findOne` always re-runs its WHERE against the live table). describe('GooglePlacePhotoMetaRepository — fresh after a raw UPDATE', () => { it('GPPMREPO-014 (fresh after a raw UPDATE, not D-shape): a raw write after an unrelated identity-map read is visible in the next findLive call, in one query (disableIdentityMap regression)', async () => { - testDb.prepare('INSERT INTO google_place_photo_meta (place_id, attribution, fetched_at, error_at) VALUES (?, ?, ?, NULL)').run('p1', 'Old', 1000); + await seedMeta('p1', 'Old', 1000, null); // The FIRST, wider setup read passes `disableIdentityMap: false` // explicitly and carries the column the later write targets // (`attribution`) — a PK-only `findOne` on `place_id` alone (not // `findLive`'s own `error_at IS NULL`-narrowed filter under test). await t.repo(GooglePlacePhotoMeta).findOne({ place_id: 'p1' }, { disableIdentityMap: false }); + // test-sql-allow: the out-of-band write this case is about has to bypass every EntityManager, the one under test included. testDb.prepare('UPDATE google_place_photo_meta SET attribution = ? WHERE place_id = ?').run('New', 'p1'); const connection = t.orm.em.getConnection(); diff --git a/server/tests/unit/db/repositories/HiddenRegions.repository.test.ts b/server/tests/unit/db/repositories/HiddenRegions.repository.test.ts index 03bb1b4c8..803a3f011 100644 --- a/server/tests/unit/db/repositories/HiddenRegions.repository.test.ts +++ b/server/tests/unit/db/repositories/HiddenRegions.repository.test.ts @@ -10,6 +10,7 @@ import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; import { createUser } from '../../../helpers/factories'; import { HiddenRegions } from '../../../../src/db/entities/HiddenRegions.entity'; import type { HiddenRegionsRepository } from '../../../../src/db/repositories/HiddenRegions.repository'; +import { insertRow } from '../../../helpers/factories/rows'; const testDb = createSnapshotTestDb(); let t: TestOrm; @@ -26,10 +27,11 @@ describe('HiddenRegionsRepository.listForUser (AT22)', () => { it('HIDDENREGREPO-001: matches SELECT region_code FROM hidden_regions WHERE user_id = ? run raw, scoped to the user', async () => { const { user } = createUser(testDb); const { user: other } = createUser(testDb, { username: 'other' }); - testDb.prepare('INSERT INTO hidden_regions (user_id, region_code, country_code) VALUES (?, ?, ?)').run(user.id, 'DE-BY', 'DE'); - testDb.prepare('INSERT INTO hidden_regions (user_id, region_code, country_code) VALUES (?, ?, ?)').run(user.id, 'DE-BE', 'DE'); - testDb.prepare('INSERT INTO hidden_regions (user_id, region_code, country_code) VALUES (?, ?, ?)').run(other.id, 'FR-IDF', 'FR'); + await insertRow(t, HiddenRegions, { user: user.id, region_code: 'DE-BY', country_code: 'DE' }); + await insertRow(t, HiddenRegions, { user: user.id, region_code: 'DE-BE', country_code: 'DE' }); + await insertRow(t, HiddenRegions, { user: other.id, region_code: 'FR-IDF', country_code: 'FR' }); + // test-sql-allow: the legacy AT22 statement is the parity oracle the repository is compared against. const legacy = testDb.prepare('SELECT region_code FROM hidden_regions WHERE user_id = ?').all(user.id); const codes = await repo.listForUser(user.id); diff --git a/server/tests/unit/db/repositories/IdempotencyKeys.repository.test.ts b/server/tests/unit/db/repositories/IdempotencyKeys.repository.test.ts index 723f1f370..a06bd8e2e 100644 --- a/server/tests/unit/db/repositories/IdempotencyKeys.repository.test.ts +++ b/server/tests/unit/db/repositories/IdempotencyKeys.repository.test.ts @@ -5,6 +5,14 @@ import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; import { createUser } from '../../../helpers/factories'; import { IdempotencyKeys } from '../../../../src/db/entities/IdempotencyKeys.entity'; import type { IdempotencyKeysRepository } from '../../../../src/db/repositories/IdempotencyKeys.repository'; +import { countRows, findRow, findRows, insertRow } from '../../../helpers/factories/rows'; + +function seedKey(key: string, userId: number, method: string, path: string, statusCode: number, responseBody: string, createdAt?: number) { + return insertRow(t, IdempotencyKeys, { + key, user: userId, method, path, status_code: statusCode, response_body: responseBody, + ...(createdAt !== undefined ? { created_at: createdAt } : {}), + }); +} const testDb = createSnapshotTestDb(); let t: TestOrm; @@ -21,8 +29,7 @@ describe('IdempotencyKeysRepository.findResponse (Plan 4 Task 1)', () => { it('IDEMPREPO-001: returns status_code/response_body only, scoped by the full (key, user, method, path) composite key', async () => { const { user } = createUser(testDb); const { user: other } = createUser(testDb, { username: 'other' }); - testDb.prepare('INSERT INTO idempotency_keys (key, user_id, method, path, status_code, response_body) VALUES (?, ?, ?, ?, ?, ?)') - .run('k', user.id, 'POST', '/api/categories', 201, '{"id":1}'); + await seedKey('k', user.id, 'POST', '/api/categories', 201, '{"id":1}'); expect(await idempotencyKeys.findResponse('k', user.id, 'POST', '/api/categories')).toEqual({ status_code: 201, response_body: '{"id":1}' }); // Same key, different user — a different row entirely, not this one. @@ -46,7 +53,11 @@ describe('IdempotencyKeysRepository.insertIfAbsent (Plan 4 Task 1)', () => { status_code: 201, response_body: '{"ok":true}', created_at: 1_700_000_000, }); - const row = testDb.prepare('SELECT key, user_id, method, path, status_code, response_body, created_at FROM idempotency_keys WHERE key = ?').get('k'); + const stored = (await findRow(t, IdempotencyKeys, { key: 'k' }))!; + const row = { + key: stored.key, user_id: stored.user_id, method: stored.method, path: stored.path, + status_code: stored.status_code, response_body: stored.response_body, created_at: stored.created_at, + }; expect(row).toEqual({ key: 'k', user_id: user.id, method: 'POST', path: '/api/places', status_code: 201, response_body: '{"ok":true}', created_at: 1_700_000_000, @@ -65,7 +76,7 @@ describe('IdempotencyKeysRepository.insertIfAbsent (Plan 4 Task 1)', () => { }); expect(await idempotencyKeys.findResponse('k', user.id, 'POST', '/api/places')).toEqual({ status_code: 201, response_body: '{"first":true}' }); - expect(testDb.prepare('SELECT COUNT(*) AS c FROM idempotency_keys').get()).toEqual({ c: 1 }); + expect(await countRows(t, IdempotencyKeys)).toBe(1); }); it('IDEMPREPO-005: the SAME key for a DIFFERENT user is a separate row, not a conflict', async () => { @@ -73,31 +84,27 @@ describe('IdempotencyKeysRepository.insertIfAbsent (Plan 4 Task 1)', () => { const { user: b } = createUser(testDb, { username: 'b' }); await idempotencyKeys.insertIfAbsent({ key: 'k', user_id: a.id, method: 'POST', path: '/x', status_code: 200, response_body: '{}', created_at: 1 }); await idempotencyKeys.insertIfAbsent({ key: 'k', user_id: b.id, method: 'POST', path: '/x', status_code: 200, response_body: '{}', created_at: 2 }); - expect(testDb.prepare('SELECT COUNT(*) AS c FROM idempotency_keys').get()).toEqual({ c: 2 }); + expect(await countRows(t, IdempotencyKeys)).toBe(2); }); }); describe('IdempotencyKeysRepository.deleteExpired (Plan 4 Task 1)', () => { it('IDEMPREPO-006: deletes rows strictly older than the cutoff, returns the row count, leaves the rest', async () => { const { user } = createUser(testDb); - testDb.prepare('INSERT INTO idempotency_keys (key, user_id, method, path, status_code, response_body, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)') - .run('old', user.id, 'POST', '/x', 200, '{}', 100); - testDb.prepare('INSERT INTO idempotency_keys (key, user_id, method, path, status_code, response_body, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)') - .run('boundary', user.id, 'POST', '/y', 200, '{}', 200); - testDb.prepare('INSERT INTO idempotency_keys (key, user_id, method, path, status_code, response_body, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)') - .run('fresh', user.id, 'POST', '/z', 200, '{}', 300); + await seedKey('old', user.id, 'POST', '/x', 200, '{}', 100); + await seedKey('boundary', user.id, 'POST', '/y', 200, '{}', 200); + await seedKey('fresh', user.id, 'POST', '/z', 200, '{}', 300); const removed = await idempotencyKeys.deleteExpired(200); expect(removed).toBe(1); - const keys = testDb.prepare('SELECT key FROM idempotency_keys ORDER BY key').all().map((r: { key: string }) => r.key); + const keys = (await findRows(t, IdempotencyKeys, {}, { key: 'asc' })).map((r) => r.key); expect(keys).toEqual(['boundary', 'fresh']); }); it('IDEMPREPO-007: zero rows removed when nothing is old enough', async () => { const { user } = createUser(testDb); - testDb.prepare('INSERT INTO idempotency_keys (key, user_id, method, path, status_code, response_body, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)') - .run('fresh', user.id, 'POST', '/z', 200, '{}', 1000); + await seedKey('fresh', user.id, 'POST', '/z', 200, '{}', 1000); expect(await idempotencyKeys.deleteExpired(500)).toBe(0); }); }); diff --git a/server/tests/unit/db/repositories/InviteTokens.repository.test.ts b/server/tests/unit/db/repositories/InviteTokens.repository.test.ts index 822a2e3a0..cf88a3b86 100644 --- a/server/tests/unit/db/repositories/InviteTokens.repository.test.ts +++ b/server/tests/unit/db/repositories/InviteTokens.repository.test.ts @@ -7,6 +7,7 @@ import { InviteTokens } from '../../../../src/db/entities/InviteTokens.entity'; import type { InviteTokensRepository } from '../../../../src/db/repositories/InviteTokens.repository'; import { UnitOfWork } from '../../../../src/nest/database/unit-of-work'; import { withRequestContext } from '../../../../src/nest/database/request-context'; +import { updateRows } from '../../../helpers/factories/rows'; const testDb = createSnapshotTestDb(); let t: TestOrm; @@ -20,6 +21,7 @@ beforeEach(() => { resetTestDb(testDb); t.clear(); }); afterAll(async () => { await t.close(); testDb.close(); }); function rawInvite(id: number): unknown { + // test-sql-allow: the raw full row is the parity oracle the repository's reads are compared against. return testDb.prepare('SELECT * FROM invite_tokens WHERE id = ?').get(id); } @@ -99,7 +101,7 @@ describe('InviteTokensRepository', () => { it('INVREPO-006: at capacity — no row matches, returns null, used_count unchanged', async () => { const created = createInviteToken(testDb, { token: 'at-cap', max_uses: 1 }); - testDb.prepare('UPDATE invite_tokens SET used_count = 1 WHERE id = ?').run(created.id); + await updateRows(t, InviteTokens, { id: created.id }, { used_count: 1 }); const row = await invites.incrementUsedCount('at-cap'); expect(row).toBeNull(); expect((rawInvite(created.id) as { used_count: number }).used_count).toBe(1); @@ -107,7 +109,7 @@ describe('InviteTokensRepository', () => { it('INVREPO-007: max_uses = 0 means unlimited — always increments', async () => { createInviteToken(testDb, { token: 'unlimited', max_uses: 0 }); - testDb.prepare("UPDATE invite_tokens SET used_count = 500 WHERE token = 'unlimited'").run(); + await updateRows(t, InviteTokens, { token: 'unlimited' }, { used_count: 500 }); const row = await invites.incrementUsedCount('unlimited'); expect(row?.used_count).toBe(501); }); @@ -138,9 +140,9 @@ describe('InviteTokensRepository', () => { const { user: admin } = createUser(testDb, { username: 'inviter-1' }); const trip = createTrip(testDb, admin.id, { title: 'Bound Trip' }); const older = createInviteToken(testDb, { token: 'older', created_by: admin.id }); - testDb.prepare('UPDATE invite_tokens SET created_at = ? WHERE id = ?').run('2026-01-01 00:00:00', older.id); + await updateRows(t, InviteTokens, { id: older.id }, { created_at: '2026-01-01 00:00:00' }); const newer = createInviteToken(testDb, { token: 'newer', created_by: admin.id }); - testDb.prepare('UPDATE invite_tokens SET created_at = ?, trip_id = ? WHERE id = ?').run('2026-02-01 00:00:00', trip.id, newer.id); + await updateRows(t, InviteTokens, { id: newer.id }, { created_at: '2026-02-01 00:00:00', trip: trip.id }); const rows = await invites.listWithCreatorAndTrip(); diff --git a/server/tests/unit/db/repositories/Notifications.repository.test.ts b/server/tests/unit/db/repositories/Notifications.repository.test.ts index 1ec4520f4..a71c162f3 100644 --- a/server/tests/unit/db/repositories/Notifications.repository.test.ts +++ b/server/tests/unit/db/repositories/Notifications.repository.test.ts @@ -5,6 +5,8 @@ import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; import { createUser, createTrip, addTripMember, type TestUser, type TestTrip } from '../../../helpers/factories'; import { Notifications } from '../../../../src/db/entities/Notifications.entity'; import type { NotificationsRepository } from '../../../../src/db/repositories/Notifications.repository'; +import { findRow, findRows, insertRow, updateRows } from '../../../helpers/factories/rows'; +import { Users } from '../../../../src/db/entities/Users.entity'; const testDb = createSnapshotTestDb(); let t: TestOrm; @@ -28,41 +30,44 @@ afterAll(async () => { }); /** Inserts one of each NT-inventory notification type, with every optional field set, for `recipientId`. Returns the three ids in insertion order (simple, boolean, navigate). */ -function seedThreeTypes(recipientId: number, senderId: number | null): { simpleId: number; booleanId: number; navigateId: number } { - const simpleId = testDb.prepare(` - INSERT INTO notifications (type, scope, target, sender_id, recipient_id, title_key, title_params, text_key, text_params) - VALUES ('simple', 'trip', ?, ?, ?, 'notif.trip_invite.title', '{"trip":"Rome"}', 'notif.trip_invite.text', '{"actor":"Alice"}') - `).run(trip.id, senderId, recipientId).lastInsertRowid as number; +async function seedThreeTypes(recipientId: number, senderId: number | null): Promise<{ simpleId: number; booleanId: number; navigateId: number }> { + const simpleId = await insertRow(t, Notifications, { + type: 'simple', scope: 'trip', target: trip.id, sender: senderId, recipient: recipientId, + title_key: 'notif.trip_invite.title', title_params: '{"trip":"Rome"}', text_key: 'notif.trip_invite.text', text_params: '{"actor":"Alice"}', + }); - const booleanId = testDb.prepare(` - INSERT INTO notifications ( - type, scope, target, sender_id, recipient_id, title_key, title_params, text_key, text_params, - positive_text_key, negative_text_key, positive_callback, negative_callback - ) VALUES ('boolean', 'trip', ?, ?, ?, 'notif.vacay_invite.title', '{"trip":"Rome"}', 'notif.vacay_invite.text', '{"actor":"Bob"}', - 'notif.action.accept', 'notif.action.decline', '{"action":"noop","payload":{"x":1}}', '{"action":"noop","payload":{"x":2}}') - `).run(trip.id, senderId, recipientId).lastInsertRowid as number; + const booleanId = await insertRow(t, Notifications, { + type: 'boolean', scope: 'trip', target: trip.id, sender: senderId, recipient: recipientId, + title_key: 'notif.vacay_invite.title', title_params: '{"trip":"Rome"}', text_key: 'notif.vacay_invite.text', text_params: '{"actor":"Bob"}', + positive_text_key: 'notif.action.accept', negative_text_key: 'notif.action.decline', + positive_callback: '{"action":"noop","payload":{"x":1}}', negative_callback: '{"action":"noop","payload":{"x":2}}', + }); - const navigateId = testDb.prepare(` - INSERT INTO notifications ( - type, scope, target, sender_id, recipient_id, title_key, title_params, text_key, text_params, - navigate_text_key, navigate_target - ) VALUES ('navigate', 'trip', ?, ?, ?, 'notif.booking_change.title', '{"trip":"Rome"}', 'notif.booking_change.text', '{"actor":"Carl"}', - 'notif.action.view_trip', '/trips/1') - `).run(trip.id, senderId, recipientId).lastInsertRowid as number; + const navigateId = await insertRow(t, Notifications, { + type: 'navigate', scope: 'trip', target: trip.id, sender: senderId, recipient: recipientId, + title_key: 'notif.booking_change.title', title_params: '{"trip":"Rome"}', text_key: 'notif.booking_change.text', text_params: '{"actor":"Carl"}', + navigate_text_key: 'notif.action.view_trip', navigate_target: '/trips/1', + }); return { simpleId, booleanId, navigateId }; } /** The legacy `SELECT * FROM notifications WHERE id = ?`, run raw, for a full-key parity comparison. */ function legacyFindById(id: number): unknown { + // test-sql-allow: the legacy full-row SELECT is the parity oracle the repository is compared against. return testDb.prepare('SELECT * FROM notifications WHERE id = ?').get(id); } +/** The notification row as it is now; fails the test when it is gone. */ +async function notificationRow(id: number) { + return (await findRow(t, Notifications, { id }))!; +} + describe('NotificationsRepository — findById/findByIdForRecipient parity (fully seeded: simple/boolean/navigate, every optional field set)', () => { it('NOTREPO-001 — findById matches the legacy full-row SELECT, byte for byte, for all three notification types', async () => { const { user: recipient } = createUser(testDb); const { user: sender } = createUser(testDb); - const { simpleId, booleanId, navigateId } = seedThreeTypes(recipient.id, sender.id); + const { simpleId, booleanId, navigateId } = await seedThreeTypes(recipient.id, sender.id); for (const id of [simpleId, booleanId, navigateId]) { const legacy = legacyFindById(id); @@ -74,8 +79,9 @@ describe('NotificationsRepository — findById/findByIdForRecipient parity (full it('NOTREPO-002 — findByIdForRecipient matches the legacy recipient-scoped SELECT, and returns undefined for the wrong recipient', async () => { const { user: recipient } = createUser(testDb); const { user: stranger } = createUser(testDb); - const { booleanId } = seedThreeTypes(recipient.id, null); + const { booleanId } = await seedThreeTypes(recipient.id, null); + // test-sql-allow: the legacy recipient-scoped SELECT is the parity oracle the repository is compared against. const legacy = testDb.prepare('SELECT * FROM notifications WHERE id = ? AND recipient_id = ?').get(booleanId, recipient.id); expect(await repo.findByIdForRecipient(booleanId, recipient.id)).toEqual(legacy); expect(await repo.findByIdForRecipient(booleanId, stranger.id)).toBeUndefined(); @@ -84,8 +90,9 @@ describe('NotificationsRepository — findById/findByIdForRecipient parity (full it('NOTREPO-003 — findWithSenderById matches the legacy LEFT JOIN users projection (sender_username/sender_avatar), including a NULL sender', async () => { const { user: recipient } = createUser(testDb); const { user: sender } = createUser(testDb, { username: 'Alice', email: 'alice@test.example.com' }); - const { simpleId } = seedThreeTypes(recipient.id, sender.id); + const { simpleId } = await seedThreeTypes(recipient.id, sender.id); + // test-sql-allow: the legacy LEFT JOIN projection is the parity oracle the repository is compared against. const legacy = testDb.prepare(` SELECT n.*, u.username AS sender_username, u.avatar AS sender_avatar FROM notifications n LEFT JOIN users u ON n.sender_id = u.id @@ -94,10 +101,11 @@ describe('NotificationsRepository — findById/findByIdForRecipient parity (full expect(await repo.findWithSenderById(simpleId)).toEqual(legacy); // A system notification (no sender) — the LEFT JOIN's NULL side. - const systemId = testDb.prepare(` - INSERT INTO notifications (type, scope, target, sender_id, recipient_id, title_key, title_params, text_key, text_params) - VALUES ('simple', 'admin', 0, NULL, ?, 'notif.version_available.title', '{}', 'notif.version_available.text', '{}') - `).run(recipient.id).lastInsertRowid as number; + const systemId = await insertRow(t, Notifications, { + type: 'simple', scope: 'admin', target: 0, sender: null, recipient: recipient.id, + title_key: 'notif.version_available.title', title_params: '{}', text_key: 'notif.version_available.text', text_params: '{}', + }); + // test-sql-allow: the legacy LEFT JOIN projection is the parity oracle the repository is compared against. const legacySystem = testDb.prepare(` SELECT n.*, u.username AS sender_username, u.avatar AS sender_avatar FROM notifications n LEFT JOIN users u ON n.sender_id = u.id @@ -109,14 +117,14 @@ describe('NotificationsRepository — findById/findByIdForRecipient parity (full it('NOTREPO-004 — listForRecipient matches the legacy joined page read, for both unreadOnly variants', async () => { const { user: recipient } = createUser(testDb); const { user: sender } = createUser(testDb); - seedThreeTypes(recipient.id, sender.id); - testDb.prepare('UPDATE notifications SET is_read = 1 WHERE recipient_id = ? LIMIT 1').run(recipient.id); - // SQLite compiled without LIMIT-on-UPDATE support in better-sqlite3 by default — fall back to an id-scoped update. - const firstId = (testDb.prepare('SELECT id FROM notifications WHERE recipient_id = ? ORDER BY id LIMIT 1').get(recipient.id) as { id: number }).id; - testDb.prepare('UPDATE notifications SET is_read = 1 WHERE id = ?').run(firstId); + await seedThreeTypes(recipient.id, sender.id); + // The recipient's first notification is read, the other two are not. + const [first] = await findRows(t, Notifications, { recipient: recipient.id }, { id: 'asc' }); + await updateRows(t, Notifications, { id: first.id }, { is_read: 1 }); for (const unreadOnly of [false, true]) { const whereAliased = unreadOnly ? 'WHERE n.recipient_id = ? AND n.is_read = 0' : 'WHERE n.recipient_id = ?'; + // test-sql-allow: the legacy joined page read is the parity oracle the repository is compared against. const legacy = testDb.prepare(` SELECT n.*, u.username AS sender_username, u.avatar AS sender_avatar FROM notifications n LEFT JOIN users u ON n.sender_id = u.id @@ -132,6 +140,7 @@ describe('NotificationsRepository — findById/findByIdForRecipient parity (full describe('NotificationsRepository — recipient resolution (NT1-NT4)', () => { it('NOTREPO-005 — getTripOwnerId matches `SELECT user_id FROM trips WHERE id = ?`', async () => { + // test-sql-allow: the legacy statement named in the case title is the parity oracle. const legacy = (testDb.prepare('SELECT user_id FROM trips WHERE id = ?').get(trip.id) as { user_id: number }).user_id; expect(await repo.getTripOwnerId(trip.id)).toBe(legacy); expect(await repo.getTripOwnerId(999999)).toBeNull(); @@ -140,11 +149,10 @@ describe('NotificationsRepository — recipient resolution (NT1-NT4)', () => { it('NOTREPO-006 — listNonGuestTripMemberIds (NT2, the guest-exclusion chokepoint, #1362): a guest member never appears, a real member always does', async () => { const { user: member } = createUser(testDb); addTripMember(testDb, trip.id, member.id); - const guestId = (testDb.prepare( - "INSERT INTO users (username, email, password_hash, role, is_guest) VALUES ('Guest', 'guest-repo@guests.invalid', '', 'user', 1)", - ).run()).lastInsertRowid as number; + const guestId = await insertRow(t, Users, { username: 'Guest', email: 'guest-repo@guests.invalid', password_hash: '', role: 'user', is_guest: 1 }); addTripMember(testDb, trip.id, guestId); + // test-sql-allow: the legacy statement named in the case title is the parity oracle. const legacy = (testDb.prepare( 'SELECT m.user_id FROM trip_members m JOIN users u ON u.id = m.user_id WHERE m.trip_id = ? AND COALESCE(u.is_guest, 0) = 0', ).all(trip.id) as { user_id: number }[]).map((r) => r.user_id); @@ -165,10 +173,9 @@ describe('NotificationsRepository — recipient resolution (NT1-NT4)', () => { const { user: admin1 } = createUser(testDb, { role: 'admin' }); const { user: admin2 } = createUser(testDb, { role: 'admin' }); createUser(testDb); // a regular user — must not appear - const guestAdminId = (testDb.prepare( - "INSERT INTO users (username, email, password_hash, role, is_guest) VALUES ('GuestAdmin', 'guest-admin@guests.invalid', '', 'admin', 1)", - ).run()).lastInsertRowid as number; + const guestAdminId = await insertRow(t, Users, { username: 'GuestAdmin', email: 'guest-admin@guests.invalid', password_hash: '', role: 'admin', is_guest: 1 }); + // test-sql-allow: the legacy statement named in the case title is the parity oracle. const legacy = (testDb.prepare( 'SELECT id FROM users WHERE role = ? AND COALESCE(is_guest, 0) = 0', ).all('admin') as { id: number }[]).map((r) => r.id); @@ -182,7 +189,8 @@ describe('NotificationsRepository — recipient resolution (NT1-NT4)', () => { it('NOTREPO-009 — findUserBasic matches `SELECT username, avatar FROM users WHERE id = ?`', async () => { const { user } = createUser(testDb); - testDb.prepare('UPDATE users SET avatar = ? WHERE id = ?').run('avatar.png', user.id); + await updateRows(t, Users, { id: user.id }, { avatar: 'avatar.png' }); + // test-sql-allow: the legacy statement named in the case title is the parity oracle. const legacy = testDb.prepare('SELECT username, avatar FROM users WHERE id = ?').get(user.id); expect(await repo.findUserBasic(user.id)).toEqual(legacy); expect(await repo.findUserBasic(999999)).toBeUndefined(); @@ -202,7 +210,7 @@ describe('NotificationsRepository — writes', () => { navigate_text_key: null, navigate_target: null, }); - const row = testDb.prepare('SELECT * FROM notifications WHERE id = ?').get(id) as Record; + const row = await notificationRow(id); expect(row.type).toBe('boolean'); expect(row.scope).toBe('trip'); expect(row.target).toBe(trip.id); @@ -216,11 +224,11 @@ describe('NotificationsRepository — writes', () => { it('NOTREPO-011 — setRead/markAllRead/deleteForRecipient/deleteAllForRecipient are recipient-scoped and return the affected-row count', async () => { const { user: recipient } = createUser(testDb); const { user: stranger } = createUser(testDb); - const { simpleId, booleanId } = seedThreeTypes(recipient.id, null); + const { simpleId, booleanId } = await seedThreeTypes(recipient.id, null); expect(await repo.setRead(simpleId, stranger.id, 1)).toBe(0); // wrong recipient — no-op expect(await repo.setRead(simpleId, recipient.id, 1)).toBe(1); - expect((testDb.prepare('SELECT is_read FROM notifications WHERE id = ?').get(simpleId) as { is_read: number }).is_read).toBe(1); + expect((await notificationRow(simpleId)).is_read).toBe(1); expect(await repo.setRead(simpleId, recipient.id, 0)).toBe(1); @@ -235,22 +243,22 @@ describe('NotificationsRepository — writes', () => { it('NOTREPO-012 — claimResponse (NT20) only succeeds once: the atomic double-submit claim', async () => { const { user: recipient } = createUser(testDb); - const { booleanId } = seedThreeTypes(recipient.id, null); + const { booleanId } = await seedThreeTypes(recipient.id, null); expect(await repo.claimResponse(booleanId, recipient.id, 'positive')).toBe(1); // A second claim on the same, now-claimed notification affects 0 rows — // the `response IS NULL` guard is the load-bearing part of this statement. expect(await repo.claimResponse(booleanId, recipient.id, 'negative')).toBe(0); - expect((testDb.prepare('SELECT response FROM notifications WHERE id = ?').get(booleanId) as { response: string }).response).toBe('positive'); + expect((await notificationRow(booleanId)).response).toBe('positive'); }); it('NOTREPO-013 — releaseResponse (NT21) restores the claim so a retry can succeed', async () => { const { user: recipient } = createUser(testDb); - const { booleanId } = seedThreeTypes(recipient.id, null); + const { booleanId } = await seedThreeTypes(recipient.id, null); await repo.claimResponse(booleanId, recipient.id, 'positive'); await repo.releaseResponse(booleanId, recipient.id, 0); - const row = testDb.prepare('SELECT response, is_read FROM notifications WHERE id = ?').get(booleanId) as { response: string | null; is_read: number }; + const row = await notificationRow(booleanId); expect(row.response).toBeNull(); expect(row.is_read).toBe(0); expect(await repo.claimResponse(booleanId, recipient.id, 'negative')).toBe(1); diff --git a/server/tests/unit/db/repositories/PhotoProviders.repository.test.ts b/server/tests/unit/db/repositories/PhotoProviders.repository.test.ts index 0d7295f0e..120a1a0d4 100644 --- a/server/tests/unit/db/repositories/PhotoProviders.repository.test.ts +++ b/server/tests/unit/db/repositories/PhotoProviders.repository.test.ts @@ -4,6 +4,7 @@ import { resetTestDb } from '../../../helpers/test-db'; import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; import { PhotoProviders } from '../../../../src/db/entities/PhotoProviders.entity'; import type { PhotoProvidersRepository } from '../../../../src/db/repositories/PhotoProviders.repository'; +import { findRow, insertRow } from '../../../helpers/factories/rows'; const testDb = createSnapshotTestDb(); let t: TestOrm; @@ -26,28 +27,28 @@ afterAll(async () => { testDb.close(); }); -function rawRow(id: string): unknown { - return testDb.prepare('SELECT * FROM photo_providers WHERE id = ?').get(id); +function storedRow(id: string) { + return findRow(t, PhotoProviders, { id }); } -function insertProvider(row: { +async function insertProvider(row: { id: string; name: string; description?: string | null; icon?: string | null; enabled: 0 | 1; sort_order?: number; -}): void { - testDb - .prepare('INSERT INTO photo_providers (id, name, description, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?)') - .run(row.id, row.name, row.description ?? null, row.icon ?? null, row.enabled, row.sort_order ?? 0); +}): Promise { + await insertRow(t, PhotoProviders, { + id: row.id, name: row.name, description: row.description ?? null, icon: row.icon ?? null, enabled: row.enabled, sort_order: row.sort_order ?? 0, + }); } describe('PhotoProvidersRepository.listEnabled', () => { it('ADDONSPPREPO-001: returns only enabled providers, ordered by sort_order then id', async () => { - insertProvider({ id: 'synology', name: 'Synology', enabled: 1, sort_order: 0 }); - insertProvider({ id: 'immich', name: 'Immich', enabled: 1, sort_order: 0 }); - insertProvider({ id: 'off', name: 'Off', enabled: 0, sort_order: -1 }); + await insertProvider({ id: 'synology', name: 'Synology', enabled: 1, sort_order: 0 }); + await insertProvider({ id: 'immich', name: 'Immich', enabled: 1, sort_order: 0 }); + await insertProvider({ id: 'off', name: 'Off', enabled: 0, sort_order: -1 }); const rows = await photoProviders.listEnabled(); // Same sort_order (0) for both enabled rows — id breaks the tie. @@ -55,7 +56,7 @@ describe('PhotoProvidersRepository.listEnabled', () => { }); it('ADDONSPPREPO-002: the enabled column comes back as the raw stored integer, not a coerced boolean', async () => { - insertProvider({ id: 'immich', name: 'Immich', enabled: 1 }); + await insertProvider({ id: 'immich', name: 'Immich', enabled: 1 }); const [row] = await photoProviders.listEnabled(); expect(row.enabled).toBe(1); expect(typeof row.enabled).toBe('number'); @@ -66,7 +67,7 @@ describe('PhotoProvidersRepository.listEnabled', () => { }); it('ADDONSPPREPO-004: carries the full row shape (id, name, description, icon, enabled, sort_order)', async () => { - insertProvider({ id: 'immich', name: 'Immich', description: 'Self-hosted photos', icon: 'image', enabled: 1, sort_order: 4 }); + await insertProvider({ id: 'immich', name: 'Immich', description: 'Self-hosted photos', icon: 'image', enabled: 1, sort_order: 4 }); const [row] = await photoProviders.listEnabled(); expect(row).toEqual({ id: 'immich', @@ -76,14 +77,14 @@ describe('PhotoProvidersRepository.listEnabled', () => { enabled: 1, sort_order: 4, }); - expect(rawRow('immich')).toMatchObject({ enabled: 1 }); + expect(await storedRow('immich')).toMatchObject({ enabled: 1 }); }); }); describe('PhotoProvidersRepository.listAll / findEnabled', () => { it('M1: listAll (the admin listing) returns every row regardless of enabled, unordered by that flag', async () => { - insertProvider({ id: 'immich', name: 'Immich', enabled: 1 }); - insertProvider({ id: 'off', name: 'Off', enabled: 0 }); + await insertProvider({ id: 'immich', name: 'Immich', enabled: 1 }); + await insertProvider({ id: 'off', name: 'Off', enabled: 0 }); const rows = await photoProviders.listAll(); expect(rows.map((r) => r.id).sort()).toEqual(['immich', 'off']); @@ -91,7 +92,7 @@ describe('PhotoProvidersRepository.listAll / findEnabled', () => { }); it('M1: findEnabled reads the enabled flag for a known provider and null for an unknown one', async () => { - insertProvider({ id: 'synology', name: 'Synology', enabled: 1 }); + await insertProvider({ id: 'synology', name: 'Synology', enabled: 1 }); expect(await photoProviders.findEnabled('synology')).toEqual({ enabled: 1 }); expect(await photoProviders.findEnabled('does-not-exist')).toBeNull(); }); @@ -103,9 +104,10 @@ describe('PhotoProvidersRepository.listAll / findEnabled', () => { // names. describe('PhotoProvidersRepository.listAllOrdered (AD27) / findById (AD31/AD40)', () => { it('ADDONSPPREPO-005: listAllOrdered matches SELECT id, name, description, icon, enabled, sort_order FROM photo_providers ORDER BY sort_order, id — unfiltered, including a disabled provider', async () => { - insertProvider({ id: 'off', name: 'Off', enabled: 0, sort_order: 1 }); - insertProvider({ id: 'immich', name: 'Immich', enabled: 1, sort_order: 0 }); + await insertProvider({ id: 'off', name: 'Off', enabled: 0, sort_order: 1 }); + await insertProvider({ id: 'immich', name: 'Immich', enabled: 1, sort_order: 0 }); + // test-sql-allow: the legacy statement is the parity oracle the repository is compared against. const legacy = testDb.prepare('SELECT id, name, description, icon, enabled, sort_order FROM photo_providers ORDER BY sort_order, id').all(); const rows = await photoProviders.listAllOrdered(); expect(rows.map((r) => r.id)).toEqual(['immich', 'off']); // includes the disabled one, unlike listEnabled @@ -117,10 +119,11 @@ describe('PhotoProvidersRepository.listAllOrdered (AD27) / findById (AD31/AD40)' }); it('ADDONSPPREPO-007: findById matches SELECT * FROM photo_providers WHERE id = ?, on both a pre-write read and a post-write re-select (byte-identical text at both call sites)', async () => { - insertProvider({ id: 'immich', name: 'Immich', description: 'Self-hosted photos', icon: 'image', enabled: 0, sort_order: 2 }); + await insertProvider({ id: 'immich', name: 'Immich', description: 'Self-hosted photos', icon: 'image', enabled: 0, sort_order: 2 }); const preWrite = await photoProviders.findById('immich'); expect(preWrite).toEqual({ id: 'immich', name: 'Immich', description: 'Self-hosted photos', icon: 'image', enabled: 0, sort_order: 2 }); + // test-sql-allow: the out-of-band write this case is about has to bypass every EntityManager, the one under test included. testDb.prepare('UPDATE photo_providers SET enabled = 1 WHERE id = ?').run('immich'); const postWrite = await photoProviders.findById('immich'); expect(postWrite?.enabled).toBe(1); diff --git a/server/tests/unit/db/repositories/ReservationTravelers.repository.test.ts b/server/tests/unit/db/repositories/ReservationTravelers.repository.test.ts index 8bc9e49c8..799af71ad 100644 --- a/server/tests/unit/db/repositories/ReservationTravelers.repository.test.ts +++ b/server/tests/unit/db/repositories/ReservationTravelers.repository.test.ts @@ -11,6 +11,9 @@ import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; import { createReservation, createTrip, createUser } from '../../../helpers/factories'; import { ReservationTravelers } from '../../../../src/db/entities/ReservationTravelers.entity'; import type { ReservationTravelersRepository } from '../../../../src/db/repositories/ReservationTravelers.repository'; +import { updateRows } from '../../../helpers/factories/rows'; +import { addReservationTraveler } from '../../../helpers/factories/reservations'; +import { Users } from '../../../../src/db/entities/Users.entity'; const testDb = createSnapshotTestDb(); let t: TestOrm; @@ -23,33 +26,35 @@ beforeAll(async () => { beforeEach(() => { resetTestDb(testDb); t.clear(); }); afterAll(async () => { await t.close(); testDb.close(); }); +// test-sql-allow: the legacy statement is the parity oracle the repository is compared against. const legacyListForTrip = (trip_id: number) => testDb.prepare(` SELECT rt.reservation_id, rt.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar, u.is_guest FROM reservation_travelers rt JOIN reservations r ON rt.reservation_id = r.id JOIN users u ON rt.user_id = u.id WHERE r.trip_id = ? ORDER BY rt.reservation_id`).all(trip_id); +// test-sql-allow: the legacy statement is the parity oracle the repository is compared against. const legacyListForReservation = (reservation_id: number) => testDb.prepare(` SELECT rt.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar, u.is_guest FROM reservation_travelers rt JOIN users u ON rt.user_id = u.id WHERE rt.reservation_id = ?`).all(reservation_id); describe('ReservationTravelersRepository — fully seeded world', () => { - const seed = () => { + const seed = async () => { const { user: owner } = createUser(testDb); const { user: named } = createUser(testDb); - testDb.prepare('UPDATE users SET display_name = ? WHERE id = ?').run('Named Traveler', named.id); + await updateRows(t, Users, { id: named.id }, { display_name: 'Named Traveler' }); const { user: guest } = createUser(testDb); - testDb.prepare('UPDATE users SET is_guest = 1 WHERE id = ?').run(guest.id); + await updateRows(t, Users, { id: guest.id }, { is_guest: 1 }); const trip = createTrip(testDb, owner.id); const flight = createReservation(testDb, trip.id, { title: 'Flight', type: 'flight' }); const hotel = createReservation(testDb, trip.id, { title: 'Hotel', type: 'hotel' }); - testDb.prepare('INSERT INTO reservation_travelers (reservation_id, user_id) VALUES (?, ?)').run(flight.id, owner.id); - testDb.prepare('INSERT INTO reservation_travelers (reservation_id, user_id) VALUES (?, ?)').run(flight.id, named.id); - testDb.prepare('INSERT INTO reservation_travelers (reservation_id, user_id) VALUES (?, ?)').run(hotel.id, guest.id); + await addReservationTraveler(t, flight.id, owner.id); + await addReservationTraveler(t, flight.id, named.id); + await addReservationTraveler(t, hotel.id, guest.id); return { owner, named, guest, trip, flight, hotel }; }; it('RS6 listForTrip — matches the legacy JOIN statement, COALESCE(display_name, username), ordered by reservation', async () => { - const { trip, flight, hotel } = seed(); + const { trip, flight, hotel } = await seed(); const legacy = legacyListForTrip(trip.id); const typed = await repo.listForTrip(trip.id); expect(typed).toEqual(legacy); @@ -59,7 +64,7 @@ describe('ReservationTravelersRepository — fully seeded world', () => { }); it('RR3 listForReservation — matches the legacy statement (no ORDER BY, no reservation_id column)', async () => { - const { flight, owner, named } = seed(); + const { flight, owner, named } = await seed(); const legacy = legacyListForReservation(flight.id); const typed = await repo.listForReservation(flight.id); expect(typed).toEqual(legacy); diff --git a/server/tests/unit/db/repositories/RoadtripDayTracks.repository.test.ts b/server/tests/unit/db/repositories/RoadtripDayTracks.repository.test.ts index 1594a027b..0e4357067 100644 --- a/server/tests/unit/db/repositories/RoadtripDayTracks.repository.test.ts +++ b/server/tests/unit/db/repositories/RoadtripDayTracks.repository.test.ts @@ -23,6 +23,7 @@ import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; import { createDay, createPlace, createTrip, createUser } from '../../../helpers/factories'; import { RoadtripDayTracks } from '../../../../src/db/entities/RoadtripDayTracks.entity'; import type { RoadtripDayTracksRepository } from '../../../../src/db/repositories/RoadtripDayTracks.repository'; +import { findRows } from '../../../helpers/factories/rows'; const testDb = createSnapshotTestDb(); let t: TestOrm; @@ -44,39 +45,40 @@ function fixture() { return { trip, dayA, placeA, placeB }; } -function rawRows(): { day_id: number; place_id: number; stray_km: number | null; created_at: string | null }[] { - return testDb.prepare('SELECT day_id, place_id, stray_km, created_at FROM roadtrip_day_tracks ORDER BY day_id').all() as never[]; +async function storedRows(): Promise<{ day_id: number | null | undefined; place_id: number; stray_km: number | null | undefined; created_at: string | null | undefined }[]> { + return (await findRows(t, RoadtripDayTracks, {}, { day: 'asc' })) + .map((r) => ({ day_id: r.day_id, place_id: r.place_id, stray_km: r.stray_km, created_at: r.created_at })); } describe('RoadtripDayTracksRepository', () => { it('RT7REPO-001: deleteForDay removes the one row for that day', async () => { const { dayA, placeA } = fixture(); await tracks.upsertTrack(dayA.id, placeA.id, null); - expect(rawRows()).toHaveLength(1); + expect(await storedRows()).toHaveLength(1); await tracks.deleteForDay(dayA.id); - expect(rawRows()).toHaveLength(0); + expect(await storedRows()).toHaveLength(0); }); it('RT7REPO-002: deleteForDay on a day with no track is a silent no-op', async () => { const { dayA } = fixture(); await expect(tracks.deleteForDay(dayA.id)).resolves.toBeUndefined(); - expect(rawRows()).toHaveLength(0); + expect(await storedRows()).toHaveLength(0); }); it('UPSERTTRACKREPO-001: a fresh day_id inserts one row', async () => { const { dayA, placeA } = fixture(); await tracks.upsertTrack(dayA.id, placeA.id, 1.5); - const rows = rawRows(); + const rows = await storedRows(); expect(rows).toEqual([{ day_id: dayA.id, place_id: placeA.id, stray_km: 1.5, created_at: expect.any(String) }]); }); it('UPSERTTRACKREPO-002: re-upserting the SAME day_id updates in place — one row, the second call\'s place_id/stray_km, created_at untouched', async () => { const { dayA, placeA, placeB } = fixture(); await tracks.upsertTrack(dayA.id, placeA.id, 1.5); - const before = rawRows()[0]!; + const before = (await storedRows())[0]!; await tracks.upsertTrack(dayA.id, placeB.id, 9.25); - const after = rawRows(); + const after = await storedRows(); // Exactly one row — if `onConflictFields: ['day']` resolved to anything // other than the physical `day_id` PK, the second call would either @@ -93,7 +95,7 @@ describe('RoadtripDayTracksRepository', () => { const dayB = createDay(testDb, trip.id); await tracks.upsertTrack(dayA.id, placeA.id, null); await tracks.upsertTrack(dayB.id, placeA.id, null); - expect(rawRows()).toHaveLength(2); + expect(await storedRows()).toHaveLength(2); }); it('UPSERTTRACKREPO-004: the rendered upsert SQL names the physical day_id column as its conflict target', async () => { diff --git a/server/tests/unit/db/repositories/Settings.repository.test.ts b/server/tests/unit/db/repositories/Settings.repository.test.ts index 1d4999553..7324279f1 100644 --- a/server/tests/unit/db/repositories/Settings.repository.test.ts +++ b/server/tests/unit/db/repositories/Settings.repository.test.ts @@ -5,6 +5,7 @@ import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; import { createUser, type TestUser } from '../../../helpers/factories'; import { Settings } from '../../../../src/db/entities/Settings.entity'; import type { SettingsRepository } from '../../../../src/db/repositories/Settings.repository'; +import { countRows, findRow, insertRow } from '../../../helpers/factories/rows'; const testDb = createSnapshotTestDb(); let t: TestOrm; @@ -27,22 +28,24 @@ afterAll(async () => { testDb.close(); }); -function rawRow(userId: number, key: string): unknown { - return testDb.prepare('SELECT * FROM settings WHERE user_id = ? AND key = ?').get(userId, key); +function storedRow(userId: number, key: string) { + return findRow(t, Settings, { user: userId, key }); } -function insertRaw(userId: number, key: string, value: string | null): void { - testDb.prepare('INSERT INTO settings (user_id, key, value) VALUES (?, ?, ?)').run(userId, key, value); +async function insertSetting(userId: number, key: string, value: string | null): Promise { + await insertRow(t, Settings, { user: userId, key, value }); } describe('settings table — the (user_id, key) unique index the ON CONFLICT target relies on', () => { it( "UNIQUE-SCHEMA-001: PRAGMA index_list('settings') reports a unique index over exactly (user_id, key), the migrated schema's inline UNIQUE(user_id, key)", () => { + // test-sql-allow: the index list comes from PRAGMA index_list, which no entity or repository maps. const indexes = testDb.prepare("PRAGMA index_list('settings')").all() as { name: string; unique: number }[]; const uniqueIndexes = indexes.filter((idx) => idx.unique); expect(uniqueIndexes.length).toBeGreaterThan(0); const columnSets = uniqueIndexes.map((idx) => { + // test-sql-allow: the index columns come from PRAGMA index_info, which no entity or repository maps. const cols = testDb.prepare(`PRAGMA index_info('${idx.name}')`).all() as { seqno: number; name: string }[]; return [...cols].sort((a, b) => a.seqno - b.seqno).map((c) => c.name); }); @@ -58,9 +61,9 @@ describe('settings table — the (user_id, key) unique index the ON CONFLICT tar describe('SettingsRepository', () => { it('SETTINGSREPO-001: getForUser reads every row for that user', async () => { - insertRaw(user.id, 'dark_mode', 'true'); - insertRaw(user.id, 'temperature_unit', 'celsius'); - insertRaw(otherUser.id, 'dark_mode', 'false'); // a different user's row — must not leak in + await insertSetting(user.id, 'dark_mode', 'true'); + await insertSetting(user.id, 'temperature_unit', 'celsius'); + await insertSetting(otherUser.id, 'dark_mode', 'false'); // a different user's row — must not leak in const rows = await settings.getForUser(user.id); expect(rows.map((r) => r.key).sort()).toEqual(['dark_mode', 'temperature_unit']); expect(rows.every((r) => r.user_id === user.id)).toBe(true); @@ -71,7 +74,7 @@ describe('SettingsRepository', () => { }); it('SETTINGSREPO-003: getOne reads a single (user, key) row', async () => { - insertRaw(user.id, 'dark_mode', 'true'); + await insertSetting(user.id, 'dark_mode', 'true'); const row = await settings.getOne(user.id, 'dark_mode'); expect(row).not.toBeNull(); expect(row?.value).toBe('true'); @@ -83,29 +86,27 @@ describe('SettingsRepository', () => { }); it("SETTINGSREPO-005: getOne scopes strictly by user — another user's row for the same key never leaks", async () => { - insertRaw(otherUser.id, 'dark_mode', 'true'); + await insertSetting(otherUser.id, 'dark_mode', 'true'); expect(await settings.getOne(user.id, 'dark_mode')).toBeNull(); }); it('SETTINGSREPO-006: upsertForUser inserts a new row exactly as the legacy INSERT would', async () => { await settings.upsertForUser(user.id, 'dark_mode', 'true'); - expect(rawRow(user.id, 'dark_mode')).toStrictEqual({ id: expect.any(Number), user_id: user.id, key: 'dark_mode', value: 'true' }); + expect(await storedRow(user.id, 'dark_mode')).toStrictEqual({ id: expect.any(Number), user_id: user.id, key: 'dark_mode', value: 'true' }); }); it('SETTINGSREPO-007: upsertForUser on an existing (user, key) replaces the value only — no duplicate row, matching ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value', async () => { - insertRaw(user.id, 'dark_mode', 'false'); + await insertSetting(user.id, 'dark_mode', 'false'); await settings.upsertForUser(user.id, 'dark_mode', 'true'); - expect(rawRow(user.id, 'dark_mode')).toStrictEqual({ id: expect.any(Number), user_id: user.id, key: 'dark_mode', value: 'true' }); - expect( - testDb.prepare('SELECT COUNT(*) as c FROM settings WHERE user_id = ? AND key = ?').get(user.id, 'dark_mode'), - ).toEqual({ c: 1 }); + expect(await storedRow(user.id, 'dark_mode')).toStrictEqual({ id: expect.any(Number), user_id: user.id, key: 'dark_mode', value: 'true' }); + expect(await countRows(t, Settings, { user: user.id, key: 'dark_mode' })).toBe(1); }); it("SETTINGSREPO-008: upsertForUser scopes by user — the same key for two different users stores two separate rows, not a collision (the conflict target is (user, key), not key alone)", async () => { await settings.upsertForUser(user.id, 'dark_mode', 'true'); await settings.upsertForUser(otherUser.id, 'dark_mode', 'false'); - expect(rawRow(user.id, 'dark_mode')).toMatchObject({ value: 'true' }); - expect(rawRow(otherUser.id, 'dark_mode')).toMatchObject({ value: 'false' }); + expect(await storedRow(user.id, 'dark_mode')).toMatchObject({ value: 'true' }); + expect(await storedRow(otherUser.id, 'dark_mode')).toMatchObject({ value: 'false' }); }); it("SETTINGSREPO-014: em.upsert infers the (user, key) conflict target from the metadata's relation-named uniques entry with NO explicit onConflictFields — the entry the generator's fix(db) commit made consumable (task-5-review.md Important 1). SettingsRepository.upsertForUser still passes onConflictFields explicitly, for clarity, not because it is required.", async () => { @@ -114,7 +115,7 @@ describe('SettingsRepository', () => { try { // Insert branch: no existing row, onConflictFields omitted entirely. await t.em.upsert(Settings, { user: user.id, key: 'dark_mode', value: 'true' }, { onConflictAction: 'merge' }); - expect(rawRow(user.id, 'dark_mode')).toStrictEqual({ id: expect.any(Number), user_id: user.id, key: 'dark_mode', value: 'true' }); + expect(await storedRow(user.id, 'dark_mode')).toStrictEqual({ id: expect.any(Number), user_id: user.id, key: 'dark_mode', value: 'true' }); // Merge branch: an existing row, same call shape — proves the SAME // inferred target both creates and merges, not just happens to insert once. @@ -123,10 +124,8 @@ describe('SettingsRepository', () => { spy.mockClear(); await t.em.upsert(Settings, { user: user.id, key: 'dark_mode', value: 'false' }, { onConflictAction: 'merge' }); - expect(rawRow(user.id, 'dark_mode')).toStrictEqual({ id: expect.any(Number), user_id: user.id, key: 'dark_mode', value: 'false' }); - expect( - testDb.prepare('SELECT COUNT(*) as c FROM settings WHERE user_id = ? AND key = ?').get(user.id, 'dark_mode'), - ).toEqual({ c: 1 }); + expect(await storedRow(user.id, 'dark_mode')).toStrictEqual({ id: expect.any(Number), user_id: user.id, key: 'dark_mode', value: 'false' }); + expect(await countRows(t, Settings, { user: user.id, key: 'dark_mode' })).toBe(1); const mergeSql = spy.mock.calls.map(([sql]) => sql).find((sql): sql is string => typeof sql === 'string' && /insert into/i.test(sql)); expect(mergeSql).toContain('on conflict (`user_id`, `key`)'); } finally { @@ -140,15 +139,17 @@ describe('SettingsRepository', () => { // never the surrogate `id` PK, so it is not PK-only and always re-queries, // needing no `refresh: true`. These pin that directly rather than assume it. it('SETTINGSREPO-012: getOne sees a raw UPDATE on the same (user, key) in the same request', async () => { - insertRaw(user.id, 'dark_mode', 'old'); + await insertSetting(user.id, 'dark_mode', 'old'); expect((await settings.getOne(user.id, 'dark_mode'))?.value).toBe('old'); // populate the identity map + // test-sql-allow: the out-of-band write this case is about has to bypass every EntityManager, the one under test included. testDb.prepare('UPDATE settings SET value = ? WHERE user_id = ? AND key = ?').run('new', user.id, 'dark_mode'); expect((await settings.getOne(user.id, 'dark_mode'))?.value).toBe('new'); }); it('SETTINGSREPO-013: getOne sees a raw DELETE on the same (user, key) in the same request', async () => { - insertRaw(user.id, 'dark_mode', 'old'); + await insertSetting(user.id, 'dark_mode', 'old'); expect((await settings.getOne(user.id, 'dark_mode'))?.value).toBe('old'); // populate the identity map + // test-sql-allow: the out-of-band delete this case is about has to bypass every EntityManager, the one under test included. testDb.prepare('DELETE FROM settings WHERE user_id = ? AND key = ?').run(user.id, 'dark_mode'); expect(await settings.getOne(user.id, 'dark_mode')).toBeNull(); }); diff --git a/server/tests/unit/db/repositories/Tags.repository.test.ts b/server/tests/unit/db/repositories/Tags.repository.test.ts index 079b14d54..db53306c8 100644 --- a/server/tests/unit/db/repositories/Tags.repository.test.ts +++ b/server/tests/unit/db/repositories/Tags.repository.test.ts @@ -7,6 +7,8 @@ import { Tags } from '../../../../src/db/entities/Tags.entity'; import type { TagsRepository } from '../../../../src/db/repositories/Tags.repository'; import { UnitOfWork } from '../../../../src/nest/database/unit-of-work'; import { withRequestContext } from '../../../../src/nest/database/request-context'; +import { findRows } from '../../../helpers/factories/rows'; +import { tagPlace } from '../../../helpers/factories/places'; const testDb = createSnapshotTestDb(); let t: TestOrm; @@ -22,9 +24,15 @@ beforeEach(() => { resetTestDb(testDb); t.clear(); }); afterAll(async () => { await t.close(); testDb.close(); }); function rawTag(id: number): unknown { + // test-sql-allow: the raw full row is the parity oracle the repository's reads are compared against. return testDb.prepare('SELECT * FROM tags WHERE id = ?').get(id); } +/** The ids of the tags on the place, through the place_tags pivot, in tag id order. */ +async function placeTagIds(placeId: number): Promise { + return (await findRows(t, Tags, { place_tags_inverse: placeId }, { id: 'asc' })).map((tag) => tag.id); +} + async function withQueryCount(fn: () => Promise): Promise<{ value: T; queries: number }> { const connection = t.orm.em.getConnection(); const spy = vi.spyOn(connection, 'execute'); @@ -109,6 +117,7 @@ describe('TagsRepository', () => { const { user } = createUser(testDb); const created = createTag(testDb, user.id, { name: 'Stale', color: '#111111' }); expect((await tags.findByIdAndUser(created.id, user.id))?.color).toBe('#111111'); // populate the identity map + // test-sql-allow: the out-of-band write this case is about has to bypass every EntityManager, the one under test included. testDb.prepare('UPDATE tags SET color = ? WHERE id = ?').run('#222222', created.id); const { value, queries } = await withQueryCount(() => tags.findByIdAndUser(created.id, user.id)); expect(value?.color).toBe('#222222'); @@ -201,6 +210,7 @@ describe('TagsRepository', () => { const { user } = createUser(testDb); const created = createTag(testDb, user.id, { name: 'OLD', color: '#aaaaaa' }); await tags.listByUser(user.id); // populate the identity map with name: 'OLD' + // test-sql-allow: the out-of-band write this case is about has to bypass every EntityManager, the one under test included. testDb.prepare('UPDATE tags SET name = ? WHERE id = ?').run('NEW', created.id); const updated = await tags.patch(created.id, { color: '#bbbbbb' }); expect(updated?.name).toBe('NEW'); @@ -217,6 +227,7 @@ describe('TagsRepository', () => { const { user } = createUser(testDb); const created = createTag(testDb, user.id, { name: 'RaceColor', color: '#111111' }); await tags.findByIdAndUser(created.id, user.id); // populate the identity map with '#111111' + // test-sql-allow: the out-of-band write this case is about has to bypass every EntityManager, the one under test included. testDb.prepare('UPDATE tags SET color = ? WHERE id = ?').run('#333333', created.id); const updated = await tags.patch(created.id, { color: '#111111' }); expect(updated?.color).toBe('#111111'); @@ -262,8 +273,8 @@ describe('TagsRepository', () => { // Plan 3c Task 1 (QH1): the batch tag-by-place loader behind // `QueryHelpersService.loadTagsByPlaceIds`, moved off `query-helpers.service.ts`. describe('listForPlaces', () => { - function attach(tagId: number, placeId: number): void { - testDb.prepare('INSERT INTO place_tags (tag_id, place_id) VALUES (?, ?)').run(tagId, placeId); + function attach(tagId: number, placeId: number): Promise { + return tagPlace(t, placeId, [tagId]); } it('LISTFORPLACESREPO-001: an empty placeIds array short-circuits to [] without querying', async () => { @@ -277,7 +288,7 @@ describe('TagsRepository', () => { const trip = createTrip(testDb, user.id); const place = createPlace(testDb, trip.id); const tag = createTag(testDb, user.id, { name: 'Beach', color: '#ff0000' }); - attach(tag.id, place.id); + await attach(tag.id, place.id); const rows = await tags.listForPlaces([place.id]); expect(rows).toEqual([ @@ -290,7 +301,7 @@ describe('TagsRepository', () => { const trip = createTrip(testDb, user.id); const place = createPlace(testDb, trip.id); const tag = createTag(testDb, user.id, { name: 'Compact', color: '#00ff00' }); - attach(tag.id, place.id); + await attach(tag.id, place.id); const rows = await tags.listForPlaces([place.id], { compact: true }); expect(rows).toEqual([{ id: tag.id, name: 'Compact', color: '#00ff00', created_at: (rawTag(tag.id) as { created_at: string }).created_at, place_id: place.id }]); @@ -303,8 +314,8 @@ describe('TagsRepository', () => { const placeA = createPlace(testDb, trip.id, { name: 'A' }); const placeB = createPlace(testDb, trip.id, { name: 'B' }); const tag = createTag(testDb, user.id); - attach(tag.id, placeA.id); - attach(tag.id, placeB.id); + await attach(tag.id, placeA.id); + await attach(tag.id, placeB.id); const rows = await tags.listForPlaces([placeA.id, placeB.id]); expect(rows.map((r) => r.place_id).sort()).toEqual([placeA.id, placeB.id].sort()); @@ -332,7 +343,8 @@ describe('TagsRepository', () => { // (`name`) — `find({})` with the base default merged in populates // nothing. await t.repo(Tags).find({}, { disableIdentityMap: false }); - attach(tag.id, place.id); + await attach(tag.id, place.id); + // test-sql-allow: the out-of-band write this case is about has to bypass every EntityManager, the one under test included. testDb.prepare('UPDATE tags SET name = ? WHERE id = ?').run('Renamed', tag.id); const rows = await tags.listForPlaces([place.id]); expect(rows[0]?.name).toBe('Renamed'); @@ -368,8 +380,8 @@ describe('TagsRepository.insertIgnore / deleteForPlace (PL5/PL12/PL13 — place_ await tags.insertIgnore(place.id, [tagA.id, tagB.id]); // Re-run with an overlapping id — the pre-existing pair must not error or duplicate. await tags.insertIgnore(place.id, [tagA.id]); - const rows = testDb.prepare('SELECT tag_id FROM place_tags WHERE place_id = ? ORDER BY tag_id').all(place.id) as { tag_id: number }[]; - expect(rows.map((r) => r.tag_id).sort((a, b) => a - b)).toEqual([tagA.id, tagB.id].sort((a, b) => a - b)); + const rows = await placeTagIds(place.id); + expect([...rows].sort((a, b) => a - b)).toEqual([tagA.id, tagB.id].sort((a, b) => a - b)); }); it('PLACETAGSREPO-002: insertIgnore with an empty array is a no-op', async () => { @@ -377,7 +389,7 @@ describe('TagsRepository.insertIgnore / deleteForPlace (PL5/PL12/PL13 — place_ const trip = createTrip(testDb, user.id); const place = createPlace(testDb, trip.id); await tags.insertIgnore(place.id, []); - const rows = testDb.prepare('SELECT tag_id FROM place_tags WHERE place_id = ?').all(place.id); + const rows = await placeTagIds(place.id); expect(rows).toEqual([]); }); @@ -390,8 +402,8 @@ describe('TagsRepository.insertIgnore / deleteForPlace (PL5/PL12/PL13 — place_ await tags.insertIgnore(place.id, [tag.id]); await tags.insertIgnore(other.id, [tag.id]); await tags.deleteForPlace(place.id); - expect(testDb.prepare('SELECT * FROM place_tags WHERE place_id = ?').all(place.id)).toEqual([]); - expect(testDb.prepare('SELECT * FROM place_tags WHERE place_id = ?').all(other.id)).toHaveLength(1); + expect(await placeTagIds(place.id)).toEqual([]); + expect(await placeTagIds(other.id)).toHaveLength(1); }); // Task 9 fix wave (B-M5, the OAUTHTOKREPO-012/REANCHORDAY-003 rollback @@ -416,7 +428,7 @@ describe('TagsRepository.insertIgnore / deleteForPlace (PL5/PL12/PL13 — place_ } catch (e) { caught = e; } expect((caught as Error).message).toBe('force rollback'); - expect(testDb.prepare('SELECT * FROM place_tags WHERE place_id = ?').all(place.id)).toEqual([]); + expect(await placeTagIds(place.id)).toEqual([]); }); it('PLACETAGSREPO-007: deleteForPlace inside a uow.transactional that then ROLLS BACK leaves the pairs in place', async () => { @@ -437,7 +449,7 @@ describe('TagsRepository.insertIgnore / deleteForPlace (PL5/PL12/PL13 — place_ } catch (e) { caught = e; } expect((caught as Error).message).toBe('force rollback'); - expect(testDb.prepare('SELECT * FROM place_tags WHERE place_id = ?').all(place.id)).toHaveLength(1); + expect(await placeTagIds(place.id)).toHaveLength(1); }); }); @@ -492,6 +504,6 @@ describe('TagsRepository.listPlaceTagsForTrip (TP46)', () => { expect((caught as Error).message).toBe('force rollback'); expect(await tags.listPlaceTagsForTrip(trip.id)).toEqual([]); - expect(testDb.prepare('SELECT * FROM place_tags WHERE place_id = ?').all(place.id)).toEqual([]); + expect(await placeTagIds(place.id)).toEqual([]); }); }); diff --git a/server/tests/unit/db/repositories/UserNoticeDismissals.repository.test.ts b/server/tests/unit/db/repositories/UserNoticeDismissals.repository.test.ts index 2a0c204ce..954dbcc49 100644 --- a/server/tests/unit/db/repositories/UserNoticeDismissals.repository.test.ts +++ b/server/tests/unit/db/repositories/UserNoticeDismissals.repository.test.ts @@ -5,6 +5,7 @@ import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; import { createUser, type TestUser } from '../../../helpers/factories'; import { UserNoticeDismissals } from '../../../../src/db/entities/UserNoticeDismissals.entity'; import type { UserNoticeDismissalsRepository } from '../../../../src/db/repositories/UserNoticeDismissals.repository'; +import { countRows, findRow, insertRow } from '../../../helpers/factories/rows'; const testDb = createSnapshotTestDb(); let t: TestOrm; @@ -27,12 +28,19 @@ afterAll(async () => { testDb.close(); }); -function insertRaw(userId: number, noticeId: string, dismissedAt: number, dismissedAppVersion: string | null): void { - testDb.prepare('INSERT INTO user_notice_dismissals (user_id, notice_id, dismissed_at, dismissed_app_version) VALUES (?, ?, ?, ?)').run(userId, noticeId, dismissedAt, dismissedAppVersion); +async function insertDismissal(userId: number, noticeId: string, dismissedAt: number, dismissedAppVersion: string | null): Promise { + await insertRow(t, UserNoticeDismissals, { user: userId, notice_id: noticeId, dismissed_at: dismissedAt, dismissed_app_version: dismissedAppVersion }); +} + +/** The dismissal's stored columns, or undefined when there is none. */ +async function storedDismissal(userId: number, noticeId: string) { + const row = await findRow(t, UserNoticeDismissals, { user: userId, notice_id: noticeId }); + return row ? { dismissed_at: row.dismissed_at, dismissed_app_version: row.dismissed_app_version } : undefined; } describe('user_notice_dismissals — the (user_id, notice_id) composite PK the ON CONFLICT target relies on (Plan 3f Task 0 R5/§14, Task 6)', () => { it("UNDREPO-SCHEMA-001: PRAGMA table_info('user_notice_dismissals') reports a 2-column PRIMARY KEY over exactly (user_id, notice_id), matching the migration DDL", () => { + // test-sql-allow: the primary key comes from PRAGMA table_info, which no entity or repository maps. const cols = testDb.prepare("PRAGMA table_info('user_notice_dismissals')").all() as { name: string; pk: number }[]; const pkCols = cols.filter((c) => c.pk > 0).sort((a, b) => a.pk - b.pk).map((c) => c.name); expect(pkCols).toEqual(['user_id', 'notice_id']); @@ -46,11 +54,12 @@ describe('user_notice_dismissals — the (user_id, notice_id) composite PK the O describe('UserNoticeDismissalsRepository — reads (SN4 parity)', () => { it('UNDREPO-001 — listForUser matches `SELECT notice_id, dismissed_app_version FROM user_notice_dismissals WHERE user_id = ?`, fully seeded across several notices/versions, and never leaks another user', async () => { - insertRaw(user.id, 'welcome-v1', 1_700_000_000_000, null); - insertRaw(user.id, 'release-notes', 1_700_000_001_000, '4.2.1'); - insertRaw(user.id, 'v3014-whitespace-collision', 1_700_000_002_000, '3.0.14'); - insertRaw(otherUser.id, 'welcome-v1', 1_700_000_003_000, '4.3.0'); // a different user's row — must not leak in + await insertDismissal(user.id, 'welcome-v1', 1_700_000_000_000, null); + await insertDismissal(user.id, 'release-notes', 1_700_000_001_000, '4.2.1'); + await insertDismissal(user.id, 'v3014-whitespace-collision', 1_700_000_002_000, '3.0.14'); + await insertDismissal(otherUser.id, 'welcome-v1', 1_700_000_003_000, '4.3.0'); // a different user's row — must not leak in + // test-sql-allow: the legacy statement is the parity oracle the repository is compared against. const legacy = (testDb.prepare('SELECT notice_id, dismissed_app_version FROM user_notice_dismissals WHERE user_id = ?').all(user.id) as { notice_id: string; dismissed_app_version: string | null }[]) .sort((a, b) => a.notice_id.localeCompare(b.notice_id)); const converted = (await repo.listForUser(user.id)) @@ -77,7 +86,7 @@ describe('UserNoticeDismissalsRepository — writes (SN5, R5)', () => { try { // Insert branch: no existing row. await repo.upsertDismissal(user.id, 'release-notes', 1_700_000_000_000, '4.2.1'); - expect(testDb.prepare('SELECT dismissed_at, dismissed_app_version FROM user_notice_dismissals WHERE user_id = ? AND notice_id = ?').get(user.id, 'release-notes')) + expect(await storedDismissal(user.id, 'release-notes')) .toEqual({ dismissed_at: 1_700_000_000_000, dismissed_app_version: '4.2.1' }); const insertSql = spy.mock.calls.map(([sql]) => sql).find((sql): sql is string => typeof sql === 'string' && /insert into/i.test(sql)); @@ -88,9 +97,9 @@ describe('UserNoticeDismissalsRepository — writes (SN5, R5)', () => { // the existing row in place rather than inserting a second one (the composite PK // is what makes this an UPDATE, not an INSERT, on the second call). await repo.upsertDismissal(user.id, 'release-notes', 1_700_000_050_000, '4.3.0'); - expect(testDb.prepare('SELECT dismissed_at, dismissed_app_version FROM user_notice_dismissals WHERE user_id = ? AND notice_id = ?').get(user.id, 'release-notes')) + expect(await storedDismissal(user.id, 'release-notes')) .toEqual({ dismissed_at: 1_700_000_050_000, dismissed_app_version: '4.3.0' }); - expect((testDb.prepare('SELECT COUNT(*) as c FROM user_notice_dismissals WHERE user_id = ? AND notice_id = ?').get(user.id, 'release-notes') as { c: number }).c).toBe(1); + expect(await countRows(t, UserNoticeDismissals, { user: user.id, notice_id: 'release-notes' })).toBe(1); const mergeSql = spy.mock.calls.map(([sql]) => sql).find((sql): sql is string => typeof sql === 'string' && /insert into/i.test(sql)); expect(mergeSql).toContain('on conflict (`user_id`, `notice_id`)'); @@ -105,10 +114,8 @@ describe('UserNoticeDismissalsRepository — writes (SN5, R5)', () => { await repo.upsertDismissal(user.id, 'welcome-v1', 1_700_000_001_000, '4.0.0'); await repo.upsertDismissal(otherUser.id, 'release-notes', 1_700_000_002_000, '4.1.0'); - expect((testDb.prepare('SELECT COUNT(*) as c FROM user_notice_dismissals').get() as { c: number }).c).toBe(3); - expect(testDb.prepare('SELECT dismissed_app_version FROM user_notice_dismissals WHERE user_id = ? AND notice_id = ?').get(user.id, 'release-notes')) - .toEqual({ dismissed_app_version: '4.2.1' }); - expect(testDb.prepare('SELECT dismissed_app_version FROM user_notice_dismissals WHERE user_id = ? AND notice_id = ?').get(otherUser.id, 'release-notes')) - .toEqual({ dismissed_app_version: '4.1.0' }); + expect(await countRows(t, UserNoticeDismissals)).toBe(3); + expect((await storedDismissal(user.id, 'release-notes'))?.dismissed_app_version).toBe('4.2.1'); + expect((await storedDismissal(otherUser.id, 'release-notes'))?.dismissed_app_version).toBe('4.1.0'); }); }); diff --git a/server/tests/unit/db/repositories/VacayPlanMembers.repository.test.ts b/server/tests/unit/db/repositories/VacayPlanMembers.repository.test.ts index 0c2972dae..7044a1420 100644 --- a/server/tests/unit/db/repositories/VacayPlanMembers.repository.test.ts +++ b/server/tests/unit/db/repositories/VacayPlanMembers.repository.test.ts @@ -11,6 +11,9 @@ import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; import { createUser } from '../../../helpers/factories'; import { VacayPlanMembers } from '../../../../src/db/entities/VacayPlanMembers.entity'; import type { VacayPlanMembersRepository } from '../../../../src/db/repositories/VacayPlanMembers.repository'; +import { insertRow, updateRows } from '../../../helpers/factories/rows'; +import { makeVacayPlan } from '../../../helpers/factories/vacay'; +import { Users } from '../../../../src/db/entities/Users.entity'; const testDb = createSnapshotTestDb(); let t: TestOrm; @@ -23,31 +26,32 @@ beforeAll(async () => { beforeEach(() => { resetTestDb(testDb); t.clear(); }); afterAll(async () => { await t.close(); testDb.close(); }); -function insertPlan(ownerId: number): number { - return Number(testDb.prepare('INSERT INTO vacay_plans (owner_id) VALUES (?)').run(ownerId).lastInsertRowid); +async function insertPlan(ownerId: number): Promise { + return (await makeVacayPlan(t, ownerId)).id; } -function insertMember(planId: number, userId: number, status: string): number { - return Number(testDb.prepare('INSERT INTO vacay_plan_members (plan_id, user_id, status) VALUES (?, ?, ?)').run(planId, userId, status).lastInsertRowid); +function insertMember(planId: number, userId: number, status: string): Promise { + return insertRow(t, VacayPlanMembers, { plan: planId, user: userId, status }); } describe('VacayPlanMembersRepository.listAvailableForFusion (VC75, getAvailableUsers — the fusion-candidate picker)', () => { it('VACAYMEMREPO-001: matches the legacy three-NOT-IN statement — excludes self, guests, existing plan members, any already-fused user, and the owner of an already-fused plan', async () => { const { user: me } = createUser(testDb); - const planId = insertPlan(me.id); + const planId = await insertPlan(me.id); const { user: candidate } = createUser(testDb, { username: 'candidate' }); const { user: existingMember } = createUser(testDb, { username: 'existingmember' }); - insertMember(planId, existingMember.id, 'pending'); + await insertMember(planId, existingMember.id, 'pending'); const { user: alreadyFusedElsewhere } = createUser(testDb, { username: 'fusedelsewhere' }); - const otherPlan = insertPlan(createUser(testDb, { username: 'otherowner' }).user.id); - insertMember(otherPlan, alreadyFusedElsewhere.id, 'accepted'); + const otherPlan = await insertPlan(createUser(testDb, { username: 'otherowner' }).user.id); + await insertMember(otherPlan, alreadyFusedElsewhere.id, 'accepted'); const { user: fusedPlanOwner } = createUser(testDb, { username: 'fusedplanowner' }); - const fusedPlan = insertPlan(fusedPlanOwner.id); + const fusedPlan = await insertPlan(fusedPlanOwner.id); const { user: theirFusedMember } = createUser(testDb, { username: 'theirfusedmember' }); - insertMember(fusedPlan, theirFusedMember.id, 'accepted'); + await insertMember(fusedPlan, theirFusedMember.id, 'accepted'); const { user: guest } = createUser(testDb, { username: 'guestuser' }); - testDb.prepare('UPDATE users SET is_guest = 1 WHERE id = ?').run(guest.id); + await updateRows(t, Users, { id: guest.id }, { is_guest: 1 }); + // test-sql-allow: the legacy VC75 statement is the parity oracle the repository is compared against. const legacy = testDb.prepare(` SELECT u.id, u.username, u.email FROM users u WHERE u.id != ? AND COALESCE(u.is_guest, 0) = 0 @@ -70,12 +74,13 @@ describe('VacayPlanMembersRepository.listAvailableForFusion (VC75, getAvailableU describe('VacayPlanMembersRepository.listPendingForPlan / listPendingForUser (VC129/130, getPlanData\'s share picker)', () => { it('VACAYMEMREPO-002: listPendingForPlan matches the legacy JOIN — outgoing invites the plan owner sent', async () => { const { user: owner } = createUser(testDb); - const planId = insertPlan(owner.id); + const planId = await insertPlan(owner.id); const { user: invitee } = createUser(testDb, { username: 'invitee' }); - insertMember(planId, invitee.id, 'pending'); + await insertMember(planId, invitee.id, 'pending'); const { user: accepted } = createUser(testDb, { username: 'accepted' }); - insertMember(planId, accepted.id, 'accepted'); + await insertMember(planId, accepted.id, 'accepted'); + // test-sql-allow: the legacy statement is the parity oracle the repository is compared against. const legacy = testDb.prepare(` SELECT m.id, m.user_id, u.username, u.email, m.created_at FROM vacay_plan_members m JOIN users u ON m.user_id = u.id @@ -88,10 +93,11 @@ describe('VacayPlanMembersRepository.listPendingForPlan / listPendingForUser (VC it('VACAYMEMREPO-003: listPendingForUser matches the legacy JOIN — the OWNER\'s username/email, not the invited member\'s', async () => { const { user: owner } = createUser(testDb, { username: 'planowner' }); - const planId = insertPlan(owner.id); + const planId = await insertPlan(owner.id); const { user: invitee } = createUser(testDb, { username: 'invitee' }); - insertMember(planId, invitee.id, 'pending'); + await insertMember(planId, invitee.id, 'pending'); + // test-sql-allow: the legacy statement is the parity oracle the repository is compared against. const legacy = testDb.prepare(` SELECT m.id, m.plan_id, u.username, u.email, m.created_at FROM vacay_plan_members m JOIN vacay_plans p ON m.plan_id = p.id JOIN users u ON p.owner_id = u.id diff --git a/server/tests/unit/db/repositories/VacayShares.repository.test.ts b/server/tests/unit/db/repositories/VacayShares.repository.test.ts index 92d9efe27..28a1c7f03 100644 --- a/server/tests/unit/db/repositories/VacayShares.repository.test.ts +++ b/server/tests/unit/db/repositories/VacayShares.repository.test.ts @@ -10,12 +10,20 @@ import { resetTestDb } from '../../../helpers/test-db'; import { createUser } from '../../../helpers/factories'; import { createTestVacaySharesRepo } from '../../../helpers/vacay-repos'; import type { VacaySharesRepository } from '../../../../src/db/repositories/VacayShares.repository'; +import { sharedTestOrm } from '../../../helpers/test-uow'; +import type { TestOrm } from '../../../helpers/test-orm'; +import { insertRow, updateRows } from '../../../helpers/factories/rows'; +import { addVacayPlanMember, makeVacayPlan } from '../../../helpers/factories/vacay'; +import { Users } from '../../../../src/db/entities/Users.entity'; +import { VacayShares } from '../../../../src/db/entities/VacayShares.entity'; const testDb = createSnapshotTestDb(); let repo: VacaySharesRepository; +let orm: TestOrm; beforeAll(async () => { repo = await createTestVacaySharesRepo(testDb); + orm = await sharedTestOrm(testDb); }); beforeEach(() => { resetTestDb(testDb); @@ -25,10 +33,8 @@ afterAll(() => testDb.close()); const LEGACY_LIST_OUTGOING = `SELECT s.id, s.user_id, u.username FROM vacay_shares s JOIN users u ON s.user_id = u.id WHERE s.owner_id = ? ORDER BY s.id`; const LEGACY_LIST_INCOMING = `SELECT s.id, s.owner_id, s.hidden, u.username FROM vacay_shares s JOIN users u ON s.owner_id = u.id WHERE s.user_id = ? ORDER BY s.id`; -function insertShare(ownerId: number, userId: number, hidden = 0): number { - return Number( - testDb.prepare('INSERT INTO vacay_shares (owner_id, user_id, hidden) VALUES (?, ?, ?)').run(ownerId, userId, hidden).lastInsertRowid, - ); +function insertShare(ownerId: number, userId: number, hidden = 0): Promise { + return insertRow(orm, VacayShares, { owner: ownerId, user: userId, hidden }); } describe('VacaySharesRepository — VC81/VC82 parity with the legacy statements', () => { @@ -37,10 +43,11 @@ describe('VacaySharesRepository — VC81/VC82 parity with the legacy statements' const { user: recipient1 } = createUser(testDb, { username: 'recipient-1' }); const { user: recipient2 } = createUser(testDb, { username: 'recipient-2' }); const { user: ownerB } = createUser(testDb, { username: 'owner-b' }); - insertShare(ownerA.id, recipient2.id); - insertShare(ownerA.id, recipient1.id); - insertShare(ownerB.id, recipient1.id); // a different owner — must not appear for ownerA + await insertShare(ownerA.id, recipient2.id); + await insertShare(ownerA.id, recipient1.id); + await insertShare(ownerB.id, recipient1.id); // a different owner — must not appear for ownerA + // test-sql-allow: the legacy VC81 statement is the parity oracle the repository is compared against. const legacy = testDb.prepare(LEGACY_LIST_OUTGOING).all(ownerA.id); const rows = await repo.listOutgoing(ownerA.id); @@ -50,6 +57,7 @@ describe('VacaySharesRepository — VC81/VC82 parity with the legacy statements' it('VC81 listOutgoing: an owner with no outgoing shares returns [], matching the legacy statement', async () => { const { user: owner } = createUser(testDb); + // test-sql-allow: the legacy VC81 statement is the parity oracle the repository is compared against. expect(await repo.listOutgoing(owner.id)).toEqual(testDb.prepare(LEGACY_LIST_OUTGOING).all(owner.id)); }); @@ -58,10 +66,11 @@ describe('VacaySharesRepository — VC81/VC82 parity with the legacy statements' const { user: ownerA } = createUser(testDb, { username: 'owner-a' }); const { user: ownerB } = createUser(testDb, { username: 'owner-b' }); const { user: elsewhere } = createUser(testDb, { username: 'elsewhere' }); - insertShare(ownerB.id, recipient.id, 1); // hidden - insertShare(ownerA.id, recipient.id, 0); - insertShare(ownerA.id, elsewhere.id, 0); // a different recipient — must not appear for `recipient` + await insertShare(ownerB.id, recipient.id, 1); // hidden + await insertShare(ownerA.id, recipient.id, 0); + await insertShare(ownerA.id, elsewhere.id, 0); // a different recipient — must not appear for `recipient` + // test-sql-allow: the legacy VC82 statement is the parity oracle the repository is compared against. const legacy = testDb.prepare(LEGACY_LIST_INCOMING).all(recipient.id); const rows = await repo.listIncoming(recipient.id); @@ -74,6 +83,7 @@ describe('VacaySharesRepository — VC81/VC82 parity with the legacy statements' it('VC82/VC91 listIncoming: a recipient with no incoming shares returns [], matching the legacy statement', async () => { const { user: recipient } = createUser(testDb); + // test-sql-allow: the legacy VC82 statement is the parity oracle the repository is compared against. expect(await repo.listIncoming(recipient.id)).toEqual(testDb.prepare(LEGACY_LIST_INCOMING).all(recipient.id)); }); }); @@ -96,13 +106,14 @@ describe('VacaySharesRepository — VC90 listAvailableForShare parity with the l const { user: guest } = createUser(testDb, { username: 'a-guest' }); const { user: sharedByOther } = createUser(testDb, { username: 'shared-by-someone-else' }); createUser(testDb, { username: 'bystander' }); - testDb.prepare('UPDATE users SET is_guest = 1 WHERE id = ?').run(guest.id); - const planId = Number(testDb.prepare('INSERT INTO vacay_plans (owner_id) VALUES (?)').run(planOwner.id).lastInsertRowid); - testDb.prepare("INSERT INTO vacay_plan_members (plan_id, user_id, status) VALUES (?, ?, 'accepted')").run(planId, accepted.id); - testDb.prepare("INSERT INTO vacay_plan_members (plan_id, user_id, status) VALUES (?, ?, 'pending')").run(planId, pending.id); - insertShare(caller.id, alreadyShared.id); - insertShare(planOwner.id, sharedByOther.id); // another owner's share — must not exclude + await updateRows(orm, Users, { id: guest.id }, { is_guest: 1 }); + const { id: planId } = await makeVacayPlan(orm, planOwner.id); + await addVacayPlanMember(orm, planId, accepted.id, 'accepted'); + await addVacayPlanMember(orm, planId, pending.id, 'pending'); + await insertShare(caller.id, alreadyShared.id); + await insertShare(planOwner.id, sharedByOther.id); // another owner's share — must not exclude + // test-sql-allow: the legacy statement is the parity oracle the repository is compared against. const legacy = testDb.prepare(LEGACY_LIST_AVAILABLE).all(caller.id, caller.id, planId, planId); const rows = await repo.listAvailableForShare(caller.id, planId); @@ -113,6 +124,7 @@ describe('VacaySharesRepository — VC90 listAvailableForShare parity with the l it('VC90 listAvailableForShare: a plan id with no plan excludes only the caller, guests and shares, matching the legacy statement', async () => { const { user: caller } = createUser(testDb, { username: 'caller' }); createUser(testDb, { username: 'someone' }); + // test-sql-allow: the legacy statement is the parity oracle the repository is compared against. const legacy = testDb.prepare(LEGACY_LIST_AVAILABLE).all(caller.id, caller.id, 999999, 999999); const rows = await repo.listAvailableForShare(caller.id, 999999); expect(rows).toEqual(legacy); diff --git a/server/tests/unit/db/repositories/VacayUserSettings.repository.test.ts b/server/tests/unit/db/repositories/VacayUserSettings.repository.test.ts index 811b73c8a..480ed0c8d 100644 --- a/server/tests/unit/db/repositories/VacayUserSettings.repository.test.ts +++ b/server/tests/unit/db/repositories/VacayUserSettings.repository.test.ts @@ -10,6 +10,7 @@ import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; import { createUser } from '../../../helpers/factories'; import { VacayUserSettings } from '../../../../src/db/entities/VacayUserSettings.entity'; import type { VacayUserSettingsRepository } from '../../../../src/db/repositories/VacayUserSettings.repository'; +import { insertRow } from '../../../helpers/factories/rows'; const testDb = createSnapshotTestDb(); let t: TestOrm; @@ -25,17 +26,18 @@ afterAll(async () => { await t.close(); testDb.close(); }); describe('VacayUserSettingsRepository.findForUser (VC3, getUserYearSettings)', () => { it('VACAYUSETREPO-001: matches SELECT * FROM vacay_user_settings WHERE user_id = ? run raw, hire_date both NULL and SET', async () => { const { user } = createUser(testDb); - testDb.prepare('INSERT INTO vacay_user_settings (user_id, year_type, year_start_month, year_start_day, hire_date) VALUES (?, ?, ?, ?, ?)') - .run(user.id, 'fiscal', 4, 1, '2020-06-15'); + await insertRow(t, VacayUserSettings, { user: user.id, year_type: 'fiscal', year_start_month: 4, year_start_day: 1, hire_date: '2020-06-15' }); + // test-sql-allow: the legacy full-row SELECT is the parity oracle the repository is compared against. const legacy = testDb.prepare('SELECT * FROM vacay_user_settings WHERE user_id = ?').get(user.id); expect(await repo.findForUser(user.id)).toEqual(legacy); }); it('VACAYUSETREPO-002: hire_date NULL matches the legacy row too', async () => { const { user } = createUser(testDb); - testDb.prepare('INSERT INTO vacay_user_settings (user_id) VALUES (?)').run(user.id); + await insertRow(t, VacayUserSettings, { user: user.id }); + // test-sql-allow: the legacy full-row SELECT is the parity oracle the repository is compared against. const legacy = testDb.prepare('SELECT * FROM vacay_user_settings WHERE user_id = ?').get(user.id); expect(await repo.findForUser(user.id)).toEqual(legacy); }); diff --git a/server/tests/unit/db/repositories/VacayUserYears.repository.test.ts b/server/tests/unit/db/repositories/VacayUserYears.repository.test.ts index 6e2009b39..4da99c414 100644 --- a/server/tests/unit/db/repositories/VacayUserYears.repository.test.ts +++ b/server/tests/unit/db/repositories/VacayUserYears.repository.test.ts @@ -10,6 +10,8 @@ import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; import { createUser } from '../../../helpers/factories'; import { VacayUserYears } from '../../../../src/db/entities/VacayUserYears.entity'; import type { VacayUserYearsRepository } from '../../../../src/db/repositories/VacayUserYears.repository'; +import { insertRow } from '../../../helpers/factories/rows'; +import { makeVacayPlan } from '../../../helpers/factories/vacay'; const testDb = createSnapshotTestDb(); let t: TestOrm; @@ -22,27 +24,27 @@ beforeAll(async () => { beforeEach(() => { resetTestDb(testDb); t.clear(); }); afterAll(async () => { await t.close(); testDb.close(); }); -function insertPlan(ownerId: number): number { - return Number(testDb.prepare('INSERT INTO vacay_plans (owner_id) VALUES (?)').run(ownerId).lastInsertRowid); +async function insertPlan(ownerId: number): Promise { + return (await makeVacayPlan(t, ownerId)).id; } describe('VacayUserYearsRepository.findForYear (VC34/98/109/123, getStats)', () => { it('VACAYUYEARREPO-001: matches the legacy row with vacation_days SET (30)', async () => { const { user } = createUser(testDb); - const planId = insertPlan(user.id); - testDb.prepare('INSERT INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, ?, ?)') - .run(user.id, planId, 2026, 25, 3); + const planId = await insertPlan(user.id); + await insertRow(t, VacayUserYears, { user: user.id, plan: planId, year: 2026, vacation_days: 25, carried_over: 3 }); + // test-sql-allow: the legacy full-row SELECT is the parity oracle the repository is compared against. const legacy = testDb.prepare('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?').get(user.id, planId, 2026); expect(await repo.findForYear(user.id, planId, 2026)).toEqual(legacy); }); it('VACAYUYEARREPO-002: matches the legacy row with vacation_days explicitly NULL — the review\'s named gap', async () => { const { user } = createUser(testDb); - const planId = insertPlan(user.id); - testDb.prepare('INSERT INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, ?, ?)') - .run(user.id, planId, 2027, null, null); + const planId = await insertPlan(user.id); + await insertRow(t, VacayUserYears, { user: user.id, plan: planId, year: 2027, vacation_days: null, carried_over: null }); + // test-sql-allow: the legacy full-row SELECT is the parity oracle the repository is compared against. const legacy = testDb.prepare('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?').get(user.id, planId, 2027); const row = await repo.findForYear(user.id, planId, 2027); expect(row).toEqual(legacy); @@ -52,7 +54,7 @@ describe('VacayUserYearsRepository.findForYear (VC34/98/109/123, getStats)', () it('VACAYUYEARREPO-003: null for a year with no row', async () => { const { user } = createUser(testDb); - const planId = insertPlan(user.id); + const planId = await insertPlan(user.id); expect(await repo.findForYear(user.id, planId, 2099)).toBeNull(); }); }); diff --git a/server/tests/unit/db/repositories/_shared/collection-role.parity.test.ts b/server/tests/unit/db/repositories/_shared/collection-role.parity.test.ts index 29e1d7cd0..a13a152db 100644 --- a/server/tests/unit/db/repositories/_shared/collection-role.parity.test.ts +++ b/server/tests/unit/db/repositories/_shared/collection-role.parity.test.ts @@ -6,6 +6,7 @@ import { createUser } from '../../../../helpers/factories'; import { Collections } from '../../../../../src/db/entities/Collections.entity'; import { CollectionMembers } from '../../../../../src/db/entities/CollectionMembers.entity'; import { resolveCollectionRole, type CollectionRole } from '../../../../../src/db/repositories/_shared/collection-role'; +import { addCollectionMember, makeCollection } from '../../../../helpers/factories/collections'; /** * Plan 3h Task 1, R6 — the collections cluster's shared collection-role @@ -48,7 +49,7 @@ afterAll(async () => { await t.close(); testDb.close(); }); type Actor = 'owner' | 'admin' | 'editor' | 'viewer' | 'pending' | 'stranger'; -function seedFixture() { +async function seedFixture() { const { user: owner } = createUser(testDb); const { user: admin } = createUser(testDb); const { user: editor } = createUser(testDb); @@ -56,16 +57,14 @@ function seedFixture() { const { user: pending } = createUser(testDb); const { user: stranger } = createUser(testDb); - const collectionId = testDb - .prepare("INSERT INTO collections (owner_id, name) VALUES (?, 'Fixture')") - .run(owner.id).lastInsertRowid as number; + const { id: collectionId } = await makeCollection(t, owner.id, { name: 'Fixture' }); const addMember = (userId: number, role: string, status: string) => - testDb.prepare('INSERT INTO collection_members (collection_id, user_id, status, role) VALUES (?, ?, ?, ?)').run(collectionId, userId, status, role); - addMember(admin.id, 'admin', 'accepted'); - addMember(editor.id, 'editor', 'accepted'); - addMember(viewer.id, 'viewer', 'accepted'); - addMember(pending.id, 'editor', 'pending'); + addCollectionMember(t, collectionId, userId, { status, role }); + await addMember(admin.id, 'admin', 'accepted'); + await addMember(editor.id, 'editor', 'accepted'); + await addMember(viewer.id, 'viewer', 'accepted'); + await addMember(pending.id, 'editor', 'pending'); return { collectionId: collectionId as number, @@ -104,7 +103,7 @@ const EXPECTED: Record { (Object.keys(EXPECTED) as Actor[]).forEach((actor) => { it(`resolves the ${actor} case to role=${String(EXPECTED[actor].role)} and the matching refusal shapes`, async () => { - const { collectionId, userIds } = seedFixture(); + const { collectionId, userIds } = await seedFixture(); const collections = t.repo(Collections); const members = t.repo(CollectionMembers); @@ -119,7 +118,7 @@ describe('_shared/collection-role — R6 parity harness (owner/admin/editor/view }); it('a stranger and a pending invite are indistinguishable to the predicate (both resolve to null, no access)', async () => { - const { collectionId, userIds } = seedFixture(); + const { collectionId, userIds } = await seedFixture(); const collections = t.repo(Collections); const members = t.repo(CollectionMembers); diff --git a/server/tests/unit/db/repositories/identity-map-writeback.test.ts b/server/tests/unit/db/repositories/identity-map-writeback.test.ts index 795a39214..fff74bad7 100644 --- a/server/tests/unit/db/repositories/identity-map-writeback.test.ts +++ b/server/tests/unit/db/repositories/identity-map-writeback.test.ts @@ -39,6 +39,7 @@ import { Users } from '../../../../src/db/entities/Users.entity'; import type { UsersRepository } from '../../../../src/db/repositories/Users.repository'; import { UnitOfWork } from '../../../../src/nest/database/unit-of-work'; import { withRequestContext } from '../../../../src/nest/database/request-context'; +import { readUser } from '../../../helpers/factories/users'; const testDb = createSnapshotTestDb(); let t: TestOrm; @@ -56,7 +57,7 @@ afterAll(async () => { await t.close(); testDb.close(); }); describe('identity-map write-back (task-1-review.md B1)', () => { it('IMWB-001: findByIdWithPasswordVersion then getApiKeyColumns then patchProfile — the profile write survives the request', async () => { const { user } = createUser(testDb, { username: 'before', email: 'before@example.test' }); - const originalPasswordVersion = (testDb.prepare('SELECT password_version FROM users WHERE id = ?').get(user.id) as { password_version: number }).password_version; + const originalPasswordVersion = (await readUser(t, user.id)).password_version; await withRequestContext(t.orm, async () => { // Projection A — the JwtAuthGuard/GlobalAuthGuard shape. @@ -71,9 +72,7 @@ describe('identity-map write-back (task-1-review.md B1)', () => { }); }); - const row = testDb - .prepare('SELECT username, email, password_version FROM users WHERE id = ?') - .get(user.id) as { username: string; email: string; password_version: number }; + const row = await readUser(t, user.id); expect(row.username).toBe('newname'); // the nativeUpdate must stick expect(row.email).toBe('before@example.test'); // untouched by either read expect(row.password_version).toBe(originalPasswordVersion); // untouched by either read @@ -90,9 +89,7 @@ describe('identity-map write-back (task-1-review.md B1)', () => { }); }); - const row = testDb - .prepare('SELECT password_hash, password_version, username FROM users WHERE id = ?') - .get(user.id) as { password_hash: string; password_version: number; username: string }; + const row = await readUser(t, user.id); expect(row.password_hash).toBe('NEWHASH'); expect(row.password_version).toBe(5); // the session-invalidation gate — must NOT revert to the pre-change value expect(row.username).toBe('someone'); // untouched by either read diff --git a/server/tests/unit/db/repositories/vacay-branch-coverage.test.ts b/server/tests/unit/db/repositories/vacay-branch-coverage.test.ts index ab5cd336c..ef7e65715 100644 --- a/server/tests/unit/db/repositories/vacay-branch-coverage.test.ts +++ b/server/tests/unit/db/repositories/vacay-branch-coverage.test.ts @@ -18,11 +18,20 @@ import { createTestVacayCompanyHolidaysRepo, createTestVacaySharesRepo, } from '../../../helpers/vacay-repos'; +import { sharedTestOrm } from '../../../helpers/test-uow'; +import { insertRow, updateRows } from '../../../helpers/factories/rows'; +import { addVacayPlanMember, makeVacayPlan } from '../../../helpers/factories/vacay'; +import { VacayCompanyHolidays } from '../../../../src/db/entities/VacayCompanyHolidays.entity'; +import { VacayHolidayCalendars } from '../../../../src/db/entities/VacayHolidayCalendars.entity'; +import { VacayPlanMembers } from '../../../../src/db/entities/VacayPlanMembers.entity'; +import { VacayPlans } from '../../../../src/db/entities/VacayPlans.entity'; +import { VacayUserColors } from '../../../../src/db/entities/VacayUserColors.entity'; const testDb = createSnapshotTestDb(); +const orm = () => sharedTestOrm(testDb); -function makePlan(ownerId: number): number { - return Number(testDb.prepare('INSERT INTO vacay_plans (owner_id) VALUES (?)').run(ownerId).lastInsertRowid); +async function makePlan(ownerId: number): Promise { + return (await makeVacayPlan(await orm(), ownerId)).id; } beforeEach(() => resetTestDb(testDb)); @@ -32,8 +41,8 @@ describe('VacayCompanyHolidaysRepository — note ?? null (VC29/VC67/VC112)', () it('listForPlan/listForRange return note: null for a row whose note column is genuinely NULL', async () => { const repo = await createTestVacayCompanyHolidaysRepo(testDb); const { user } = createUser(testDb); - const planId = makePlan(user.id); - testDb.prepare('INSERT INTO vacay_company_holidays (plan_id, date, note) VALUES (?, ?, NULL)').run(planId, '2026-12-25'); + const planId = await makePlan(user.id); + await insertRow(await orm(), VacayCompanyHolidays, { plan: planId, date: '2026-12-25', note: null }); expect(await repo.listForPlan(planId)).toEqual([{ date: '2026-12-25', note: null, fraction: 1 }]); expect(await repo.listForRange(planId, '2026-12-01', '2027-01-01')).toMatchObject([{ date: '2026-12-25', note: null }]); @@ -45,9 +54,9 @@ describe('VacayUserColorsRepository — color ?? null (VC79/VC57.../VC59)', () = const repo = await createTestVacayUserColorsRepo(testDb); const { user: u1 } = createUser(testDb); const { user: u2 } = createUser(testDb); - const planId = makePlan(u1.id); - testDb.prepare('INSERT INTO vacay_user_colors (user_id, plan_id, color) VALUES (?, ?, NULL)').run(u1.id, planId); - testDb.prepare('INSERT INTO vacay_user_colors (user_id, plan_id, color) VALUES (?, ?, ?)').run(u2.id, planId, '#111111'); + const planId = await makePlan(u1.id); + await insertRow(await orm(), VacayUserColors, { user: u1.id, plan: planId, color: null }); + await insertRow(await orm(), VacayUserColors, { user: u2.id, plan: planId, color: '#111111' }); expect(await repo.listForPlan(planId)).toEqual(expect.arrayContaining([{ color: null }, { color: '#111111' }])); expect(await repo.findColor(u1.id, planId)).toEqual({ color: null }); @@ -65,13 +74,11 @@ describe('VacayHolidayCalendarsRepository — findById/findScopedForPlan not-fou it('findScopedForPlan returns the row for the correct plan, null under a DIFFERENT plan', async () => { const repo = await createTestVacayHolidayCalendarsRepo(testDb); const { user } = createUser(testDb); - const planId = makePlan(user.id); - const otherPlanId = makePlan(createUser(testDb).user.id); - const id = Number( - testDb - .prepare("INSERT INTO vacay_holiday_calendars (plan_id, type, region, color, sort_order) VALUES (?, 'public_holiday', 'US', '#fecaca', 0)") - .run(planId).lastInsertRowid, - ); + const planId = await makePlan(user.id); + const otherPlanId = await makePlan(createUser(testDb).user.id); + const id = await insertRow(await orm(), VacayHolidayCalendars, { + plan: planId, type: 'public_holiday', region: 'US', color: '#fecaca', sort_order: 0, + }); expect((await repo.findScopedForPlan(id, planId))?.id).toBe(id); expect(await repo.findScopedForPlan(id, otherPlanId)).toBeNull(); expect((await repo.findById(id))?.id).toBe(id); @@ -83,15 +90,15 @@ describe('VacayPlanMembersRepository — findMembership/findAcceptedForUser (VC4 const repo = await createTestVacayPlanMembersRepo(testDb); const { user: owner } = createUser(testDb); const { user: target } = createUser(testDb); - const planId = makePlan(owner.id); + const planId = await makePlan(owner.id); expect(await repo.findMembership(planId, target.id)).toBeNull(); - testDb.prepare("INSERT INTO vacay_plan_members (plan_id, user_id, status) VALUES (?, ?, 'pending')").run(planId, target.id); + await addVacayPlanMember(await orm(), planId, target.id, 'pending'); const pending = await repo.findMembership(planId, target.id); expect(pending).toMatchObject({ status: 'pending' }); - testDb.prepare('UPDATE vacay_plan_members SET status = NULL WHERE plan_id = ? AND user_id = ?').run(planId, target.id); + await updateRows(await orm(), VacayPlanMembers, { plan: planId, user: target.id }, { status: null }); expect(await repo.findMembership(planId, target.id)).toEqual({ id: pending!.id, status: null }); }); @@ -99,13 +106,11 @@ describe('VacayPlanMembersRepository — findMembership/findAcceptedForUser (VC4 const repo = await createTestVacayPlanMembersRepo(testDb); const { user: owner } = createUser(testDb); const { user: target } = createUser(testDb); - const planId = makePlan(owner.id); + const planId = await makePlan(owner.id); expect(await repo.findAcceptedForUser(target.id)).toBeNull(); - const id = Number( - testDb.prepare("INSERT INTO vacay_plan_members (plan_id, user_id, status) VALUES (?, ?, 'accepted')").run(planId, target.id).lastInsertRowid, - ); + const { id } = await addVacayPlanMember(await orm(), planId, target.id, 'accepted'); expect(await repo.findAcceptedForUser(target.id)).toEqual({ id }); }); }); @@ -114,7 +119,7 @@ describe('VacayPlansRepository — findOwnerId/getHolidaysEnabled/findVacayUser it('findOwnerId returns null for a plan id that does not exist, the owner id for one that does', async () => { const repo = await createTestVacayPlansRepo(testDb); const { user } = createUser(testDb); - const planId = makePlan(user.id); + const planId = await makePlan(user.id); expect(await repo.findOwnerId(999999)).toBeNull(); expect(await repo.findOwnerId(planId)).toEqual({ owner_id: user.id }); }); @@ -122,13 +127,13 @@ describe('VacayPlansRepository — findOwnerId/getHolidaysEnabled/findVacayUser it('getHolidaysEnabled returns null for a missing plan, null for a genuinely NULL column, and the value when set', async () => { const repo = await createTestVacayPlansRepo(testDb); const { user } = createUser(testDb); - const planId = makePlan(user.id); + const planId = await makePlan(user.id); expect(await repo.getHolidaysEnabled(999999)).toBeNull(); - testDb.prepare('UPDATE vacay_plans SET holidays_enabled = NULL WHERE id = ?').run(planId); + await updateRows(await orm(), VacayPlans, { id: planId }, { holidays_enabled: null }); expect(await repo.getHolidaysEnabled(planId)).toBeNull(); - testDb.prepare('UPDATE vacay_plans SET holidays_enabled = 1 WHERE id = ?').run(planId); + await updateRows(await orm(), VacayPlans, { id: planId }, { holidays_enabled: 1 }); expect(await repo.getHolidaysEnabled(planId)).toBe(1); });