diff --git a/server/tests/unit/nest/accommodations.service.test.ts b/server/tests/unit/nest/accommodations.service.test.ts index 4b86a6099..ae0c1e1bc 100644 --- a/server/tests/unit/nest/accommodations.service.test.ts +++ b/server/tests/unit/nest/accommodations.service.test.ts @@ -13,17 +13,6 @@ vi.mock('../../../src/db/database', async () => { db, closeDb: () => {}, reinitialize: () => {}, - // Real enough for the stop-type stamp a stay write makes: it announces the - // place it typed, and a null here would hide that the write happened at all. - getPlaceWithTags: (id: number | string) => db.prepare('SELECT * FROM places WHERE id = ?').get(id) ?? null, - canAccessTrip: (tripId: any, userId: number) => - db.prepare(` - SELECT t.id, t.user_id FROM trips t - LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? - WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL) - `).get(userId, tripId, userId), - isOwner: (tripId: any, userId: number) => - !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId), }; }); @@ -43,6 +32,49 @@ import { AccommodationsDomainModule } from '../../../src/nest/accommodations/acc import { AccommodationsController } from '../../../src/nest/accommodations/accommodations.controller'; import { expectRegisteredProvider, expectRegisteredController } from '../../helpers/module-providers'; import { createTestUnitOfWork, createTestAppSettingsRepo, createTestReservationsRepo } from '../../helpers/test-uow'; +import type { FilterQuery } from '@mikro-orm/core'; +import { sharedTestOrm } from '../../helpers/test-uow'; +import { countRows, deleteRows, findRow, findRows, insertRow, updateRows } from '../../helpers/factories/rows'; +import { AppSettings } from '../../../src/db/entities/AppSettings.entity'; +import { AssignmentParticipants } from '../../../src/db/entities/AssignmentParticipants.entity'; +import { BudgetItems } from '../../../src/db/entities/BudgetItems.entity'; +import { DayAccommodations } from '../../../src/db/entities/DayAccommodations.entity'; +import { DayAssignments } from '../../../src/db/entities/DayAssignments.entity'; +import { Places } from '../../../src/db/entities/Places.entity'; +import { Reservations } from '../../../src/db/entities/Reservations.entity'; +import { RoadtripVias } from '../../../src/db/entities/RoadtripVias.entity'; + +const orm = () => sharedTestOrm(testDb); + +/** The first booking linked to a stay, or null. */ +async function linkedReservation(accommodationId: number) { + return findRow(await orm(), Reservations, { accommodation_id: String(accommodationId) }); +} + +/** Every booking linked to a stay. */ +async function linkedReservations(accommodationId: number) { + return findRows(await orm(), Reservations, { accommodation_id: String(accommodationId) }, { id: 'asc' }); +} + +/** How many bookings a stay holds. */ +async function linkedCount(accommodationId: number): Promise<{ n: number }> { + return { n: await countRows(await orm(), Reservations, { accommodation_id: String(accommodationId) }) }; +} + +/** A second hotel booking on the same stay. */ +async function addSecondBooking(tripId: number, accommodationId: number): Promise { + return insertRow(await orm(), Reservations, { trip: tripId, type: 'hotel', title: 'Second booking', accommodation_id: String(accommodationId) }); +} + +/** An expense, optionally on a booking. */ +async function addExpense(tripId: number, name: string, category: string, totalPrice: number, reservationId: number | null): Promise { + return insertRow(await orm(), BudgetItems, { trip: tripId, name, category, total_price: totalPrice, reservation: reservationId }); +} + +/** Sets columns on day assignments. */ +async function setStops(where: FilterQuery, data: Partial>) { + await updateRows(await orm(), DayAssignments, where, data); +} // Named `svc` so the moved cases read exactly as they did on DaysService. let svc: Awaited>; @@ -133,8 +165,8 @@ describe('createAccommodation', () => { place_id: place.id, start_day_id: day.id, end_day_id: day.id, })) as any; - const reservation = testDb.prepare('SELECT * FROM reservations WHERE accommodation_id = ?').get(accom.id) as any; - expect(reservation).toBeDefined(); + const reservation = await linkedReservation(accom.id); + expect(reservation).not.toBeNull(); expect(reservation.type).toBe('hotel'); expect(reservation.status).toBe('confirmed'); }); @@ -152,7 +184,7 @@ describe('createAccommodation', () => { })) as any; expect(accom.place_name).toBeNull(); - const reservation = testDb.prepare('SELECT title FROM reservations WHERE accommodation_id = ?').get(accom.id) as any; + const reservation = (await linkedReservation(accom.id))!; expect(reservation.title).toBe('Hotel'); }); }); @@ -208,8 +240,8 @@ describe('updateAccommodation', () => { expect(updated).toBeDefined(); // Verify linked reservation metadata was synced - const reservation = testDb.prepare('SELECT * FROM reservations WHERE accommodation_id = ?').get(accom.id) as any; - expect(reservation).toBeDefined(); + const reservation = await linkedReservation(accom.id); + expect(reservation).not.toBeNull(); const meta = JSON.parse(reservation.metadata || '{}'); expect(meta.check_in_time).toBe('16:00'); expect(meta.check_out_time).toBe('12:00'); @@ -246,7 +278,7 @@ describe('updateAccommodation', () => { const { accommodation: updated } = (await svc.updateAccommodation(accom.id, existing as any, { check_in: '15:00' })) as any; expect(updated.check_in).toBe('15:00'); - expect(testDb.prepare('SELECT COUNT(*) as n FROM reservations WHERE accommodation_id = ?').get(accom.id)).toMatchObject({ n: 0 }); + expect(await linkedCount(accom.id)).toMatchObject({ n: 0 }); }); it('ACC-007 — merges into the reservation metadata instead of replacing it', async () => { @@ -264,7 +296,7 @@ describe('updateAccommodation', () => { const existing = (await svc.getAccommodation(accom.id, trip.id))!; await svc.updateAccommodation(accom.id, existing as any, { check_in_end: '20:00', check_out: '11:00' }); - const reservation = testDb.prepare('SELECT metadata FROM reservations WHERE accommodation_id = ?').get(accom.id) as any; + const reservation = (await linkedReservation(accom.id))!; expect(JSON.parse(reservation.metadata)).toEqual({ check_in_time: '15:00', check_in_end_time: '20:00', check_out_time: '11:00', }); @@ -280,12 +312,12 @@ describe('updateAccommodation', () => { const { accommodation: accom } = (await svc.createAccommodation(trip.id, { place_id: place.id, start_day_id: day.id, end_day_id: day.id, })) as any; - testDb.prepare('UPDATE reservations SET confirmation_number = ? WHERE accommodation_id = ?').run('RES-9', accom.id); + await updateRows(await orm(), Reservations, { accommodation_id: String(accom.id) }, { confirmation_number: 'RES-9' }); const existing = (await svc.getAccommodation(accom.id, trip.id))!; await svc.updateAccommodation(accom.id, existing as any, { check_in: '15:00' }); - const reservation = testDb.prepare('SELECT confirmation_number FROM reservations WHERE accommodation_id = ?').get(accom.id) as any; + const reservation = (await linkedReservation(accom.id))!; expect(reservation.confirmation_number).toBe('RES-9'); }); @@ -300,12 +332,12 @@ describe('updateAccommodation', () => { // The linked reservation already carries a DIFFERENT, non-null confirmation — // if the stay-side update used `coalesceParam`'s (existing-wins) direction // instead of `coalesceOverride`'s (new-wins), it would keep this value. - testDb.prepare('UPDATE reservations SET confirmation_number = ? WHERE accommodation_id = ?').run('RES-OLD', accom.id); + await updateRows(await orm(), Reservations, { accommodation_id: String(accom.id) }, { confirmation_number: 'RES-OLD' }); const existing = (await svc.getAccommodation(accom.id, trip.id))!; await svc.updateAccommodation(accom.id, existing as any, { confirmation: 'ACC-CHANGED' }); - const reservation = testDb.prepare('SELECT confirmation_number FROM reservations WHERE accommodation_id = ?').get(accom.id) as any; + const reservation = (await linkedReservation(accom.id))!; expect(reservation.confirmation_number).toBe('ACC-CHANGED'); }); }); @@ -320,7 +352,7 @@ describe('deleteAccommodation', () => { place_id: place.id, start_day_id: day.id, end_day_id: day.id, })) as any; - const reservation = testDb.prepare('SELECT id FROM reservations WHERE accommodation_id = ?').get(accom.id) as any; + const reservation = (await linkedReservation(accom.id))!; const result = await svc.deleteAccommodation(accom.id); expect(result.linkedReservationId).toBe(reservation.id); @@ -329,8 +361,8 @@ describe('deleteAccommodation', () => { expect(await svc.getAccommodation(accom.id, trip.id)).toBeUndefined(); // Reservation is gone - const deletedRes = testDb.prepare('SELECT id FROM reservations WHERE id = ?').get(reservation.id); - expect(deletedRes).toBeUndefined(); + const deletedRes = await findRow(await orm(), Reservations, { id: reservation.id }); + expect(deletedRes).toBeNull(); }); it('DAY-SVC-026 — returns null linkedReservationId when no reservation was linked', async () => { @@ -341,7 +373,7 @@ describe('deleteAccommodation', () => { const accom = createDayAccommodation(testDb, trip.id, place.id, day.id, day.id) as any; // Remove the auto-created reservation so there's no linked one - testDb.prepare('DELETE FROM reservations WHERE accommodation_id = ?').run(accom.id); + await deleteRows(await orm(), Reservations, { accommodation_id: String(accom.id) }); const result = await svc.deleteAccommodation(accom.id); expect(result.linkedReservationId).toBeNull(); @@ -359,10 +391,8 @@ describe('deleteAccommodation', () => { place_id: place.id, start_day_id: day.id, end_day_id: day.id, })) as any; - const reservation = testDb.prepare('SELECT id FROM reservations WHERE accommodation_id = ?').get(accom.id) as any; - const budgetItemId = testDb.prepare( - 'INSERT INTO budget_items (trip_id, name, category, total_price, reservation_id) VALUES (?, ?, ?, ?, ?)' - ).run(trip.id, 'Hotel stay', 'Accommodation', 240, reservation.id).lastInsertRowid as number; + const reservation = (await linkedReservation(accom.id))!; + const budgetItemId = await addExpense(trip.id, 'Hotel stay', 'Accommodation', 240, reservation.id); const result = await svc.deleteAccommodation(accom.id); @@ -373,7 +403,7 @@ describe('deleteAccommodation', () => { deletedBudgetItemIds: [budgetItemId], mirror: { created: null, moved: null, updated: [], removed: [{ id: expect.any(Number), dayId: day.id }], stamped: null }, }); - expect(testDb.prepare('SELECT id FROM budget_items WHERE id = ?').get(budgetItemId)).toBeUndefined(); + expect(await findRow(await orm(), BudgetItems, { id: budgetItemId })).toBeNull(); }); it('ACC-035 — every expense on every linked booking goes with the stay (#2084)', async () => { @@ -386,23 +416,19 @@ describe('deleteAccommodation', () => { const { accommodation: accom } = (await svc.createAccommodation(trip.id, { place_id: place.id, start_day_id: day.id, end_day_id: day.id, })) as any; - const first = testDb.prepare('SELECT id FROM reservations WHERE accommodation_id = ?').get(accom.id) as any; - const second = testDb.prepare( - 'INSERT INTO reservations (trip_id, type, title, accommodation_id) VALUES (?, ?, ?, ?)' - ).run(trip.id, 'hotel', 'Second booking', accom.id).lastInsertRowid as number; - const expense = (reservationId: number | null, name: string) => testDb.prepare( - 'INSERT INTO budget_items (trip_id, name, category, total_price, reservation_id) VALUES (?, ?, ?, ?, ?)' - ).run(trip.id, name, 'accommodation', 50, reservationId).lastInsertRowid as number; - const room = expense(first.id, 'Room'); - const breakfast = expense(first.id, 'Breakfast'); - const parking = expense(second, 'Parking'); - const unrelated = expense(null, 'Coffee'); + const first = (await linkedReservation(accom.id))!; + const second = await addSecondBooking(trip.id, accom.id); + const expense = (reservationId: number | null, name: string) => addExpense(trip.id, name, 'accommodation', 50, reservationId); + const room = await expense(first.id, 'Room'); + const breakfast = await expense(first.id, 'Breakfast'); + const parking = await expense(second, 'Parking'); + const unrelated = await expense(null, 'Coffee'); const result = await svc.deleteAccommodation(accom.id); expect(result.deletedBudgetItemIds).toEqual([room, breakfast, parking]); expect(result.deletedBudgetItemId).toBe(room); - expect(testDb.prepare('SELECT id FROM budget_items WHERE trip_id = ?').all(trip.id)).toEqual([{ id: unrelated }]); + expect((await findRows(await orm(), BudgetItems, { trip: trip.id }, { id: 'asc' })).map(row => ({ id: row.id }))).toEqual([{ id: unrelated }]); }); it('ACC-010 — a second booking pointed at the same stay goes too, instead of being orphaned', async () => { @@ -417,16 +443,14 @@ describe('deleteAccommodation', () => { place_id: place.id, start_day_id: day.id, end_day_id: day.id, })) as any; - const first = testDb.prepare('SELECT id FROM reservations WHERE accommodation_id = ?').get(accom.id) as any; - const second = testDb.prepare( - 'INSERT INTO reservations (trip_id, type, title, accommodation_id) VALUES (?, ?, ?, ?)' - ).run(trip.id, 'hotel', 'Second booking', accom.id).lastInsertRowid as number; + const first = (await linkedReservation(accom.id))!; + const second = await addSecondBooking(trip.id, accom.id); const result = await svc.deleteAccommodation(accom.id); expect(result.linkedReservationIds).toEqual([first.id, second]); expect(result.linkedReservationId).toBe(first.id); - expect(testDb.prepare('SELECT COUNT(*) as n FROM reservations WHERE accommodation_id = ?').get(accom.id)).toMatchObject({ n: 0 }); + expect(await linkedCount(accom.id)).toMatchObject({ n: 0 }); }); it('ACC-011 — updating the stay syncs the times onto every linked booking', async () => { @@ -437,14 +461,12 @@ describe('deleteAccommodation', () => { const { accommodation: accom } = (await svc.createAccommodation(trip.id, { place_id: place.id, start_day_id: day.id, end_day_id: day.id, })) as any; - testDb.prepare( - 'INSERT INTO reservations (trip_id, type, title, accommodation_id) VALUES (?, ?, ?, ?)' - ).run(trip.id, 'hotel', 'Second booking', accom.id); + await addSecondBooking(trip.id, accom.id); - const existing = testDb.prepare('SELECT * FROM day_accommodations WHERE id = ?').get(accom.id) as any; - await svc.updateAccommodation(accom.id, existing, { check_in: '15:00', check_out: '11:00' }); + const existing = (await findRow(await orm(), DayAccommodations, { id: accom.id }))!; + await svc.updateAccommodation(accom.id, existing as Parameters[1], { check_in: '15:00', check_out: '11:00' }); - const rows = testDb.prepare('SELECT metadata FROM reservations WHERE accommodation_id = ?').all(accom.id) as Array<{ metadata: string | null }>; + const rows = await linkedReservations(accom.id); expect(rows).toHaveLength(2); for (const row of rows) { expect(JSON.parse(row.metadata || '{}')).toMatchObject({ check_in_time: '15:00', check_out_time: '11:00' }); @@ -478,7 +500,7 @@ describe('quirk fixes', () => { try { await expect(svc.deleteAccommodation(accom.id)).rejects.toThrow(); // The earlier reservation delete inside the transaction rolled back. - expect(testDb.prepare('SELECT COUNT(*) as n FROM reservations WHERE accommodation_id = ?').get(accom.id)).toMatchObject({ n: 1 }); + expect(await linkedCount(accom.id)).toMatchObject({ n: 1 }); } finally { testDb.exec('DROP TRIGGER boom'); } @@ -543,7 +565,7 @@ describe('route-facing delegators', () => { const { accommodation: updated } = (await svc.update(String(accom.id), existing as any, { check_in: '16:00', notes: 'late arrival' })) as any; expect(updated).toMatchObject({ check_in: '16:00', notes: 'late arrival' }); - const reservation = testDb.prepare('SELECT metadata FROM reservations WHERE accommodation_id = ?').get(accom.id) as any; + const reservation = (await linkedReservation(accom.id))!; expect(JSON.parse(reservation.metadata).check_in_time).toBe('16:00'); }); @@ -553,7 +575,7 @@ describe('route-facing delegators', () => { const day = createDay(testDb, trip.id) as any; const place = createPlace(testDb, trip.id, { name: 'Hotel' }) as any; const { accommodation: accom } = (await svc.create(trip.id, { place_id: place.id, start_day_id: day.id, end_day_id: day.id })) as any; - const reservation = testDb.prepare('SELECT id FROM reservations WHERE accommodation_id = ?').get(accom.id) as any; + const reservation = (await linkedReservation(accom.id))!; // The controller broadcasts reservation:deleted off this return value. expect(await svc.remove(String(accom.id))).toEqual({ @@ -622,7 +644,7 @@ describe('trip access and edit permission', () => { } finally { // The permission cache is module-scoped and outlives resetTestDb — drop the row and // the cache together, or every later case in this file inherits the raised level. - testDb.prepare("DELETE FROM app_settings WHERE key = 'perm_day_edit'").run(); + await deleteRows(await orm(), AppSettings, { key: 'perm_day_edit' }); await permissions.invalidatePermissionsCache(); } }); @@ -647,9 +669,9 @@ describe('trip access and edit permission', () => { * entered a second time as an ordinary stop. */ describe('the day stop a booking implies', () => { - const stopsOn = (dayId: number) => - testDb.prepare('SELECT id, place_id, order_index, accommodation_id FROM day_assignments WHERE day_id = ? ORDER BY order_index').all(dayId) as - { id: number; place_id: number; order_index: number; accommodation_id: number | null }[]; + const stopsOn = async (dayId: number) => + (await findRows(await orm(), DayAssignments, { day: dayId }, { order_index: 'asc' })) + .map(row => ({ id: row.id, place_id: row.place_id, order_index: row.order_index, accommodation_id: row.accommodation_id ?? null })); const book = async (tripId: number, placeId: number | null, startDayId: number, endDayId: number) => (await svc.createAccommodation(tripId, { place_id: placeId as number, start_day_id: startDayId, end_day_id: endDayId })) as any; @@ -662,7 +684,7 @@ describe('the day stop a booking implies', () => { const { accommodation, mirror } = await book(trip.id, place.id, day.id, day.id); - const stops = stopsOn(day.id); + const stops = (await stopsOn(day.id)); expect(stops).toHaveLength(1); expect(stops[0]).toMatchObject({ place_id: place.id, accommodation_id: accommodation.id }); expect(mirror.created).toMatchObject({ id: stops[0].id, day_id: day.id, place_id: place.id }); @@ -686,8 +708,8 @@ describe('the day stop a booking implies', () => { await book(trip.id, hotel.id, day.id, day.id); - expect(stopsOn(day.id).map(a => a.place_id)).toEqual([hotel.id, museum.id]); - expect(stopsOn(day.id).map(a => a.order_index)).toEqual([0, 1]); + expect((await stopsOn(day.id)).map(a => a.place_id)).toEqual([hotel.id, museum.id]); + expect((await stopsOn(day.id)).map(a => a.order_index)).toEqual([0, 1]); }); it('ACC-022b a check-in puts the night before whatever is pinned to a later hour', async () => { @@ -698,11 +720,11 @@ describe('the day stop a booking implies', () => { const afternoon = createPlace(testDb, trip.id, { name: 'Mercure' }); const hotel = createPlace(testDb, trip.id, { name: 'Billstedt' }); const pinned = createDayAssignment(testDb, day.id, afternoon.id); - testDb.prepare("UPDATE day_assignments SET assignment_time = '16:00' WHERE id = ?").run(pinned.id); + await setStops({ id: pinned.id }, { assignment_time: '16:00' }); await svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, check_in: '11:00' }); - expect(stopsOn(day.id).map(a => a.place_id)).toEqual([hotel.id, afternoon.id]); + expect((await stopsOn(day.id)).map(a => a.place_id)).toEqual([hotel.id, afternoon.id]); }); it('ACC-022c a stop pinned to an earlier hour stays ahead of the check-in', async () => { @@ -712,11 +734,11 @@ describe('the day stop a booking implies', () => { const morning = createPlace(testDb, trip.id, { name: 'Museum' }); const hotel = createPlace(testDb, trip.id, { name: 'Billstedt' }); const pinned = createDayAssignment(testDb, day.id, morning.id); - testDb.prepare("UPDATE day_assignments SET assignment_time = '09:00' WHERE id = ?").run(pinned.id); + await setStops({ id: pinned.id }, { assignment_time: '09:00' }); await svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, check_in: '18:00' }); - expect(stopsOn(day.id).map(a => a.place_id)).toEqual([morning.id, hotel.id]); + expect((await stopsOn(day.id)).map(a => a.place_id)).toEqual([morning.id, hotel.id]); }); it('ACC-022d a stop without an hour of its own follows the night, whatever the check-in', async () => { @@ -734,7 +756,7 @@ describe('the day stop a booking implies', () => { await svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, check_in: '10:00' }); - expect(stopsOn(day.id).map(a => a.place_id)).toEqual([hotel.id, fuel.id, farm.id]); + expect((await stopsOn(day.id)).map(a => a.place_id)).toEqual([hotel.id, fuel.id, farm.id]); }); it('ACC-022f the night goes behind the last stop whose own hour is at or before the check-in', async () => { @@ -747,12 +769,12 @@ describe('the day stop a booking implies', () => { const first = createDayAssignment(testDb, day.id, early.id); createDayAssignment(testDb, day.id, loose.id); const third = createDayAssignment(testDb, day.id, late.id); - testDb.prepare("UPDATE day_assignments SET assignment_time = '08:00' WHERE id = ?").run(first.id); - testDb.prepare("UPDATE day_assignments SET assignment_time = '14:00' WHERE id = ?").run(third.id); + await setStops({ id: first.id }, { assignment_time: '08:00' }); + await setStops({ id: third.id }, { assignment_time: '14:00' }); await svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, check_in: '10:00' }); - expect(stopsOn(day.id).map(a => a.place_id)).toEqual([early.id, hotel.id, loose.id, late.id]); + expect((await stopsOn(day.id)).map(a => a.place_id)).toEqual([early.id, hotel.id, loose.id, late.id]); }); it('ACC-022g a new check-in seats the night afresh, a change of notes leaves a dragged night alone', async () => { @@ -763,23 +785,25 @@ describe('the day stop a booking implies', () => { createDayAssignment(testDb, day.id, harbour.id); createDayAssignment(testDb, day.id, market.id); const { accommodation } = (await svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id })) as any; - expect(stopsOn(day.id).map(a => a.place_id)).toEqual([hotel.id, harbour.id, market.id]); + expect((await stopsOn(day.id)).map(a => a.place_id)).toEqual([hotel.id, harbour.id, market.id]); // Dragged to the end of the day by hand. - const own = stopsOn(day.id).find(a => a.place_id === hotel.id)!; - testDb.prepare('UPDATE day_assignments SET order_index = order_index - 1 WHERE day_id = ? AND order_index > ?').run(day.id, own.order_index); - testDb.prepare('UPDATE day_assignments SET order_index = 2 WHERE id = ?').run(own.id); - expect(stopsOn(day.id).map(a => a.place_id)).toEqual([harbour.id, market.id, hotel.id]); + const own = (await stopsOn(day.id)).find(a => a.place_id === hotel.id)!; + for (const later of await findRows(await orm(), DayAssignments, { day: day.id, order_index: { $gt: own.order_index } })) { + await setStops({ id: later.id }, { order_index: later.order_index - 1 }); + } + await setStops({ id: own.id }, { order_index: 2 }); + expect((await stopsOn(day.id)).map(a => a.place_id)).toEqual([harbour.id, market.id, hotel.id]); let existing = (await svc.getAccommodation(accommodation.id, trip.id))!; const quiet = (await svc.updateAccommodation(accommodation.id, existing, { notes: 'late arrival' })) as any; expect(quiet.mirror.moved).toBeNull(); - expect(stopsOn(day.id).map(a => a.place_id)).toEqual([harbour.id, market.id, hotel.id]); + expect((await stopsOn(day.id)).map(a => a.place_id)).toEqual([harbour.id, market.id, hotel.id]); existing = (await svc.getAccommodation(accommodation.id, trip.id))!; const reseated = (await svc.updateAccommodation(accommodation.id, existing, { check_in: '10:00' })) as any; expect(reseated.mirror.moved).toMatchObject({ oldDayId: day.id, assignment: { id: own.id } }); - expect(stopsOn(day.id).map(a => a.place_id)).toEqual([hotel.id, harbour.id, market.id]); + expect((await stopsOn(day.id)).map(a => a.place_id)).toEqual([hotel.id, harbour.id, market.id]); }); it('ACC-022e moving the check-in later re-seats the night', async () => { @@ -789,16 +813,16 @@ describe('the day stop a booking implies', () => { const afternoon = createPlace(testDb, trip.id, { name: 'Mercure' }); const hotel = createPlace(testDb, trip.id, { name: 'Billstedt' }); const pinned = createDayAssignment(testDb, day.id, afternoon.id); - testDb.prepare("UPDATE day_assignments SET assignment_time = '16:00' WHERE id = ?").run(pinned.id); + await setStops({ id: pinned.id }, { assignment_time: '16:00' }); const { accommodation } = (await svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, check_in: '11:00', })) as any; - expect(stopsOn(day.id).map(a => a.place_id)).toEqual([hotel.id, afternoon.id]); + expect((await stopsOn(day.id)).map(a => a.place_id)).toEqual([hotel.id, afternoon.id]); const existing = (await svc.getAccommodation(accommodation.id, trip.id))!; await svc.updateAccommodation(accommodation.id, existing, { check_in: '20:00' }); - expect(stopsOn(day.id).map(a => a.place_id)).toEqual([afternoon.id, hotel.id]); + expect((await stopsOn(day.id)).map(a => a.place_id)).toEqual([afternoon.id, hotel.id]); }); it('ACC-022i a second night on the day counts by its check-in', async () => { @@ -808,11 +832,11 @@ describe('the day stop a booking implies', () => { const [noon, morning] = ['Rue de Paris', 'Brandenburger Tor'].map(name => createPlace(testDb, trip.id, { name })); await svc.createAccommodation(trip.id, { place_id: noon.id, start_day_id: day.id, end_day_id: day.id, check_in: '12:00' }); await svc.createAccommodation(trip.id, { place_id: morning.id, start_day_id: day.id, end_day_id: day.id, check_in: '10:00' }); - expect(stopsOn(day.id).map(a => a.place_id)).toEqual([morning.id, noon.id]); + expect((await stopsOn(day.id)).map(a => a.place_id)).toEqual([morning.id, noon.id]); const evening = createPlace(testDb, trip.id, { name: 'Late' }); await svc.createAccommodation(trip.id, { place_id: evening.id, start_day_id: day.id, end_day_id: day.id, check_in: '18:00' }); - expect(stopsOn(day.id).map(a => a.place_id)).toEqual([morning.id, noon.id, evening.id]); + expect((await stopsOn(day.id)).map(a => a.place_id)).toEqual([morning.id, noon.id, evening.id]); }); it('ACC-022h an edit that leaves the check-in alone still corrects a night the clocks contradict', async () => { @@ -826,25 +850,25 @@ describe('the day stop a booking implies', () => { const [harbour, museum, hotel] = ['Hafen', 'Museum', 'Rostock'].map(name => createPlace(testDb, trip.id, { name })); createDayAssignment(testDb, day.id, harbour.id); const pinned = createDayAssignment(testDb, day.id, museum.id); - testDb.prepare("UPDATE day_assignments SET assignment_time = '14:00' WHERE id = ?").run(pinned.id); + await setStops({ id: pinned.id }, { assignment_time: '14:00' }); const { accommodation } = (await svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, check_in: '10:00' })) as any; - expect(stopsOn(day.id).map(a => a.place_id)).toEqual([hotel.id, harbour.id, museum.id]); - const own = stopsOn(day.id).find(a => a.place_id === hotel.id)!; + expect((await stopsOn(day.id)).map(a => a.place_id)).toEqual([hotel.id, harbour.id, museum.id]); + const own = (await stopsOn(day.id)).find(a => a.place_id === hotel.id)!; // Behind the harbour only: allowed, and left alone. - testDb.prepare('UPDATE day_assignments SET order_index = 0 WHERE day_id = ? AND place_id = ?').run(day.id, harbour.id); - testDb.prepare('UPDATE day_assignments SET order_index = 1 WHERE id = ?').run(own.id); + await setStops({ day: day.id, place: harbour.id }, { order_index: 0 }); + await setStops({ id: own.id }, { order_index: 1 }); let existing = (await svc.getAccommodation(accommodation.id, trip.id))!; expect(((await svc.updateAccommodation(accommodation.id, existing, { notes: 'late' })) as any).mirror.moved).toBeNull(); - expect(stopsOn(day.id).map(a => a.place_id)).toEqual([harbour.id, hotel.id, museum.id]); + expect((await stopsOn(day.id)).map(a => a.place_id)).toEqual([harbour.id, hotel.id, museum.id]); // Behind the afternoon: contradicted by the clocks, and seated afresh, which is // the front, ahead of the harbour that has no hour of its own. - testDb.prepare('UPDATE day_assignments SET order_index = 1 WHERE day_id = ? AND place_id = ?').run(day.id, museum.id); - testDb.prepare('UPDATE day_assignments SET order_index = 2 WHERE id = ?').run(own.id); + await setStops({ day: day.id, place: museum.id }, { order_index: 1 }); + await setStops({ id: own.id }, { order_index: 2 }); existing = (await svc.getAccommodation(accommodation.id, trip.id))!; expect(((await svc.updateAccommodation(accommodation.id, existing, { notes: 'later' })) as any).mirror.moved).not.toBeNull(); - expect(stopsOn(day.id).map(a => a.place_id)).toEqual([hotel.id, harbour.id, museum.id]); + expect((await stopsOn(day.id)).map(a => a.place_id)).toEqual([hotel.id, harbour.id, museum.id]); }); it('ACC-022j two nights with the same check-in settle by their booking and stay settled through a change of notes', async () => { @@ -857,9 +881,8 @@ describe('the day stop a booking implies', () => { const [first, second] = ['Adlon', 'Mercure'].map(name => createPlace(testDb, trip.id, { name })); const a = await svc.createAccommodation(trip.id, { place_id: first.id, start_day_id: day.id, end_day_id: day.id, check_in: '15:00' }) as any; const b = await svc.createAccommodation(trip.id, { place_id: second.id, start_day_id: day.id, end_day_id: day.id, check_in: '15:00' }) as any; - expect(stopsOn(day.id).map(s => s.place_id)).toEqual([first.id, second.id]); - const between = Number(testDb.prepare('INSERT INTO roadtrip_vias (day_id, after_order_index, sequence, lat, lng) VALUES (?, 0, 0, 52.5, 13.4)') - .run(day.id).lastInsertRowid); + expect((await stopsOn(day.id)).map(s => s.place_id)).toEqual([first.id, second.id]); + const between = await insertRow(await orm(), RoadtripVias, { day: day.id, after_order_index: 0, sequence: 0, lat: 52.5, lng: 13.4 }); for (const booked of [a, b]) { const existing = (await svc.getAccommodation(booked.accommodation.id, trip.id))!; @@ -867,16 +890,17 @@ describe('the day stop a booking implies', () => { expect(quiet.mirror.moved).toBeNull(); expect(quiet.mirror.vias).toBeUndefined(); } - expect(stopsOn(day.id).map(s => s.place_id)).toEqual([first.id, second.id]); - expect(testDb.prepare('SELECT after_order_index FROM roadtrip_vias WHERE id = ?').get(between)).toEqual({ after_order_index: 0 }); + expect((await stopsOn(day.id)).map(s => s.place_id)).toEqual([first.id, second.id]); + expect({ after_order_index: (await findRow(await orm(), RoadtripVias, { id: between }))!.after_order_index }).toEqual({ after_order_index: 0 }); // Dragged the other way round on purpose: an equal clock is no contradiction. - testDb.prepare('UPDATE day_assignments SET order_index = CASE place_id WHEN ? THEN 1 ELSE 0 END WHERE day_id = ?').run(first.id, day.id); + await setStops({ day: day.id, place: first.id }, { order_index: 1 }); + await setStops({ day: day.id, place: { $ne: first.id } }, { order_index: 0 }); for (const booked of [a, b]) { const existing = (await svc.getAccommodation(booked.accommodation.id, trip.id))!; expect((await svc.updateAccommodation(booked.accommodation.id, existing, { notes: 'later' }) as any).mirror.moved).toBeNull(); } - expect(stopsOn(day.id).map(s => s.place_id)).toEqual([second.id, first.id]); + expect((await stopsOn(day.id)).map(s => s.place_id)).toEqual([second.id, first.id]); }); it('ACC-022k two nights without a check-in: the earlier booking leads', async () => { @@ -888,10 +912,10 @@ describe('the day stop a booking implies', () => { await svc.createAccommodation(trip.id, { place_id: first.id, start_day_id: day.id, end_day_id: day.id }); const b = await svc.createAccommodation(trip.id, { place_id: second.id, start_day_id: day.id, end_day_id: day.id }) as any; - expect(stopsOn(day.id).map(s => s.place_id)).toEqual([first.id, second.id, museum.id]); + expect((await stopsOn(day.id)).map(s => s.place_id)).toEqual([first.id, second.id, museum.id]); const existing = (await svc.getAccommodation(b.accommodation.id, trip.id))!; expect((await svc.updateAccommodation(b.accommodation.id, existing, { notes: 'late' }) as any).mirror.moved).toBeNull(); - expect(stopsOn(day.id).map(s => s.place_id)).toEqual([first.id, second.id, museum.id]); + expect((await stopsOn(day.id)).map(s => s.place_id)).toEqual([first.id, second.id, museum.id]); }); it('ACC-022l a night on a day dragged out of clock order is settled where a fresh seat puts it', async () => { @@ -902,16 +926,16 @@ describe('the day stop a booking implies', () => { const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id); const [late, early, hotel] = ['Museum', 'Aral', 'Rostock'].map(name => createPlace(testDb, trip.id, { name })); - testDb.prepare("UPDATE day_assignments SET assignment_time = '14:00' WHERE id = ?").run(createDayAssignment(testDb, day.id, late.id).id); - testDb.prepare("UPDATE day_assignments SET assignment_time = '08:00' WHERE id = ?").run(createDayAssignment(testDb, day.id, early.id).id); + await setStops({ id: createDayAssignment(testDb, day.id, late.id).id }, { assignment_time: '14:00' }); + await setStops({ id: createDayAssignment(testDb, day.id, early.id).id }, { assignment_time: '08:00' }); const { accommodation } = await svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, check_in: '10:00' }) as any; - expect(stopsOn(day.id).map(s => s.place_id)).toEqual([late.id, early.id, hotel.id]); + expect((await stopsOn(day.id)).map(s => s.place_id)).toEqual([late.id, early.id, hotel.id]); const existing = (await svc.getAccommodation(accommodation.id, trip.id))!; const quiet = await svc.updateAccommodation(accommodation.id, existing, { notes: 'late arrival' }) as any; expect(quiet.mirror.moved).toBeNull(); - expect(stopsOn(day.id).map(s => s.place_id)).toEqual([late.id, early.id, hotel.id]); + expect((await stopsOn(day.id)).map(s => s.place_id)).toEqual([late.id, early.id, hotel.id]); }); it('ACC-023 the place is typed as lodging, so the rail draws it as a service stop', async () => { @@ -922,7 +946,7 @@ describe('the day stop a booking implies', () => { const { mirror } = await book(trip.id, place.id, day.id, day.id); - expect(testDb.prepare('SELECT stop_type FROM places WHERE id = ?').get(place.id)).toMatchObject({ stop_type: 'hotel' }); + expect(await findRow(await orm(), Places, { id: place.id })).toMatchObject({ stop_type: 'hotel' }); // Announced, or the places list keeps the stale null until a reload. expect(mirror.stamped).toMatchObject({ id: place.id, stop_type: 'hotel' }); // And the stop carries it, so the rail does not number it. @@ -934,11 +958,11 @@ describe('the day stop a booking implies', () => { const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id); const place = createPlace(testDb, trip.id, { name: 'Camping Isar' }); - testDb.prepare("UPDATE places SET stop_type = 'campsite' WHERE id = ?").run(place.id); + await updateRows(await orm(), Places, { id: place.id }, { stop_type: 'campsite' }); const { mirror } = await book(trip.id, place.id, day.id, day.id); - expect(testDb.prepare('SELECT stop_type FROM places WHERE id = ?').get(place.id)).toMatchObject({ stop_type: 'campsite' }); + expect(await findRow(await orm(), Places, { id: place.id })).toMatchObject({ stop_type: 'campsite' }); expect(mirror.stamped).toBeNull(); }); @@ -954,7 +978,7 @@ describe('the day stop a booking implies', () => { const { mirror } = await book(trip.id, place.id, day.id, day.id); - expect(stopsOn(day.id)).toEqual([expect.objectContaining({ id: own.id, accommodation_id: null })]); + expect((await stopsOn(day.id))).toEqual([expect.objectContaining({ id: own.id, accommodation_id: null })]); expect(mirror.created).toBeNull(); }); @@ -970,9 +994,9 @@ describe('the day stop a booking implies', () => { await book(trip.id, place.id, arrive.id, leave.id); - expect(stopsOn(arrive.id)).toHaveLength(1); - expect(stopsOn(middle.id)).toHaveLength(0); - expect(stopsOn(leave.id)).toHaveLength(0); + expect((await stopsOn(arrive.id))).toHaveLength(1); + expect((await stopsOn(middle.id))).toHaveLength(0); + expect((await stopsOn(leave.id))).toHaveLength(0); }); it('ACC-027 a stay with no place writes no stop instead of throwing', async () => { @@ -985,7 +1009,7 @@ describe('the day stop a booking implies', () => { const { mirror } = await book(trip.id, null, day.id, day.id); expect(mirror).toEqual({ created: null, moved: null, updated: [], removed: [], stamped: null }); - expect(stopsOn(day.id)).toHaveLength(0); + expect((await stopsOn(day.id))).toHaveLength(0); }); it('ACC-028 moving the booking to another day moves its stop with it', async () => { @@ -995,15 +1019,15 @@ describe('the day stop a booking implies', () => { const day2 = createDay(testDb, trip.id); const place = createPlace(testDb, trip.id, { name: 'Hotel Adlon' }); const { accommodation } = await book(trip.id, place.id, day1.id, day1.id); - const before = stopsOn(day1.id)[0]; + const before = (await stopsOn(day1.id))[0]; const existing = (await svc.getAccommodation(accommodation.id, trip.id))!; const { mirror } = (await svc.updateAccommodation(accommodation.id, existing, { start_day_id: day2.id, end_day_id: day2.id })) as any; - expect(stopsOn(day1.id)).toHaveLength(0); + expect((await stopsOn(day1.id))).toHaveLength(0); // The same row, carried over. Not a delete and a fresh insert: everything the // traveller hung on this stop is keyed by its id. - expect(stopsOn(day2.id)).toEqual([expect.objectContaining({ id: before.id, place_id: place.id, accommodation_id: accommodation.id })]); + expect((await stopsOn(day2.id))).toEqual([expect.objectContaining({ id: before.id, place_id: place.id, accommodation_id: accommodation.id })]); expect(mirror.removed).toEqual([]); expect(mirror.created).toBeNull(); expect(mirror.moved).toMatchObject({ oldDayId: day1.id, assignment: { id: before.id, day_id: day2.id } }); @@ -1016,14 +1040,13 @@ describe('the day stop a booking implies', () => { const day2 = createDay(testDb, trip.id); const place = createPlace(testDb, trip.id, { name: 'Hotel Adlon' }); const { accommodation } = await book(trip.id, place.id, day1.id, day1.id); - const stop = stopsOn(day1.id)[0]; - testDb.prepare('UPDATE day_assignments SET notes = ?, assignment_time = ?, end_day = 1 WHERE id = ?') - .run('ask for the quiet side', '15:30', stop.id); + const stop = (await stopsOn(day1.id))[0]; + await setStops({ id: stop.id }, { notes: 'ask for the quiet side', assignment_time: '15:30', end_day: 1 }); const existing = (await svc.getAccommodation(accommodation.id, trip.id))!; await svc.updateAccommodation(accommodation.id, existing, { start_day_id: day2.id, end_day_id: day2.id }); - const after = testDb.prepare('SELECT * FROM day_assignments WHERE id = ?').get(stop.id) as Record; + const after = (await findRow(await orm(), DayAssignments, { id: stop.id }))!; expect(after).toMatchObject({ day_id: day2.id, notes: 'ask for the quiet side', assignment_time: '15:30', end_day: 1 }); }); @@ -1034,15 +1057,15 @@ describe('the day stop a booking implies', () => { const day2 = createDay(testDb, trip.id); const place = createPlace(testDb, trip.id, { name: 'Hotel Adlon' }); const { accommodation } = await book(trip.id, place.id, day1.id, day1.id); - const stop = stopsOn(day1.id)[0]; + const stop = (await stopsOn(day1.id))[0]; // assignment_participants.assignment_id is ON DELETE CASCADE, so a delete and // re-insert would take these with it without a word. - testDb.prepare('INSERT INTO assignment_participants (assignment_id, user_id) VALUES (?, ?)').run(stop.id, user.id); + await insertRow(await orm(), AssignmentParticipants, { assignment: stop.id, user: user.id }); const existing = (await svc.getAccommodation(accommodation.id, trip.id))!; await svc.updateAccommodation(accommodation.id, existing, { start_day_id: day2.id, end_day_id: day2.id }); - const kept = testDb.prepare('SELECT user_id FROM assignment_participants WHERE assignment_id = ?').all(stop.id); + const kept = (await findRows(await orm(), AssignmentParticipants, { assignment: stop.id })).map(row => ({ user_id: row.user_id })); expect(kept).toEqual([{ user_id: user.id }]); }); @@ -1053,14 +1076,14 @@ describe('the day stop a booking implies', () => { const day2 = createDay(testDb, trip.id); const place = createPlace(testDb, trip.id, { name: 'Hotel Adlon' }); const { accommodation } = await book(trip.id, place.id, day1.id, day1.id); - const own = stopsOn(day1.id)[0]; + const own = (await stopsOn(day1.id))[0]; const byHand = createDayAssignment(testDb, day2.id, place.id) as { id: number }; const existing = (await svc.getAccommodation(accommodation.id, trip.id))!; const { mirror } = (await svc.updateAccommodation(accommodation.id, existing, { start_day_id: day2.id, end_day_id: day2.id })) as any; - expect(stopsOn(day1.id)).toHaveLength(0); - expect(stopsOn(day2.id).map((row: { id: number }) => row.id)).toEqual([byHand.id]); + expect((await stopsOn(day1.id))).toHaveLength(0); + expect((await stopsOn(day2.id)).map((row: { id: number }) => row.id)).toEqual([byHand.id]); expect(mirror.removed).toEqual([{ id: own.id, dayId: day1.id }]); expect(mirror.moved).toBeNull(); }); @@ -1078,7 +1101,7 @@ describe('the day stop a booking implies', () => { const existing = (await svc.getAccommodation(accommodation.id, trip.id))!; await svc.updateAccommodation(accommodation.id, existing, { start_day_id: day2.id, end_day_id: day2.id }); - expect(stopsOn(day1.id).map((row: { order_index: number }) => row.order_index)).toEqual([0]); + expect((await stopsOn(day1.id)).map((row: { order_index: number }) => row.order_index)).toEqual([0]); }); it('ACC-029 editing an unrelated field leaves the stop exactly where it is', async () => { @@ -1087,12 +1110,12 @@ describe('the day stop a booking implies', () => { const day = createDay(testDb, trip.id); const place = createPlace(testDb, trip.id, { name: 'Hotel Adlon' }); const { accommodation } = await book(trip.id, place.id, day.id, day.id); - const before = stopsOn(day.id); + const before = (await stopsOn(day.id)); const existing = (await svc.getAccommodation(accommodation.id, trip.id))!; const { mirror } = (await svc.updateAccommodation(accommodation.id, existing, { check_in: '16:00' })) as any; - expect(stopsOn(day.id)).toEqual(before); + expect((await stopsOn(day.id))).toEqual(before); expect(mirror).toEqual({ created: null, moved: null, updated: [], removed: [], stamped: null }); }); @@ -1105,19 +1128,19 @@ describe('the day stop a booking implies', () => { const day = createDay(testDb, trip.id); const dinner = createPlace(testDb, trip.id, { name: 'Osteria' }); const hotel = createPlace(testDb, trip.id, { name: 'Hotel Adlon' }); - testDb.prepare("UPDATE places SET place_time = '19:00' WHERE id = ?").run(dinner.id); + await updateRows(await orm(), Places, { id: dinner.id }, { place_time: '19:00' }); createDayAssignment(testDb, day.id, dinner.id); const { accommodation } = await book(trip.id, hotel.id, day.id, day.id); - const stop = stopsOn(day.id).find((row: { place_id: number }) => row.place_id === hotel.id)!; + const stop = (await stopsOn(day.id)).find((row: { place_id: number }) => row.place_id === hotel.id)!; // The backfill's placement, written by hand: the night behind the dinner. - testDb.prepare('UPDATE day_assignments SET order_index = 0 WHERE day_id = ? AND place_id = ?').run(day.id, dinner.id); - testDb.prepare('UPDATE day_assignments SET order_index = 1 WHERE id = ?').run(stop.id); - expect(stopsOn(day.id).map((row: { place_id: number }) => row.place_id)).toEqual([dinner.id, hotel.id]); + await setStops({ day: day.id, place: dinner.id }, { order_index: 0 }); + await setStops({ id: stop.id }, { order_index: 1 }); + expect((await stopsOn(day.id)).map((row: { place_id: number }) => row.place_id)).toEqual([dinner.id, hotel.id]); const existing = (await svc.getAccommodation(accommodation.id, trip.id))!; const { mirror } = (await svc.updateAccommodation(accommodation.id, existing, { check_in: '15:00' })) as any; - expect(stopsOn(day.id).map((row: { place_id: number }) => row.place_id)).toEqual([hotel.id, dinner.id]); + expect((await stopsOn(day.id)).map((row: { place_id: number }) => row.place_id)).toEqual([hotel.id, dinner.id]); // Re-seated, not rebuilt: the same row, one place further forward. expect(mirror.moved).toMatchObject({ oldDayId: day.id, assignment: { id: stop.id } }); expect(mirror.removed).toEqual([]); @@ -1137,8 +1160,8 @@ describe('the day stop a booking implies', () => { const existing = (await svc.getAccommodation(stay.id, trip.id))!; await svc.updateAccommodation(stay.id, existing, { start_day_id: second.id, end_day_id: second.id }); - expect(stopsOn(second.id)).toEqual([]); - expect(stopsOn(day.id)).toEqual([expect.objectContaining({ place_id: place.id, accommodation_id: null })]); + expect((await stopsOn(second.id))).toEqual([]); + expect((await stopsOn(day.id))).toEqual([expect.objectContaining({ place_id: place.id, accommodation_id: null })]); }); it('ACC-031 cancelling a booking takes back its own stop and leaves the other one', async () => { @@ -1149,11 +1172,11 @@ describe('the day stop a booking implies', () => { const museum = createPlace(testDb, trip.id, { name: 'Pergamon' }); const own = createDayAssignment(testDb, day.id, museum.id); const { accommodation } = await book(trip.id, hotel.id, day.id, day.id); - const mirrored = stopsOn(day.id).find(a => a.place_id === hotel.id)!; + const mirrored = (await stopsOn(day.id)).find(a => a.place_id === hotel.id)!; const { mirror } = await svc.deleteAccommodation(accommodation.id); - expect(stopsOn(day.id)).toEqual([expect.objectContaining({ id: own.id })]); + expect((await stopsOn(day.id))).toEqual([expect.objectContaining({ id: own.id })]); expect(mirror.removed).toEqual([{ id: mirrored.id, dayId: day.id }]); }); @@ -1169,7 +1192,7 @@ describe('the day stop a booking implies', () => { const { mirror } = await svc.deleteAccommodation(accommodation.id); - expect(stopsOn(day.id)).toEqual([expect.objectContaining({ id: own.id })]); + expect((await stopsOn(day.id))).toEqual([expect.objectContaining({ id: own.id })]); expect(mirror.removed).toEqual([]); }); @@ -1182,15 +1205,15 @@ describe('the day stop a booking implies', () => { const day = createDay(testDb, trip.id); const place = createPlace(testDb, trip.id, { name: 'Hotel Adlon' }); const { accommodation } = await book(trip.id, place.id, day.id, day.id); - const mirrored = stopsOn(day.id)[0]; + const mirrored = (await stopsOn(day.id))[0]; const { mirror } = await svc.deleteAccommodation(accommodation.id, { keepStop: true }); // Still there, and now unmarked: nothing may move or delete it on the next edit. - expect(stopsOn(day.id)).toEqual([expect.objectContaining({ id: mirrored.id, place_id: place.id, accommodation_id: null })]); + expect((await stopsOn(day.id))).toEqual([expect.objectContaining({ id: mirrored.id, place_id: place.id, accommodation_id: null })]); // Nothing to announce either — no session has to take a stop off its day plan. expect(mirror.removed).toEqual([]); - expect(testDb.prepare('SELECT id FROM day_accommodations WHERE id = ?').get(accommodation.id)).toBeUndefined(); + expect(await findRow(await orm(), DayAccommodations, { id: accommodation.id })).toBeNull(); }); it('ACC-032c remove() forwards keepStop, which is the only door the REST route uses', async () => { @@ -1202,7 +1225,7 @@ describe('the day stop a booking implies', () => { await svc.remove(accommodation.id, { keepStop: true }); - expect(stopsOn(day.id)).toEqual([expect.objectContaining({ place_id: place.id, accommodation_id: null })]); + expect((await stopsOn(day.id))).toEqual([expect.objectContaining({ place_id: place.id, accommodation_id: null })]); }); it('ACC-033 announceMirror sends the removal before the arrival', async () => { @@ -1242,7 +1265,7 @@ describe('the day stop a booking implies', () => { const day = createDay(testDb, trip.id); const place = createPlace(testDb, trip.id, { name: 'Hotel Adlon' }); const { accommodation } = await book(trip.id, place.id, day.id, day.id); - const stop = stopsOn(day.id)[0]; + const stop = (await stopsOn(day.id))[0]; const { mirror } = (await svc.remove(accommodation.id, { keepStop: true })) as any; @@ -1260,46 +1283,42 @@ describe('the drawn roads a booking moves', () => { // night seated by its check-in ahead of the afternoon renumbers every stop behind // it, and every road drawn behind those stops has to move with them, or the // detour is driven on the wrong leg for everyone. - const stopsOn = (dayId: number) => - (testDb.prepare('SELECT place_id FROM day_assignments WHERE day_id = ? ORDER BY order_index').all(dayId) as { place_id: number }[]) - .map(row => row.place_id); - const addVia = (dayId: number, afterOrderIndex: number): number => - Number(testDb.prepare('INSERT INTO roadtrip_vias (day_id, after_order_index, sequence, lat, lng) VALUES (?, ?, 0, 48.1, 11.5)') - .run(dayId, afterOrderIndex).lastInsertRowid); - const viaAnchors = (dayId: number) => - testDb.prepare('SELECT id, after_order_index FROM roadtrip_vias WHERE day_id = ? ORDER BY id').all(dayId) as - { id: number; after_order_index: number }[]; + const stopsOn = async (dayId: number) => + (await findRows(await orm(), DayAssignments, { day: dayId }, { order_index: 'asc' })).map(row => row.place_id); + const addVia = async (dayId: number, afterOrderIndex: number): Promise => + insertRow(await orm(), RoadtripVias, { day: dayId, after_order_index: afterOrderIndex, sequence: 0, lat: 48.1, lng: 11.5 }); + const viaAnchors = async (dayId: number) => + (await findRows(await orm(), RoadtripVias, { day: dayId }, { id: 'asc' })).map(row => ({ id: row.id, after_order_index: row.after_order_index })); /** The day's roads as the route reads them: leg by leg, and in order within a leg. */ - const legOrder = (dayId: number) => - testDb.prepare('SELECT id, after_order_index, sequence FROM roadtrip_vias WHERE day_id = ? ORDER BY after_order_index, sequence, id').all(dayId) as - { id: number; after_order_index: number; sequence: number }[]; - const pin = (assignmentId: number, time: string) => - testDb.prepare('UPDATE day_assignments SET assignment_time = ? WHERE id = ?').run(time, assignmentId); + const legOrder = async (dayId: number) => + (await findRows(await orm(), RoadtripVias, { day: dayId }, { after_order_index: 'asc', sequence: 'asc', id: 'asc' })) + .map(row => ({ id: row.id, after_order_index: row.after_order_index, sequence: row.sequence })); + const pin = (assignmentId: number, time: string) => setStops({ id: assignmentId }, { assignment_time: time }); /** A(0) pinned to nine, B(1) pinned to noon, C(2): the day a check-in at eleven lands in the middle of. */ - function afternoonDay() { + async function afternoonDay() { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id); const [a, b, c, hotel] = ['Harbour', 'Mercure', 'Museum', 'Billstedt'].map(name => createPlace(testDb, trip.id, { name })); - pin(createDayAssignment(testDb, day.id, a.id).id, '09:00'); - pin(createDayAssignment(testDb, day.id, b.id).id, '12:00'); + await pin(createDayAssignment(testDb, day.id, a.id).id, '09:00'); + await pin(createDayAssignment(testDb, day.id, b.id).id, '12:00'); createDayAssignment(testDb, day.id, c.id); return { trip, day, a, b, c, hotel }; } it('ACC-034 a night seated ahead of the afternoon carries the roads behind it along', async () => { - const { trip, day, a, b, c, hotel } = afternoonDay(); - const afterA = addVia(day.id, 0); - const afterB = addVia(day.id, 1); + const { trip, day, a, b, c, hotel } = await afternoonDay(); + const afterA = await addVia(day.id, 0); + const afterB = await addVia(day.id, 1); // Behind the last stop: the drive into the next day. It stays with C, which is // still last, rather than being dropped as a leg C never had. - const intoTomorrow = addVia(day.id, 2); + const intoTomorrow = await addVia(day.id, 2); const { mirror } = (await svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, check_in: '11:00' })) as any; - expect(stopsOn(day.id)).toEqual([a.id, hotel.id, b.id, c.id]); - expect(viaAnchors(day.id)).toEqual([ + expect((await stopsOn(day.id))).toEqual([a.id, hotel.id, b.id, c.id]); + expect((await viaAnchors(day.id))).toEqual([ { id: afterA, after_order_index: 0 }, { id: afterB, after_order_index: 2 }, { id: intoTomorrow, after_order_index: 3 }, @@ -1317,27 +1336,27 @@ describe('the drawn roads a booking moves', () => { }); it('ACC-034b a night seated behind every drawn road moves none and reports none', async () => { - const { trip, day, a, b, c, hotel } = afternoonDay(); - const afterA = addVia(day.id, 0); - const afterB = addVia(day.id, 1); + const { trip, day, a, b, c, hotel } = await afternoonDay(); + const afterA = await addVia(day.id, 0); + const afterB = await addVia(day.id, 1); const { mirror } = (await svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, check_in: '18:00' })) as any; - expect(stopsOn(day.id)).toEqual([a.id, b.id, hotel.id, c.id]); - expect(viaAnchors(day.id)).toEqual([{ id: afterA, after_order_index: 0 }, { id: afterB, after_order_index: 1 }]); + expect((await stopsOn(day.id))).toEqual([a.id, b.id, hotel.id, c.id]); + expect((await viaAnchors(day.id))).toEqual([{ id: afterA, after_order_index: 0 }, { id: afterB, after_order_index: 1 }]); expect(mirror.vias).toBeUndefined(); }); it('ACC-034c re-seating the night by a later check-in re-pins the roads around it', async () => { - const { trip, day, a, b, c, hotel } = afternoonDay(); + const { trip, day, a, b, c, hotel } = await afternoonDay(); // C pinned to the afternoon as well, so an evening check-in puts the night last. - testDb.prepare("UPDATE day_assignments SET assignment_time = '15:00' WHERE day_id = ? AND place_id = ?").run(day.id, c.id); + await setStops({ day: day.id, place: c.id }, { assignment_time: '15:00' }); const { accommodation } = (await svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, check_in: '11:00' })) as any; - expect(stopsOn(day.id)).toEqual([a.id, hotel.id, b.id, c.id]); - const afterA = addVia(day.id, 0); - const afterHotel = addVia(day.id, 1); - const afterB = addVia(day.id, 2); - const afterC = addVia(day.id, 3); + expect((await stopsOn(day.id))).toEqual([a.id, hotel.id, b.id, c.id]); + const afterA = await addVia(day.id, 0); + const afterHotel = await addVia(day.id, 1); + const afterB = await addVia(day.id, 2); + const afterC = await addVia(day.id, 3); const existing = (await svc.getAccommodation(accommodation.id, trip.id))!; const { mirror } = (await svc.updateAccommodation(accommodation.id, existing, { check_in: '20:00' })) as any; @@ -1346,13 +1365,13 @@ describe('the drawn roads a booking moves', () => { // behind B follows it one number up. The one behind C was the drive into the next // day, which leaves from the hotel now, so it goes as well rather than bending the // drive from C to the hotel through a point on the road to tomorrow. - expect(stopsOn(day.id)).toEqual([a.id, b.id, c.id, hotel.id]); - expect(viaAnchors(day.id)).toEqual([ + expect((await stopsOn(day.id))).toEqual([a.id, b.id, c.id, hotel.id]); + expect((await viaAnchors(day.id))).toEqual([ { id: afterA, after_order_index: 0 }, { id: afterB, after_order_index: 1 }, ]); - expect(viaAnchors(day.id).map(via => via.id)).not.toContain(afterHotel); - expect(viaAnchors(day.id).map(via => via.id)).not.toContain(afterC); + expect((await viaAnchors(day.id)).map(via => via.id)).not.toContain(afterHotel); + expect((await viaAnchors(day.id)).map(via => via.id)).not.toContain(afterC); expect(mirror.moved).not.toBeNull(); expect(mirror.vias).toEqual([{ dayId: day.id, vias: expect.arrayContaining([expect.objectContaining({ id: afterB, after_order_index: 1 })]) }]); }); @@ -1362,23 +1381,23 @@ describe('the drawn roads a booking moves', () => { const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id); const [a, b, hotel] = ['Harbour', 'Mercure', 'Billstedt'].map(name => createPlace(testDb, trip.id, { name })); - pin(createDayAssignment(testDb, day.id, a.id).id, '09:00'); - pin(createDayAssignment(testDb, day.id, b.id).id, '12:00'); + await pin(createDayAssignment(testDb, day.id, a.id).id, '09:00'); + await pin(createDayAssignment(testDb, day.id, b.id).id, '12:00'); const { accommodation } = (await svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, check_in: '11:00' })) as any; - expect(stopsOn(day.id)).toEqual([a.id, hotel.id, b.id]); + expect((await stopsOn(day.id))).toEqual([a.id, hotel.id, b.id]); // The road out of the hotel is drawn first: by id alone it would lead the leg // it ends up sharing with A's road. - const afterHotel = addVia(day.id, 1); - const afterA = addVia(day.id, 0); - const intoTomorrow = addVia(day.id, 2); + const afterHotel = await addVia(day.id, 1); + const afterA = await addVia(day.id, 0); + const intoTomorrow = await addVia(day.id, 2); const { mirror } = await svc.deleteAccommodation(accommodation.id); - expect(stopsOn(day.id)).toEqual([a.id, b.id]); + expect((await stopsOn(day.id))).toEqual([a.id, b.id]); // The two legs around the hotel are one leg now, A to B, and the road out of the // hotel joins A's road on it, behind it: the drive runs A's point first, then the // hotel's, then B. The drive into the next day stays behind B, which is still last. - expect(legOrder(day.id)).toEqual([ + expect((await legOrder(day.id))).toEqual([ { id: afterA, after_order_index: 0, sequence: 0 }, { id: afterHotel, after_order_index: 0, sequence: 1 }, { id: intoTomorrow, after_order_index: 1, sequence: 0 }, @@ -1396,38 +1415,38 @@ describe('the drawn roads a booking moves', () => { const day1 = createDay(testDb, trip.id); const day2 = createDay(testDb, trip.id); const [a, b, c, hotel] = ['Harbour', 'Mercure', 'Museum', 'Billstedt'].map(name => createPlace(testDb, trip.id, { name })); - pin(createDayAssignment(testDb, day1.id, a.id).id, '09:00'); - pin(createDayAssignment(testDb, day1.id, b.id).id, '12:00'); - pin(createDayAssignment(testDb, day2.id, c.id).id, '14:00'); + await pin(createDayAssignment(testDb, day1.id, a.id).id, '09:00'); + await pin(createDayAssignment(testDb, day1.id, b.id).id, '12:00'); + await pin(createDayAssignment(testDb, day2.id, c.id).id, '14:00'); const { accommodation } = (await svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day1.id, end_day_id: day1.id, check_in: '11:00' })) as any; - expect(stopsOn(day1.id)).toEqual([a.id, hotel.id, b.id]); - const afterA = addVia(day1.id, 0); - const afterHotel = addVia(day1.id, 1); - const outOfC = addVia(day2.id, 0); + expect((await stopsOn(day1.id))).toEqual([a.id, hotel.id, b.id]); + const afterA = await addVia(day1.id, 0); + const afterHotel = await addVia(day1.id, 1); + const outOfC = await addVia(day2.id, 0); const existing = (await svc.getAccommodation(accommodation.id, trip.id))!; const { mirror } = (await svc.updateAccommodation(accommodation.id, existing, { start_day_id: day2.id, end_day_id: day2.id })) as any; - expect(stopsOn(day1.id)).toEqual([a.id, b.id]); - expect(stopsOn(day2.id)).toEqual([hotel.id, c.id]); + expect((await stopsOn(day1.id))).toEqual([a.id, b.id]); + expect((await stopsOn(day2.id))).toEqual([hotel.id, c.id]); // The hotel's road on day one joins A's on the leg that is left, behind it. - expect(legOrder(day1.id)).toEqual([ + expect((await legOrder(day1.id))).toEqual([ { id: afterA, after_order_index: 0, sequence: 0 }, { id: afterHotel, after_order_index: 0, sequence: 1 }, ]); // C is still the last stop of its day, one number further on. - expect(viaAnchors(day2.id)).toEqual([{ id: outOfC, after_order_index: 1 }]); + expect((await viaAnchors(day2.id))).toEqual([{ id: outOfC, after_order_index: 1 }]); expect(mirror.vias.map((day: { dayId: number }) => day.dayId).sort()).toEqual([day1.id, day2.id].sort()); }); it('ACC-034f a stop the booking hands to the traveller moves no road', async () => { - const { trip, day, hotel } = afternoonDay(); + const { trip, day, hotel } = await afternoonDay(); const { accommodation } = (await svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, check_in: '11:00' })) as any; - const afterB = addVia(day.id, 2); + const afterB = await addVia(day.id, 2); const { mirror } = await svc.deleteAccommodation(accommodation.id, { keepStop: true }); - expect(viaAnchors(day.id)).toEqual([{ id: afterB, after_order_index: 2 }]); + expect((await viaAnchors(day.id))).toEqual([{ id: afterB, after_order_index: 2 }]); expect(mirror.vias).toBeUndefined(); }); @@ -1435,18 +1454,18 @@ describe('the drawn roads a booking moves', () => { // A stay that owns two stops on one day cannot be carried across, so both go and // one comes back, and the day's roads are re-pinned at each step. What the mirror // reports is the state that was left behind, not both steps. - const { trip, day, a, b, c, hotel } = afternoonDay(); + const { trip, day, a, b, c, hotel } = await afternoonDay(); const { accommodation } = (await svc.createAccommodation(trip.id, { place_id: hotel.id, start_day_id: day.id, end_day_id: day.id, check_in: '11:00' })) as any; - testDb.prepare('INSERT INTO day_assignments (day_id, place_id, order_index, accommodation_id) VALUES (?, ?, 4, ?)').run(day.id, hotel.id, accommodation.id); - const afterA = addVia(day.id, 0); - const afterHotel = addVia(day.id, 1); - const afterB = addVia(day.id, 2); - addVia(day.id, 3); + await insertRow(await orm(), DayAssignments, { day: day.id, place: hotel.id, order_index: 4, accommodation_id: accommodation.id }); + const afterA = await addVia(day.id, 0); + const afterHotel = await addVia(day.id, 1); + const afterB = await addVia(day.id, 2); + await addVia(day.id, 3); const existing = (await svc.getAccommodation(accommodation.id, trip.id))!; const { mirror } = (await svc.updateAccommodation(accommodation.id, existing, { notes: 'late arrival' })) as any; - expect(stopsOn(day.id)).toEqual([a.id, hotel.id, b.id, c.id]); + expect((await stopsOn(day.id))).toEqual([a.id, hotel.id, b.id, c.id]); expect(mirror.removed).toHaveLength(2); expect(mirror.created).not.toBeNull(); // Both stops went, so the hotel's road joined A's; then the night came back diff --git a/server/tests/unit/nest/collections.service.test.ts b/server/tests/unit/nest/collections.service.test.ts index 5260f080f..cb7cd240e 100644 --- a/server/tests/unit/nest/collections.service.test.ts +++ b/server/tests/unit/nest/collections.service.test.ts @@ -18,14 +18,6 @@ vi.mock('../../../src/db/database', async () => { db, closeDb: () => {}, reinitialize: () => {}, - getPlaceWithTags: () => null, - canAccessTrip: (tripId: number | string, userId: number) => - db - .prepare( - 'SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)', - ) - .get(userId, tripId, userId), - isOwner: () => false, }; }); @@ -56,6 +48,52 @@ import { createTestCollectionPlacesRepo, createTestCollectionPlaceRatingsRepo, createTestTripsRepo, createTestTripMembersRepo, createTestPlaceRatingsRepo, createTestTagsRepo, createTestUsersRepo, } from '../../helpers/test-uow'; +import { sharedTestOrm } from '../../helpers/test-uow'; +import { countRows, deleteRows, findRow, findRows, insertRow, updateRows } from '../../helpers/factories/rows'; +import { addCollectionMember } from '../../helpers/factories/collections'; +import { CollectionLabels } from '../../../src/db/entities/CollectionLabels.entity'; +import { CollectionMembers } from '../../../src/db/entities/CollectionMembers.entity'; +import { CollectionPlaceRatings } from '../../../src/db/entities/CollectionPlaceRatings.entity'; +import { CollectionPlaces } from '../../../src/db/entities/CollectionPlaces.entity'; +import { Collections } from '../../../src/db/entities/Collections.entity'; +import { GooglePlacePhotoMeta } from '../../../src/db/entities/GooglePlacePhotoMeta.entity'; +import { PlaceRatings } from '../../../src/db/entities/PlaceRatings.entity'; +import { Places } from '../../../src/db/entities/Places.entity'; +import { Tags } from '../../../src/db/entities/Tags.entity'; +import { Users } from '../../../src/db/entities/Users.entity'; + +const orm = () => sharedTestOrm(testDb); + +/** Puts the user on the list as an accepted member, with the role the column defaults to unless given. */ +async function joinCollection(colId: number, userId: number, role?: 'viewer' | 'editor' | 'admin') { + await addCollectionMember(await orm(), colId, userId, role ? { role } : {}); +} + +/** How many saved places a list holds. */ +async function placesIn(colId: number): Promise<{ n: number }> { + return { n: await countRows(await orm(), CollectionPlaces, { collection: colId }) }; +} + +/** How many member rows (any status) a list holds. */ +async function membersOf(colId: number): Promise<{ n: number }> { + return { n: await countRows(await orm(), CollectionMembers, { collection: colId }) }; +} + +/** The newest place on a trip. */ +async function newestPlace(tripId: number): Promise<{ id: number }> { + const [row] = await findRows(await orm(), Places, { trip: tripId }, { id: 'desc' }); + return { id: row.id }; +} + +/** The votes stored on a trip place. */ +async function placeVotes(placeId: number): Promise<{ user_id: number; rating: number }[]> { + return (await findRows(await orm(), PlaceRatings, { place: placeId })).map(v => ({ user_id: v.user_id, rating: v.rating })); +} + +/** The votes stored on a saved place. */ +async function savedPlaceVotes(collectionPlaceId: number): Promise<{ user_id: number; rating: number }[]> { + return (await findRows(await orm(), CollectionPlaceRatings, { collectionPlace: collectionPlaceId })).map(v => ({ user_id: v.user_id, rating: v.rating })); +} const storageFx = makeStorageFixture(''); let svc: CollectionsService; @@ -180,8 +218,8 @@ describe('collections CRUD + visibility', () => { const b = createUser(testDb).user; const other = await svc.createCollection(b.id, { name: 'B-list' }); // b's first list → sort_order 0 await svc.reorderCollections(a.id, [other.id, col.id]); // a cannot see other → skipped; col → index 1 - const otherRow = testDb.prepare('SELECT sort_order FROM collections WHERE id = ?').get(other.id) as { sort_order: number }; - const colRow = testDb.prepare('SELECT sort_order FROM collections WHERE id = ?').get(col.id) as { sort_order: number }; + const otherRow = (await findRow(await orm(), Collections, { id: other.id }))!; + const colRow = (await findRow(await orm(), Collections, { id: col.id }))!; expect(otherRow.sort_order).toBe(0); // untouched — not visible to a expect(colRow.sort_order).toBe(1); // reordered to its index in the visible-filtered list }); @@ -194,10 +232,11 @@ describe('saved places + dedup', () => { const owner = createUser(testDb).user; const member = createUser(testDb).user; const col = await svc.createCollection(owner.id, { name: 'Shared' }); - testDb.prepare("INSERT INTO collection_members (collection_id, user_id, status) VALUES (?, ?, 'accepted')").run(col.id, member.id); + await joinCollection(col.id, member.id); const res = await svc.savePlace(member.id, { collection_id: col.id, name: 'Senso-ji', lat: 35.71, lng: 139.79 }); expect(res.place).toBeDefined(); + // test-sql-allow: SELECT * shows the table's real columns, and the assertion is that no itinerary column is among them. const row = testDb.prepare('SELECT * FROM collection_places WHERE id = ?').get(res.place!.id) as Record; expect(row.owner_id).toBe(owner.id); expect(row.saved_by).toBe(member.id); @@ -216,7 +255,7 @@ describe('saved places + dedup', () => { const forced = await svc.savePlace(u.id, { collection_id: col.id, name: 'eiffel tower', force: true }); expect(forced.place).toBeDefined(); - expect(testDb.prepare('SELECT COUNT(*) n FROM collection_places WHERE collection_id = ?').get(col.id)).toEqual({ n: 2 }); + expect(await placesIn(col.id)).toEqual({ n: 2 }); }); it('COLLECTIONS-SVC-012: savePlace attaches tags', async () => { @@ -319,7 +358,7 @@ describe('saved places + dedup', () => { const col = await svc.createCollection(u.id, { name: 'Rome' }); const trip = createTrip(testDb, u.id); const place = createPlace(testDb, trip.id, { name: 'Trattoria da Enzo' }); - testDb.prepare('UPDATE places SET google_ftid = ? WHERE id = ?').run('0x1:0x2', place.id); + await updateRows(await orm(), Places, { id: place.id }, { google_ftid: '0x1:0x2' }); await svc.savePlace(u.id, { collection_id: col.id, name: 'Dinner Tuesday', lat: 41.88, lng: 12.47, google_ftid: '0x1:0x2' }); const out = await svc.saveFromTripPlaces(u.id, col.id, trip.id, [place.id]); @@ -335,7 +374,7 @@ describe('saved places + dedup', () => { const col = await svc.createCollection(u.id, { name: 'Rome' }); const trip = createTrip(testDb, u.id); const place = createPlace(testDb, trip.id, { name: 'Trattoria da Enzo' }); - testDb.prepare('UPDATE places SET google_ftid = ? WHERE id = ?').run('0x1:0x2', place.id); + await updateRows(await orm(), Places, { id: place.id }, { google_ftid: '0x1:0x2' }); await svc.savePlace(u.id, { collection_id: col.id, name: 'Dinner Tuesday', lat: 41.88, lng: 12.47, google_ftid: '0x1:0x2' }); const listed = (await svc.importablePlaces(u.id, col.id, trip.id)).places.find(p => p.place_id === place.id); @@ -391,12 +430,12 @@ describe('status + updatePlace move', () => { const targetOwner = createUser(testDb).user; const b = await svc.createCollection(targetOwner.id, { name: 'B' }); // owner is also an accepted member of b so the move target is visible to them - testDb.prepare("INSERT INTO collection_members (collection_id, user_id, status) VALUES (?, ?, 'accepted')").run(b.id, owner.id); + await joinCollection(b.id, owner.id); const p = (await svc.savePlace(owner.id, { collection_id: a.id, name: 'Movable' })).place!; const moved = await svc.updatePlace(owner.id, p.id, { collection_id: b.id }); expect(moved.collection_id).toBe(b.id); - const row = testDb.prepare('SELECT owner_id FROM collection_places WHERE id = ?').get(p.id) as { owner_id: number }; + const row = (await findRow(await orm(), CollectionPlaces, { id: p.id }))!; expect(row.owner_id).toBe(targetOwner.id); // reset to the target collection's owner }); @@ -448,10 +487,10 @@ describe('copyToTrip', () => { expect(res.copied).toBe(1); expect(res.skipped.map((s) => s.name)).toEqual(['Pantheon']); - const inserted = testDb.prepare("SELECT * FROM places WHERE trip_id = ? AND name = 'Colosseum'").get(trip.id) as Record; + const inserted = (await findRow(await orm(), Places, { trip: trip.id, name: 'Colosseum' }))!; expect(inserted.reservation_status).toBe('none'); // itinerary column took the table default expect(inserted.duration_minutes).toBe(60); - const tagLink = testDb.prepare('SELECT COUNT(*) n FROM place_tags WHERE place_id = ?').get(inserted.id); + const tagLink = { n: await countRows(await orm(), Tags, { place_tags_inverse: inserted.id }) }; expect(tagLink).toEqual({ n: 1 }); }); @@ -498,9 +537,9 @@ describe('delete places', () => { const p1 = (await svc.savePlace(u.id, { collection_id: col.id, name: 'A' })).place!; const p2 = (await svc.savePlace(u.id, { collection_id: col.id, name: 'B' })).place!; await svc.deletePlace(u.id, p1.id); - expect(testDb.prepare('SELECT COUNT(*) n FROM collection_places WHERE collection_id = ?').get(col.id)).toEqual({ n: 1 }); + expect(await placesIn(col.id)).toEqual({ n: 1 }); expect(await svc.deletePlacesMany(u.id, [p2.id])).toEqual([p2.id]); - expect(testDb.prepare('SELECT COUNT(*) n FROM collection_places WHERE collection_id = ?').get(col.id)).toEqual({ n: 0 }); + expect(await placesIn(col.id)).toEqual({ n: 0 }); }); }); @@ -554,7 +593,7 @@ describe('fusion invitations', () => { const { owner, target, col } = await setup(); await svc.sendInvite(col.id, owner.id, owner.username, owner.email, target.id); await svc.declineInvite(target.id, col.id, undefined); - expect(testDb.prepare('SELECT COUNT(*) n FROM collection_members WHERE collection_id = ?').get(col.id)).toEqual({ n: 0 }); + expect(await membersOf(col.id)).toEqual({ n: 0 }); }); it('COLLECTIONS-SVC-035: cancelInvite is owner-only', async () => { @@ -562,7 +601,7 @@ describe('fusion invitations', () => { await svc.sendInvite(col.id, owner.id, owner.username, owner.email, target.id); await expect(svc.cancelInvite(col.id, target.id, target.id)).rejects.toThrow(); // non-owner await svc.cancelInvite(col.id, owner.id, target.id); // owner ok - expect(testDb.prepare('SELECT COUNT(*) n FROM collection_members WHERE collection_id = ?').get(col.id)).toEqual({ n: 0 }); + expect(await membersOf(col.id)).toEqual({ n: 0 }); }); it('COLLECTIONS-SVC-036: leaveCollection — member ok, owner blocked (400)', async () => { @@ -595,7 +634,7 @@ describe('fusion invitations', () => { const owner = createUser(testDb).user; const normal = createUser(testDb).user; const guest = createUser(testDb).user; - testDb.prepare('UPDATE users SET is_guest = 1 WHERE id = ?').run(guest.id); + await updateRows(await orm(), Users, { id: guest.id }, { is_guest: 1 }); const col = await svc.createCollection(owner.id, { name: 'C' }); const ids = (await svc.availableUsers(owner.id, col.id)).map((u) => u.id); expect(ids).toContain(normal.id); @@ -636,9 +675,9 @@ describe('deleteCollection', () => { expect(targets).not.toContain(owner.id); expect(broadcastToUser.mock.calls.every((c) => (c[1] as { type: string }).type === 'collections:deleted')).toBe(true); - expect(testDb.prepare('SELECT COUNT(*) n FROM collections WHERE id = ?').get(col.id)).toEqual({ n: 0 }); - expect(testDb.prepare('SELECT COUNT(*) n FROM collection_places WHERE collection_id = ?').get(col.id)).toEqual({ n: 0 }); - expect(testDb.prepare('SELECT COUNT(*) n FROM collection_members WHERE collection_id = ?').get(col.id)).toEqual({ n: 0 }); + expect(await countRows(await orm(), Collections, { id: col.id })).toBe(0); + expect(await placesIn(col.id)).toEqual({ n: 0 }); + expect(await membersOf(col.id)).toEqual({ n: 0 }); }); }); @@ -649,13 +688,13 @@ describe('owner_id semantics', () => { const owner = createUser(testDb).user; const member = createUser(testDb).user; const col = await svc.createCollection(owner.id, { name: 'Shared' }); - testDb.prepare("INSERT INTO collection_members (collection_id, user_id, status) VALUES (?, ?, 'accepted')").run(col.id, member.id); + await joinCollection(col.id, member.id); const p = (await svc.savePlace(member.id, { collection_id: col.id, name: 'Kept' })).place!; - testDb.prepare('DELETE FROM users WHERE id = ?').run(member.id); + await deleteRows(await orm(), Users, { id: member.id }); - const row = testDb.prepare('SELECT owner_id, saved_by FROM collection_places WHERE id = ?').get(p.id) as { owner_id: number; saved_by: number | null }; - expect(row).toBeDefined(); + const row = (await findRow(await orm(), CollectionPlaces, { id: p.id }))!; + expect(row).not.toBeNull(); expect(row.owner_id).toBe(owner.id); expect(row.saved_by).toBeNull(); // ON DELETE SET NULL }); @@ -668,27 +707,27 @@ describe('photo-cache widening', () => { const u = createUser(testDb).user; const col = await svc.createCollection(u.id, { name: 'Photos' }); // cache meta for place_id 'gp-x', referenced ONLY by a collection_places row. - testDb.prepare('INSERT INTO google_place_photo_meta (place_id, attribution, fetched_at) VALUES (?, ?, ?)').run('gp-x', null, Date.now()); + await insertRow(await orm(), GooglePlacePhotoMeta, { place_id: 'gp-x', attribution: null, fetched_at: Date.now() }); await svc.savePlace(u.id, { collection_id: col.id, name: 'Cached', google_place_id: 'gp-x' }); await removeIfUnreferenced('gp-x'); // would evict if isReferenced ignored collection_places - const meta = testDb.prepare('SELECT 1 FROM google_place_photo_meta WHERE place_id = ?').get('gp-x'); - expect(meta).toBeDefined(); + const meta = await findRow(await orm(), GooglePlacePhotoMeta, { place_id: 'gp-x' }); + expect(meta).not.toBeNull(); }); it('COLLECTIONS-SVC-043: an unreferenced photo is still reclaimable', async () => { - testDb.prepare('INSERT INTO google_place_photo_meta (place_id, attribution, fetched_at) VALUES (?, ?, ?)').run('gp-orphan', null, Date.now()); + await insertRow(await orm(), GooglePlacePhotoMeta, { place_id: 'gp-orphan', attribution: null, fetched_at: Date.now() }); await removeIfUnreferenced('gp-orphan'); - const meta = testDb.prepare('SELECT 1 FROM google_place_photo_meta WHERE place_id = ?').get('gp-orphan'); - expect(meta).toBeUndefined(); + const meta = await findRow(await orm(), GooglePlacePhotoMeta, { place_id: 'gp-orphan' }); + expect(meta).toBeNull(); }); }); // ── Labels ─────────────────────────────────────────────────────────────────── function addMember(colId: number, userId: number, role: 'viewer' | 'editor' | 'admin') { - testDb.prepare("INSERT INTO collection_members (collection_id, user_id, status, role) VALUES (?, ?, 'accepted', ?)").run(colId, userId, role); + return joinCollection(colId, userId, role); } describe('collection labels', () => { @@ -708,12 +747,12 @@ describe('collection labels', () => { const owner = createUser(testDb).user; const viewer = createUser(testDb).user; const col = await svc.createCollection(owner.id, { name: 'Trip' }); - addMember(col.id, viewer.id, 'viewer'); + await addMember(col.id, viewer.id, 'viewer'); await expect(svc.createLabel(viewer.id, col.id, 'X')).rejects.toThrow(expect.objectContaining({ status: 403, message: 'You have read-only access to this list' })); expect((await svc.getCollection(owner.id, col.id)).collection.labels).toHaveLength(0); const editor = createUser(testDb).user; - addMember(col.id, editor.id, 'editor'); + await addMember(col.id, editor.id, 'editor'); expect((await svc.createLabel(editor.id, col.id, 'Museums')).id).toBeGreaterThan(0); }); @@ -839,7 +878,7 @@ describe('collaborative ratings (#1435)', () => { const member = createUser(testDb).user; const col = await svc.createCollection(owner.id, { name: 'Shared rate' }); // A viewer (read-only) member — still allowed to cast a personal vote. - testDb.prepare("INSERT INTO collection_members (collection_id, user_id, status, role) VALUES (?, ?, 'accepted', 'viewer')").run(col.id, member.id); + await joinCollection(col.id, member.id, 'viewer'); const p = (await svc.savePlace(owner.id, { collection_id: col.id, name: 'Notre-Dame' })).place!; await svc.setRating(owner.id, p.id, 5); @@ -861,22 +900,21 @@ describe('collaborative ratings (#1435)', () => { const shared = createUser(testDb).user; // member of BOTH the trip and the collection const tripOnly = createUser(testDb).user; // member of the trip only const col = await svc.createCollection(owner.id, { name: 'From trip rated' }); - testDb.prepare("INSERT INTO collection_members (collection_id, user_id, status, role) VALUES (?, ?, 'accepted', 'editor')").run(col.id, shared.id); + await joinCollection(col.id, shared.id, 'editor'); const trip = createTrip(testDb, owner.id); addTripMember(testDb, trip.id, shared.id); addTripMember(testDb, trip.id, tripOnly.id); const place = createPlace(testDb, trip.id, { name: 'Colosseum' }); - const ins = testDb.prepare('INSERT INTO place_ratings (place_id, user_id, rating) VALUES (?, ?, ?)'); - ins.run(place.id, owner.id, 5); - ins.run(place.id, shared.id, 4); - ins.run(place.id, tripOnly.id, 1); + await insertRow(await orm(), PlaceRatings, { place: place.id, user: owner.id, rating: 5 }); + await insertRow(await orm(), PlaceRatings, { place: place.id, user: shared.id, rating: 4 }); + await insertRow(await orm(), PlaceRatings, { place: place.id, user: tripOnly.id, rating: 1 }); const saved = (await svc.savePlace(owner.id, { collection_id: col.id, name: 'Colosseum', source_trip_id: trip.id, source_place_id: place.id, })).place!; - const votes = testDb.prepare('SELECT user_id, rating FROM collection_place_ratings WHERE collection_place_id = ?').all(saved.id) as { user_id: number; rating: number }[]; + const votes = await savedPlaceVotes(saved.id); const voterIds = votes.map(v => v.user_id).sort((a, b) => a - b); expect(voterIds).toEqual([owner.id, shared.id].sort((a, b) => a - b)); expect(votes.find(v => v.user_id === tripOnly.id)).toBeUndefined(); @@ -886,7 +924,7 @@ describe('collaborative ratings (#1435)', () => { const owner = createUser(testDb).user; const member = createUser(testDb).user; const col = await svc.createCollection(owner.id, { name: 'Copyable' }); - testDb.prepare("INSERT INTO collection_members (collection_id, user_id, status, role) VALUES (?, ?, 'accepted', 'editor')").run(col.id, member.id); + await joinCollection(col.id, member.id, 'editor'); const cp = (await svc.savePlace(owner.id, { collection_id: col.id, name: 'Trevi' })).place!; await svc.setRating(owner.id, cp.id, 5); await svc.setRating(member.id, cp.id, 3); @@ -896,8 +934,8 @@ describe('collaborative ratings (#1435)', () => { const res = await svc.copyToTrip(owner.id, { trip_id: trip.id, place_ids: [cp.id] }); expect(res.copied).toBe(1); - const newPlace = testDb.prepare('SELECT id FROM places WHERE trip_id = ? ORDER BY id DESC LIMIT 1').get(trip.id) as { id: number }; - const votes = testDb.prepare('SELECT user_id, rating FROM place_ratings WHERE place_id = ?').all(newPlace.id) as { user_id: number; rating: number }[]; + const newPlace = await newestPlace(trip.id); + const votes = await placeVotes(newPlace.id); expect(votes).toHaveLength(2); expect(votes.find(v => v.user_id === owner.id)?.rating).toBe(5); expect(votes.find(v => v.user_id === member.id)?.rating).toBe(3); @@ -908,17 +946,17 @@ describe('collaborative ratings (#1435)', () => { const victim = createUser(testDb).user; const col = await svc.createCollection(attacker.id, { name: 'Harvest attempt' }); // The victim is a member of the attacker's collection (so they'd be "eligible"). - testDb.prepare("INSERT INTO collection_members (collection_id, user_id, status, role) VALUES (?, ?, 'accepted', 'editor')").run(col.id, victim.id); + await joinCollection(col.id, victim.id, 'editor'); // A PRIVATE trip the attacker is not on, with the victim's vote on a place. const privateTrip = createTrip(testDb, victim.id); const secret = createPlace(testDb, privateTrip.id, { name: 'Secret spot' }); - testDb.prepare('INSERT INTO place_ratings (place_id, user_id, rating) VALUES (?, ?, ?)').run(secret.id, victim.id, 5); + await insertRow(await orm(), PlaceRatings, { place: secret.id, user: victim.id, rating: 5 }); const saved = (await svc.savePlace(attacker.id, { collection_id: col.id, name: 'x', source_trip_id: privateTrip.id, source_place_id: secret.id, })).place!; - const stolen = testDb.prepare('SELECT * FROM collection_place_ratings WHERE collection_place_id = ?').all(saved.id); + const stolen = await savedPlaceVotes(saved.id); expect(stolen).toHaveLength(0); // no access to the source trip → nothing copied }); @@ -928,7 +966,7 @@ describe('collaborative ratings (#1435)', () => { const notInTrip = createUser(testDb).user; // collection member only const col = await svc.createCollection(owner.id, { name: 'Mixed membership' }); for (const u of [inTrip, notInTrip]) { - testDb.prepare("INSERT INTO collection_members (collection_id, user_id, status, role) VALUES (?, ?, 'accepted', 'editor')").run(col.id, u.id); + await joinCollection(col.id, u.id, 'editor'); } const cp = (await svc.savePlace(owner.id, { collection_id: col.id, name: 'Pantheon' })).place!; await svc.setRating(owner.id, cp.id, 5); @@ -939,8 +977,8 @@ describe('collaborative ratings (#1435)', () => { addTripMember(testDb, trip.id, inTrip.id); await svc.copyToTrip(owner.id, { trip_id: trip.id, place_ids: [cp.id] }); - const newPlace = testDb.prepare('SELECT id FROM places WHERE trip_id = ? ORDER BY id DESC LIMIT 1').get(trip.id) as { id: number }; - const ids = (testDb.prepare('SELECT user_id FROM place_ratings WHERE place_id = ?').all(newPlace.id) as { user_id: number }[]) + const newPlace = await newestPlace(trip.id); + const ids = (await placeVotes(newPlace.id)) .map(v => v.user_id).sort((a, b) => a - b); expect(ids).toEqual([owner.id, inTrip.id].sort((a, b) => a - b)); expect(ids).not.toContain(notInTrip.id); @@ -989,15 +1027,15 @@ describe('atomic bulk writes (post-fold quirk fixes)', () => { const mine = await svc.createCollection(u.id, { name: 'Mine' }); const shared = await svc.createCollection(otherOwner.id, { name: 'Shared' }); // u is an editor on the shared list — can add/edit but NOT delete (owner/admin only). - testDb.prepare("INSERT INTO collection_members (collection_id, user_id, status, role) VALUES (?, ?, 'accepted', 'editor')").run(shared.id, u.id); + await joinCollection(shared.id, u.id, 'editor'); const p1 = (await svc.savePlace(u.id, { collection_id: mine.id, name: 'Deletable' })).place!; const p2 = (await svc.savePlace(u.id, { collection_id: shared.id, name: 'Protected' })).place!; // The relocated legacy interleaved checks with deletes, so p1 was gone by // the time p2's 403 fired. Now every id is checked first: nothing deleted. await expect(svc.deletePlacesMany(u.id, [p1.id, p2.id])).rejects.toThrow('Only an admin can delete places from this list'); - expect(testDb.prepare('SELECT COUNT(*) n FROM collection_places WHERE id = ?').get(p1.id)).toEqual({ n: 1 }); - expect(testDb.prepare('SELECT COUNT(*) n FROM collection_places WHERE id = ?').get(p2.id)).toEqual({ n: 1 }); + expect(await countRows(await orm(), CollectionPlaces, { id: p1.id })).toBe(1); + expect(await countRows(await orm(), CollectionPlaces, { id: p2.id })).toBe(1); }); it('COLLECTIONS-SVC-091: assignLabels permission-checks every list before writing anything', async () => { @@ -1005,16 +1043,15 @@ describe('atomic bulk writes (post-fold quirk fixes)', () => { const otherOwner = createUser(testDb).user; const mine = await svc.createCollection(u.id, { name: 'Mine' }); const readonly = await svc.createCollection(otherOwner.id, { name: 'ReadOnly' }); - testDb.prepare("INSERT INTO collection_members (collection_id, user_id, status, role) VALUES (?, ?, 'accepted', 'viewer')").run(readonly.id, u.id); + await joinCollection(readonly.id, u.id, 'viewer'); const label = await svc.createLabel(u.id, mine.id, 'Coast'); const pa = (await svc.savePlace(u.id, { collection_id: mine.id, name: 'A' })).place!; - const pb = testDb.prepare('INSERT INTO collection_places (collection_id, owner_id, saved_by, name) VALUES (?, ?, ?, ?)') - .run(readonly.id, otherOwner.id, otherOwner.id, 'B').lastInsertRowid as number; + const pb = await insertRow(await orm(), CollectionPlaces, { collection: readonly.id, owner: otherOwner.id, savedByRef: otherOwner.id, name: 'B' }); // The relocated legacy checked per list inside the write loop, so `mine` // was labeled before `readonly`'s 403 fired. Now all lists check first. await expect(svc.assignLabels(u.id, [label.id], [pa.id, Number(pb)], false)).rejects.toThrow('You have read-only access to this list'); - expect(testDb.prepare('SELECT COUNT(*) n FROM collection_place_labels WHERE collection_place_id = ?').get(pa.id)).toEqual({ n: 0 }); + expect(await countRows(await orm(), CollectionLabels, { collection_place_labels_inverse: pa.id })).toBe(0); }); it('COLLECTIONS-SVC-092: from-trip saves forward the socket id so the origin client does not echo', async () => { @@ -1039,8 +1076,8 @@ describe('atomic bulk writes (post-fold quirk fixes)', () => { // ── Bulk "visited" from a trip (#1469) ─────────────────────────────────────── /** The stored status of a saved place, read straight off the row. */ -function statusOf(placeId: number): string { - return (testDb.prepare('SELECT status FROM collection_places WHERE id = ?').get(placeId) as { status: string }).status; +async function statusOf(placeId: number): Promise { + return (await findRow(await orm(), CollectionPlaces, { id: placeId }))!.status; } describe('bulk status', () => { @@ -1054,7 +1091,7 @@ describe('bulk status', () => { // pb is already visited, so only pa is a change. expect(await svc.setStatusMany(u.id, [pa.id, pb.id], 'visited')).toEqual({ updated: 1 }); - expect(statusOf(pa.id)).toBe('visited'); + expect(await statusOf(pa.id)).toBe('visited'); expect(await svc.setStatusMany(u.id, [pa.id, pb.id], 'visited')).toEqual({ updated: 0 }); }); @@ -1063,12 +1100,12 @@ describe('bulk status', () => { const viewer = createUser(testDb).user; const mine = await svc.createCollection(viewer.id, { name: 'Mine' }); const readonly = await svc.createCollection(owner.id, { name: 'Shared' }); - addMember(readonly.id, viewer.id, 'viewer'); + await addMember(readonly.id, viewer.id, 'viewer'); const p1 = (await svc.savePlace(viewer.id, { collection_id: mine.id, name: 'Louvre' })).place!; const p2 = (await svc.savePlace(owner.id, { collection_id: readonly.id, name: 'Louvre' })).place!; await expect(svc.setStatusMany(viewer.id, [p1.id, p2.id], 'visited')).rejects.toThrow('You have read-only access to this list'); - expect(statusOf(p1.id)).toBe('idea'); + expect(await statusOf(p1.id)).toBe('idea'); }); it('COLLECTIONS-SVC-095: setStatusFromTrip marks every saved copy of the selected trip places', async () => { @@ -1084,7 +1121,7 @@ describe('bulk status', () => { await svc.saveFromTripPlace(u.id, a.id, trip.id, orsay.id); expect(await svc.setStatusFromTrip(u.id, trip.id, [louvre.id], 'visited')).toEqual({ updated: 2, places: 1 }); - const statuses = testDb.prepare('SELECT status FROM collection_places ORDER BY id').all() as { status: string }[]; + const statuses = await findRows(await orm(), CollectionPlaces, {}, { id: 'asc' }); expect(statuses.map(s => s.status)).toEqual(['visited', 'visited', 'idea']); }); @@ -1097,7 +1134,7 @@ describe('bulk status', () => { const saved = (await svc.saveFromTripPlace(u.id, col.id, trip.id, place.id)).place!; // Renamed on both sides, and moved far enough that coordinates cannot match. await svc.updatePlace(u.id, saved.id, { name: 'Dinner spot', lat: 45, lng: 9 }); - testDb.prepare('UPDATE places SET name = ? WHERE id = ?').run('Enzo', place.id); + await updateRows(await orm(), Places, { id: place.id }, { name: 'Enzo' }); expect(await svc.setStatusFromTrip(u.id, trip.id, [place.id], 'visited')).toEqual({ updated: 1, places: 1 }); }); @@ -1120,18 +1157,18 @@ describe('bulk status', () => { const trip = createTrip(testDb, viewer.id); const place = createPlace(testDb, trip.id, { name: 'Louvre', lat: 48.8606, lng: 2.3376 }); const readonly = await svc.createCollection(owner.id, { name: 'Shared' }); - addMember(readonly.id, viewer.id, 'viewer'); + await addMember(readonly.id, viewer.id, 'viewer'); const theirs = (await svc.savePlace(owner.id, { collection_id: readonly.id, name: 'Louvre', lat: 48.8606, lng: 2.3376 })).place!; expect(await svc.setStatusFromTrip(viewer.id, trip.id, [place.id], 'visited')).toEqual({ updated: 0, places: 0 }); - expect(statusOf(theirs.id)).toBe('idea'); + expect(await statusOf(theirs.id)).toBe('idea'); }); it('COLLECTIONS-SVC-099: findMembership reports the per-list status and edit right', async () => { const owner = createUser(testDb).user; const viewer = createUser(testDb).user; const readonly = await svc.createCollection(owner.id, { name: 'Shared' }); - addMember(readonly.id, viewer.id, 'viewer'); + await addMember(readonly.id, viewer.id, 'viewer'); const saved = (await svc.savePlace(owner.id, { collection_id: readonly.id, name: 'Louvre', google_place_id: 'gp-9' })).place!; await svc.setStatus(owner.id, saved.id, 'visited'); @@ -1191,7 +1228,7 @@ describe('exportCollection / importCollection (#2198)', () => { const owner = createUser(testDb).user; const member = createUser(testDb).user; const { col, market } = await seedList(owner.id); - addMember(col.id, member.id, 'editor'); + await addMember(col.id, member.id, 'editor'); await svc.setRating(member.id, market.id, 5); const file = await svc.exportCollection(owner.id, col.id); @@ -1219,7 +1256,7 @@ describe('exportCollection / importCollection (#2198)', () => { const viewer = createUser(testDb).user; const stranger = createUser(testDb).user; const { col } = await seedList(owner.id); - addMember(col.id, viewer.id, 'viewer'); + await addMember(col.id, viewer.id, 'viewer'); expect((await svc.exportCollection(viewer.id, col.id)).places).toHaveLength(2); try { @@ -1328,7 +1365,7 @@ describe('exportCollection / importCollection (#2198)', () => { } as never], } })).collection as { id: number }; - const row = testDb.prepare('SELECT * FROM collection_places WHERE collection_id = ?').get(created.id) as Record; + const row = (await findRow(await orm(), CollectionPlaces, { collection: created.id }))!; expect(row.owner_id).toBe(owner.id); expect(row.saved_by).toBe(owner.id); expect(row.source_trip_id).toBeNull(); @@ -1399,7 +1436,7 @@ describe('importIntoCollection', () => { expect(after.collection).toMatchObject({ name: 'Lisbon', description: 'Mine', color: '#111827' }); expect(after.places.map(p => p.name)).toEqual(['Time Out Market', 'Belém', 'Alfama']); // Appended after what was there, so a manual order survives. - const orders = testDb.prepare('SELECT name, sort_order FROM collection_places WHERE collection_id = ? ORDER BY sort_order').all(col.id); + const orders = (await findRows(await orm(), CollectionPlaces, { collection: col.id }, { sort_order: 'asc' })).map(r => ({ name: r.name, sort_order: r.sort_order })); expect(orders).toEqual([ { name: 'Time Out Market', sort_order: 0 }, { name: 'Belém', sort_order: 1 }, @@ -1461,8 +1498,8 @@ describe('importIntoCollection', () => { const viewer = createUser(testDb).user; const stranger = createUser(testDb).user; const col = await svc.createCollection(owner.id, { name: 'Shared' }); - addMember(col.id, editor.id, 'editor'); - addMember(col.id, viewer.id, 'viewer'); + await addMember(col.id, editor.id, 'editor'); + await addMember(col.id, viewer.id, 'viewer'); expect(await svc.importIntoCollection(editor.id, col.id, { file: file([{ name: 'Belém' }]) })).toMatchObject({ imported: 1 }); await expect(svc.importIntoCollection(viewer.id, col.id, { file: file([{ name: 'Alfama' }]) })).rejects.toThrow(); @@ -1479,19 +1516,19 @@ describe('importIntoCollection', () => { const owner = createUser(testDb).user; const editor = createUser(testDb).user; const col = await svc.createCollection(owner.id, { name: 'Shared' }); - addMember(col.id, editor.id, 'editor'); + await addMember(col.id, editor.id, 'editor'); await svc.importIntoCollection(editor.id, col.id, { file: file([{ name: 'Belém' }]) }); - const row = testDb.prepare('SELECT owner_id, saved_by FROM collection_places WHERE collection_id = ?').get(col.id); - expect(row).toEqual({ owner_id: owner.id, saved_by: editor.id }); + const stored = (await findRow(await orm(), CollectionPlaces, { collection: col.id }))!; + expect({ owner_id: stored.owner_id, saved_by: stored.saved_by }).toEqual({ owner_id: owner.id, saved_by: editor.id }); }); it('COLLECTIONS-SVC-126: everyone on the list is told once, and nothing is said when nothing changed', async () => { const owner = createUser(testDb).user; const member = createUser(testDb).user; const col = await svc.createCollection(owner.id, { name: 'Shared' }); - addMember(col.id, member.id, 'editor'); + await addMember(col.id, member.id, 'editor'); await svc.savePlace(owner.id, { collection_id: col.id, name: 'Belém' }); broadcastToUser.mockClear(); @@ -1600,12 +1637,12 @@ describe('exportCollectionGpx / readCollectionGpx (#2301)', () => { it('COLLECTIONS-SVC-123: reading a GPX writes nothing, whatever is in it', async () => { const owner = createUser(testDb).user; - const before = testDb.prepare('SELECT COUNT(*) AS n FROM collections').get() as { n: number }; + const before = await countRows(await orm(), Collections); svc.readCollectionGpx({ gpx: fixture('garmin-sym.gpx') }); expect(() => svc.readCollectionGpx({ gpx: fixture('hostile-doctype.gpx') })).toThrow(expect.objectContaining({ code: 'unreadable' })); - expect(testDb.prepare('SELECT COUNT(*) AS n FROM collections').get()).toEqual(before); + expect(await countRows(await orm(), Collections)).toBe(before); expect((await svc.listCollections(owner.id)).collections).toHaveLength(0); }); }); @@ -1631,9 +1668,9 @@ describe('Plan 3h Task 1 — repository conversion parity', () => { const viewer = createUser(testDb).user; const pending = createUser(testDb).user; const col = await svc.createCollection(owner.id, { name: 'Full house', description: 'Every role', color: '#111111' }); - addMember(col.id, admin.id, 'admin'); - addMember(col.id, editor.id, 'editor'); - addMember(col.id, viewer.id, 'viewer'); + await addMember(col.id, admin.id, 'admin'); + await addMember(col.id, editor.id, 'editor'); + await addMember(col.id, viewer.id, 'viewer'); await svc.sendInvite(col.id, owner.id, owner.username, owner.email, pending.id); const tag = createTag(testDb, owner.id, { name: 'Foodie' }); @@ -1649,7 +1686,9 @@ describe('Plan 3h Task 1 — repository conversion parity', () => { // the same seeded rows — the oracle this test proves the repository // conversion against, kept structurally separate from the production // code path it verifies. + // test-sql-allow: legacy read-model oracle, recomputed from raw SQL on purpose. const colRow = testDb.prepare('SELECT * FROM collections WHERE id = ?').get(col.id) as Record; + // test-sql-allow: legacy read-model oracle, recomputed from raw SQL on purpose. const placeCount = (testDb.prepare('SELECT COUNT(*) AS n FROM collection_places WHERE collection_id = ?').get(col.id) as { n: number }).n; const legacyMembers = [ { user_id: owner.id, username: owner.username, email: owner.email, avatar: null, status: 'accepted', role: 'admin', is_owner: true }, @@ -1665,8 +1704,10 @@ describe('Plan 3h Task 1 — repository conversion parity', () => { .sort((a, b) => a.u.username.localeCompare(b.u.username)) .map(({ u, role, status }) => ({ user_id: u.id, username: u.username, email: u.email, avatar: null, status, role, is_owner: false })), ]; + // test-sql-allow: legacy read-model oracle, recomputed from raw SQL on purpose. const placeRow = testDb.prepare('SELECT * FROM collection_places WHERE collection_id = ?').get(col.id) as Record; const ratingRows = testDb + // test-sql-allow: legacy read-model oracle, recomputed from raw SQL on purpose. .prepare('SELECT cpr.user_id, cpr.rating FROM collection_place_ratings cpr WHERE cpr.collection_place_id = ? ORDER BY cpr.created_at') .all(place.id) as { user_id: number; rating: number }[]; @@ -1702,6 +1743,7 @@ describe('Plan 3h Task 1 — repository conversion parity', () => { // cascade already happened by the time any broadcast fires. const memberCountAtBroadcastTime: number[] = []; broadcastToUser.mockImplementation(() => { + // test-sql-allow: the count has to be taken synchronously, at the moment the broadcast fires. memberCountAtBroadcastTime.push((testDb.prepare('SELECT COUNT(*) AS n FROM collection_members WHERE collection_id = ?').get(col.id) as { n: number }).n); }); @@ -1777,9 +1819,9 @@ describe('Plan 3h Task 2 — R6 two-layer authorization order', () => { const strangerOwner = createUser(testDb).user; const strangerTrip = createTrip(testDb, strangerOwner.id); // owner (the caller) is not on this trip - const before = (testDb.prepare('SELECT COUNT(*) AS n FROM places WHERE trip_id = ?').get(strangerTrip.id) as { n: number }).n; + const before = await countRows(await orm(), Places, { trip: strangerTrip.id }); await expect(svc.copyToTrip(owner.id, { trip_id: strangerTrip.id, place_ids: [cp.id] })).rejects.toThrow(); - const after = (testDb.prepare('SELECT COUNT(*) AS n FROM places WHERE trip_id = ?').get(strangerTrip.id) as { n: number }).n; + const after = await countRows(await orm(), Places, { trip: strangerTrip.id }); expect(after).toBe(before); // no places row (CL64), and therefore no place_tags/place_ratings rows either }); }); diff --git a/server/tests/unit/nest/notifications.service.test.ts b/server/tests/unit/nest/notifications.service.test.ts index 4abf56430..29a6670c8 100644 --- a/server/tests/unit/nest/notifications.service.test.ts +++ b/server/tests/unit/nest/notifications.service.test.ts @@ -97,6 +97,12 @@ import { setPluginChannelSource } from '../../../src/nest/notifications/channel- // it for its own signatures and never re-exported it. import type { ExternalChannel } from '../../../src/nest/notifications/notification-events'; import { makeNotificationsService, makeNotificationPreferencesService } from '../../helpers/notifications'; +import { sharedTestOrm } from '../../helpers/test-uow'; +import { countRows, findRow, findRows, insertRow, updateRows } from '../../helpers/factories/rows'; +import { setUserSetting } from '../../helpers/factories/settings'; +import { addTripMember, makeTrip } from '../../helpers/factories/trips'; +import { Notifications } from '../../../src/db/entities/Notifications.entity'; +import { Users } from '../../../src/db/entities/Users.entity'; // Built in beforeAll: the service now takes a UnitOfWork, which is async to build. let notifications: NotificationsService; @@ -110,28 +116,37 @@ function setSmtp(): void { setAppSetting(testDb, 'smtp_from', 'trek@test.com'); } -function setUserWebhookUrl(userId: number, url = 'https://hooks.test.com/webhook'): void { - testDb.prepare("INSERT OR REPLACE INTO settings (user_id, key, value) VALUES (?, 'webhook_url', ?)").run(userId, url); +const orm = () => sharedTestOrm(testDb); + +async function setUserWebhookUrl(userId: number, url = 'https://hooks.test.com/webhook'): Promise { + await setUserSetting(await orm(), userId, 'webhook_url', url); } function setAdminWebhookUrl(url = 'https://hooks.test.com/admin-webhook'): void { setAppSetting(testDb, 'admin_webhook_url', url); } -function getInAppNotifications(recipientId: number) { - return testDb.prepare('SELECT * FROM notifications WHERE recipient_id = ? ORDER BY id').all(recipientId) as Array<{ - id: number; - type: string; - scope: string; - navigate_target: string | null; - navigate_text_key: string | null; - title_key: string; - text_key: string; - }>; +async function getInAppNotifications(recipientId: number) { + return findRows(await orm(), Notifications, { recipient: recipientId }, { id: 'asc' }); } -function countAllNotifications(): number { - return (testDb.prepare('SELECT COUNT(*) as c FROM notifications').get() as { c: number }).c; +async function countAllNotifications(): Promise { + return countRows(await orm(), Notifications); +} + +/** Every notification's recipient, lowest id first. */ +async function recipientIds(): Promise { + return (await findRows(await orm(), Notifications, {}, { recipient: 'asc' })).map(r => r.recipient_id); +} + +/** A trip with just its owner, returning its id. */ +async function newTrip(title: string, userId: number): Promise { + return (await makeTrip(await orm(), userId, { title })).id; +} + +/** Points the user's email at the address the mail assertions expect. */ +async function setRecipientEmail(userId: number): Promise { + await updateRows(await orm(), Users, { id: userId }, { email: 'recipient@test.com' }); } // ── Setup ────────────────────────────────────────────────────────────────── @@ -161,43 +176,43 @@ describe('send() — multi-channel dispatch', () => { it('NSVC-001 — dispatches to all 3 channels (inapp, email, webhook) when all are active', async () => { const { user } = createUser(testDb); setSmtp(); - setUserWebhookUrl(user.id); + await setUserWebhookUrl(user.id); setNotificationChannels(testDb, 'email,webhook'); - testDb.prepare('UPDATE users SET email = ? WHERE id = ?').run('recipient@test.com', user.id); + await setRecipientEmail(user.id); - const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Paris', user.id)).lastInsertRowid as number; + const tripId = await newTrip('Paris', user.id); await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } }); expect(sendMailMock).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenCalledTimes(1); expect(broadcastMock).toHaveBeenCalledTimes(1); - expect(countAllNotifications()).toBe(1); + expect(await countAllNotifications()).toBe(1); }); it('NSVC-002 — skips email/webhook when no channels are active (in-app still fires)', async () => { const { user } = createUser(testDb); setSmtp(); - setUserWebhookUrl(user.id); + await setUserWebhookUrl(user.id); setNotificationChannels(testDb, 'none'); - const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Rome', user.id)).lastInsertRowid as number; + const tripId = await newTrip('Rome', user.id); await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Rome', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } }); expect(sendMailMock).not.toHaveBeenCalled(); expect(fetchMock).not.toHaveBeenCalled(); expect(broadcastMock).toHaveBeenCalledTimes(1); - expect(countAllNotifications()).toBe(1); + expect(await countAllNotifications()).toBe(1); }); it('NSVC-003 — sends only email when only email channel is active', async () => { const { user } = createUser(testDb); setSmtp(); setNotificationChannels(testDb, 'email'); - testDb.prepare('UPDATE users SET email = ? WHERE id = ?').run('recipient@test.com', user.id); + await setRecipientEmail(user.id); - const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Berlin', user.id)).lastInsertRowid as number; + const tripId = await newTrip('Berlin', user.id); await send({ event: 'booking_change', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Berlin', actor: 'Bob', booking: 'Hotel', type: 'hotel', tripId: String(tripId) } }); @@ -215,10 +230,10 @@ describe('send() — per-user preference filtering', () => { const { user } = createUser(testDb); setSmtp(); setNotificationChannels(testDb, 'email'); - testDb.prepare('UPDATE users SET email = ? WHERE id = ?').run('recipient@test.com', user.id); + await setRecipientEmail(user.id); disableNotificationPref(testDb, user.id, 'trip_invite', 'email'); - const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Paris', user.id)).lastInsertRowid as number; + const tripId = await newTrip('Paris', user.id); await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } }); @@ -232,23 +247,23 @@ describe('send() — per-user preference filtering', () => { setNotificationChannels(testDb, 'none'); disableNotificationPref(testDb, user.id, 'collab_message', 'inapp'); - const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Trip', user.id)).lastInsertRowid as number; + const tripId = await newTrip('Trip', user.id); await send({ event: 'collab_message', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Trip', actor: 'Alice', tripId: String(tripId) } }); expect(broadcastMock).not.toHaveBeenCalled(); - expect(countAllNotifications()).toBe(0); + expect(await countAllNotifications()).toBe(0); }); it('NSVC-006 — still sends webhook when user has email disabled but webhook enabled', async () => { const { user } = createUser(testDb); setSmtp(); - setUserWebhookUrl(user.id); + await setUserWebhookUrl(user.id); setNotificationChannels(testDb, 'email,webhook'); - testDb.prepare('UPDATE users SET email = ? WHERE id = ?').run('recipient@test.com', user.id); + await setRecipientEmail(user.id); disableNotificationPref(testDb, user.id, 'trip_invite', 'email'); - const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Paris', user.id)).lastInsertRowid as number; + const tripId = await newTrip('Paris', user.id); await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } }); @@ -269,16 +284,16 @@ describe('send() — recipient resolution', () => { const { user: actor } = createUser(testDb); setNotificationChannels(testDb, 'none'); - const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Trip', owner.id)).lastInsertRowid as number; - testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, member1.id); - testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, member2.id); - testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, actor.id); + const tripId = await newTrip('Trip', owner.id); + await addTripMember(await orm(), tripId, member1.id); + await addTripMember(await orm(), tripId, member2.id); + await addTripMember(await orm(), tripId, actor.id); await send({ event: 'booking_change', actorId: actor.id, scope: 'trip', targetId: tripId, params: { trip: 'Trip', actor: 'Actor', booking: 'Hotel', type: 'hotel', tripId: String(tripId) } }); // Owner, member1, member2 get it; actor is excluded - expect(countAllNotifications()).toBe(3); - const recipients = (testDb.prepare('SELECT recipient_id FROM notifications ORDER BY recipient_id').all() as { recipient_id: number }[]).map(r => r.recipient_id); + expect(await countAllNotifications()).toBe(3); + const recipients = await recipientIds(); expect(recipients).toContain(owner.id); expect(recipients).toContain(member1.id); expect(recipients).toContain(member2.id); @@ -290,20 +305,20 @@ describe('send() — recipient resolution', () => { const { user: member } = createUser(testDb); setNotificationChannels(testDb, 'none'); - const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Trip', owner.id)).lastInsertRowid as number; - testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, member.id); + const tripId = await newTrip('Trip', owner.id); + await addTripMember(await orm(), tripId, member.id); // A guest joined into the trip — assignable, but has no inbox. - const guestId = (testDb.prepare("INSERT INTO users (username, email, password_hash, role, is_guest) VALUES ('Guest', 'guest-x@guests.invalid', '', 'user', 1)").run()).lastInsertRowid as number; - testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, guestId); + const guestId = await insertRow(await orm(), Users, { username: 'Guest', email: 'guest-x@guests.invalid', password_hash: '', role: 'user', is_guest: 1 }); + await addTripMember(await orm(), tripId, guestId); await send({ event: 'booking_change', actorId: owner.id, scope: 'trip', targetId: tripId, params: { trip: 'Trip', actor: 'Owner', booking: 'Hotel', type: 'hotel', tripId: String(tripId) } }); - let recipients = (testDb.prepare('SELECT recipient_id FROM notifications').all() as { recipient_id: number }[]).map(r => r.recipient_id); + let recipients = await recipientIds(); expect(recipients).toContain(member.id); expect(recipients).not.toContain(guestId); // Even a direct user-scope notification (e.g. a todo assigned to the guest) is dropped. await send({ event: 'vacay_invite', actorId: owner.id, scope: 'user', targetId: guestId, params: { actor: 'owner@test.com', planId: '1' } }); - recipients = (testDb.prepare('SELECT recipient_id FROM notifications').all() as { recipient_id: number }[]).map(r => r.recipient_id); + recipients = await recipientIds(); expect(recipients).not.toContain(guestId); }); @@ -311,10 +326,10 @@ describe('send() — recipient resolution', () => { const { user: owner } = createUser(testDb); const { user: member } = createUser(testDb); - const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Trip', owner.id)).lastInsertRowid as number; - testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, member.id); - const guestId = (testDb.prepare("INSERT INTO users (username, email, password_hash, role, is_guest) VALUES ('Guest', 'guest-y@guests.invalid', '', 'user', 1)").run()).lastInsertRowid as number; - testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, guestId); + const tripId = await newTrip('Trip', owner.id); + await addTripMember(await orm(), tripId, member.id); + const guestId = await insertRow(await orm(), Users, { username: 'Guest', email: 'guest-y@guests.invalid', password_hash: '', role: 'user', is_guest: 1 }); + await addTripMember(await orm(), tripId, guestId); const tripRecipients = await notifications.resolveRecipients('trip', tripId); expect(tripRecipients).toContain(owner.id); @@ -332,8 +347,8 @@ describe('send() — recipient resolution', () => { await send({ event: 'vacay_invite', actorId: other.id, scope: 'user', targetId: target.id, params: { actor: 'other@test.com', planId: '42' } }); - expect(countAllNotifications()).toBe(1); - const notif = testDb.prepare('SELECT recipient_id FROM notifications LIMIT 1').get() as { recipient_id: number }; + expect(await countAllNotifications()).toBe(1); + const notif = (await findRow(await orm(), Notifications, {}))!; expect(notif.recipient_id).toBe(target.id); }); @@ -345,8 +360,8 @@ describe('send() — recipient resolution', () => { await send({ event: 'version_available', actorId: null, scope: 'admin', targetId: 0, params: { version: '2.0.0' } }); - expect(countAllNotifications()).toBe(2); - const recipients = (testDb.prepare('SELECT recipient_id FROM notifications ORDER BY recipient_id').all() as { recipient_id: number }[]).map(r => r.recipient_id); + expect(await countAllNotifications()).toBe(2); + const recipients = await recipientIds(); expect(recipients).toContain(admin1.id); expect(recipients).toContain(admin2.id); }); @@ -369,11 +384,11 @@ describe('send() — recipient resolution', () => { // Trip with no members, sending as the trip owner (actor excluded from trip scope) const { user: owner } = createUser(testDb); setNotificationChannels(testDb, 'none'); - const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Solo', owner.id)).lastInsertRowid as number; + const tripId = await newTrip('Solo', owner.id); await send({ event: 'booking_change', actorId: owner.id, scope: 'trip', targetId: tripId, params: { trip: 'Solo', actor: 'owner@test.com', booking: 'Hotel', type: 'hotel', tripId: String(tripId) } }); - expect(countAllNotifications()).toBe(0); + expect(await countAllNotifications()).toBe(0); expect(broadcastMock).not.toHaveBeenCalled(); }); }); @@ -389,7 +404,7 @@ describe('send() — in-app notification content', () => { await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: '42' } }); - const notifs = getInAppNotifications(user.id); + const notifs = await getInAppNotifications(user.id); expect(notifs.length).toBe(1); expect(notifs[0].type).toBe('navigate'); expect(notifs[0].title_key).toBe('notif.trip_invite.title'); @@ -405,7 +420,7 @@ describe('send() — in-app notification content', () => { // vacay_invite without planId → no navigate target → simple type await send({ event: 'vacay_invite', actorId: null, scope: 'user', targetId: user.id, params: { actor: 'Alice' } }); - const notifs = getInAppNotifications(user.id); + const notifs = await getInAppNotifications(user.id); expect(notifs.length).toBe(1); expect(notifs[0].type).toBe('simple'); expect(notifs[0].navigate_target).toBeNull(); @@ -417,7 +432,7 @@ describe('send() — in-app notification content', () => { await send({ event: 'version_available', actorId: null, scope: 'admin', targetId: 0, params: { version: '9.9.9' } }); - const notifs = getInAppNotifications(admin.id); + const notifs = await getInAppNotifications(admin.id); expect(notifs.length).toBe(1); expect(notifs[0].navigate_target).toBe('/admin'); expect(notifs[0].title_key).toBe('notif.version_available.title'); @@ -443,7 +458,7 @@ describe('send() — in-app notification content', () => { params: { backend: 'minio', op: 'put', key: 'trips/1/b.jpg', error: 'ECONNRESET', suppressed: '0' }, }); - const notifs = getInAppNotifications(admin.id); + const notifs = await getInAppNotifications(admin.id); expect(notifs.length).toBe(2); expect(notifs[0].text_key).toBe('notif.replica_failure.textSuppressed'); expect(notifs[1].text_key).toBe('notif.replica_failure.text'); @@ -461,7 +476,7 @@ describe('send() — in-app notification content', () => { params: { backend: 'minio', op: 'put', key: 'trips/1/c.jpg', error: 'ECONNRESET' }, }); - const notifs = getInAppNotifications(admin.id); + const notifs = await getInAppNotifications(admin.id); expect(notifs.length).toBe(1); expect(notifs[0].text_key).toBe('notif.replica_failure.text'); }); @@ -476,11 +491,11 @@ describe('send() — email/webhook links', () => { const { user } = createUser(testDb); setSmtp(); setNotificationChannels(testDb, 'email'); - testDb.prepare('UPDATE users SET email = ? WHERE id = ?').run('recipient@test.com', user.id); + await setRecipientEmail(user.id); // Set user language to French - testDb.prepare("INSERT OR REPLACE INTO settings (user_id, key, value) VALUES (?, 'language', 'fr')").run(user.id); + await setUserSetting(await orm(), user.id, 'language', 'fr'); - const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Paris', user.id)).lastInsertRowid as number; + const tripId = await newTrip('Paris', user.id); await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } }); @@ -492,7 +507,7 @@ describe('send() — email/webhook links', () => { it('NSVC-016 — webhook payload includes link field when navigate target is available', async () => { const { user } = createUser(testDb); - setUserWebhookUrl(user.id, 'https://hooks.test.com/generic-webhook'); + await setUserWebhookUrl(user.id, 'https://hooks.test.com/generic-webhook'); setNotificationChannels(testDb, 'webhook'); await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: '55' } }); @@ -528,7 +543,7 @@ describe('send() — boolean in-app type', () => { }, }); - const notifs = getInAppNotifications(user.id); + const notifs = await getInAppNotifications(user.id); expect(notifs.length).toBe(1); const row = notifs[0] as any; expect(row.type).toBe('boolean'); @@ -545,41 +560,41 @@ describe('send() — channel failure resilience', () => { it('NSVC-018 — email failure does not prevent in-app or webhook delivery', async () => { const { user } = createUser(testDb); setSmtp(); - setUserWebhookUrl(user.id); + await setUserWebhookUrl(user.id); setNotificationChannels(testDb, 'email,webhook'); - testDb.prepare('UPDATE users SET email = ? WHERE id = ?').run('recipient@test.com', user.id); + await setRecipientEmail(user.id); // Make email throw sendMailMock.mockRejectedValueOnce(new Error('SMTP connection refused')); - const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Trip', user.id)).lastInsertRowid as number; + const tripId = await newTrip('Trip', user.id); await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Trip', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } }); // In-app and webhook still fire despite email failure expect(broadcastMock).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenCalledTimes(1); - expect(countAllNotifications()).toBe(1); + expect(await countAllNotifications()).toBe(1); }); it('NSVC-019 — webhook failure does not prevent in-app or email delivery', async () => { const { user } = createUser(testDb); setSmtp(); - setUserWebhookUrl(user.id); + await setUserWebhookUrl(user.id); setNotificationChannels(testDb, 'email,webhook'); - testDb.prepare('UPDATE users SET email = ? WHERE id = ?').run('recipient@test.com', user.id); + await setRecipientEmail(user.id); // Make webhook throw fetchMock.mockRejectedValueOnce(new Error('Network error')); - const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Trip', user.id)).lastInsertRowid as number; + const tripId = await newTrip('Trip', user.id); await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Trip', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } }); // In-app and email still fire despite webhook failure expect(broadcastMock).toHaveBeenCalledTimes(1); expect(sendMailMock).toHaveBeenCalledTimes(1); - expect(countAllNotifications()).toBe(1); + expect(await countAllNotifications()).toBe(1); }); }); @@ -588,10 +603,10 @@ describe('send() reports what it delivered', () => { it('NSVC-022: counts in-app and every channel that went out', async () => { const { user } = createUser(testDb); setSmtp(); - setUserWebhookUrl(user.id); + await setUserWebhookUrl(user.id); setNotificationChannels(testDb, 'email,webhook'); - testDb.prepare('UPDATE users SET email = ? WHERE id = ?').run('recipient@test.com', user.id); - const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Trip', user.id)).lastInsertRowid as number; + await setRecipientEmail(user.id); + const tripId = await newTrip('Trip', user.id); await expect( send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Trip', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } }), @@ -602,10 +617,10 @@ describe('send() reports what it delivered', () => { const { user } = createUser(testDb); setSmtp(); setNotificationChannels(testDb, 'email'); - testDb.prepare('UPDATE users SET email = ? WHERE id = ?').run('recipient@test.com', user.id); + await setRecipientEmail(user.id); disableNotificationPref(testDb, user.id, 'trip_reminder', 'inapp'); sendMailMock.mockRejectedValueOnce(new Error('SMTP connection refused')); - const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Trip', user.id)).lastInsertRowid as number; + const tripId = await newTrip('Trip', user.id); await expect( send({ event: 'trip_reminder', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Trip', tripId: String(tripId) } }), @@ -616,7 +631,7 @@ describe('send() reports what it delivered', () => { const { user } = createUser(testDb); setNotificationChannels(testDb, 'none'); disableNotificationPref(testDb, user.id, 'collab_message', 'inapp'); - const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Trip', user.id)).lastInsertRowid as number; + const tripId = await newTrip('Trip', user.id); await expect( send({ event: 'collab_message', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Trip', actor: 'Alice', tripId: String(tripId) } }), @@ -629,8 +644,8 @@ describe('send() reports what it delivered', () => { // ── Ntfy dispatch ───────────────────────────────────────────────────────────── -function setUserNtfyTopic(userId: number, topic = 'my-trek-topic'): void { - testDb.prepare("INSERT OR REPLACE INTO settings (user_id, key, value) VALUES (?, 'ntfy_topic', ?)").run(userId, topic); +async function setUserNtfyTopic(userId: number, topic = 'my-trek-topic'): Promise { + await setUserSetting(await orm(), userId, 'ntfy_topic', topic); } function setAdminNtfyTopic(topic = 'trek-admin-alerts'): void { @@ -644,9 +659,9 @@ describe('send() — ntfy channel dispatch', () => { it('NTFY-SVCB-001 — ntfy fires when channel active and user has topic configured', async () => { const { user } = createUser(testDb); - setUserNtfyTopic(user.id); + await setUserNtfyTopic(user.id); setNotificationChannels(testDb, 'ntfy'); - const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Tokyo', user.id)).lastInsertRowid as number; + const tripId = await newTrip('Tokyo', user.id); await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Tokyo', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } }); @@ -659,7 +674,7 @@ describe('send() — ntfy channel dispatch', () => { it('NTFY-SVCB-002 — ntfy skips when channel not in active channels', async () => { const { user } = createUser(testDb); - setUserNtfyTopic(user.id); + await setUserNtfyTopic(user.id); setNotificationChannels(testDb, 'none'); fetchMock.mockClear(); @@ -794,7 +809,7 @@ describe('send() — plugin notification channels', () => { await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Rome', actor: 'Alice', invitee: 'Bob', tripId: '1' } }); expect(sendMailMock).toHaveBeenCalledTimes(1); - expect(getInAppNotifications(user.id).length).toBe(1); + expect((await getInAppNotifications(user.id)).length).toBe(1); }); it('NSVC-PLUG-006 — never receives an admin-scoped event', async () => { diff --git a/server/tests/unit/nest/roadtrip-plan.service.test.ts b/server/tests/unit/nest/roadtrip-plan.service.test.ts index 7b3c601db..c7cec23c9 100644 --- a/server/tests/unit/nest/roadtrip-plan.service.test.ts +++ b/server/tests/unit/nest/roadtrip-plan.service.test.ts @@ -20,6 +20,12 @@ import { createTestReservationEndpointsRepo, createTestReservationDayPositionsRepo, } from '../../helpers/test-uow'; +import { sharedTestOrm } from '../../helpers/test-uow'; +import { findRow, insertRow, updateRows } from '../../helpers/factories/rows'; +import { DayAssignments } from '../../../src/db/entities/DayAssignments.entity'; +import { Places } from '../../../src/db/entities/Places.entity'; +import { ReservationEndpoints } from '../../../src/db/entities/ReservationEndpoints.entity'; +import { Reservations } from '../../../src/db/entities/Reservations.entity'; import type { RoadtripPreferences } from '@trek/shared'; import { bookendAssignmentId, type RoadtripStop } from '@trek/shared/roadtrip'; @@ -64,16 +70,50 @@ async function planRepos() { ] as const; } +/** Sets columns on one day assignment. */ +async function setVisit(id: number, fields: Partial>) { + await updateRows(await sharedTestOrm(db), DayAssignments, { id }, fields); +} + +/** Sets columns on one place. */ +async function setPlace(id: number, fields: Partial>) { + await updateRows(await sharedTestOrm(db), Places, { id }, fields); +} + +/** The day a visit sits on. */ +async function dayOf(assignmentId: number): Promise<{ day_id: number }> { + const row = (await findRow(await sharedTestOrm(db), DayAssignments, { id: assignmentId }))!; + return { day_id: row.day_id }; +} + +/** Adds a booking and returns its id. */ +async function addReservation(fields: { + trip: number; title: string; type: string; day?: number | null; endDay?: number | null; + reservation_time?: string; reservation_end_time?: string; accommodation_id?: number; day_plan_position?: number; +}): Promise { + const { accommodation_id, ...rest } = fields; + return insertRow(await sharedTestOrm(db), Reservations, { + ...rest, + ...(accommodation_id === undefined ? {} : { accommodation_id: String(accommodation_id) }), + }); +} + +/** Adds one located terminal to a booking. */ +async function addEndpoint(reservation: number, role: string, sequence: number, name: string, code: string | null, lat: number, lng: number) { + await insertRow(await sharedTestOrm(db), ReservationEndpoints, { reservation, role, sequence, name, code, lat, lng }); +} + async function setup() { const { user } = createUser(db); const trip = createTrip(db, user.id); const day = createDay(db, trip.id); - const visits = ['Hamburg', 'Lueneburg', 'Celle'].map((name, i) => { + const visits: ReturnType[] = []; + for (const [i, name] of ['Hamburg', 'Lueneburg', 'Celle'].entries()) { const place = createPlace(db, trip.id, { name, lat: 53 - i * 0.3, lng: 10 }); - db.prepare('UPDATE places SET duration_minutes = ? WHERE id = ?').run(i === 0 ? 0 : 30, place.id); - return createDayAssignment(db, day.id, place.id); - }); - db.prepare("UPDATE day_assignments SET assignment_time = '09:00' WHERE id = ?").run(visits[0].id); + await setPlace(place.id, { duration_minutes: i === 0 ? 0 : 30 }); + visits.push(createDayAssignment(db, day.id, place.id)); + } + await setVisit(visits[0].id, { assignment_time: '09:00' }); const plans = new RoadtripPlanService( { getUserSettings: () => ({}) } as never, { read: () => ({}) } as never, @@ -92,10 +132,8 @@ beforeEach(() => { describe('a visit end time on the road trip', () => { it('is read from the visit, and from the place when the visit has none', async () => { const { user, trip, visits, plans } = await setup(); - db.prepare("UPDATE day_assignments SET assignment_end_time = '14:00' WHERE id = ?").run(visits[1].id); - db.prepare( - "UPDATE places SET end_time = '18:00' WHERE id = (SELECT place_id FROM day_assignments WHERE id = ?)", - ).run(visits[2].id); + await setVisit(visits[1].id, { assignment_end_time: '14:00' }); + await setPlace((await findRow(await sharedTestOrm(db), DayAssignments, { id: visits[2].id }))!.place_id, { end_time: '18:00' }); const context = await plans.context(trip.id, user.id); @@ -108,7 +146,7 @@ describe('a visit end time on the road trip', () => { it('is when the drive leaves the stop, in place of its stay', async () => { const { user, trip, visits, plans } = await setup(); - db.prepare("UPDATE day_assignments SET assignment_end_time = '14:00' WHERE id = ?").run(visits[1].id); + await setVisit(visits[1].id, { assignment_end_time: '14:00' }); const { calculated } = await plans.calculate(trip.id, user.id); const day = calculated.days[0]; @@ -124,7 +162,7 @@ describe('a visit end time on the road trip', () => { it('is reported when the drive gets there after it', async () => { const { user, trip, visits, plans } = await setup(); - db.prepare("UPDATE day_assignments SET assignment_end_time = '09:30' WHERE id = ?").run(visits[1].id); + await setVisit(visits[1].id, { assignment_end_time: '09:30' }); const { calculated } = await plans.calculate(trip.id, user.id); @@ -183,31 +221,25 @@ describe('a booked night on the road trip (#2410)', () => { }); describe('a booking the traveller rides (#2428)', () => { - function withFlight(dayId: number, tripId: number, over: { end_day_id?: number; dep?: string; arr?: string } = {}) { - const result = db - .prepare( - `INSERT INTO reservations (trip_id, title, type, day_id, end_day_id, reservation_time, reservation_end_time) - VALUES (?, ?, 'flight', ?, ?, ?, ?)`, - ) - .run(tripId, 'LH 2020 HAM → MUC', dayId, over.end_day_id ?? dayId, over.dep ?? '13:20', over.arr ?? '14:30'); - const id = Number(result.lastInsertRowid); - const endpoint = db.prepare( - 'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng) VALUES (?, ?, ?, ?, ?, ?, ?)', - ); - endpoint.run(id, 'from', 0, 'Hamburg Airport', 'HAM', 53.63, 9.99); - endpoint.run(id, 'to', 1, 'Munich Airport', 'MUC', 48.35, 11.78); + async function withFlight(dayId: number, tripId: number, over: { end_day_id?: number; dep?: string; arr?: string } = {}) { + const id = await addReservation({ + trip: tripId, title: 'LH 2020 HAM → MUC', type: 'flight', day: dayId, endDay: over.end_day_id ?? dayId, + reservation_time: over.dep ?? '13:20', reservation_end_time: over.arr ?? '14:30', + }); + await addEndpoint(id, 'from', 0, 'Hamburg Airport', 'HAM', 53.63, 9.99); + await addEndpoint(id, 'to', 1, 'Munich Airport', 'MUC', 48.35, 11.78); return id; } it('seats the terminals in the day and never asks the router for the ride', async () => { const { user, trip, visits, plans } = await setup(); - db.prepare("UPDATE day_assignments SET assignment_time = '10:00' WHERE id = ?").run(visits[1].id); + await setVisit(visits[1].id, { assignment_time: '10:00' }); // Pinned to the minute the drive reaches it anyway, so the clock alone seats the // flight here. Untimed, Celle lies south of Lueneburg with the airport north of both, // and the flight would go where it adds the least road (#2461, see below). - db.prepare("UPDATE day_assignments SET assignment_time = '15:30' WHERE id = ?").run(visits[2].id); - const day = db.prepare('SELECT day_id FROM day_assignments WHERE id = ?').get(visits[0].id) as { day_id: number }; - const flightId = withFlight(day.day_id, trip.id); + await setVisit(visits[2].id, { assignment_time: '15:30' }); + const day = await dayOf(visits[0].id); + const flightId = await withFlight(day.day_id, trip.id); const { context, calculated } = await plans.calculate(trip.id, user.id); @@ -243,10 +275,10 @@ describe('a booking the traveller rides (#2428)', () => { it('lets a booking without located terminals fall through, a hire car without a desk among them', async () => { const { user, trip, visits, plans } = await setup(); - const day = db.prepare('SELECT day_id FROM day_assignments WHERE id = ?').get(visits[0].id) as { day_id: number }; - db.prepare("INSERT INTO reservations (trip_id, title, type, day_id) VALUES (?, 'Hire car', 'car', ?)").run(trip.id, day.day_id); - db.prepare("INSERT INTO reservations (trip_id, title, type, day_id, reservation_time) VALUES (?, 'Somewhere', 'train', ?, '11:00')").run(trip.id, day.day_id); - db.prepare("INSERT INTO reservations (trip_id, title, type, day_id, reservation_time) VALUES (?, 'Cab', 'taxi', ?, '11:00')").run(trip.id, day.day_id); + const day = await dayOf(visits[0].id); + await addReservation({ trip: trip.id, title: 'Hire car', type: 'car', day: day.day_id }); + await addReservation({ trip: trip.id, title: 'Somewhere', type: 'train', day: day.day_id, reservation_time: '11:00' }); + await addReservation({ trip: trip.id, title: 'Cab', type: 'taxi', day: day.day_id, reservation_time: '11:00' }); const { context, calculated } = await plans.calculate(trip.id, user.id); @@ -256,21 +288,14 @@ describe('a booking the traveller rides (#2428)', () => { it("puts a hire car's desks on the road: the pick-up opens the day, the return closes it, one run through both", async () => { const { user, trip, visits, plans } = await setup(); - db.prepare("UPDATE day_assignments SET assignment_time = '10:00' WHERE id = ?").run(visits[1].id); - db.prepare("UPDATE day_assignments SET assignment_time = '12:00' WHERE id = ?").run(visits[2].id); - const day = db.prepare('SELECT day_id FROM day_assignments WHERE id = ?').get(visits[0].id) as { day_id: number }; - const result = db - .prepare( - `INSERT INTO reservations (trip_id, title, type, day_id, end_day_id, reservation_time, reservation_end_time) - VALUES (?, 'Sixt', 'car', ?, ?, '08:00', '18:00')`, - ) - .run(trip.id, day.day_id, day.day_id); - const carId = Number(result.lastInsertRowid); - const endpoint = db.prepare( - 'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng) VALUES (?, ?, ?, ?, ?, ?, ?)', - ); - endpoint.run(carId, 'from', 0, 'Sixt Hauptbahnhof', null, 53.55, 10.0); - endpoint.run(carId, 'to', 1, 'Sixt Airport', 'HAM', 53.63, 9.99); + await setVisit(visits[1].id, { assignment_time: '10:00' }); + await setVisit(visits[2].id, { assignment_time: '12:00' }); + const day = await dayOf(visits[0].id); + const carId = await addReservation({ + trip: trip.id, title: 'Sixt', type: 'car', day: day.day_id, endDay: day.day_id, reservation_time: '08:00', reservation_end_time: '18:00', + }); + await addEndpoint(carId, 'from', 0, 'Sixt Hauptbahnhof', null, 53.55, 10.0); + await addEndpoint(carId, 'to', 1, 'Sixt Airport', 'HAM', 53.63, 9.99); const router = hourlyRouter(); const service = new RoadtripPlanService( { getUserSettings: () => ({}) } as never, @@ -324,28 +349,20 @@ describe('a ferry across the day, and one on no day (#2461)', () => { return { user, trip, day, router, plans }; } - function withFerry(tripId: number, dayId: number | null, title: string, located = true): number { - const id = Number( - db - .prepare( - `INSERT INTO reservations (trip_id, title, type, day_id, end_day_id, reservation_time, reservation_end_time) - VALUES (?, ?, 'ferry', ?, ?, '09:30', '15:00')`, - ) - .run(tripId, title, dayId, dayId).lastInsertRowid, - ); + async function withFerry(tripId: number, dayId: number | null, title: string, located = true): Promise { + const id = await addReservation({ + trip: tripId, title, type: 'ferry', day: dayId, endDay: dayId, reservation_time: '09:30', reservation_end_time: '15:00', + }); if (located) { - const endpoint = db.prepare( - 'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng) VALUES (?, ?, ?, ?, ?, ?, ?)', - ); - endpoint.run(id, 'from', 0, 'IJmuiden', null, 52.4581, 4.5879); - endpoint.run(id, 'to', 1, 'Port of Tyne', null, 54.9925, -1.4522); + await addEndpoint(id, 'from', 0, 'IJmuiden', null, 52.4581, 4.5879); + await addEndpoint(id, 'to', 1, 'Port of Tyne', null, 54.9925, -1.4522); } return id; } it('drives to the pier and on from the far one, and never overland between the two shores', async () => { const { user, trip, day, router, plans } = await crossing(); - withFerry(trip.id, day.id, 'IJmuiden to Newcastle'); + await withFerry(trip.id, day.id, 'IJmuiden to Newcastle'); const { calculated, undatedRides } = await plans.calculate(trip.id, user.id); @@ -362,10 +379,10 @@ describe('a ferry across the day, and one on no day (#2461)', () => { it('names a located ride on no day, and the tool answers with the same list', async () => { const { user, trip, day, plans } = await crossing(); - const undated = withFerry(trip.id, null, 'Ferry without a date'); - withFerry(trip.id, day.id, 'Ferry on the day'); - withFerry(trip.id, null, 'Ferry without terminals', false); - db.prepare("INSERT INTO reservations (trip_id, title, type) VALUES (?, 'Dinner', 'restaurant')").run(trip.id); + const undated = await withFerry(trip.id, null, 'Ferry without a date'); + await withFerry(trip.id, day.id, 'Ferry on the day'); + await withFerry(trip.id, null, 'Ferry without terminals', false); + await addReservation({ trip: trip.id, title: 'Dinner', type: 'restaurant' }); const plan = await plans.calculate(trip.id, user.id); @@ -409,17 +426,13 @@ describe('a booked night at both ends of its days', () => { ); return { user, trip: created, router, plans }; } - const visit = (tripId: number, dayId: number, name: string, at: { lat: number; lng: number }) => { + const visit = async (tripId: number, dayId: number, name: string, at: { lat: number; lng: number }) => { const place = createPlace(db, tripId, { name, ...at }); - db.prepare('UPDATE places SET duration_minutes = 0 WHERE id = ?').run(place.id); + await setPlace(place.id, { duration_minutes: 0 }); return { place, assignment: createDayAssignment(db, dayId, place.id) }; }; const linkBooking = (tripId: number, stayId: number) => - Number( - db - .prepare("INSERT INTO reservations (trip_id, title, type, accommodation_id) VALUES (?, 'Motel', 'hotel', ?)") - .run(tripId, stayId).lastInsertRowid, - ); + addReservation({ trip: tripId, title: 'Motel', type: 'hotel', accommodation_id: stayId }); const shape = (stops: RoadtripStop[]) => stops.map((s) => (s.bookend ? `${s.bookend.phase}:${s.bookend.accommodationId}` : s.name)); const lats = (router: ReturnType) => @@ -429,10 +442,10 @@ describe('a booked night at both ends of its days', () => { async function cam(settings?: RoadtripPreferences) { const t = await trip(settings); const [d1, d2, d3] = [createDay(db, t.trip.id), createDay(db, t.trip.id), createDay(db, t.trip.id)]; - const getaway = visit(t.trip.id, d1.id, 'Getaway', GETAWAY); - visit(t.trip.id, d1.id, 'Lookout', { lat: -33.73, lng: 150.35 }); - visit(t.trip.id, d1.id, 'Falls', { lat: -33.65, lng: 150.38 }); - const wallinga = visit(t.trip.id, d2.id, 'Wallinga', WALLINGA); + const getaway = await visit(t.trip.id, d1.id, 'Getaway', GETAWAY); + await visit(t.trip.id, d1.id, 'Lookout', { lat: -33.73, lng: 150.35 }); + await visit(t.trip.id, d1.id, 'Falls', { lat: -33.65, lng: 150.38 }); + const wallinga = await visit(t.trip.id, d2.id, 'Wallinga', WALLINGA); const stayA = createDayAccommodation(db, t.trip.id, getaway.place.id, d1.id, d2.id, { check_in: '14:00', check_out: '10:00', @@ -445,25 +458,24 @@ describe('a booked night at both ends of its days', () => { async function simeon(settings?: RoadtripPreferences) { const t = await trip(settings); const days = [1, 2, 3, 4].map(() => createDay(db, t.trip.id)); - const h = visit(t.trip.id, days[0].id, 'H', HOTEL); - ( - [ - [0, 'P1', 45.1], - [0, 'P2', 45.2], - [1, 'P3', 45.3], - [1, 'P4', 45.4], - [2, 'P5', 45.5], - [2, 'P6', 45.6], - ] as const - ).forEach(([day, name, lat]) => visit(t.trip.id, days[day].id, name, { lat, lng: 7.5 })); + const h = await visit(t.trip.id, days[0].id, 'H', HOTEL); + const stops = [ + [0, 'P1', 45.1], + [0, 'P2', 45.2], + [1, 'P3', 45.3], + [1, 'P4', 45.4], + [2, 'P5', 45.5], + [2, 'P6', 45.6], + ] as const; + for (const [day, name, lat] of stops) await visit(t.trip.id, days[day].id, name, { lat, lng: 7.5 }); const stay = createDayAccommodation(db, t.trip.id, h.place.id, days[0].id, days[3].id, { check_in: '15:00' }); return { ...t, days, stay }; } it('seats the check-in day back at the stay and starts the transfer day there, as the rule in shared does', async () => { const { user, trip: created, router, plans, days, stayA } = await cam(); - const reservationId = linkBooking(created.id, stayA.id); - linkBooking(created.id, stayA.id); + const reservationId = await linkBooking(created.id, stayA.id); + await linkBooking(created.id, stayA.id); const { calculated, failures } = await plans.calculate(created.id, user.id); @@ -536,9 +548,8 @@ describe('a booked night at both ends of its days', () => { it('drives out of the morning hotel the way the first place is reached from it, as the browser and the day plan do', async () => { const { user, trip: created, router, plans } = await simeon(); - db.prepare( - "UPDATE day_assignments SET incoming_leg_transport_mode = 'walking' WHERE place_id = (SELECT id FROM places WHERE trip_id = ? AND name = 'P3')", - ).run(created.id); + const p3 = (await findRow(await sharedTestOrm(db), Places, { trip: created.id, name: 'P3' }))!; + await updateRows(await sharedTestOrm(db), DayAssignments, { place: p3.id }, { incoming_leg_transport_mode: 'walking' }); const { calculated } = await plans.calculate(created.id, user.id); @@ -554,15 +565,15 @@ describe('a booked night at both ends of its days', () => { it('drops the stop tonight’s booking put first on its check-in day, as the browser does, and keeps it while off (#2546)', async () => { const t = await trip(); const [d1, d2, d3] = [createDay(db, t.trip.id), createDay(db, t.trip.id), createDay(db, t.trip.id)]; - const getaway = visit(t.trip.id, d1.id, 'Getaway', GETAWAY); - visit(t.trip.id, d1.id, 'Lookout', { lat: -33.73, lng: 150.35 }); - const wallinga = visit(t.trip.id, d2.id, 'Wallinga', WALLINGA); - visit(t.trip.id, d2.id, 'Cave', { lat: -34.2, lng: 150.8 }); - visit(t.trip.id, d2.id, 'Bakery', { lat: -34.3, lng: 150.7 }); + const getaway = await visit(t.trip.id, d1.id, 'Getaway', GETAWAY); + await visit(t.trip.id, d1.id, 'Lookout', { lat: -33.73, lng: 150.35 }); + const wallinga = await visit(t.trip.id, d2.id, 'Wallinga', WALLINGA); + await visit(t.trip.id, d2.id, 'Cave', { lat: -34.2, lng: 150.8 }); + await visit(t.trip.id, d2.id, 'Bakery', { lat: -34.3, lng: 150.7 }); const stayA = createDayAccommodation(db, t.trip.id, getaway.place.id, d1.id, d2.id); const stayB = createDayAccommodation(db, t.trip.id, wallinga.place.id, d2.id, d3.id); // The Wallinga stop is the one the booking wrote, seated first without a check-in. - db.prepare('UPDATE day_assignments SET accommodation_id = ? WHERE id = ?').run(stayB.id, wallinga.assignment.id); + await setVisit(wallinga.assignment.id, { accommodation_id: stayB.id }); const { calculated } = await t.plans.calculate(t.trip.id, t.user.id); expect(shape(calculated.days[1].stops)).toEqual([`morning:${stayA.id}`, 'Cave', 'Bakery', `evening:${stayB.id}`]); @@ -593,8 +604,8 @@ describe('a booked night at both ends of its days', () => { it('reads every stay with its place, its nights and its earliest booking into the context', async () => { const { user, trip: created, plans, days, stayA, stayB } = await cam(); - const first = linkBooking(created.id, stayA.id); - linkBooking(created.id, stayA.id); + const first = await linkBooking(created.id, stayA.id); + await linkBooking(created.id, stayA.id); const context = await plans.context(created.id, user.id); @@ -618,7 +629,7 @@ describe('a booked night at both ends of its days', () => { it('names the hotel in the failures of a run it pushes past the router’s waypoint limit', async () => { const t = await trip({}); const [d1, d2] = [createDay(db, t.trip.id), createDay(db, t.trip.id)]; - for (let i = 0; i < 100; i++) visit(t.trip.id, d1.id, `Stop ${i}`, { lat: 40 + i * 0.01, lng: 5 }); + for (let i = 0; i < 100; i++) await visit(t.trip.id, d1.id, `Stop ${i}`, { lat: 40 + i * 0.01, lng: 5 }); const hotel = createPlace(db, t.trip.id, { name: 'Tonight', ...HOTEL }); createDayAccommodation(db, t.trip.id, hotel.id, d1.id, d2.id); @@ -643,24 +654,17 @@ describe('a booked night at both ends of its days', () => { async function trip45(settings?: RoadtripPreferences) { const t = await trip(settings); const days = [1, 2, 3, 4, 5].map(() => createDay(db, t.trip.id)); - const atlantic = visit(t.trip.id, days[0].id, 'Hotel Atlantic Hamburg', { lat: 53.5573, lng: 10.0056 }); - const munich = visit(t.trip.id, days[2].id, 'Hotel Bayerischer Hof', { lat: 48.1403, lng: 11.5732 }); + const atlantic = await visit(t.trip.id, days[0].id, 'Hotel Atlantic Hamburg', { lat: 53.5573, lng: 10.0056 }); + const munich = await visit(t.trip.id, days[2].id, 'Hotel Bayerischer Hof', { lat: 48.1403, lng: 11.5732 }); const stays = { check_in: '15:00', check_out: '11:00' }; const hamburgStay = createDayAccommodation(db, t.trip.id, atlantic.place.id, days[0].id, days[2].id, stays); createDayAccommodation(db, t.trip.id, munich.place.id, days[2].id, days[4].id, stays); - const flight = Number( - db - .prepare( - `INSERT INTO reservations (trip_id, title, type, day_id, end_day_id, reservation_time, reservation_end_time, day_plan_position) - VALUES (?, 'LH 2078 HAM-MUC', 'flight', ?, ?, '2026-11-04T15:15', '2026-11-04T17:20', 0.5)`, - ) - .run(t.trip.id, days[2].id, days[2].id).lastInsertRowid, - ); - const endpoint = db.prepare( - 'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng) VALUES (?, ?, ?, ?, ?, ?, ?)', - ); - endpoint.run(flight, 'from', 0, 'Hamburg (HAM)', 'HAM', 53.630402, 9.98823); - endpoint.run(flight, 'to', 1, 'Munich (MUC)', 'MUC', 48.353802, 11.7861); + const flight = await addReservation({ + trip: t.trip.id, title: 'LH 2078 HAM-MUC', type: 'flight', day: days[2].id, endDay: days[2].id, + reservation_time: '2026-11-04T15:15', reservation_end_time: '2026-11-04T17:20', day_plan_position: 0.5, + }); + await addEndpoint(flight, 'from', 0, 'Hamburg (HAM)', 'HAM', 53.630402, 9.98823); + await addEndpoint(flight, 'to', 1, 'Munich (MUC)', 'MUC', 48.353802, 11.7861); return { ...t, flightDay: days[2], hamburgStay }; } @@ -693,9 +697,7 @@ describe('a booked night at both ends of its days', () => { it('drives no road between two stays on a day a flight saved without its airports moves the traveller (#2476)', async () => { const { user, trip: created, router, plans, days, stayA } = await cam(); - db.prepare( - "INSERT INTO reservations (trip_id, title, type, day_id, end_day_id, reservation_time) VALUES (?, 'Flight', 'flight', ?, ?, '12:00')", - ).run(created.id, days[1].id, days[1].id); + await addReservation({ trip: created.id, title: 'Flight', type: 'flight', day: days[1].id, endDay: days[1].id, reservation_time: '12:00' }); const { calculated, failures } = await plans.calculate(created.id, user.id); diff --git a/server/tests/unit/nest/settings.service.test.ts b/server/tests/unit/nest/settings.service.test.ts index 9a520637c..d21a2b77e 100644 --- a/server/tests/unit/nest/settings.service.test.ts +++ b/server/tests/unit/nest/settings.service.test.ts @@ -45,6 +45,9 @@ import { createUser } from '../../helpers/factories'; import type { TestOrm } from '../../helpers/test-orm'; import { SettingsService } from '../../../src/nest/settings/settings.service'; import { sharedTestOrm, createTestUnitOfWork, createTestAppSettingsRepo, createTestSettingsRepo } from '../../helpers/test-uow'; +import { findRows } from '../../helpers/factories/rows'; +import { Settings } from '../../../src/db/entities/Settings.entity'; +import { readUserSetting, setAppSetting, setUserSetting } from '../../helpers/factories/settings'; let svc: SettingsService; let t: TestOrm; @@ -84,16 +87,16 @@ describe('getUserSettings', () => { it('SET-SVC-002 — returns stored plain string values', async () => { const { user } = createUser(testDb); - testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'theme', 'dark')").run(user.id); + await setUserSetting(t, user.id, 'theme', 'dark'); const s = await svc.getUserSettings(user.id); expect(s.theme).toBe('dark'); }); it('SET-SVC-003 — JSON-parses values that are valid JSON', async () => { const { user } = createUser(testDb); - testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'count', '42')").run(user.id); - testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'flag', 'true')").run(user.id); - testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'obj', '{\"x\":1}')").run(user.id); + await setUserSetting(t, user.id, 'count', '42'); + await setUserSetting(t, user.id, 'flag', 'true'); + await setUserSetting(t, user.id, 'obj', '{"x":1}'); const s = await svc.getUserSettings(user.id); expect(s.count).toBe(42); expect(s.flag).toBe(true); @@ -102,21 +105,21 @@ describe('getUserSettings', () => { it('SET-SVC-004 — falls back to raw string when value is not valid JSON', async () => { const { user } = createUser(testDb); - testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'raw', 'not-json')").run(user.id); + await setUserSetting(t, user.id, 'raw', 'not-json'); const s = await svc.getUserSettings(user.id); expect(s.raw).toBe('not-json'); }); it('SET-SVC-005 — webhook_url with a value is masked as ••••••••', async () => { const { user } = createUser(testDb); - testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'webhook_url', 'https://secret.example.com')").run(user.id); + await setUserSetting(t, user.id, 'webhook_url', 'https://secret.example.com'); const s = await svc.getUserSettings(user.id); expect(s.webhook_url).toBe('••••••••'); }); it('SET-SVC-006 — webhook_url with empty value returns empty string', async () => { const { user } = createUser(testDb); - testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'webhook_url', '')").run(user.id); + await setUserSetting(t, user.id, 'webhook_url', ''); const s = await svc.getUserSettings(user.id); expect(s.webhook_url).toBe(''); }); @@ -124,8 +127,8 @@ describe('getUserSettings', () => { it('SET-SVC-007 — only returns settings for the requesting user', async () => { const { user: a } = createUser(testDb); const { user: b } = createUser(testDb); - testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'key_a', '\"a\"')").run(a.id); - testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'key_b', '\"b\"')").run(b.id); + await setUserSetting(t, a.id, 'key_a', '"a"'); + await setUserSetting(t, b.id, 'key_b', '"b"'); const s = await svc.getUserSettings(a.id); expect(s).toHaveProperty('key_a'); expect(s).not.toHaveProperty('key_b'); @@ -134,46 +137,44 @@ describe('getUserSettings', () => { // Admin "user defaults" fall-through (#1634) — a system-wide Mapbox token must // reach a user who left their own token blank. const setAdminDefault = (settingKey: string, value: string) => - testDb.prepare( - "INSERT INTO app_settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value", - ).run(`default_user_setting_${settingKey}`, value); + setAppSetting(t, `default_user_setting_${settingKey}`, value); it('SET-SVC-020 — new user with no rows inherits the admin default token', async () => { const { user } = createUser(testDb); - setAdminDefault('mapbox_access_token', 'pk.admin'); + await setAdminDefault('mapbox_access_token', 'pk.admin'); expect((await svc.getUserSettings(user.id)).mapbox_access_token).toBe('pk.admin'); }); it('SET-SVC-021 — an empty user token falls through to the admin default', async () => { const { user } = createUser(testDb); - setAdminDefault('mapbox_access_token', 'pk.admin'); - testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'mapbox_access_token', '')").run(user.id); + await setAdminDefault('mapbox_access_token', 'pk.admin'); + await setUserSetting(t, user.id, 'mapbox_access_token', ''); expect((await svc.getUserSettings(user.id)).mapbox_access_token).toBe('pk.admin'); }); it('SET-SVC-022 — a non-empty user token overrides the admin default', async () => { const { user } = createUser(testDb); - setAdminDefault('mapbox_access_token', 'pk.admin'); - testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'mapbox_access_token', 'pk.user')").run(user.id); + await setAdminDefault('mapbox_access_token', 'pk.admin'); + await setUserSetting(t, user.id, 'mapbox_access_token', 'pk.user'); expect((await svc.getUserSettings(user.id)).mapbox_access_token).toBe('pk.user'); }); it('SET-SVC-023 — an empty value with no admin default stays empty (no regression)', async () => { const { user } = createUser(testDb); - testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'mapbox_style', '')").run(user.id); + await setUserSetting(t, user.id, 'mapbox_style', ''); expect((await svc.getUserSettings(user.id)).mapbox_style).toBe(''); }); it('SET-SVC-024 — a non-defaultable empty value is preserved even against a same-named default', async () => { const { user } = createUser(testDb); // 'theme' is not a defaultable key: an empty stored value must be returned as-is. - testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'theme', '')").run(user.id); + await setUserSetting(t, user.id, 'theme', ''); expect((await svc.getUserSettings(user.id)).theme).toBe(''); }); it('SET-SVC-025 — an inherited admin llm_api_key is masked, never returned in cleartext', async () => { const { user } = createUser(testDb); - setAdminDefault('llm_api_key', 'sk-admin-secret'); + await setAdminDefault('llm_api_key', 'sk-admin-secret'); // No user row → the value is inherited from the admin default; it is a secret // and must reach the client masked, not in cleartext. expect((await svc.getUserSettings(user.id)).llm_api_key).toBe('••••••••'); @@ -181,8 +182,8 @@ describe('getUserSettings', () => { it('SET-SVC-026 — an empty user llm_api_key falls back to the admin key, still masked', async () => { const { user } = createUser(testDb); - setAdminDefault('llm_api_key', 'sk-admin-secret'); - testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'llm_api_key', '')").run(user.id); + await setAdminDefault('llm_api_key', 'sk-admin-secret'); + await setUserSetting(t, user.id, 'llm_api_key', ''); expect((await svc.getUserSettings(user.id)).llm_api_key).toBe('••••••••'); }); @@ -192,9 +193,7 @@ describe('getUserSettings', () => { const { user } = createUser(testDb); await svc.upsertSetting(user.id, 'carto_api_key', 'carto-user'); // Passthrough crypto mock: the stored row is whatever maybe_encrypt_api_key returned. - const raw = testDb - .prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'carto_api_key'") - .get(user.id) as { value: string }; + const raw = { value: await readUserSetting(t, user.id, 'carto_api_key') }; expect(raw.value).toBe('carto-user'); expect((await svc.getUserSettings(user.id)).carto_api_key).toBe('carto-user'); @@ -206,15 +205,15 @@ describe('getUserSettings', () => { it('SET-SVC-032 — an empty user carto_api_key falls back to the admin default', async () => { const { user } = createUser(testDb); - setAdminDefault('carto_api_key', 'carto-admin'); - testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'carto_api_key', '')").run(user.id); + await setAdminDefault('carto_api_key', 'carto-admin'); + await setUserSetting(t, user.id, 'carto_api_key', ''); expect((await svc.getUserSettings(user.id)).carto_api_key).toBe('carto-admin'); }); it('SET-SVC-033 — a managed instance injects the operator key over both the user and the admin value', async () => { const { user } = createUser(testDb); - setAdminDefault('carto_api_key', 'carto-admin'); - testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'carto_api_key', 'carto-user')").run(user.id); + await setAdminDefault('carto_api_key', 'carto-admin'); + await setUserSetting(t, user.id, 'carto_api_key', 'carto-user'); vi.stubEnv('TREK_MANAGED', 'true'); vi.stubEnv('CARTO_API_KEY', 'carto-operator'); try { @@ -262,14 +261,14 @@ describe('upsertSetting', () => { it('SET-SVC-010 — serializes object values as JSON', async () => { const { user } = createUser(testDb); await svc.upsertSetting(user.id, 'prefs', { dark: true, size: 14 }); - const raw = testDb.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'prefs'").get(user.id) as any; + const raw = { value: await readUserSetting(t, user.id, 'prefs') }; expect(raw.value).toBe('{"dark":true,"size":14}'); }); it('SET-SVC-011 — serializes boolean values as strings', async () => { const { user } = createUser(testDb); await svc.upsertSetting(user.id, 'notifications', true); - const raw = testDb.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'notifications'").get(user.id) as any; + const raw = { value: await readUserSetting(t, user.id, 'notifications') }; expect(raw.value).toBe('true'); }); @@ -277,7 +276,7 @@ describe('upsertSetting', () => { const { user } = createUser(testDb); await svc.upsertSetting(user.id, 'webhook_url', 'https://hook.example.com'); // With passthrough mock, value is stored as-is - const raw = testDb.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'webhook_url'").get(user.id) as any; + const raw = { value: await readUserSetting(t, user.id, 'webhook_url') }; expect(raw.value).toBe('https://hook.example.com'); // But getUserSettings masks it const s = await svc.getUserSettings(user.id); @@ -321,8 +320,8 @@ describe('bulkUpsertSettings', () => { it('SET-SVC-017 — all changes are committed atomically (transaction)', async () => { const { user } = createUser(testDb); await svc.bulkUpsertSettings(user.id, { p: '1', q: '2' }); - const rows = testDb.prepare('SELECT key FROM settings WHERE user_id = ?').all(user.id) as any[]; - const keys = rows.map((r: any) => r.key); + const rows = await findRows(t, Settings, { user: user.id }); + const keys = rows.map((r) => r.key); expect(keys).toContain('p'); expect(keys).toContain('q'); }); @@ -339,7 +338,7 @@ describe('bulkUpsertSettings', () => { // Was a `vi.spyOn(testDb, 'prepare').mockImplementationOnce(...)` targeting // the legacy raw `INSERT INTO settings ...` statement. Now that // bulkUpsertSettings writes through SettingsRepository.upsertForUser - // (em.upsert, via Kysely), the FIRST `.prepare()` call inside + // (em.upsert, via Kysely), the FIRST `prepare` call inside // `uow.transactional(...)` is Kysely's own internal machinery, not the // application statement — intercepting it corrupted the connection's // transaction/mutex state for the rest of the file (every test after this @@ -364,7 +363,7 @@ describe('legacy quirk fixes', () => { it('SET-SVC-027 — null serializes as the empty string, not the string "null"', async () => { const { user } = createUser(testDb); await svc.upsertSetting(user.id, 'llm_api_key', null); - const raw = testDb.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'llm_api_key'").get(user.id) as { value: string }; + const raw = { value: await readUserSetting(t, user.id, 'llm_api_key') }; expect(raw.value).toBe(''); // The legacy "null" storage leaked back out of getDecryptedUserSetting as // the literal string "null"; a cleared secret must read as null. @@ -400,9 +399,9 @@ describe('legacy quirk fixes', () => { const count = await svc.bulkUpsertSettings(user.id, { ntfy_topic: 'trek', ntfy_token: '••••••••' }); expect(count).toBe(1); // Passthrough crypto mock: the stored value must still be the real token. - const raw = testDb.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'ntfy_token'").get(user.id) as { value: string }; + const raw = { value: await readUserSetting(t, user.id, 'ntfy_token') }; expect(raw.value).toBe('tok-real'); - expect(testDb.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'ntfy_topic'").get(user.id)).toEqual({ value: 'trek' }); + expect({ value: await readUserSetting(t, user.id, 'ntfy_topic') }).toEqual({ value: 'trek' }); }); it('SET-SVC-030 — bulk returns the count of keys actually written', async () => {