From e4d0b4040479ca9fba29583e622d61a57fdb039d Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 8 Oct 2026 17:12:10 +0200 Subject: [PATCH] test(server): seed the trip and vacay service specs through the repositories The two largest service suites seeded and inspected rows with raw statements. They now write through the ORM factories and read through small local readers that return a row the way the SELECT did (only the selected columns, undefined when there is none), so every assertion keeps its exact meaning. The trips database mock loses its dead canAccessTrip, isOwner and getPlaceWithTags stubs. Raw statements stay, marked, where they are the subject: the legacy day grid rebuild that the fuzz case runs as its oracle inside a synchronous transaction it rolls back, the reservation seed that reproduces the legacy REAL binding into the TEXT stay link, and the legacy entry read model the vacay suite compares against. --- server/tests/unit/nest/trips.service.test.ts | 788 ++++++++++--------- server/tests/unit/nest/vacay.service.test.ts | 425 +++++----- 2 files changed, 607 insertions(+), 606 deletions(-) diff --git a/server/tests/unit/nest/trips.service.test.ts b/server/tests/unit/nest/trips.service.test.ts index 0e9ec87c1..e90466ce5 100644 --- a/server/tests/unit/nest/trips.service.test.ts +++ b/server/tests/unit/nest/trips.service.test.ts @@ -25,15 +25,6 @@ vi.mock('../../../src/db/database', async () => { db, closeDb: () => {}, reinitialize: () => {}, - getPlaceWithTags: () => null, - canAccessTrip: (tripId: any, userId: number) => - db.prepare(` - SELECT t.id, t.user_id FROM trips t - LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? - WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL) - `).get(userId, tripId, userId), - isOwner: (tripId: any, userId: number) => - !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId), }; return mock; }); @@ -132,6 +123,76 @@ import { SettingsService } from '../../../src/nest/settings/settings.service'; import { noGoogleQuota } from '../../helpers/google-quota'; import { createTestBudgetSettlementsRepo } from '../../helpers/budget-repos'; import { MaintenanceRepository } from '../../../src/db/repositories/MaintenanceRepository'; +import type { EntityClass, EntityDTO, FilterQuery, FindOptions } from '@mikro-orm/core'; +import { countRows, deleteRows, findRow, findRows, insertRow, updateRows } from '../../helpers/factories/rows'; +import { setAppSetting, setUserSetting } from '../../helpers/factories/settings'; +import { tagPlace } from '../../helpers/factories/places'; +import { AssignmentParticipants } from '../../../src/db/entities/AssignmentParticipants.entity'; +import { BudgetCategoryOrder } from '../../../src/db/entities/BudgetCategoryOrder.entity'; +import { BudgetItemMembers } from '../../../src/db/entities/BudgetItemMembers.entity'; +import { BudgetItemPayers } from '../../../src/db/entities/BudgetItemPayers.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 { DayNotes } from '../../../src/db/entities/DayNotes.entity'; +import { Days } from '../../../src/db/entities/Days.entity'; +import { Journeys } from '../../../src/db/entities/Journeys.entity'; +import { PackingBags } from '../../../src/db/entities/PackingBags.entity'; +import { PackingItems } from '../../../src/db/entities/PackingItems.entity'; +import { Places } from '../../../src/db/entities/Places.entity'; +import { Reservations } from '../../../src/db/entities/Reservations.entity'; +import { RoadtripDayBoundaries } from '../../../src/db/entities/RoadtripDayBoundaries.entity'; +import { RoadtripDayTracks } from '../../../src/db/entities/RoadtripDayTracks.entity'; +import { RoadtripPreferences } from '../../../src/db/entities/RoadtripPreferences.entity'; +import { RoadtripVias } from '../../../src/db/entities/RoadtripVias.entity'; +import { Tags } from '../../../src/db/entities/Tags.entity'; +import { TodoItems } from '../../../src/db/entities/TodoItems.entity'; +import { TourWaypoints } from '../../../src/db/entities/TourWaypoints.entity'; +import { Tours } from '../../../src/db/entities/Tours.entity'; +import { TripMembers } from '../../../src/db/entities/TripMembers.entity'; +import { Trips } from '../../../src/db/entities/Trips.entity'; +import { Users } from '../../../src/db/entities/Users.entity'; +import { legacyBoundIntegerText } from '../../../src/nest/common/row-id'; + +const orm = () => sharedTestOrm(testDb); + +/** The stored row matching `where`, or undefined when there is none. */ +async function storedRow(entity: EntityClass, where: FilterQuery): Promise | undefined> { + return (await findRow(await orm(), entity, where)) ?? undefined; +} + +/** Only `cols` of a stored row, the way a SELECT of those columns returns it. */ +function pickColumns(row: EntityDTO, cols: string[]): Record { + const all = row as Record; + return Object.fromEntries(cols.map((col) => [col, all[col]])); +} + +/** Only `cols` of the stored row matching `where`, or undefined when there is none. */ +async function storedFields( + entity: EntityClass, + where: FilterQuery, + cols: string[], +): Promise | undefined> { + const row = await findRow(await orm(), entity, where); + return row ? pickColumns(row, cols) : undefined; +} + +/** The stored rows matching `where`, whole or cut down to `cols`. */ +async function storedRows( + entity: EntityClass, + where: FilterQuery, + orderBy?: FindOptions['orderBy'], + cols?: string[], +): Promise>> { + const rows = await findRows(await orm(), entity, where, orderBy); + return cols ? rows.map((row) => pickColumns(row, cols)) : (rows as Array>); +} + +/** The tag a place carries, read the way a `SELECT tag_id FROM place_tags` would. */ +async function placeTagRow(placeId: number): Promise<{ tag_id: number } | undefined> { + const [tag] = await findRows(await orm(), Tags, { place_tags_inverse: placeId }); + return tag ? { tag_id: tag.id } : undefined; +} // Real sibling services over the same in-memory DB — updateTrip's date-shift // resyncs and the summary/bundle aggregation run their actual SQL. @@ -294,18 +355,18 @@ afterAll(() => { // ── Helpers ─────────────────────────────────────────────────────────────────── -function getDays(tripId: number) { - return testDb.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number').all(tripId) as { +async function getDays(tripId: number) { + return await storedRows(Days, { trip: tripId }, { day_number: 'asc' }) as { id: number; trip_id: number; day_number: number; date: string | null; }[]; } -function getAssignments(dayId: number) { - return testDb.prepare('SELECT * FROM day_assignments WHERE day_id = ?').all(dayId) as { id: number; day_id: number }[]; +async function getAssignments(dayId: number) { + return await storedRows(DayAssignments, { day: dayId }) as { id: number; day_id: number }[]; } -function getNotes(dayId: number) { - return testDb.prepare('SELECT * FROM day_notes WHERE day_id = ?').all(dayId) as { id: number; day_id: number }[]; +async function getNotes(dayId: number) { + return await storedRows(DayNotes, { day: dayId }) as { id: number; day_id: number }[]; } function addDaysIso(date: string, n: number) { @@ -318,7 +379,7 @@ describe('generateDays', () => { it('TRIP-SVC-010: full range shift preserves day assignments and notes positionally', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { start_date: '2025-06-01', end_date: '2025-06-05' }); - const daysBefore = getDays(trip.id); + const daysBefore = (await getDays(trip.id)); expect(daysBefore).toHaveLength(5); const place = createPlace(testDb, trip.id); @@ -328,7 +389,7 @@ describe('generateDays', () => { // Shift forward 9 days — zero overlap with original dates await svc.generateDays(trip.id, '2025-06-10', '2025-06-14'); - const daysAfter = getDays(trip.id); + const daysAfter = (await getDays(trip.id)); expect(daysAfter).toHaveLength(5); expect(daysAfter.map(d => d.date)).toEqual([ '2025-06-10', '2025-06-11', '2025-06-12', '2025-06-13', '2025-06-14', @@ -337,16 +398,16 @@ describe('generateDays', () => { // day_number 1 (formerly June 1) now has date June 10 — assignment still attached const day1 = daysAfter[0]; const day2 = daysAfter[1]; - expect(getAssignments(day1.id)).toHaveLength(1); - expect(getAssignments(day1.id)[0].id).toBe(assignment.id); - expect(getNotes(day2.id)).toHaveLength(1); - expect(getNotes(day2.id)[0].id).toBe(note.id); + expect((await getAssignments(day1.id))).toHaveLength(1); + expect((await getAssignments(day1.id))[0].id).toBe(assignment.id); + expect((await getNotes(day2.id))).toHaveLength(1); + expect((await getNotes(day2.id))[0].id).toBe(note.id); }); it('TRIP-SVC-011: shrinking range deletes overflow days and their assignments (issue #909)', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { start_date: '2025-07-01', end_date: '2025-07-05' }); - const daysBefore = getDays(trip.id); + const daysBefore = (await getDays(trip.id)); expect(daysBefore).toHaveLength(5); const place = createPlace(testDb, trip.id); @@ -356,7 +417,7 @@ describe('generateDays', () => { // Shrink from 5 to 3 days — surplus days and their content are removed await svc.generateDays(trip.id, '2025-07-01', '2025-07-03'); - const daysAfter = getDays(trip.id); + const daysAfter = (await getDays(trip.id)); expect(daysAfter).toHaveLength(3); expect(daysAfter.map(d => d.date)).toEqual(['2025-07-01', '2025-07-02', '2025-07-03']); }); @@ -364,12 +425,12 @@ describe('generateDays', () => { it('TRIP-SVC-016: shrinking range deletes empty overflow days (issue #909)', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { start_date: '2025-07-01', end_date: '2025-07-07' }); - expect(getDays(trip.id)).toHaveLength(7); + expect((await getDays(trip.id))).toHaveLength(7); // Shrink 7 → 5; days 6 and 7 have no content await svc.generateDays(trip.id, '2025-07-01', '2025-07-05'); - const daysAfter = getDays(trip.id); + const daysAfter = (await getDays(trip.id)); expect(daysAfter).toHaveLength(5); expect(daysAfter.map(d => d.date)).toEqual([ '2025-07-01', '2025-07-02', '2025-07-03', '2025-07-04', '2025-07-05', @@ -379,7 +440,7 @@ describe('generateDays', () => { it('TRIP-SVC-012: growing range keeps existing day content and appends new empty days', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { start_date: '2025-08-01', end_date: '2025-08-03' }); - const daysBefore = getDays(trip.id); + const daysBefore = (await getDays(trip.id)); expect(daysBefore).toHaveLength(3); const place = createPlace(testDb, trip.id); @@ -388,19 +449,19 @@ describe('generateDays', () => { // Grow to 5 days await svc.generateDays(trip.id, '2025-08-01', '2025-08-05'); - const daysAfter = getDays(trip.id); + const daysAfter = (await getDays(trip.id)); expect(daysAfter).toHaveLength(5); expect(daysAfter.map(d => d.date)).toEqual([ '2025-08-01', '2025-08-02', '2025-08-03', '2025-08-04', '2025-08-05', ]); // Existing day 1 retains its assignment - expect(getAssignments(daysAfter[0].id)).toHaveLength(1); - expect(getAssignments(daysAfter[0].id)[0].id).toBe(assignment.id); + expect((await getAssignments(daysAfter[0].id))).toHaveLength(1); + expect((await getAssignments(daysAfter[0].id))[0].id).toBe(assignment.id); // New days 4 and 5 are empty - expect(getAssignments(daysAfter[3].id)).toHaveLength(0); - expect(getAssignments(daysAfter[4].id)).toHaveLength(0); + expect((await getAssignments(daysAfter[3].id))).toHaveLength(0); + expect((await getAssignments(daysAfter[4].id))).toHaveLength(0); }); it('TRIP-SVC-062: a range longer than a year gets every one of its days (#2403)', async () => { @@ -408,7 +469,7 @@ describe('generateDays', () => { const trip = createTrip(testDb, user.id, { start_date: '2025-01-26', end_date: '2025-01-28' }); // The reporter's range: 368 days, and the days used to stop at 365. await svc.generateDays(trip.id, '2025-01-26', '2026-01-28'); - const days = getDays(trip.id); + const days = (await getDays(trip.id)); expect(days).toHaveLength(368); expect(days[364].date).toBe('2026-01-25'); expect(days[367]).toMatchObject({ day_number: 368, date: '2026-01-28' }); @@ -418,13 +479,13 @@ describe('generateDays', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); await svc.generateDays(trip.id, null, null, MAX_TRIP_DAYS + 50); - expect(getDays(trip.id)).toHaveLength(MAX_TRIP_DAYS); + expect((await getDays(trip.id))).toHaveLength(MAX_TRIP_DAYS); }); it('TRIP-SVC-013: clearing dates converts all days to dateless without destroying assignments', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { start_date: '2025-09-01', end_date: '2025-09-04' }); - const daysBefore = getDays(trip.id); + const daysBefore = (await getDays(trip.id)); expect(daysBefore).toHaveLength(4); const place = createPlace(testDb, trip.id); @@ -433,22 +494,22 @@ describe('generateDays', () => { // Clear both dates await svc.generateDays(trip.id, null, null); - const daysAfter = getDays(trip.id); + const daysAfter = (await getDays(trip.id)); expect(daysAfter).toHaveLength(4); expect(daysAfter.every(d => d.date === null)).toBe(true); // The assignment on the former day 2 still exists const formerDay2 = daysAfter.find(d => d.id === daysBefore[1].id); expect(formerDay2).toBeDefined(); - expect(getAssignments(formerDay2!.id)).toHaveLength(1); - expect(getAssignments(formerDay2!.id)[0].id).toBe(assignment.id); + expect((await getAssignments(formerDay2!.id))).toHaveLength(1); + expect((await getAssignments(formerDay2!.id))[0].id).toBe(assignment.id); }); it('TRIP-SVC-014: partial overlap shift remaps by position (day 1→3 kept, 4-5 overflow)', async () => { // Original: Jun 1-5. New: Jun 3-7 (overlap on Jun 3-5, but we map by position) const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { start_date: '2025-10-01', end_date: '2025-10-05' }); - const daysBefore = getDays(trip.id); + const daysBefore = (await getDays(trip.id)); const place = createPlace(testDb, trip.id); // Assign to each of the 5 days for (const day of daysBefore) createDayAssignment(testDb, day.id, place.id); @@ -456,7 +517,7 @@ describe('generateDays', () => { // Shift forward 2 days (partial overlap with original range) await svc.generateDays(trip.id, '2025-10-03', '2025-10-07'); - const daysAfter = getDays(trip.id); + const daysAfter = (await getDays(trip.id)); expect(daysAfter).toHaveLength(5); expect(daysAfter.map(d => d.date)).toEqual([ '2025-10-03', '2025-10-04', '2025-10-05', '2025-10-06', '2025-10-07', @@ -464,7 +525,7 @@ describe('generateDays', () => { // All 5 assignments survive for (const day of daysAfter) { - expect(getAssignments(day.id)).toHaveLength(1); + expect((await getAssignments(day.id))).toHaveLength(1); } }); @@ -476,11 +537,11 @@ describe('generateDays', () => { const trip = createTrip(testDb, user.id, { start_date: '2025-11-01', end_date: '2025-11-03' }); // Insert 2 dateless days directly - const daysBefore = getDays(trip.id); - testDb.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, NULL)').run(trip.id, 4); - testDb.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, NULL)').run(trip.id, 5); + const daysBefore = (await getDays(trip.id)); + await insertRow(await orm(), Days, { trip: trip.id, day_number: 4, date: null }); + await insertRow(await orm(), Days, { trip: trip.id, day_number: 5, date: null }); - const allDays = getDays(trip.id); + const allDays = (await getDays(trip.id)); expect(allDays).toHaveLength(5); const place = createPlace(testDb, trip.id); @@ -491,7 +552,7 @@ describe('generateDays', () => { // This is the scenario that triggered the UNIQUE collision bug await svc.generateDays(trip.id, '2025-11-01', '2025-11-04'); - const daysAfter = getDays(trip.id); + const daysAfter = (await getDays(trip.id)); expect(daysAfter).toHaveLength(5); const dated = daysAfter.filter(d => d.date !== null); @@ -500,8 +561,8 @@ describe('generateDays', () => { expect(dateless).toHaveLength(1); // The remaining dateless day still has its assignment - expect(getAssignments(dateless[0].id)).toHaveLength(1); - expect(getAssignments(dateless[0].id)[0].id).toBe(assignment.id); + expect((await getAssignments(dateless[0].id))).toHaveLength(1); + expect((await getAssignments(dateless[0].id))[0].id).toBe(assignment.id); // All day_numbers are unique 1..5 const nums = daysAfter.map(d => d.day_number).sort((a, b) => a - b); @@ -513,7 +574,7 @@ describe('generateDays', () => { // A 7-day trip, then cleared to dateless placeholders (day_count = 7). const trip = createTrip(testDb, user.id, { start_date: '2025-12-01', end_date: '2025-12-07' }); await svc.generateDays(trip.id, null, null); - const dateless = getDays(trip.id); + const dateless = (await getDays(trip.id)); expect(dateless).toHaveLength(7); expect(dateless.every(d => d.date === null)).toBe(true); @@ -525,69 +586,69 @@ describe('generateDays', () => { // the dates; the four empty leftovers must be removed, the one with content kept. await svc.generateDays(trip.id, '2026-01-10', '2026-01-11'); - const daysAfter = getDays(trip.id); + const daysAfter = (await getDays(trip.id)); const dated = daysAfter.filter(d => d.date !== null); const stillDateless = daysAfter.filter(d => d.date === null); expect(dated.map(d => d.date)).toEqual(['2026-01-10', '2026-01-11']); // day_count is COUNT(*) FROM days: 2 dated + 1 content-bearing dateless = 3 (not the stale 7) expect(daysAfter).toHaveLength(3); expect(stillDateless).toHaveLength(1); - expect(getAssignments(stillDateless[0].id)[0].id).toBe(assignment.id); + expect((await getAssignments(stillDateless[0].id))[0].id).toBe(assignment.id); }); // ── generateDays carries out the shared planDayGrid plan ────────────────── // The trip dialog warns about lost days by the same plan, so the plan has to // be exactly what the rebuild did before it was written down in shared. - function addUndatedDay(tripId: number) { - const next = (testDb.prepare('SELECT COALESCE(MAX(day_number), 0) + 1 AS n FROM days WHERE trip_id = ?').get(tripId) as { n: number }).n; - const id = Number(testDb.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, NULL)').run(tripId, next).lastInsertRowid); + async function addUndatedDay(tripId: number) { + const [last] = await findRows(await orm(), Days, { trip: tripId }, { day_number: 'desc' }); + const next = (last?.day_number ?? 0) + 1; + const id = Number(await insertRow(await orm(), Days, { trip: tripId, day_number: next, date: null })); return id; } - function addStay(tripId: number, placeId: number, startDayId: number, endDayId: number) { - return Number(testDb.prepare( - 'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id) VALUES (?, ?, ?, ?)', - ).run(tripId, placeId, startDayId, endDayId).lastInsertRowid); + async function addStay(tripId: number, placeId: number, startDayId: number, endDayId: number) { + return Number(await insertRow(await orm(), DayAccommodations, { trip: tripId, place: placeId, startDay: startDayId, endDay: endDayId })); } - const stayExists = (id: number) => !!testDb.prepare('SELECT 1 FROM day_accommodations WHERE id = ?').get(id); + const stayExists = async (id: number) => !!await storedRow(DayAccommodations, { id }); it('TRIP-SVC-074: a stay from a removed day to a spare day goes, and the spare day it left empty goes too', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { start_date: '2025-07-01', end_date: '2025-07-05' }); - const days = getDays(trip.id); - const spare = addUndatedDay(trip.id); + const days = (await getDays(trip.id)); + const spare = await addUndatedDay(trip.id); const place = createPlace(testDb, trip.id); - const stay = addStay(trip.id, place.id, days[4].id, spare); + const stay = await addStay(trip.id, place.id, days[4].id, spare); const plan = await svc.generateDays(trip.id, '2025-07-01', '2025-07-04'); expect(plan.removed.map(r => [r.id, r.reason])).toEqual([[days[4].id, 'overflow'], [spare, 'spare']]); - expect(stayExists(stay)).toBe(false); - expect(getDays(trip.id).map(d => d.id)).toEqual(days.slice(0, 4).map(d => d.id)); + expect(await stayExists(stay)).toBe(false); + expect((await getDays(trip.id)).map(d => d.id)).toEqual(days.slice(0, 4).map(d => d.id)); }); it('TRIP-SVC-075: only moving the dates drops an empty spare day and keeps one with a note', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { start_date: '2025-07-01', end_date: '2025-07-03' }); - const empty = addUndatedDay(trip.id); - const noted = addUndatedDay(trip.id); + const empty = await addUndatedDay(trip.id); + const noted = await addUndatedDay(trip.id); createDayNote(testDb, noted, trip.id, { text: 'Buffer' }); const plan = await svc.generateDays(trip.id, '2025-07-11', '2025-07-13'); expect(plan.removed).toEqual([{ id: empty, day_number: 4, date: null, reason: 'spare' }]); - const after = getDays(trip.id); + const after = (await getDays(trip.id)); expect(after.map(d => d.date)).toEqual(['2025-07-11', '2025-07-12', '2025-07-13', null]); expect(after[3]).toMatchObject({ id: noted, day_number: 4 }); - expect(getNotes(noted)).toHaveLength(1); + expect((await getNotes(noted))).toHaveLength(1); }); it('TRIP-SVC-076: without dates only empty days are trimmed, the highest numbers first', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const ids = Array.from({ length: 6 }, () => addUndatedDay(trip.id)); + const ids: number[] = []; + for (let i = 0; i < 6; i++) ids.push(await addUndatedDay(trip.id)); const place = createPlace(testDb, trip.id); createDayAssignment(testDb, ids[1], place.id); createDayAssignment(testDb, ids[5], place.id); @@ -595,18 +656,18 @@ describe('generateDays', () => { const plan = await svc.generateDays(trip.id, null, null, 3); expect(plan.removed.map(r => r.id)).toEqual([ids[4], ids[3], ids[2]]); - expect(getDays(trip.id).map(d => [d.id, d.day_number])).toEqual([[ids[0], 1], [ids[1], 2], [ids[5], 3]]); + expect((await getDays(trip.id)).map(d => [d.id, d.day_number])).toEqual([[ids[0], 1], [ids[1], 2], [ids[5], 3]]); }); it('TRIP-SVC-077: generateDays returns the plan it carried out', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { start_date: '2025-07-01', end_date: '2025-07-03' }); - const before = getDays(trip.id); - const spare = addUndatedDay(trip.id); + const before = (await getDays(trip.id)); + const spare = await addUndatedDay(trip.id); const plan = await svc.generateDays(trip.id, '2025-07-01', '2025-07-06'); - const after = getDays(trip.id); + const after = (await getDays(trip.id)); expect(plan.removed).toEqual([]); expect(plan.rows.map(r => r.date)).toEqual(after.map(d => d.date)); expect(plan.rows.slice(0, 4).map(r => r.id)).toEqual([...before.map(d => d.id), spare]); @@ -616,26 +677,32 @@ describe('generateDays', () => { const shrink = await svc.generateDays(trip.id, '2025-07-01', '2025-07-02'); expect(shrink.removed.map(r => r.id)).toEqual(after.slice(2).map(d => d.id)); - expect(getDays(trip.id).map(d => d.id)).toEqual(shrink.rows.map(r => r.id)); + expect((await getDays(trip.id)).map(d => d.id)).toEqual(shrink.rows.map(r => r.id)); }); it('TRIP-SVC-078: fuzz, 300 random day grids end exactly where the rebuild before the shared plan left them', async () => { // The rebuild as it stood before planDayGrid, kept here as the oracle. function legacyGenerateDays(tripId: number, startDate: string | null, endDate: string | null, dayCount?: number) { + // test-sql-allow: the legacy rebuild oracle runs inside a synchronous transaction it rolls back, so it stays on the raw handle. const existing = testDb.prepare('SELECT id, day_number, date FROM days WHERE trip_id = ?').all(tripId) as { id: number; day_number: number; date: string | null }[]; + // test-sql-allow: the legacy rebuild oracle runs inside a synchronous transaction it rolls back, so it stays on the raw handle. const setDayNumber = testDb.prepare('UPDATE days SET day_number = ? WHERE id = ?'); const renumber = (list: { id: number }[]) => { list.forEach((d, i) => setDayNumber.run(-(i + 1), d.id)); list.forEach((d, i) => setDayNumber.run(i + 1, d.id)); }; if (!startDate || !endDate) { + // test-sql-allow: the legacy rebuild oracle runs inside a synchronous transaction it rolls back, so it stays on the raw handle. for (const d of existing.filter(d => d.date)) testDb.prepare('UPDATE days SET date = NULL WHERE id = ?').run(d.id); + // test-sql-allow: the legacy rebuild oracle runs inside a synchronous transaction it rolls back, so it stays on the raw handle. const all = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(tripId) as { id: number }[]; const target = Math.min(Math.max(dayCount ?? (all.length || 7), 1), MAX_TRIP_DAYS); const needed = target - all.length; if (needed > 0) { + // test-sql-allow: the legacy rebuild oracle runs inside a synchronous transaction it rolls back, so it stays on the raw handle. for (let i = 0; i < needed; i++) testDb.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, NULL)').run(tripId, all.length + i + 1); } else if (needed < 0) { + // test-sql-allow: the legacy rebuild oracle runs inside a synchronous transaction it rolls back, so it stays on the raw handle. const candidates = testDb.prepare( `SELECT d.id FROM days d WHERE d.trip_id = ? AND NOT EXISTS (SELECT 1 FROM day_assignments da WHERE da.day_id = d.id) @@ -643,8 +710,10 @@ describe('generateDays', () => { AND NOT EXISTS (SELECT 1 FROM day_accommodations dac WHERE dac.start_day_id = d.id OR dac.end_day_id = d.id) ORDER BY d.day_number DESC LIMIT ?`, ).all(tripId, -needed) as { id: number }[]; + // test-sql-allow: the legacy rebuild oracle runs inside a synchronous transaction it rolls back, so it stays on the raw handle. for (const d of candidates) testDb.prepare('DELETE FROM days WHERE id = ?').run(d.id); } + // test-sql-allow: the legacy rebuild oracle runs inside a synchronous transaction it rolls back, so it stays on the raw handle. renumber(testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(tripId) as { id: number }[]); return; } @@ -653,14 +722,18 @@ describe('generateDays', () => { const dated = existing.filter(d => d.date).sort((a, b) => a.day_number - b.day_number); const dateless = existing.filter(d => !d.date).sort((a, b) => a.day_number - b.day_number); [...dated, ...dateless].forEach((d, i) => setDayNumber.run(-(i + 1), d.id)); + // test-sql-allow: the legacy rebuild oracle runs inside a synchronous transaction it rolls back, so it stays on the raw handle. const assignDay = testDb.prepare('UPDATE days SET date = ?, day_number = ? WHERE id = ?'); let datelessIdx = 0; targetDates.forEach((date, i) => { if (i < dated.length) assignDay.run(date, i + 1, dated[i].id); else if (datelessIdx < dateless.length) assignDay.run(date, i + 1, dateless[datelessIdx++].id); + // test-sql-allow: the legacy rebuild oracle runs inside a synchronous transaction it rolls back, so it stays on the raw handle. else testDb.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, ?)').run(tripId, i + 1, date); }); + // test-sql-allow: the legacy rebuild oracle runs inside a synchronous transaction it rolls back, so it stays on the raw handle. for (let i = targetDates.length; i < dated.length; i++) testDb.prepare('DELETE FROM days WHERE id = ?').run(dated[i].id); + // test-sql-allow: the legacy rebuild oracle runs inside a synchronous transaction it rolls back, so it stays on the raw handle. const isEmpty = testDb.prepare( `SELECT NOT EXISTS (SELECT 1 FROM day_assignments da WHERE da.day_id = @id) AND NOT EXISTS (SELECT 1 FROM day_notes dn WHERE dn.day_id = @id) @@ -669,9 +742,11 @@ describe('generateDays', () => { const maxAssigned = Math.max(targetDates.length, dated.length); let kept = 0; for (let i = datelessIdx; i < dateless.length; i++) { + // test-sql-allow: the legacy rebuild oracle runs inside a synchronous transaction it rolls back, so it stays on the raw handle. if ((isEmpty.get({ id: dateless[i].id }) as { empty: number }).empty) testDb.prepare('DELETE FROM days WHERE id = ?').run(dateless[i].id); else setDayNumber.run(maxAssigned + ++kept, dateless[i].id); } + // test-sql-allow: the legacy rebuild oracle runs inside a synchronous transaction it rolls back, so it stays on the raw handle. renumber(testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(tripId) as { id: number }[]); } @@ -686,11 +761,15 @@ describe('generateDays', () => { const int = (lo: number, hi: number) => lo + Math.floor(rand() * (hi - lo + 1)); const snapshot = (tripId: number) => ({ + // test-sql-allow: the legacy rebuild oracle runs inside a synchronous transaction it rolls back, so it stays on the raw handle. days: testDb.prepare('SELECT id, day_number, date FROM days WHERE trip_id = ? ORDER BY day_number').all(tripId), + // test-sql-allow: the legacy rebuild oracle runs inside a synchronous transaction it rolls back, so it stays on the raw handle. stays: testDb.prepare('SELECT id FROM day_accommodations WHERE trip_id = ? ORDER BY id').all(tripId), + // test-sql-allow: the legacy rebuild oracle runs inside a synchronous transaction it rolls back, so it stays on the raw handle. assignments: testDb.prepare( 'SELECT da.id, da.day_id FROM day_assignments da JOIN days d ON d.id = da.day_id WHERE d.trip_id = ? ORDER BY da.id', ).all(tripId), + // test-sql-allow: the legacy rebuild oracle runs inside a synchronous transaction it rolls back, so it stays on the raw handle. notes: testDb.prepare('SELECT id, day_id FROM day_notes WHERE trip_id = ? ORDER BY id').all(tripId), }); const ROLLBACK = new Error('rollback'); @@ -704,13 +783,13 @@ describe('generateDays', () => { const count = int(0, 9); for (let n = 1; n <= count; n++) { const date = rand() < 0.7 ? addDaysIso('2026-03-20', int(0, 20)) : null; - const id = Number(testDb.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, ?)').run(trip.id, n, date).lastInsertRowid); + const id = await insertRow(await orm(), Days, { trip: trip.id, day_number: n, date }); dayIds.push(id); if (rand() < 0.3) createDayAssignment(testDb, id, place.id); if (rand() < 0.2) createDayNote(testDb, id, trip.id); } if (dayIds.length > 0) { - for (let k = int(0, 3); k > 0; k--) addStay(trip.id, place.id, dayIds[int(0, dayIds.length - 1)], dayIds[int(0, dayIds.length - 1)]); + for (let k = int(0, 3); k > 0; k--) await addStay(trip.id, place.id, dayIds[int(0, dayIds.length - 1)], dayIds[int(0, dayIds.length - 1)]); } let range: [string | null, string | null, number | undefined]; if (rand() < 0.25) { @@ -810,22 +889,20 @@ describe('deleteOldCover', () => { }); describe('resyncReservationDays (#1288)', () => { - const dayFor = (tripId: number, date: string) => - (testDb.prepare('SELECT id FROM days WHERE trip_id = ? AND date = ?').get(tripId, date) as { id: number }).id; - const insertDatedReservation = (tripId: number, dayId: number, time: string) => - Number(testDb.prepare( - "INSERT INTO reservations (trip_id, day_id, title, reservation_time, type, status) VALUES (?, ?, 'Dinner', ?, 'restaurant', 'pending')", - ).run(tripId, dayId, time).lastInsertRowid); + const dayFor = async (tripId: number, date: string) => + (await storedFields(Days, { trip: tripId, date }, ['id']) as { id: number }).id; + const insertDatedReservation = async (tripId: number, dayId: number, time: string) => + Number(await insertRow(await orm(), Reservations, { trip: tripId, day: dayId, title: 'Dinner', reservation_time: time, type: 'restaurant', status: 'pending' })); it('TRIP-SVC-018: changing the start date re-anchors a dated reservation to the day matching its time', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { start_date: '2025-06-01', end_date: '2025-06-05' }); - const resId = insertDatedReservation(trip.id, dayFor(trip.id, '2025-06-02'), '2025-06-02T19:00:00'); + const resId = (await insertDatedReservation(trip.id, (await dayFor(trip.id, '2025-06-02')), '2025-06-02T19:00:00')); // Shift the whole range one day forward (days become 2025-06-02..06). await svc.updateTrip(trip.id, user.id, { start_date: '2025-06-02', end_date: '2025-06-06' }, 'user'); - const res = testDb.prepare('SELECT day_id FROM reservations WHERE id = ?').get(resId) as { day_id: number }; + const res = await storedFields(Reservations, { id: resId }, ['day_id']) as { day_id: number }; // The booking stays on its absolute date (2025-06-02) instead of shifting with its old day row. - expect(res.day_id).toBe(dayFor(trip.id, '2025-06-02')); + expect(res.day_id).toBe((await dayFor(trip.id, '2025-06-02'))); }); it('TRIP-SVC-018b: the trip row and its rebuilt days are one write: a failing rebuild keeps the old dates', async () => { @@ -844,47 +921,45 @@ describe('resyncReservationDays (#1288)', () => { it('TRIP-SVC-019: a reservation whose date falls outside the new range keeps its day_id (not nulled)', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { start_date: '2025-06-01', end_date: '2025-06-05' }); - const origDayId = dayFor(trip.id, '2025-06-02'); - const resId = insertDatedReservation(trip.id, origDayId, '2025-06-02T19:00:00'); + const origDayId = (await dayFor(trip.id, '2025-06-02')); + const resId = (await insertDatedReservation(trip.id, origDayId, '2025-06-02T19:00:00')); // Shift far forward so 2025-06-02 is no longer covered by any day. await svc.updateTrip(trip.id, user.id, { start_date: '2025-06-10', end_date: '2025-06-14' }, 'user'); - const res = testDb.prepare('SELECT day_id FROM reservations WHERE id = ?').get(resId) as { day_id: number }; + const res = await storedFields(Reservations, { id: resId }, ['day_id']) as { day_id: number }; expect(res.day_id).toBe(origDayId); }); }); describe('resyncAccommodationDays (#1288)', () => { - const dayFor = (tripId: number, date: string) => - (testDb.prepare('SELECT id FROM days WHERE trip_id = ? AND date = ?').get(tripId, date) as { id: number }).id; + const dayFor = async (tripId: number, date: string) => + (await storedFields(Days, { trip: tripId, date }, ['id']) as { id: number }).id; const insertAccommodation = async (tripId: number, startDayId: number, endDayId: number) => { const place = createPlace(testDb, tripId, { name: 'Grand Hotel' }); const { accommodation: acc } = (await createAccommodation(tripId, { place_id: place.id, start_day_id: startDayId, end_day_id: endDayId, })) as { accommodation: { id: number } }; - const linkedRes = testDb.prepare( - 'SELECT id FROM reservations WHERE accommodation_id = ?', - ).get(acc.id) as { id: number }; + const linkedRes = await storedFields(Reservations, { accommodation_id: legacyBoundIntegerText(acc.id) }, ['id']) as { id: number }; return { accId: acc.id, linkedResId: linkedRes.id }; }; - const getAcc = (id: number) => - testDb.prepare('SELECT start_day_id, end_day_id FROM day_accommodations WHERE id = ?').get(id) as + const getAcc = async (id: number) => + await storedFields(DayAccommodations, { id }, ['start_day_id', 'end_day_id']) as { start_day_id: number; end_day_id: number }; - const getRes = (id: number) => - testDb.prepare('SELECT day_id, reservation_time FROM reservations WHERE id = ?').get(id) as + const getRes = async (id: number) => + await storedFields(Reservations, { id }, ['day_id', 'reservation_time']) as { day_id: number | null; reservation_time: string | null }; it('TRIP-SVC-035: extending the start keeps an accommodation on its absolute dates', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { start_date: '2025-06-10', end_date: '2025-06-14' }); - const { accId, linkedResId } = await insertAccommodation(trip.id, dayFor(trip.id, '2025-06-11'), dayFor(trip.id, '2025-06-13')); + const { accId, linkedResId } = await insertAccommodation(trip.id, (await dayFor(trip.id, '2025-06-11')), (await dayFor(trip.id, '2025-06-13'))); // Add a day at the start: days re-date positionally (old 06-11 row becomes 06-10, …). await svc.updateTrip(trip.id, user.id, { start_date: '2025-06-09', end_date: '2025-06-14' }, 'user'); - const acc = getAcc(accId); - expect(acc.start_day_id).toBe(dayFor(trip.id, '2025-06-11')); - expect(acc.end_day_id).toBe(dayFor(trip.id, '2025-06-13')); - const res = getRes(linkedResId); + const acc = (await getAcc(accId)); + expect(acc.start_day_id).toBe((await dayFor(trip.id, '2025-06-11'))); + expect(acc.end_day_id).toBe((await dayFor(trip.id, '2025-06-13'))); + const res = (await getRes(linkedResId)); expect(res.day_id).toBe(acc.start_day_id); expect(res.reservation_time?.slice(0, 10)).toBe('2025-06-11'); }); @@ -895,28 +970,28 @@ describe('resyncAccommodationDays (#1288)', () => { // it, or the route runs through a day the traveller is no longer staying on. const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { start_date: '2025-06-10', end_date: '2025-06-14' }); - const { accId } = await insertAccommodation(trip.id, dayFor(trip.id, '2025-06-11'), dayFor(trip.id, '2025-06-13')); - const stopOf = () => testDb.prepare('SELECT day_id FROM day_assignments WHERE accommodation_id = ?').get(accId) as { day_id: number }; - expect(stopOf().day_id).toBe(dayFor(trip.id, '2025-06-11')); + const { accId } = await insertAccommodation(trip.id, (await dayFor(trip.id, '2025-06-11')), (await dayFor(trip.id, '2025-06-13'))); + const stopOf = async () => await storedFields(DayAssignments, { accommodation_id: accId }, ['day_id']) as { day_id: number }; + expect((await stopOf()).day_id).toBe((await dayFor(trip.id, '2025-06-11'))); await svc.updateTrip(trip.id, user.id, { start_date: '2025-06-09', end_date: '2025-06-14' }, 'user'); - expect(stopOf().day_id).toBe(getAcc(accId).start_day_id); - expect(stopOf().day_id).toBe(dayFor(trip.id, '2025-06-11')); + expect((await stopOf()).day_id).toBe((await getAcc(accId)).start_day_id); + expect((await stopOf()).day_id).toBe((await dayFor(trip.id, '2025-06-11'))); }); it('TRIP-SVC-036: moving the whole trip out of the old range keeps the accommodation glued to its days', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { start_date: '2025-06-01', end_date: '2025-06-05' }); - const startDayId = dayFor(trip.id, '2025-06-02'); - const endDayId = dayFor(trip.id, '2025-06-03'); + const startDayId = (await dayFor(trip.id, '2025-06-02')); + const endDayId = (await dayFor(trip.id, '2025-06-03')); const { accId, linkedResId } = await insertAccommodation(trip.id, startDayId, endDayId); await svc.updateTrip(trip.id, user.id, { start_date: '2025-07-01', end_date: '2025-07-05' }, 'user'); - const acc = getAcc(accId); + const acc = (await getAcc(accId)); expect(acc.start_day_id).toBe(startDayId); expect(acc.end_day_id).toBe(endDayId); // The linked reservation follows the (re-dated) start day instead of keeping a stale date snapshot. - const res = getRes(linkedResId); + const res = (await getRes(linkedResId)); expect(res.day_id).toBe(startDayId); expect(res.reservation_time?.slice(0, 10)).toBe('2025-07-02'); }); @@ -924,31 +999,27 @@ describe('resyncAccommodationDays (#1288)', () => { it("TRIP-SVC-038: date_shift_mode 'shift_all' glues bookings to their days and restamps their times", async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { start_date: '2025-06-01', end_date: '2025-06-05' }); - const origDayId = dayFor(trip.id, '2025-06-02'); - const resId = Number(testDb.prepare( - "INSERT INTO reservations (trip_id, day_id, title, reservation_time, type, status) VALUES (?, ?, 'Dinner', '2025-06-02T19:00:00', 'restaurant', 'pending')", - ).run(trip.id, origDayId).lastInsertRowid); - const { accId } = await insertAccommodation(trip.id, origDayId, dayFor(trip.id, '2025-06-03')); + const origDayId = (await dayFor(trip.id, '2025-06-02')); + const resId = Number(await insertRow(await orm(), Reservations, { trip: trip.id, day: origDayId, title: 'Dinner', reservation_time: '2025-06-02T19:00:00', type: 'restaurant', status: 'pending' })); + const { accId } = await insertAccommodation(trip.id, origDayId, (await dayFor(trip.id, '2025-06-03'))); await svc.updateTrip(trip.id, user.id, { start_date: '2025-06-03', end_date: '2025-06-07', date_shift_mode: 'shift_all' }, 'user'); // The booking stays on its day row (now 2025-06-04) and its time follows. - const res = testDb.prepare('SELECT day_id, reservation_time FROM reservations WHERE id = ?').get(resId) as + const res = await storedFields(Reservations, { id: resId }, ['day_id', 'reservation_time']) as { day_id: number; reservation_time: string }; expect(res.day_id).toBe(origDayId); expect(res.reservation_time).toBe('2025-06-04T19:00:00'); // The accommodation stays glued to its (re-dated) day rows too. - const acc = getAcc(accId); + const acc = (await getAcc(accId)); expect(acc.start_day_id).toBe(origDayId); }); it('TRIP-SVC-037: a dated hotel reservation without a linked accommodation is re-anchored like other bookings', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { start_date: '2025-06-01', end_date: '2025-06-05' }); - const resId = Number(testDb.prepare( - "INSERT INTO reservations (trip_id, day_id, title, reservation_time, type, status) VALUES (?, ?, 'Imported hotel', ?, 'hotel', 'pending')", - ).run(trip.id, dayFor(trip.id, '2025-06-02'), '2025-06-02T15:00:00').lastInsertRowid); + const resId = Number(await insertRow(await orm(), Reservations, { trip: trip.id, day: (await dayFor(trip.id, '2025-06-02')), title: 'Imported hotel', reservation_time: '2025-06-02T15:00:00', type: 'hotel', status: 'pending' })); await svc.updateTrip(trip.id, user.id, { start_date: '2025-06-02', end_date: '2025-06-06' }, 'user'); - const res = testDb.prepare('SELECT day_id FROM reservations WHERE id = ?').get(resId) as { day_id: number }; - expect(res.day_id).toBe(dayFor(trip.id, '2025-06-02')); + const res = await storedFields(Reservations, { id: resId }, ['day_id']) as { day_id: number }; + expect(res.day_id).toBe((await dayFor(trip.id, '2025-06-02'))); }); }); @@ -962,11 +1033,11 @@ describe('transferOwnership (#973)', () => { const result = await membersSvc.transferOwnership(trip.id, member.id, owner.id); expect(result.toEmail).toBe(member.email); - const updated = testDb.prepare('SELECT user_id FROM trips WHERE id = ?').get(trip.id) as { user_id: number }; + const updated = await storedFields(Trips, { id: trip.id }, ['user_id']) as { user_id: number }; expect(updated.user_id).toBe(member.id); // New owner no longer sits in trip_members, former owner now does. - const memberIds = (testDb.prepare('SELECT user_id FROM trip_members WHERE trip_id = ?').all(trip.id) as { user_id: number }[]).map(r => r.user_id); + const memberIds = (await storedRows(TripMembers, { trip: trip.id }, undefined, ['user_id']) as { user_id: number }[]).map(r => r.user_id); expect(memberIds).toContain(owner.id); expect(memberIds).not.toContain(member.id); }); @@ -1003,14 +1074,14 @@ describe('guest members (#1362)', () => { expect(member.username).toBe('Anna'); expect(member.is_guest).toBe(true); - const row = testDb.prepare('SELECT username, email, password_hash, is_guest, role FROM users WHERE id = ?').get(member.id) as any; + const row = await storedFields(Users, { id: member.id }, ['username', 'email', 'password_hash', 'is_guest', 'role']) as any; expect(row.is_guest).toBe(1); expect(row.password_hash).toBe(''); expect(row.email).toMatch(/@guests\.invalid$/); expect(row.role).toBe('user'); // Joined as a trip member. - const m = testDb.prepare('SELECT id FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, member.id); + const m = await storedFields(TripMembers, { trip: trip.id, user: member.id }, ['id']); expect(m).toBeTruthy(); // Surfaces in listMembers with is_guest=true and the typed display name. @@ -1029,7 +1100,7 @@ describe('guest members (#1362)', () => { expect(a.member.username).toBe('Sam'); expect(b.member.username).toBe('Sam'); expect(b.member.id).not.toBe(a.member.id); - const usernames = testDb.prepare('SELECT username FROM users WHERE id IN (?, ?)').all(a.member.id, b.member.id) as { username: string }[]; + const usernames = await storedRows(Users, { id: { $in: [a.member.id, b.member.id] } }, undefined, ['username']) as { username: string }[]; expect(usernames[0].username).not.toBe(usernames[1].username); }); @@ -1041,7 +1112,7 @@ describe('guest members (#1362)', () => { const { member } = await membersSvc.createGuest(trip.id, 'Bob', owner.id); expect(await membersSvc.renameGuest(trip.id, member.id, 'Robert')).toBe(true); - expect((testDb.prepare('SELECT display_name FROM users WHERE id = ?').get(member.id) as any).display_name).toBe('Robert'); + expect((await storedFields(Users, { id: member.id }, ['display_name']) as any).display_name).toBe('Robert'); // A real user cannot be renamed through the guest path… expect(await membersSvc.renameGuest(trip.id, owner.id, 'Hacked')).toBe(false); @@ -1058,8 +1129,8 @@ describe('guest members (#1362)', () => { expect(await membersSvc.deleteGuest(trip.id, owner.id)).toBe(false); expect(await membersSvc.deleteGuest(trip.id, member.id)).toBe(true); - expect(testDb.prepare('SELECT id FROM users WHERE id = ?').get(member.id)).toBeUndefined(); - expect(testDb.prepare('SELECT id FROM trip_members WHERE user_id = ?').get(member.id)).toBeUndefined(); + expect(await storedFields(Users, { id: member.id }, ['id'])).toBeUndefined(); + expect(await storedFields(TripMembers, { user: member.id }, ['id'])).toBeUndefined(); }); it('TRIP-SVC-034: a guest is never invitable (addMember) nor a transfer target', async () => { @@ -1082,8 +1153,8 @@ describe('folded trip CRUD', () => { const { user: member } = createUser(testDb); const trip = createTrip(testDb, owner.id, { start_date: '2025-06-01', end_date: '2025-06-02' }); addTripMember(testDb, trip.id, member.id); - testDb.prepare("INSERT INTO budget_items (trip_id, category, name, total_price) VALUES (?, 'food', 'Dinner', 40)").run(trip.id); - testDb.prepare("INSERT INTO packing_items (trip_id, name, checked) VALUES (?, 'Socks', 1)").run(trip.id); + await insertRow(await orm(), BudgetItems, { trip: trip.id, category: 'food', name: 'Dinner', total_price: 40 }); + await insertRow(await orm(), PackingItems, { trip: trip.id, name: 'Socks', checked: 1 }); const summary = (await readModelSvc.getTripSummary(trip.id, owner.id))!; expect(summary).toBeTruthy(); @@ -1109,7 +1180,7 @@ describe('folded trip CRUD', () => { const shared = createTrip(testDb, other.id, { title: 'Shared' }); addTripMember(testDb, shared.id, owner.id); const archived = createTrip(testDb, owner.id, { title: 'Old' }); - testDb.prepare('UPDATE trips SET is_archived = 1 WHERE id = ?').run(archived.id); + await updateRows(await orm(), Trips, { id: archived.id }, { is_archived: 1 }); const active = (await svc.list(owner.id, 0)) as any[]; expect(active.map(t => t.id).sort()).toEqual([own.id, shared.id].sort()); @@ -1127,85 +1198,77 @@ describe('folded trip CRUD', () => { }); expect(reminderDays).toBe(3); // out-of-range → default 3 expect((trip as any).currency).toBe('EUR'); // no currency → 'EUR' - expect(getDays(tripId)).toHaveLength(3); + expect((await getDays(tripId))).toHaveLength(3); }); it('TRIP-SVC-090: one given date makes a week, counted in calendar days across a DST change', async () => { const { user } = createUser(testDb); const fromStart = await svc.create(user.id, { title: 'Spring', start_date: '2026-03-25' }); expect(fromStart.trip).toMatchObject({ start_date: '2026-03-25', end_date: '2026-03-31' }); - expect(getDays(fromStart.tripId)).toHaveLength(7); + expect((await getDays(fromStart.tripId))).toHaveLength(7); const fromEnd = await svc.create(user.id, { title: 'Autumn', end_date: '2026-10-30' }); expect(fromEnd.trip).toMatchObject({ start_date: '2026-10-24', end_date: '2026-10-30' }); }); it('TRIP-SVC-091: the trip and its days commit together', async () => { const { user } = createUser(testDb); - const before = (testDb.prepare('SELECT COUNT(*) AS c FROM trips').get() as { c: number }).c; + const before = ({ c: await countRows(await orm(), Trips, {}) } as { c: number }).c; const generate = vi.spyOn(svc, 'generateDays').mockRejectedValueOnce(new Error('disk I/O error')); await expect(svc.create(user.id, { title: 'Half', start_date: '2026-05-01', end_date: '2026-05-03' })).rejects.toThrow('disk I/O error'); generate.mockRestore(); - expect((testDb.prepare('SELECT COUNT(*) AS c FROM trips').get() as { c: number }).c).toBe(before); + expect(({ c: await countRows(await orm(), Trips, {}) } as { c: number }).c).toBe(before); }); it('TRIP-SVC-080: create without a currency takes the display currency, admin default included, else EUR', async () => { const { user } = createUser(testDb); - const setUser = (value: string) => - testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'default_currency', ?) ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value") - .run(user.id, JSON.stringify(value)); - const setAdmin = (value: string) => - testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('default_user_setting_default_currency', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value") - .run(JSON.stringify(value)); + const setUser = async (value: string) => + setUserSetting(await orm(), user.id, 'default_currency', JSON.stringify(value)); + const setAdmin = async (value: string) => + setAppSetting(await orm(), 'default_user_setting_default_currency', JSON.stringify(value)); const currencyOf = async (data: Parameters[1]) => ((await svc.create(user.id, data)).trip as any).currency; expect(await currencyOf({ title: 'Nothing set' })).toBe('EUR'); // "Trip currency" in the settings stores an empty string, which counts as unset. - setUser(''); + await setUser(''); expect(await currencyOf({ title: 'Cleared, no admin default' })).toBe('EUR'); - setUser(' '); + await setUser(' '); expect(await currencyOf({ title: 'Blank, no admin default' })).toBe('EUR'); - setUser(''); - setAdmin('CHF'); + await setUser(''); + await setAdmin('CHF'); expect(await currencyOf({ title: 'Admin default' })).toBe('CHF'); - setUser('USD'); + await setUser('USD'); expect(await currencyOf({ title: 'Own display currency' })).toBe('USD'); - setUser(''); + await setUser(''); expect(await currencyOf({ title: 'Back on the admin default' })).toBe('CHF'); // An explicit currency always wins. - setUser('USD'); + await setUser('USD'); expect(await currencyOf({ title: 'Explicit', currency: 'JPY' })).toBe('JPY'); }); it('TRIP-SVC-064: create refuses a range past MAX_TRIP_DAYS and writes nothing', async () => { const { user } = createUser(testDb); - const before = (testDb.prepare('SELECT COUNT(*) AS n FROM trips').get() as { n: number }).n; + const before = ({ n: await countRows(await orm(), Trips, {}) } as { n: number }).n; await expect(svc.create(user.id, { title: 'Decade', start_date: '2026-01-01', end_date: '2036-01-01' })) .rejects.toThrow(`A trip can span at most ${MAX_TRIP_DAYS} days`); - expect((testDb.prepare('SELECT COUNT(*) AS n FROM trips').get() as { n: number }).n).toBe(before); + expect(({ n: await countRows(await orm(), Trips, {}) } as { n: number }).n).toBe(before); // The longest allowed range goes through in full. const { tripId } = await svc.create(user.id, { title: 'Longest', start_date: '2026-01-01', end_date: addDaysIso('2026-01-01', MAX_TRIP_DAYS - 1) }); - expect(getDays(tripId)).toHaveLength(MAX_TRIP_DAYS); + expect((await getDays(tripId))).toHaveLength(MAX_TRIP_DAYS); }); it('TRIP-SVC-045: remove deletes the trip, cleans skeleton journey entries and detaches filled ones', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const journeyId = Number(testDb.prepare( - "INSERT INTO journeys (user_id, title, created_at, updated_at) VALUES (?, 'J', 0, 0)", - ).run(user.id).lastInsertRowid); - testDb.prepare( - "INSERT INTO journey_entries (journey_id, source_trip_id, author_id, type, title, entry_date, created_at, updated_at) VALUES (?, ?, ?, 'skeleton', 'S', '2025-06-01', 0, 0)", - ).run(journeyId, trip.id, user.id); - const filledId = Number(testDb.prepare( - "INSERT INTO journey_entries (journey_id, source_trip_id, author_id, type, title, entry_date, created_at, updated_at) VALUES (?, ?, ?, 'story', 'F', '2025-06-01', 0, 0)", - ).run(journeyId, trip.id, user.id).lastInsertRowid); + const journeyId = Number(await insertRow(await orm(), Journeys, { user: user.id, title: 'J', created_at: 0, updated_at: 0 })); + await insertRow(await orm(), JourneyEntries, { journey: journeyId, sourceTrip: trip.id, author: user.id, type: 'skeleton', title: 'S', entry_date: '2025-06-01', created_at: 0, updated_at: 0 }); + const filledId = Number(await insertRow(await orm(), JourneyEntries, { journey: journeyId, sourceTrip: trip.id, author: user.id, type: 'story', title: 'F', entry_date: '2025-06-01', created_at: 0, updated_at: 0 })); const info = await svc.remove(trip.id, user.id, 'user'); expect(info).toMatchObject({ tripId: trip.id, ownerId: user.id, isAdminDelete: false }); - expect(testDb.prepare('SELECT id FROM trips WHERE id = ?').get(trip.id)).toBeUndefined(); - expect(testDb.prepare("SELECT id FROM journey_entries WHERE type = 'skeleton'").get()).toBeUndefined(); - const filled = testDb.prepare('SELECT source_trip_id FROM journey_entries WHERE id = ?').get(filledId) as any; + expect(await storedFields(Trips, { id: trip.id }, ['id'])).toBeUndefined(); + expect(await storedFields(JourneyEntries, { type: 'skeleton' }, ['id'])).toBeUndefined(); + const filled = await storedFields(JourneyEntries, { id: filledId }, ['source_trip_id']) as any; expect(filled.source_trip_id).toBeNull(); // Missing trips throw the byte-identical error. @@ -1215,26 +1278,26 @@ describe('folded trip CRUD', () => { it('TRIP-SVC-046: copy duplicates days/places/assignments and resets packing to unchecked', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Origin', start_date: '2025-06-01', end_date: '2025-06-02' }); - const days = getDays(trip.id); + const days = (await getDays(trip.id)); const place = createPlace(testDb, trip.id, { name: 'Louvre' }); createDayAssignment(testDb, days[0].id, place.id); - testDb.prepare("INSERT INTO packing_items (trip_id, name, checked) VALUES (?, 'Socks', 1)").run(trip.id); + await insertRow(await orm(), PackingItems, { trip: trip.id, name: 'Socks', checked: 1 }); const newTripId = await svc.copy(trip.id, user.id, 'Clone'); - const copied = testDb.prepare('SELECT title, is_archived FROM trips WHERE id = ?').get(newTripId) as any; + const copied = await storedFields(Trips, { id: newTripId }, ['title', 'is_archived']) as any; expect(copied.title).toBe('Clone'); expect(copied.is_archived).toBe(0); - expect(getDays(newTripId)).toHaveLength(2); - const newPlaces = testDb.prepare('SELECT id, name FROM places WHERE trip_id = ?').all(newTripId) as any[]; + expect((await getDays(newTripId))).toHaveLength(2); + const newPlaces = await storedRows(Places, { trip: newTripId }, undefined, ['id', 'name']) as any[]; expect(newPlaces.map(p => p.name)).toEqual(['Louvre']); - expect(getAssignments(getDays(newTripId)[0].id)).toHaveLength(1); - const packing = testDb.prepare('SELECT checked FROM packing_items WHERE trip_id = ?').all(newTripId) as any[]; + expect(await getAssignments((await getDays(newTripId))[0].id)).toHaveLength(1); + const packing = await storedRows(PackingItems, { trip: newTripId }, undefined, ['checked']) as any[]; expect(packing).toEqual([{ checked: 0 }]); // No title → source title (|| fallback). const secondCopy = await svc.copy(trip.id, user.id); - expect((testDb.prepare('SELECT title FROM trips WHERE id = ?').get(secondCopy) as any).title).toBe('Origin'); + expect((await storedFields(Trips, { id: secondCopy }, ['title']) as any).title).toBe('Origin'); }); it('TRIP-SVC-061: copy carries the road-trip shaping, not just the places', async () => { @@ -1244,59 +1307,50 @@ describe('folded trip CRUD', () => { // only once somebody edits the copy, with nothing left to recover from. const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Norway', start_date: '2025-06-01', end_date: '2025-06-02' }); - const days = getDays(trip.id); + const days = (await getDays(trip.id)); const stop = createPlace(testDb, trip.id, { name: 'Geiranger' }); const track = createPlace(testDb, trip.id, { name: 'Scenic route' }); - testDb.prepare("UPDATE places SET stop_type = 'fuel' WHERE id = ?").run(stop.id); - testDb.prepare("UPDATE places SET route_geometry = '[[1,2],[3,4]]' WHERE id = ?").run(track.id); + await updateRows(await orm(), Places, { id: stop.id }, { stop_type: 'fuel' }); + await updateRows(await orm(), Places, { id: track.id }, { route_geometry: '[[1,2],[3,4]]' }); createDayAssignment(testDb, days[0].id, stop.id); - testDb.prepare( - 'INSERT INTO roadtrip_vias (day_id, after_order_index, sequence, lat, lng) VALUES (?, 0, 0, 62.1, 7.2), (?, 0, 1, 62.2, 7.3)', - ).run(days[0].id, days[0].id); - testDb.prepare('INSERT INTO roadtrip_day_tracks (day_id, place_id, stray_km) VALUES (?, ?, 1.5)') - .run(days[0].id, track.id); + await insertRow(await orm(), RoadtripVias, { day: days[0].id, after_order_index: 0, sequence: 0, lat: 62.1, lng: 7.2 }); + await insertRow(await orm(), RoadtripVias, { day: days[0].id, after_order_index: 0, sequence: 1, lat: 62.2, lng: 7.3 }); + await insertRow(await orm(), RoadtripDayTracks, { day: days[0].id, place: track.id, stray_km: 1.5 }); const newTripId = await svc.copy(trip.id, user.id, 'Clone'); - const newDays = getDays(newTripId); + const newDays = (await getDays(newTripId)); // The kind of stop each place is survives the copy. - const copiedStop = testDb.prepare("SELECT stop_type FROM places WHERE trip_id = ? AND name = 'Geiranger'") - .get(newTripId) as { stop_type: string | null }; + const copiedStop = await storedFields(Places, { trip: newTripId, name: 'Geiranger' }, ['stop_type']) as { stop_type: string | null }; expect(copiedStop.stop_type).toBe('fuel'); - const vias = testDb.prepare('SELECT after_order_index, sequence, lat, lng FROM roadtrip_vias WHERE day_id = ? ORDER BY sequence') - .all(newDays[0].id) as { after_order_index: number; sequence: number; lat: number; lng: number }[]; + const vias = await storedRows(RoadtripVias, { day: newDays[0].id }, { sequence: 'asc' }, ['after_order_index', 'sequence', 'lat', 'lng']) as { after_order_index: number; sequence: number; lat: number; lng: number }[]; expect(vias).toEqual([ { after_order_index: 0, sequence: 0, lat: 62.1, lng: 7.2 }, { after_order_index: 0, sequence: 1, lat: 62.2, lng: 7.3 }, ]); // The track points at the COPY's place, never back at the original. - const copiedTrack = testDb.prepare('SELECT place_id, stray_km FROM roadtrip_day_tracks WHERE day_id = ?') - .get(newDays[0].id) as { place_id: number; stray_km: number }; - const copiedTrackPlace = testDb.prepare("SELECT id FROM places WHERE trip_id = ? AND name = 'Scenic route'") - .get(newTripId) as { id: number }; + const copiedTrack = await storedFields(RoadtripDayTracks, { day: newDays[0].id }, ['place_id', 'stray_km']) as { place_id: number; stray_km: number }; + const copiedTrackPlace = await storedFields(Places, { trip: newTripId, name: 'Scenic route' }, ['id']) as { id: number }; expect(copiedTrack.place_id).toBe(copiedTrackPlace.id); expect(copiedTrack.stray_km).toBe(1.5); // And the original keeps exactly what it had. - expect(testDb.prepare('SELECT COUNT(*) c FROM roadtrip_vias WHERE day_id = ?').get(days[0].id)).toEqual({ c: 2 }); + expect({ c: await countRows(await orm(), RoadtripVias, { day: days[0].id }) }).toEqual({ c: 2 }); }); it('TRIP-SVC-060: copying a trip keeps a staged booking staged', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Origin', start_date: '2025-06-01', end_date: '2025-06-02' }); - testDb.prepare(`INSERT INTO reservations (trip_id, title, type, status, ingest_state) - VALUES (?, 'Parked', 'flight', 'confirmed', 'staged')`).run(trip.id); - testDb.prepare(`INSERT INTO reservations (trip_id, title, type, status) - VALUES (?, 'Booked', 'flight', 'confirmed')`).run(trip.id); + await insertRow(await orm(), Reservations, { trip: trip.id, title: 'Parked', type: 'flight', status: 'confirmed', ingest_state: 'staged' }); + await insertRow(await orm(), Reservations, { trip: trip.id, title: 'Booked', type: 'flight', status: 'confirmed' }); const newTripId = await svc.copy(trip.id, user.id, 'Clone'); // Without ingest_state on the duplicate INSERT the staged row falls back to // the column default and shows up in the copy's public feed. - const rows = testDb.prepare('SELECT title, ingest_state FROM reservations WHERE trip_id = ? ORDER BY title') - .all(newTripId) as any[]; + const rows = await storedRows(Reservations, { trip: newTripId }, { title: 'asc' }, ['title', 'ingest_state']) as any[]; expect(rows).toEqual([ { title: 'Booked', ingest_state: 'live' }, { title: 'Parked', ingest_state: 'staged' }, @@ -1313,13 +1367,14 @@ describe('folded trip CRUD', () => { const { user: owner } = createUser(testDb); const { user: member } = createUser(testDb); const trip = createTrip(testDb, owner.id, { title: 'Origin', start_date: '2025-06-01', end_date: '2025-06-02' }); - const ins = testDb.prepare('INSERT INTO packing_items (trip_id, name, checked, is_private, owner_id) VALUES (?, ?, 0, ?, ?)'); - ins.run(trip.id, 'Shared tent', 0, null); // Common - ins.run(trip.id, "Owner's diary", 1, owner.id); // the owner's Personal - ins.run(trip.id, "Member's meds", 1, member.id); // the copier's own Personal + const ins = async (name: string, isPrivate: number, ownerId: number | null) => + insertRow(await orm(), PackingItems, { trip: trip.id, name, checked: 0, is_private: isPrivate, owner: ownerId }); + await ins('Shared tent', 0, null); // Common + await ins("Owner's diary", 1, owner.id); // the owner's Personal + await ins("Member's meds", 1, member.id); // the copier's own Personal const newTripId = await svc.copy(trip.id, member.id, 'Copy'); - const rows = testDb.prepare('SELECT name, is_private, owner_id FROM packing_items WHERE trip_id = ? ORDER BY name').all(newTripId) as any[]; + const rows = await storedRows(PackingItems, { trip: newTripId }, { name: 'asc' }, ['name', 'is_private', 'owner_id']) as any[]; // The owner's private row is gone, not relabelled as Common. expect(rows.map(r => r.name)).toEqual(["Member's meds", 'Shared tent']); @@ -1332,61 +1387,52 @@ describe('folded trip CRUD', () => { const { user: owner } = createUser(testDb); const { user: friend } = createUser(testDb); const trip = createTrip(testDb, owner.id, { title: 'Linked', start_date: '2025-06-01', end_date: '2025-06-02' }); - const days = getDays(trip.id); + const days = (await getDays(trip.id)); const place = createPlace(testDb, trip.id, { name: 'Hotel Le Test' }); const assignment = createDayAssignment(testDb, days[0].id, place.id); - testDb.prepare('INSERT INTO assignment_participants (assignment_id, user_id) VALUES (?, ?)').run(assignment.id, owner.id); + await insertRow(await orm(), AssignmentParticipants, { assignment: assignment.id, user: owner.id }); - const accomId = Number(testDb.prepare(` - INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out) - VALUES (?, ?, ?, ?, '15:00', '11:00') - `).run(trip.id, place.id, days[0].id, days[1].id).lastInsertRowid); + const accomId = Number(await insertRow(await orm(), DayAccommodations, { trip: trip.id, place: place.id, startDay: days[0].id, endDay: days[1].id, check_in: '15:00', check_out: '11:00' })); - const resId = Number(testDb.prepare(` - INSERT INTO reservations (trip_id, day_id, assignment_id, accommodation_id, title, type, url) - VALUES (?, ?, ?, ?, 'Hotel booking', 'hotel', 'https://example.test/booking') - `).run(trip.id, days[0].id, assignment.id, accomId).lastInsertRowid); + const resId = Number(await insertRow(await orm(), Reservations, { trip: trip.id, day: days[0].id, assignment: assignment.id, accommodation_id: legacyBoundIntegerText(accomId), title: 'Hotel booking', type: 'hotel', url: 'https://example.test/booking' })); - const itemId = Number(testDb.prepare(` - INSERT INTO budget_items (trip_id, category, name, total_price, persons, reservation_id, currency, exchange_rate, expense_date) - VALUES (?, 'Accommodation', 'Hotel', 240, 2, ?, 'JPY', 0.0062, '2025-06-01') - `).run(trip.id, resId).lastInsertRowid); - testDb.prepare('INSERT INTO budget_item_members (budget_item_id, user_id, paid, amount) VALUES (?, ?, 1, 120)').run(itemId, owner.id); - testDb.prepare('INSERT INTO budget_item_members (budget_item_id, user_id, paid, amount) VALUES (?, ?, 0, 120)').run(itemId, friend.id); - testDb.prepare('INSERT INTO budget_item_payers (budget_item_id, user_id, amount) VALUES (?, ?, 240)').run(itemId, owner.id); - testDb.prepare("INSERT INTO todo_items (trip_id, name, checked) VALUES (?, 'Book transfer', 1)").run(trip.id); + const itemId = Number(await insertRow(await orm(), BudgetItems, { trip: trip.id, category: 'Accommodation', name: 'Hotel', total_price: 240, persons: 2, reservation: resId, currency: 'JPY', exchange_rate: 0.0062, expense_date: '2025-06-01' })); + await insertRow(await orm(), BudgetItemMembers, { budgetItem: itemId, user: owner.id, paid: 1, amount: 120 }); + await insertRow(await orm(), BudgetItemMembers, { budgetItem: itemId, user: friend.id, paid: 0, amount: 120 }); + await insertRow(await orm(), BudgetItemPayers, { budgetItem: itemId, user: owner.id, amount: 240 }); + await insertRow(await orm(), TodoItems, { trip: trip.id, name: 'Book transfer', checked: 1 }); const newTripId = await svc.copy(trip.id, owner.id, 'Linked copy'); // Budget → reservation link points at the copied reservation, not null / not the old id. - const newItem = testDb.prepare('SELECT * FROM budget_items WHERE trip_id = ?').get(newTripId) as any; - const newRes = testDb.prepare('SELECT * FROM reservations WHERE trip_id = ?').get(newTripId) as any; + const newItem = await storedRow(BudgetItems, { trip: newTripId }) as any; + const newRes = await storedRow(Reservations, { trip: newTripId }) as any; expect(newRes.id).not.toBe(resId); expect(newItem.reservation_id).toBe(newRes.id); expect(newItem).toMatchObject({ currency: 'JPY', exchange_rate: 0.0062, expense_date: '2025-06-01' }); // Reservation → accommodation resolves to the copied accommodation (accommodation_id is TEXT). - const newAccom = testDb.prepare('SELECT * FROM day_accommodations WHERE trip_id = ?').get(newTripId) as any; + const newAccom = await storedRow(DayAccommodations, { trip: newTripId }) as any; expect(newAccom.id).not.toBe(accomId); expect(Number(newRes.accommodation_id)).toBe(newAccom.id); expect(newRes.url).toBe('https://example.test/booking'); // Splits carried over with per-member paid flags and amounts. - const members = testDb.prepare('SELECT user_id, paid, amount FROM budget_item_members WHERE budget_item_id = ? ORDER BY user_id').all(newItem.id) as any[]; + const members = await storedRows(BudgetItemMembers, { budgetItem: newItem.id }, { user: 'asc' }, ['user_id', 'paid', 'amount']) as any[]; expect(members).toEqual([ { user_id: owner.id, paid: 1, amount: 120 }, { user_id: friend.id, paid: 0, amount: 120 }, ]); - const payers = testDb.prepare('SELECT user_id, amount FROM budget_item_payers WHERE budget_item_id = ?').all(newItem.id) as any[]; + const payers = await storedRows(BudgetItemPayers, { budgetItem: newItem.id }, undefined, ['user_id', 'amount']) as any[]; expect(payers).toEqual([{ user_id: owner.id, amount: 240 }]); // Assignment participants copied onto the remapped assignment. - const newAssignment = getAssignments(getDays(newTripId)[0].id)[0]; - const participants = testDb.prepare('SELECT user_id FROM assignment_participants WHERE assignment_id = ?').all(newAssignment.id) as any[]; + const newAssignment = (await getAssignments((await getDays(newTripId))[0].id))[0]; + const participants = await storedRows(AssignmentParticipants, { assignment: newAssignment.id }, undefined, ['user_id']) as any[]; expect(participants).toEqual([{ user_id: owner.id }]); // To-dos come across but reset to unchecked (documented behaviour). - const todos = testDb.prepare('SELECT name, checked FROM todo_items WHERE trip_id = ?').all(newTripId) as any[]; + const todos = await storedRows(TodoItems, { trip: newTripId }, undefined, ['name', 'checked']) as any[]; expect(todos).toEqual([{ name: 'Book transfer', checked: 0 }]); }); }); @@ -1492,8 +1538,8 @@ describe('TripsService wrapper helpers', () => { const trip = createTrip(testDb, owner.id, { start_date: '2025-06-01', end_date: '2025-06-02' }); addTripMember(testDb, trip.id, viewer.id); // A personal item of the OWNER must stay out of the other member's bundle. - testDb.prepare("INSERT INTO packing_items (trip_id, name, is_private, owner_id) VALUES (?, 'Secret', 1, ?)").run(trip.id, owner.id); - testDb.prepare("INSERT INTO packing_items (trip_id, name) VALUES (?, 'Shared')").run(trip.id); + await insertRow(await orm(), PackingItems, { trip: trip.id, name: 'Secret', is_private: 1, owner: owner.id }); + await insertRow(await orm(), PackingItems, { trip: trip.id, name: 'Shared' }); const result = (await readModelSvc.bundle(String(trip.id), { user_id: owner.id }, viewer.id)) as any; expect(result.days).toHaveLength(2); @@ -1512,7 +1558,7 @@ describe('folded quirk branches', () => { it('TRIP-SVC-047: updateTrip admin edit collects changes and the owner email; reminder 0 reads "none"', async () => { const { user: owner } = createUser(testDb); const { user: admin } = createUser(testDb); - testDb.prepare("UPDATE users SET role = 'admin' WHERE id = ?").run(admin.id); + await updateRows(await orm(), Users, { id: admin.id }, { role: 'admin' }); const trip = createTrip(testDb, owner.id, { title: 'Old' }); const result = await svc.updateTrip(trip.id, admin.id, { title: 'New', is_archived: true, reminder_days: 0 }, 'admin'); @@ -1535,15 +1581,15 @@ describe('folded quirk branches', () => { // A pre-migration row (or any row the `is_archived` column default never // touched) can genuinely hold NULL — the legacy statement wrote it back // unfolded whenever the request itself never sent `is_archived`. - testDb.prepare('UPDATE trips SET is_archived = NULL WHERE id = ?').run(trip.id); + await updateRows(await orm(), Trips, { id: trip.id }, { is_archived: null }); await svc.updateTrip(trip.id, user.id, { title: 'Renamed' }, 'user'); - expect((testDb.prepare('SELECT is_archived FROM trips WHERE id = ?').get(trip.id) as { is_archived: number | null }).is_archived).toBeNull(); + expect((await storedFields(Trips, { id: trip.id }, ['is_archived']) as { is_archived: number | null }).is_archived).toBeNull(); // Explicitly archiving still writes 1/0 as before — only the "untouched, was NULL" path is preserved. await svc.updateTrip(trip.id, user.id, { is_archived: true }, 'user'); - expect((testDb.prepare('SELECT is_archived FROM trips WHERE id = ?').get(trip.id) as { is_archived: number | null }).is_archived).toBe(1); + expect((await storedFields(Trips, { id: trip.id }, ['is_archived']) as { is_archived: number | null }).is_archived).toBe(1); }); it('TRIP-SVC-072 (Task 7 security review L2, absorbed): the trip UPDATE (TP25) commits before the days-regeneration transaction — a failed regen leaves the new dates in place', async () => { @@ -1561,12 +1607,12 @@ describe('folded quirk branches', () => { // TP25's own write already landed — R5/§18.6's documented, unfixed quirk: // it runs BEFORE generateDays' transaction, so a regen failure never // rolls it back with the day rows it failed to regenerate. - const row = testDb.prepare('SELECT start_date, end_date FROM trips WHERE id = ?').get(trip.id) as { start_date: string; end_date: string }; + const row = await storedFields(Trips, { id: trip.id }, ['start_date', 'end_date']) as { start_date: string; end_date: string }; expect(row).toEqual({ start_date: '2025-07-01', end_date: '2025-07-03' }); // The day rows themselves never got touched by the failed regen — still // the original 3, on their original dates. - expect(getDays(trip.id)).toHaveLength(3); - expect(getDays(trip.id).map(d => d.date)).toEqual(['2025-06-01', '2025-06-02', '2025-06-03']); + expect((await getDays(trip.id))).toHaveLength(3); + expect((await getDays(trip.id)).map(d => d.date)).toEqual(['2025-06-01', '2025-06-02', '2025-06-03']); }); it('TRIP-SVC-073 (Task 7 security review L2, absorbed): the two-phase renumber avoids a UNIQUE(trip_id, day_number) collision on a genuine swap', async () => { @@ -1586,7 +1632,7 @@ describe('folded quirk branches', () => { await svc.generateDays(trip.id, '2025-01-01', '2025-01-03'); - const daysAfter = getDays(trip.id); + const daysAfter = (await getDays(trip.id)); expect(daysAfter).toHaveLength(3); const byId = new Map(daysAfter.map(d => [d.id, d])); expect(byId.get(dayA.id)).toMatchObject({ day_number: 1, date: '2025-01-01' }); @@ -1594,8 +1640,8 @@ describe('folded quirk branches', () => { expect(byId.get(dayB.id)).toMatchObject({ day_number: 3, date: '2025-01-03' }); // dayB's own identity (and its assignment) survived the swap — renumbered // in place, never deleted-and-recreated. - expect(getAssignments(dayB.id)).toHaveLength(1); - expect(getAssignments(dayB.id)[0].id).toBe(assignmentOnB.id); + expect((await getAssignments(dayB.id))).toHaveLength(1); + expect((await getAssignments(dayB.id))[0].id).toBe(assignmentOnB.id); }); it('TRIP-SVC-065: updateTrip refuses a range past MAX_TRIP_DAYS before touching the row', async () => { @@ -1603,8 +1649,8 @@ describe('folded quirk branches', () => { const trip = createTrip(testDb, user.id, { title: 'Week', start_date: '2026-07-01', end_date: '2026-07-07' }); await expect(svc.updateTrip(trip.id, user.id, { title: 'Decade', end_date: '2036-07-01' }, 'user')) .rejects.toThrow(`A trip can span at most ${MAX_TRIP_DAYS} days`); - expect(testDb.prepare('SELECT title, end_date FROM trips WHERE id = ?').get(trip.id)).toEqual({ title: 'Week', end_date: '2026-07-07' }); - expect(getDays(trip.id)).toHaveLength(7); + expect(await storedFields(Trips, { id: trip.id }, ['title', 'end_date'])).toEqual({ title: 'Week', end_date: '2026-07-07' }); + expect((await getDays(trip.id))).toHaveLength(7); // Moving only the start keeps the stored end and is measured against it. await expect(svc.updateTrip(trip.id, user.id, { start_date: '2020-01-01' }, 'user')) .rejects.toThrow(`A trip can span at most ${MAX_TRIP_DAYS} days`); @@ -1613,14 +1659,14 @@ describe('folded quirk branches', () => { it('TRIP-SVC-066: a trip whose stored range already exceeds the limit can still be renamed', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Legacy' }); - testDb.prepare("UPDATE trips SET start_date = '2020-01-01', end_date = '2030-01-01' WHERE id = ?").run(trip.id); + await updateRows(await orm(), Trips, { id: trip.id }, { start_date: '2020-01-01', end_date: '2030-01-01' }); const result = await svc.updateTrip(trip.id, user.id, { title: 'Renamed' }, 'user'); expect(result.newTitle).toBe('Renamed'); expect(result.changes).toEqual({ title: 'Renamed' }); // A day_count would rebuild the grid over the whole stored range, so it is held to the limit too. await expect(svc.updateTrip(trip.id, user.id, { day_count: 5 }, 'user')) .rejects.toThrow(`A trip can span at most ${MAX_TRIP_DAYS} days`); - expect(getDays(trip.id)).toHaveLength(0); + expect((await getDays(trip.id))).toHaveLength(0); }); it('TRIP-SVC-067: a start date moved past the stored end is refused instead of emptying the trip', async () => { @@ -1628,8 +1674,8 @@ describe('folded quirk branches', () => { const trip = createTrip(testDb, user.id, { title: 'Week', start_date: '2026-07-01', end_date: '2026-07-07' }); await expect(svc.updateTrip(trip.id, user.id, { start_date: '2026-07-10' }, 'user')) .rejects.toThrow('End date must be after start date'); - expect(testDb.prepare('SELECT start_date FROM trips WHERE id = ?').get(trip.id)).toEqual({ start_date: '2026-07-01' }); - expect(getDays(trip.id)).toHaveLength(7); + expect(await storedFields(Trips, { id: trip.id }, ['start_date'])).toEqual({ start_date: '2026-07-01' }); + expect((await getDays(trip.id))).toHaveLength(7); }); it('TRIP-SVC-049: addMember inserts the membership and reports the trip title; removeMember deletes it', async () => { @@ -1648,53 +1694,49 @@ describe('folded quirk branches', () => { await expect(membersSvc.addMember(trip.id, '', owner.id, owner.id)).rejects.toThrow('Email or username required'); await membersSvc.removeMember(trip.id, invitee.id); - expect(testDb.prepare('SELECT id FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, invitee.id)).toBeUndefined(); + expect(await storedFields(TripMembers, { trip: trip.id, user: invitee.id }, ['id'])).toBeUndefined(); }); it('TRIP-SVC-050: copy remaps tags, accommodations, reservations, day notes, budget, bags and category order', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Deep', start_date: '2025-06-01', end_date: '2025-06-02' }); - const days = getDays(trip.id); + const days = (await getDays(trip.id)); const place = createPlace(testDb, trip.id, { name: 'Hotel Zed' }); const assignment = createDayAssignment(testDb, days[0].id, place.id); - const tagId = Number(testDb.prepare("INSERT INTO tags (name, user_id) VALUES ('beach', ?)").run(user.id).lastInsertRowid); - testDb.prepare('INSERT INTO place_tags (place_id, tag_id) VALUES (?, ?)').run(place.id, tagId); - const accomId = Number(testDb.prepare( - "INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out) VALUES (?, ?, ?, ?, '15:00', '11:00')", - ).run(trip.id, place.id, days[0].id, days[1].id).lastInsertRowid); - testDb.prepare( - 'INSERT INTO reservations (trip_id, day_id, end_day_id, place_id, assignment_id, accommodation_id, title, type) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', - ).run(trip.id, days[0].id, days[1].id, place.id, assignment.id, accomId, 'Stay', 'hotel'); - testDb.prepare("INSERT INTO budget_items (trip_id, category, name, total_price) VALUES (?, 'stay', 'Hotel', 120)").run(trip.id); - const bagId = Number(testDb.prepare("INSERT INTO packing_bags (trip_id, name) VALUES (?, 'Backpack')").run(trip.id).lastInsertRowid); - testDb.prepare("INSERT INTO packing_items (trip_id, name, checked, bag_id) VALUES (?, 'Towel', 1, ?)").run(trip.id, bagId); + const tagId = Number(await insertRow(await orm(), Tags, { name: 'beach', user: user.id })); + await tagPlace(await orm(), place.id, [tagId]); + const accomId = Number(await insertRow(await orm(), DayAccommodations, { trip: trip.id, place: place.id, startDay: days[0].id, endDay: days[1].id, check_in: '15:00', check_out: '11:00' })); + await insertRow(await orm(), Reservations, { trip: trip.id, day: days[0].id, endDay: days[1].id, place: place.id, assignment: assignment.id, accommodation_id: legacyBoundIntegerText(accomId), title: 'Stay', type: 'hotel' }); + await insertRow(await orm(), BudgetItems, { trip: trip.id, category: 'stay', name: 'Hotel', total_price: 120 }); + const bagId = Number(await insertRow(await orm(), PackingBags, { trip: trip.id, name: 'Backpack' })); + await insertRow(await orm(), PackingItems, { trip: trip.id, name: 'Towel', checked: 1, bag: bagId }); createDayNote(testDb, days[0].id, trip.id, { text: 'note' }); - testDb.prepare("INSERT INTO todo_items (trip_id, name, checked) VALUES (?, 'Book', 1)").run(trip.id); - testDb.prepare("INSERT INTO budget_category_order (trip_id, category, sort_order) VALUES (?, 'stay', 2)").run(trip.id); + await insertRow(await orm(), TodoItems, { trip: trip.id, name: 'Book', checked: 1 }); + await insertRow(await orm(), BudgetCategoryOrder, { trip: trip.id, category: 'stay', sort_order: 2 }); const newTripId = await svc.copy(trip.id, user.id); - const newDays = getDays(newTripId); + const newDays = (await getDays(newTripId)); expect(newDays).toHaveLength(2); - const newPlace = testDb.prepare('SELECT id FROM places WHERE trip_id = ?').get(newTripId) as { id: number }; - expect(testDb.prepare('SELECT tag_id FROM place_tags WHERE place_id = ?').get(newPlace.id)).toEqual({ tag_id: tagId }); - const newAccom = testDb.prepare('SELECT id, place_id, start_day_id, end_day_id FROM day_accommodations WHERE trip_id = ?').get(newTripId) as any; + const newPlace = await storedFields(Places, { trip: newTripId }, ['id']) as { id: number }; + expect(await placeTagRow(newPlace.id)).toEqual({ tag_id: tagId }); + const newAccom = await storedFields(DayAccommodations, { trip: newTripId }, ['id', 'place_id', 'start_day_id', 'end_day_id']) as any; expect(newAccom.place_id).toBe(newPlace.id); expect(newAccom.start_day_id).toBe(newDays[0].id); - const newRes = testDb.prepare('SELECT day_id, end_day_id, place_id, accommodation_id FROM reservations WHERE trip_id = ?').get(newTripId) as any; + const newRes = await storedFields(Reservations, { trip: newTripId }, ['day_id', 'end_day_id', 'place_id', 'accommodation_id']) as any; // The legacy copyTripById nulled this link (TEXT column vs number-keyed map); // fixed with smoke-test I-01 — the copy now coerces and remaps it. expect(newRes.day_id).toBe(newDays[0].id); expect(newRes.end_day_id).toBe(newDays[1].id); expect(newRes.place_id).toBe(newPlace.id); expect(Number(newRes.accommodation_id)).toBe(newAccom.id); - expect((testDb.prepare('SELECT COUNT(*) AS n FROM budget_items WHERE trip_id = ?').get(newTripId) as any).n).toBe(1); - const newItem = testDb.prepare('SELECT checked, bag_id FROM packing_items WHERE trip_id = ?').get(newTripId) as any; + expect(({ n: await countRows(await orm(), BudgetItems, { trip: newTripId }) } as any).n).toBe(1); + const newItem = await storedFields(PackingItems, { trip: newTripId }, ['checked', 'bag_id']) as any; expect(newItem.checked).toBe(0); expect(newItem.bag_id).not.toBeNull(); - expect((testDb.prepare('SELECT checked, assigned_user_id FROM todo_items WHERE trip_id = ?').get(newTripId) as any)).toEqual({ checked: 0, assigned_user_id: null }); - expect((testDb.prepare('SELECT sort_order FROM budget_category_order WHERE trip_id = ?').get(newTripId) as any).sort_order).toBe(2); - expect((testDb.prepare('SELECT COUNT(*) AS n FROM day_notes WHERE trip_id = ?').get(newTripId) as any).n).toBe(1); + expect((await storedFields(TodoItems, { trip: newTripId }, ['checked', 'assigned_user_id']) as any)).toEqual({ checked: 0, assigned_user_id: null }); + expect((await storedFields(BudgetCategoryOrder, { trip: newTripId }, ['sort_order']) as any).sort_order).toBe(2); + expect(({ n: await countRows(await orm(), DayNotes, { trip: newTripId }) } as any).n).toBe(1); // Missing source throws the byte-identical error. await expect(svc.copy(99999, user.id)).rejects.toThrow('Trip not found'); @@ -1710,34 +1752,33 @@ describe('copy — whole-trip parity (Task 8)', () => { const trip = createTrip(testDb, owner.id, { title: 'Full Fixture', start_date: '2025-09-01', end_date: '2025-09-03', }); - testDb.prepare("UPDATE trips SET description = 'A full trip', currency = 'EUR', cover_image = 'cover.png', reminder_days = 5 WHERE id = ?").run(trip.id); + await updateRows(await orm(), Trips, { id: trip.id }, { description: 'A full trip', currency: 'EUR', cover_image: 'cover.png', reminder_days: 5 }); addTripMember(testDb, trip.id, member.id); - const days = getDays(trip.id); - testDb.prepare("UPDATE days SET notes = 'Pack light', title = 'Arrival' WHERE id = ?").run(days[0].id); - testDb.prepare("UPDATE days SET notes = 'Checkout' WHERE id = ?").run(days[1].id); + const days = (await getDays(trip.id)); + await updateRows(await orm(), Days, { id: days[0].id }, { notes: 'Pack light', title: 'Arrival' }); + await updateRows(await orm(), Days, { id: days[1].id }, { notes: 'Checkout' }); const stop = createPlace(testDb, trip.id, { name: 'Hotel Full', description: 'Nice place' }); const track = createPlace(testDb, trip.id, { name: 'Scenic road' }); - testDb.prepare("UPDATE places SET reservation_status = 'booked', reservation_notes = 'rn', reservation_datetime = '2025-09-01T14:00', route_color = '#ff0000', stop_type = 'hotel', fill_percent = 80 WHERE id = ?").run(stop.id); + await updateRows(await orm(), Places, { id: stop.id }, { reservation_status: 'booked', reservation_notes: 'rn', reservation_datetime: '2025-09-01T14:00', route_color: '#ff0000', stop_type: 'hotel', fill_percent: 80 }); - const tag = Number(testDb.prepare("INSERT INTO tags (name, user_id) VALUES ('beach', ?)").run(owner.id).lastInsertRowid); - testDb.prepare('INSERT INTO place_tags (place_id, tag_id) VALUES (?, ?)').run(stop.id, tag); + const tag = Number(await insertRow(await orm(), Tags, { name: 'beach', user: owner.id })); + await tagPlace(await orm(), stop.id, [tag]); const assignment = createDayAssignment(testDb, days[0].id, stop.id); - testDb.prepare(`UPDATE day_assignments SET reservation_status = 'booked', reservation_notes = 'arn', reservation_datetime = '2025-09-01T15:00', - assignment_time = '15:00', assignment_end_time = '16:00', end_day = 1 WHERE id = ?`).run(assignment.id); - testDb.prepare('INSERT INTO assignment_participants (assignment_id, user_id) VALUES (?, ?), (?, ?)').run(assignment.id, owner.id, assignment.id, member.id); + await updateRows(await orm(), DayAssignments, { id: assignment.id }, { reservation_status: 'booked', reservation_notes: 'arn', reservation_datetime: '2025-09-01T15:00', assignment_time: '15:00', assignment_end_time: '16:00', end_day: 1 }); + await insertRow(await orm(), AssignmentParticipants, { assignment: assignment.id, user: owner.id }); + await insertRow(await orm(), AssignmentParticipants, { assignment: assignment.id, user: member.id }); - testDb.prepare('INSERT INTO roadtrip_vias (day_id, after_order_index, sequence, lat, lng) VALUES (?, 0, 0, 48.1, 2.1)').run(days[0].id); - testDb.prepare('INSERT INTO roadtrip_day_tracks (day_id, place_id, stray_km) VALUES (?, ?, 2.5)').run(days[0].id, track.id); - testDb.prepare("INSERT INTO roadtrip_preferences (trip_id, key, value) VALUES (?, 'avoid_tolls', 'true')").run(trip.id); - testDb.prepare('INSERT INTO roadtrip_day_boundaries (trip_id, day_number, from_assignment_id, to_assignment_id, fraction) VALUES (?, 1, ?, NULL, 0.5)').run(trip.id, assignment.id); + await insertRow(await orm(), RoadtripVias, { day: days[0].id, after_order_index: 0, sequence: 0, lat: 48.1, lng: 2.1 }); + await insertRow(await orm(), RoadtripDayTracks, { day: days[0].id, place: track.id, stray_km: 2.5 }); + await insertRow(await orm(), RoadtripPreferences, { trip: trip.id, key: 'avoid_tolls', value: 'true' }); + await insertRow(await orm(), RoadtripDayBoundaries, { trip: trip.id, day_number: 1, fromAssignment: assignment.id, toAssignment: null, fraction: 0.5 }); - const accomId = Number(testDb.prepare( - "INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes) VALUES (?, ?, ?, ?, '15:00', '15:30', '11:00', 'CONF-1', 'Late checkout ok')", - ).run(trip.id, stop.id, days[0].id, days[1].id).lastInsertRowid); - testDb.prepare('UPDATE day_assignments SET accommodation_id = ? WHERE id = ?').run(accomId, assignment.id); + const accomId = Number(await insertRow(await orm(), DayAccommodations, { trip: trip.id, place: stop.id, startDay: days[0].id, endDay: days[1].id, check_in: '15:00', check_in_end: '15:30', check_out: '11:00', confirmation: 'CONF-1', notes: 'Late checkout ok' })); + await updateRows(await orm(), DayAssignments, { id: assignment.id }, { accommodation_id: accomId }); + // test-sql-allow: the seed reproduces the legacy raw binding of a number into the TEXT accommodation_id. const resId = Number(testDb.prepare(` INSERT INTO reservations (trip_id, day_id, end_day_id, place_id, assignment_id, accommodation_id, title, reservation_time, reservation_end_time, location, confirmation_number, notes, url, status, type, metadata, day_plan_position, needs_review, ingest_state, @@ -1749,29 +1790,27 @@ describe('copy — whole-trip parity (Task 8)', () => { // (this is the SOURCE row's own accommodation_id, written the same way // the pre-migration `copy` bound it — a plain number through // better-sqlite3 into the TEXT column). - expect((testDb.prepare('SELECT accommodation_id FROM reservations WHERE id = ?').get(resId) as { accommodation_id: string }).accommodation_id).toBe(`${accomId}.0`); + expect((await storedFields(Reservations, { id: resId }, ['accommodation_id']) as { accommodation_id: string }).accommodation_id).toBe(`${accomId}.0`); - const itemId = Number(testDb.prepare( - "INSERT INTO budget_items (trip_id, category, name, total_price, reservation_id, currency) VALUES (?, 'Accommodation', 'Hotel', 300, ?, 'EUR')", - ).run(trip.id, resId).lastInsertRowid); - testDb.prepare('INSERT INTO budget_item_members (budget_item_id, user_id, paid, amount) VALUES (?, ?, 1, 150), (?, ?, 0, 150)') - .run(itemId, owner.id, itemId, member.id); - testDb.prepare('INSERT INTO budget_item_payers (budget_item_id, user_id, amount) VALUES (?, ?, 300)').run(itemId, owner.id); - testDb.prepare("INSERT INTO budget_category_order (trip_id, category, sort_order) VALUES (?, 'Accommodation', 1)").run(trip.id); + const itemId = Number(await insertRow(await orm(), BudgetItems, { trip: trip.id, category: 'Accommodation', name: 'Hotel', total_price: 300, reservation: resId, currency: 'EUR' })); + await insertRow(await orm(), BudgetItemMembers, { budgetItem: itemId, user: owner.id, paid: 1, amount: 150 }); + await insertRow(await orm(), BudgetItemMembers, { budgetItem: itemId, user: member.id, paid: 0, amount: 150 }); + await insertRow(await orm(), BudgetItemPayers, { budgetItem: itemId, user: owner.id, amount: 300 }); + await insertRow(await orm(), BudgetCategoryOrder, { trip: trip.id, category: 'Accommodation', sort_order: 1 }); - const bagId = Number(testDb.prepare("INSERT INTO packing_bags (trip_id, name) VALUES (?, 'Carry-on')").run(trip.id).lastInsertRowid); - testDb.prepare('INSERT INTO packing_items (trip_id, name, checked, bag_id) VALUES (?, ?, 1, ?)').run(trip.id, 'Shared tent', bagId); - testDb.prepare('INSERT INTO packing_items (trip_id, name, checked, is_private, owner_id) VALUES (?, ?, 1, 1, ?)').run(trip.id, "Owner's diary", owner.id); - testDb.prepare('INSERT INTO packing_items (trip_id, name, checked, is_private, owner_id) VALUES (?, ?, 1, 1, ?)').run(trip.id, "Member's meds", member.id); + const bagId = Number(await insertRow(await orm(), PackingBags, { trip: trip.id, name: 'Carry-on' })); + await insertRow(await orm(), PackingItems, { trip: trip.id, name: 'Shared tent', checked: 1, bag: bagId }); + await insertRow(await orm(), PackingItems, { trip: trip.id, name: "Owner's diary", checked: 1, is_private: 1, owner: owner.id }); + await insertRow(await orm(), PackingItems, { trip: trip.id, name: "Member's meds", checked: 1, is_private: 1, owner: member.id }); createDayNote(testDb, days[0].id, trip.id, { text: 'Remember passport', time: '08:00', icon: '🛂', sort_order: 1 }); - testDb.prepare("INSERT INTO todo_items (trip_id, name, checked, category, sort_order) VALUES (?, 'Book taxi', 1, 'travel', 1)").run(trip.id); + await insertRow(await orm(), TodoItems, { trip: trip.id, name: 'Book taxi', checked: 1, category: 'travel', sort_order: 1 }); // ── copy, run by the OWNER (so their own private item copies, member's does not) ── const newTripId = await svc.copy(trip.id, owner.id, 'Full Copy'); // trips - const newTrip = testDb.prepare('SELECT * FROM trips WHERE id = ?').get(newTripId) as Record; + const newTrip = await storedRow(Trips, { id: newTripId }) as Record; expect(newTrip).toMatchObject({ user_id: owner.id, title: 'Full Copy', description: 'A full trip', start_date: '2025-09-01', end_date: '2025-09-03', currency: 'EUR', @@ -1779,31 +1818,31 @@ describe('copy — whole-trip parity (Task 8)', () => { }); // days - const newDays = getDays(newTripId); + const newDays = (await getDays(newTripId)); expect(newDays).toHaveLength(3); expect(newDays.map(d => ({ day_number: d.day_number, date: d.date }))).toEqual( days.map(d => ({ day_number: d.day_number, date: d.date })), ); - expect((testDb.prepare('SELECT notes, title FROM days WHERE id = ?').get(newDays[0].id) as { notes: string | null; title: string | null })).toEqual({ notes: 'Pack light', title: 'Arrival' }); - expect((testDb.prepare('SELECT notes FROM days WHERE id = ?').get(newDays[1].id) as { notes: string | null }).notes).toBe('Checkout'); + expect((await storedFields(Days, { id: newDays[0].id }, ['notes', 'title']) as { notes: string | null; title: string | null })).toEqual({ notes: 'Pack light', title: 'Arrival' }); + expect((await storedFields(Days, { id: newDays[1].id }, ['notes']) as { notes: string | null }).notes).toBe('Checkout'); // places - const newStop = testDb.prepare('SELECT * FROM places WHERE trip_id = ? AND name = ?').get(newTripId, 'Hotel Full') as { + const newStop = await storedRow(Places, { trip: newTripId, name: 'Hotel Full' }) as { id: number; description: string | null; reservation_status: string | null; reservation_notes: string | null; reservation_datetime: string | null; route_color: string | null; stop_type: string | null; fill_percent: number | null; }; - const newTrack = testDb.prepare('SELECT * FROM places WHERE trip_id = ? AND name = ?').get(newTripId, 'Scenic road') as { id: number }; + const newTrack = await storedRow(Places, { trip: newTripId, name: 'Scenic road' }) as { id: number }; expect(newStop).toMatchObject({ description: 'Nice place', reservation_status: 'booked', reservation_notes: 'rn', reservation_datetime: '2025-09-01T14:00', route_color: '#ff0000', stop_type: 'hotel', fill_percent: 80, }); // place_tags - expect((testDb.prepare('SELECT tag_id FROM place_tags WHERE place_id = ?').get(newStop.id) as { tag_id: number }).tag_id).toBe(tag); + expect((await placeTagRow(newStop.id))!.tag_id).toBe(tag); // day_assignments (+ the TP57 accommodation stamp) - const newAssignment = getAssignments(newDays[0].id)[0]; - const newAssignmentFull = testDb.prepare('SELECT * FROM day_assignments WHERE id = ?').get(newAssignment.id) as { + const newAssignment = (await getAssignments(newDays[0].id))[0]; + const newAssignmentFull = await storedRow(DayAssignments, { id: newAssignment.id }) as { place_id: number; reservation_status: string | null; reservation_notes: string | null; reservation_datetime: string | null; assignment_time: string | null; assignment_end_time: string | null; end_day: number; accommodation_id: number | null; }; @@ -1813,20 +1852,20 @@ describe('copy — whole-trip parity (Task 8)', () => { }); // assignment_participants - const newParticipants = testDb.prepare('SELECT user_id FROM assignment_participants WHERE assignment_id = ?').all(newAssignment.id) as { user_id: number }[]; + const newParticipants = await storedRows(AssignmentParticipants, { assignment: newAssignment.id }, undefined, ['user_id']) as { user_id: number }[]; expect(newParticipants.map(p => p.user_id).sort((a, b) => a - b)).toEqual([owner.id, member.id].sort((a, b) => a - b)); // roadtrip_vias / roadtrip_day_tracks - const newVia = testDb.prepare('SELECT after_order_index, sequence, lat, lng FROM roadtrip_vias WHERE day_id = ?').get(newDays[0].id) as { + const newVia = await storedFields(RoadtripVias, { day: newDays[0].id }, ['after_order_index', 'sequence', 'lat', 'lng']) as { after_order_index: number; sequence: number; lat: number; lng: number; }; expect(newVia).toEqual({ after_order_index: 0, sequence: 0, lat: 48.1, lng: 2.1 }); - const newTrackRow = testDb.prepare('SELECT place_id, stray_km FROM roadtrip_day_tracks WHERE day_id = ?').get(newDays[0].id) as { place_id: number; stray_km: number | null }; + const newTrackRow = await storedFields(RoadtripDayTracks, { day: newDays[0].id }, ['place_id', 'stray_km']) as { place_id: number; stray_km: number | null }; expect(newTrackRow).toEqual({ place_id: newTrack.id, stray_km: 2.5 }); // roadtrip_preferences / roadtrip_day_boundaries - expect((testDb.prepare("SELECT value FROM roadtrip_preferences WHERE trip_id = ? AND key = 'avoid_tolls'").get(newTripId) as { value: string }).value).toBe('true'); - const newBoundary = testDb.prepare('SELECT day_number, from_assignment_id, to_assignment_id, fraction FROM roadtrip_day_boundaries WHERE trip_id = ?').get(newTripId) as { + expect((await storedFields(RoadtripPreferences, { trip: newTripId, key: 'avoid_tolls' }, ['value']) as { value: string }).value).toBe('true'); + const newBoundary = await storedFields(RoadtripDayBoundaries, { trip: newTripId }, ['day_number', 'from_assignment_id', 'to_assignment_id', 'fraction']) as { day_number: number; from_assignment_id: number; to_assignment_id: number | null; fraction: number; }; expect(newBoundary).toEqual({ day_number: 1, from_assignment_id: newAssignment.id, to_assignment_id: null, fraction: 0.5 }); @@ -1835,11 +1874,11 @@ describe('copy — whole-trip parity (Task 8)', () => { // key set modulo id/trip_id/place_id/start_day_id/end_day_id/created_at, // every one of which is either a remapped FK or excluded on principle), // and the assignment's accommodation_id stamped at the NEW accommodation - const newAccom = testDb.prepare('SELECT * FROM day_accommodations WHERE trip_id = ?').get(newTripId) as { + const newAccom = await storedRow(DayAccommodations, { trip: newTripId }) as { id: number; place_id: number | null; start_day_id: number; end_day_id: number; check_in: string | null; check_in_end: string | null; check_out: string | null; confirmation: string | null; notes: string | null; }; - const sourceAccom = testDb.prepare('SELECT check_in, check_in_end, check_out, confirmation, notes FROM day_accommodations WHERE id = ?').get(accomId) as { + const sourceAccom = await storedFields(DayAccommodations, { id: accomId }, ['check_in', 'check_in_end', 'check_out', 'confirmation', 'notes']) as { check_in: string | null; check_in_end: string | null; check_out: string | null; confirmation: string | null; notes: string | null; }; expect({ check_in: newAccom.check_in, check_in_end: newAccom.check_in_end, check_out: newAccom.check_out, confirmation: newAccom.confirmation, notes: newAccom.notes }).toEqual(sourceAccom); @@ -1850,7 +1889,7 @@ describe('copy — whole-trip parity (Task 8)', () => { // set modulo id/trip_id/day_id/end_day_id/place_id/assignment_id/ // created_at (remapped FKs) and the external_*/sync_enabled columns // (deliberately NOT copied, asserted below). - const newRes = testDb.prepare('SELECT * FROM reservations WHERE trip_id = ?').get(newTripId) as { + const newRes = await storedRow(Reservations, { trip: newTripId }) as { id: number; day_id: number | null; end_day_id: number | null; place_id: number | null; assignment_id: number | null; title: string; reservation_time: string | null; reservation_end_time: string | null; location: string | null; confirmation_number: string | null; notes: string | null; url: string | null; status: string | null; type: string | null; @@ -1882,21 +1921,21 @@ describe('copy — whole-trip parity (Task 8)', () => { expect(newRes.sync_enabled).toBe(1); // the column's own DB DEFAULT, never the source's `0` // budget_items / members / payers / category order - const newItem = testDb.prepare('SELECT * FROM budget_items WHERE trip_id = ?').get(newTripId) as { + const newItem = await storedRow(BudgetItems, { trip: newTripId }) as { id: number; category: string; name: string; total_price: number; reservation_id: number | null; currency: string | null; }; expect(newItem).toMatchObject({ category: 'Accommodation', name: 'Hotel', total_price: 300, reservation_id: newRes.id, currency: 'EUR' }); - const newMembers = testDb.prepare('SELECT user_id, paid, amount FROM budget_item_members WHERE budget_item_id = ? ORDER BY user_id').all(newItem.id) as { user_id: number; paid: number; amount: number | null }[]; + const newMembers = await storedRows(BudgetItemMembers, { budgetItem: newItem.id }, { user: 'asc' }, ['user_id', 'paid', 'amount']) as { user_id: number; paid: number; amount: number | null }[]; expect(newMembers).toEqual([owner.id, member.id].sort((a, b) => a - b).map((uid) => uid === owner.id ? { user_id: owner.id, paid: 1, amount: 150 } : { user_id: member.id, paid: 0, amount: 150 }, )); - const newPayers = testDb.prepare('SELECT user_id, amount FROM budget_item_payers WHERE budget_item_id = ?').all(newItem.id) as { user_id: number; amount: number }[]; + const newPayers = await storedRows(BudgetItemPayers, { budgetItem: newItem.id }, undefined, ['user_id', 'amount']) as { user_id: number; amount: number }[]; expect(newPayers).toEqual([{ user_id: owner.id, amount: 300 }]); - expect((testDb.prepare("SELECT sort_order FROM budget_category_order WHERE trip_id = ? AND category = 'Accommodation'").get(newTripId) as { sort_order: number }).sort_order).toBe(1); + expect((await storedFields(BudgetCategoryOrder, { trip: newTripId, category: 'Accommodation' }, ['sort_order']) as { sort_order: number }).sort_order).toBe(1); // packing_bags / packing_items (incl. the TP68 privacy filter — the copIER is the owner) - const newBag = testDb.prepare("SELECT id FROM packing_bags WHERE trip_id = ? AND name = 'Carry-on'").get(newTripId) as { id: number }; - const newPacking = testDb.prepare('SELECT name, checked, is_private, owner_id, bag_id FROM packing_items WHERE trip_id = ? ORDER BY name').all(newTripId) as { + const newBag = await storedFields(PackingBags, { trip: newTripId, name: 'Carry-on' }, ['id']) as { id: number }; + const newPacking = await storedRows(PackingItems, { trip: newTripId }, { name: 'asc' }, ['name', 'checked', 'is_private', 'owner_id', 'bag_id']) as { name: string; checked: number | null; is_private: number; owner_id: number | null; bag_id: number | null; }[]; expect(newPacking.map(p => p.name)).toEqual(['Owner\'s diary', 'Shared tent']); // member's private item is NOT copied @@ -1904,30 +1943,30 @@ describe('copy — whole-trip parity (Task 8)', () => { expect(newPacking.find(p => p.name === "Owner's diary")).toMatchObject({ checked: 0, is_private: 1, owner_id: owner.id }); // day_notes - const newNote = testDb.prepare('SELECT day_id, text, time, icon, sort_order FROM day_notes WHERE trip_id = ?').get(newTripId) as { + const newNote = await storedFields(DayNotes, { trip: newTripId }, ['day_id', 'text', 'time', 'icon', 'sort_order']) as { day_id: number; text: string; time: string | null; icon: string | null; sort_order: number | null; }; expect(newNote).toEqual({ day_id: newDays[0].id, text: 'Remember passport', time: '08:00', icon: '🛂', sort_order: 1 }); // todo_items — reset to unchecked, no assignee - const newTodo = testDb.prepare('SELECT name, checked, category, sort_order, assigned_user_id FROM todo_items WHERE trip_id = ?').get(newTripId) as { + const newTodo = await storedFields(TodoItems, { trip: newTripId }, ['name', 'checked', 'category', 'sort_order', 'assigned_user_id']) as { name: string; checked: number | null; category: string | null; sort_order: number | null; assigned_user_id: number | null; }; expect(newTodo).toEqual({ name: 'Book taxi', checked: 0, category: 'travel', sort_order: 1, assigned_user_id: null }); // The source trip is untouched. - expect(testDb.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(trip.id)).toEqual({ n: 3 }); - expect(testDb.prepare('SELECT COUNT(*) AS n FROM packing_items WHERE trip_id = ?').get(trip.id)).toEqual({ n: 3 }); + expect({ n: await countRows(await orm(), Days, { trip: trip.id }) }).toEqual({ n: 3 }); + expect({ n: await countRows(await orm(), PackingItems, { trip: trip.id }) }).toEqual({ n: 3 }); }); it('TRIP-SVC-070 (mutation-proved): copy is atomic — a failing late insert leaves no new trip and no partial rows', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Rollback source', start_date: '2025-10-01', end_date: '2025-10-02' }); - const days = getDays(trip.id); + const days = (await getDays(trip.id)); const place = createPlace(testDb, trip.id, { name: 'Doomed place' }); createDayAssignment(testDb, days[0].id, place.id); - const tripsBefore = (testDb.prepare('SELECT COUNT(*) AS n FROM trips').get() as { n: number }).n; - const daysBefore = (testDb.prepare('SELECT COUNT(*) AS n FROM days').get() as { n: number }).n; + const tripsBefore = ({ n: await countRows(await orm(), Trips, {}) } as { n: number }).n; + const daysBefore = ({ n: await countRows(await orm(), Days, {}) } as { n: number }).n; const dayNotesRepo = await createTestDayNotesRepo(testDb); const spy = vi.spyOn(dayNotesRepo, 'insertNoteCopy').mockRejectedValueOnce(new Error('boom')); @@ -1940,61 +1979,62 @@ describe('copy — whole-trip parity (Task 8)', () => { // No new trip, and every earlier insert in the same transaction (trips/days/ // places/assignments/…) rolled back with it — nothing partially lands. - expect((testDb.prepare('SELECT COUNT(*) AS n FROM trips').get() as { n: number }).n).toBe(tripsBefore); - expect((testDb.prepare('SELECT COUNT(*) AS n FROM days').get() as { n: number }).n).toBe(daysBefore); - expect(testDb.prepare("SELECT id FROM trips WHERE title = 'Never lands'").get()).toBeUndefined(); + expect(({ n: await countRows(await orm(), Trips, {}) } as { n: number }).n).toBe(tripsBefore); + expect(({ n: await countRows(await orm(), Days, {}) } as { n: number }).n).toBe(daysBefore); + expect(await storedFields(Trips, { title: 'Never lands' }, ['id'])).toBeUndefined(); // The source trip itself is untouched. - expect(testDb.prepare('SELECT id FROM trips WHERE id = ?').get(trip.id)).toBeDefined(); + expect(await storedFields(Trips, { id: trip.id }, ['id'])).toBeDefined(); }); }); describe('copy: Tours (#2586)', () => { /** A Tour on `tripId`: the place, its `tours` facet and three route waypoints. */ - function seedTour(tripId: number, name: string) { + async function seedTour(tripId: number, name: string) { const place = createPlace(testDb, tripId, { name }); - testDb.prepare("UPDATE places SET transport_mode = 'walking', route_geometry = '[[47,11],[47.2,11.2]]' WHERE id = ?").run(place.id); + await updateRows(await orm(), Places, { id: place.id }, { transport_mode: 'walking', route_geometry: '[[47,11],[47.2,11.2]]' }); createTour(testDb, place.id, { distance: 12.5, match_confidence: 0.9, max_hiking_difficulty: 4, created_at: '2025-01-02 03:04:05' }); - testDb.prepare("UPDATE tours SET elevation_gain = 800, elevation_loss = 790, duration = 5.5, difficulty = 'T3', wanderer_ref = 'w-1' WHERE place_id = ?").run(place.id); - const insert = testDb.prepare('INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence) VALUES (?, ?, ?, ?, ?)'); - insert.run(place.id, 47, 11, 'start', 0); - insert.run(place.id, 47.1, 11.1, 'via', 1); - insert.run(place.id, 47.2, 11.2, 'end', 2); + await updateRows(await orm(), Tours, { place: place.id }, { elevation_gain: 800, elevation_loss: 790, duration: 5.5, difficulty: 'T3', wanderer_ref: 'w-1' }); + const insert = async (lat: number, lng: number, role: string, sequence: number) => + insertRow(await orm(), TourWaypoints, { place: place.id, lat, lng, role, sequence }); + await insert(47, 11, 'start', 0); + await insert(47.1, 11.1, 'via', 1); + await insert(47.2, 11.2, 'end', 2); return place; } - const tourColumns = (placeId: number) => { - const { place_id: _placeId, ...rest } = testDb.prepare('SELECT * FROM tours WHERE place_id = ?').get(placeId) as Record; + const tourColumns = async (placeId: number) => { + const { place_id: _placeId, ...rest } = await storedRow(Tours, { place: placeId }) as Record; return rest; }; - const waypoints = (placeId: number) => - testDb.prepare('SELECT lat, lng, role, sequence FROM tour_waypoints WHERE place_id = ? ORDER BY sequence').all(placeId); + const waypoints = async (placeId: number) => + await storedRows(TourWaypoints, { place: placeId }, { sequence: 'asc' }, ['lat', 'lng', 'role', 'sequence']); it('TRIP-SVC-081: a copied Tour keeps its facet row and waypoints, remapped onto the copied place', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Alps' }); - const tour = seedTour(trip.id, 'Ridge walk'); + const tour = await seedTour(trip.id, 'Ridge walk'); createPlace(testDb, trip.id, { name: 'Plain stop' }); const newTripId = await svc.copy(trip.id, user.id, 'Alps again'); - const newTour = testDb.prepare("SELECT id FROM places WHERE trip_id = ? AND name = 'Ridge walk'").get(newTripId) as { id: number }; - const newPlain = testDb.prepare("SELECT id FROM places WHERE trip_id = ? AND name = 'Plain stop'").get(newTripId) as { id: number }; + const newTour = await storedFields(Places, { trip: newTripId, name: 'Ridge walk' }, ['id']) as { id: number }; + const newPlain = await storedFields(Places, { trip: newTripId, name: 'Plain stop' }, ['id']) as { id: number }; expect(newTour.id).not.toBe(tour.id); - expect(tourColumns(newTour.id)).toEqual(tourColumns(tour.id)); - expect(tourColumns(newTour.id)).toMatchObject({ tour_type: 'hike', created_at: '2025-01-02 03:04:05', max_hiking_difficulty: 4 }); - expect(waypoints(newTour.id)).toEqual(waypoints(tour.id)); - expect(waypoints(newTour.id)).toHaveLength(3); + expect((await tourColumns(newTour.id))).toEqual((await tourColumns(tour.id))); + expect((await tourColumns(newTour.id))).toMatchObject({ tour_type: 'hike', created_at: '2025-01-02 03:04:05', max_hiking_difficulty: 4 }); + expect((await waypoints(newTour.id))).toEqual((await waypoints(tour.id))); + expect((await waypoints(newTour.id))).toHaveLength(3); // The plain place stays plain, and the source keeps its own rows. - expect(testDb.prepare('SELECT 1 FROM tours WHERE place_id = ?').get(newPlain.id)).toBeUndefined(); - expect(testDb.prepare('SELECT COUNT(*) AS n FROM tours t JOIN places p ON p.id = t.place_id WHERE p.trip_id = ?').get(trip.id)).toEqual({ n: 1 }); - expect(waypoints(tour.id)).toHaveLength(3); + expect(await storedRow(Tours, { place: newPlain.id })).toBeUndefined(); + expect({ n: await countRows(await orm(), Tours, { place: { trip: trip.id } }) }).toEqual({ n: 1 }); + expect((await waypoints(tour.id))).toHaveLength(3); }); it('TRIP-SVC-082: a failing waypoint insert rolls the copied Tour back with the rest of the copy', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Doomed alps' }); - seedTour(trip.id, 'Doomed ridge'); - const toursBefore = (testDb.prepare('SELECT COUNT(*) AS n FROM tours').get() as { n: number }).n; + await seedTour(trip.id, 'Doomed ridge'); + const toursBefore = ({ n: await countRows(await orm(), Tours, {}) } as { n: number }).n; const waypointsRepo = await createTestTourWaypointsRepo(testDb); const spy = vi.spyOn(waypointsRepo, 'insertForPlace').mockRejectedValueOnce(new Error('boom')); @@ -2004,8 +2044,8 @@ describe('copy: Tours (#2586)', () => { spy.mockRestore(); } - expect((testDb.prepare('SELECT COUNT(*) AS n FROM tours').get() as { n: number }).n).toBe(toursBefore); - expect(testDb.prepare("SELECT id FROM trips WHERE title = 'Never lands'").get()).toBeUndefined(); + expect(({ n: await countRows(await orm(), Tours, {}) } as { n: number }).n).toBe(toursBefore); + expect(await storedFields(Trips, { title: 'Never lands' }, ['id'])).toBeUndefined(); }); }); @@ -2026,12 +2066,8 @@ describe('quirk fixes', () => { it('TRIP-SVC-051 (mutation-proved): remove is atomic — a failed trip DELETE keeps the journey entries intact', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const journeyId = Number(testDb.prepare( - "INSERT INTO journeys (user_id, title, created_at, updated_at) VALUES (?, 'J', 0, 0)", - ).run(user.id).lastInsertRowid); - testDb.prepare( - "INSERT INTO journey_entries (journey_id, source_trip_id, author_id, type, title, entry_date, created_at, updated_at) VALUES (?, ?, ?, 'skeleton', 'S', '2025-06-01', 0, 0)", - ).run(journeyId, trip.id, user.id); + const journeyId = Number(await insertRow(await orm(), Journeys, { user: user.id, title: 'J', created_at: 0, updated_at: 0 })); + await insertRow(await orm(), JourneyEntries, { journey: journeyId, sourceTrip: trip.id, author: user.id, type: 'skeleton', title: 'S', entry_date: '2025-06-01', created_at: 0, updated_at: 0 }); const tripsRepo = await createTestTripsRepo(testDb); const spy = vi.spyOn(tripsRepo, 'deleteById').mockRejectedValueOnce(new Error('boom')); @@ -2042,14 +2078,14 @@ describe('quirk fixes', () => { } // The skeleton cleanup rolled back with the failed delete. - expect(testDb.prepare("SELECT id FROM journey_entries WHERE type = 'skeleton'").get()).toBeDefined(); - expect(testDb.prepare('SELECT id FROM trips WHERE id = ?').get(trip.id)).toBeDefined(); + expect(await storedFields(JourneyEntries, { type: 'skeleton' }, ['id'])).toBeDefined(); + expect(await storedFields(Trips, { id: trip.id }, ['id'])).toBeDefined(); }); // R8 (Plan 3c program brief item 8): the SQL-text-keyed `failingConnection` // Proxy this test used to build a `failingMembers(match)` service around is // rewritten as a repository-level fault (Task 6 converted `deleteGuest` off - // `this.db.prepare(...)` entirely, onto `UsersRepository.deleteGuest` — the + // the raw `this.db` statements entirely, onto `UsersRepository.deleteGuest` — the // Proxy could never trigger any more, since no statement text runs through // `this.db` inside `deleteGuest`'s transaction). it('TRIP-SVC-052: deleteGuest is atomic — a failed user DELETE rolls the budget re-split back', async () => { @@ -2067,14 +2103,14 @@ describe('quirk fixes', () => { } // Neither the guest nor their split membership was touched. - expect(testDb.prepare('SELECT id FROM users WHERE id = ?').get(guest.id)).toBeDefined(); - const row = testDb.prepare('SELECT persons FROM budget_items WHERE id = ?').get(item.id) as { persons: number | null }; + expect(await storedFields(Users, { id: guest.id }, ['id'])).toBeDefined(); + const row = await storedFields(BudgetItems, { id: item.id }, ['persons']) as { persons: number | null }; expect(row.persons).toBe(2); }); it('TRIP-SVC-053: listMembers prefers the owner display_name over the raw username (quirk fix)', async () => { const { user: owner } = createUser(testDb, { username: 'owner-handle' }); - testDb.prepare('UPDATE users SET display_name = ? WHERE id = ?').run('Olive Displayed', owner.id); + await updateRows(await orm(), Users, { id: owner.id }, { display_name: 'Olive Displayed' }); const trip = createTrip(testDb, owner.id); const { owner: row } = await membersSvc.listMembers(trip.id, owner.id); expect(row.username).toBe('Olive Displayed'); @@ -2097,10 +2133,10 @@ describe('searchPlaces (#2190)', () => { const foreign = createTrip(testDb, other.id, { title: 'Hidden' }); createPlace(testDb, own.id, { name: "Dante's Diner" }); const museum = createPlace(testDb, own.id, { name: 'Museum' }); - testDb.prepare('UPDATE places SET address = ? WHERE id = ?').run('1 Diner Street', museum.id); + await updateRows(await orm(), Places, { id: museum.id }, { address: '1 Diner Street' }); createPlace(testDb, shared.id, { name: 'City Lights Books' }); createPlace(testDb, foreign.id, { name: 'Secret Diner' }); - testDb.prepare('UPDATE trips SET is_archived = 1 WHERE id = ?').run(own.id); + await updateRows(await orm(), Trips, { id: own.id }, { is_archived: 1 }); expect(await svc.searchPlaces(user.id, 'diner')).toEqual([{ trip_id: own.id, places: ["Dante's Diner", 'Museum'] }]); expect(await svc.searchPlaces(user.id, 'books')).toEqual([{ trip_id: shared.id, places: ['City Lights Books'] }]); @@ -2145,7 +2181,7 @@ describe('activeTrip (startup destination)', () => { it('TRIP-SVC-057: skips archived trips and returns undefined when nothing is left', async () => { const { user } = createUser(testDb); const archived = createTrip(testDb, user.id, { title: 'archived', start_date: '2026-08-05', end_date: '2026-08-12' }); - testDb.prepare('UPDATE trips SET is_archived = 1 WHERE id = ?').run(archived.id); + await updateRows(await orm(), Trips, { id: archived.id }, { is_archived: 1 }); expect(await svc.activeTrip(user.id, TODAY)).toBeUndefined(); }); diff --git a/server/tests/unit/nest/vacay.service.test.ts b/server/tests/unit/nest/vacay.service.test.ts index 65c839430..df44cf52b 100644 --- a/server/tests/unit/nest/vacay.service.test.ts +++ b/server/tests/unit/nest/vacay.service.test.ts @@ -10,7 +10,6 @@ vi.mock('../../../src/db/database', async () => { db, closeDb: () => {}, reinitialize: () => {}, - canAccessTrip: () => null, }; return mock; }); @@ -39,6 +38,58 @@ import { createTestVacaySharesRepo, createTestVacayUserSettingsRepo, } from '../../helpers/vacay-repos'; import { createTestVacayHolidayCalendarsRepo, createTestSchoolHolidayRegionsRepo } from '../../helpers/school-holidays-repos'; +import type { EntityClass, EntityDTO, FilterQuery, FindOptions } from '@mikro-orm/core'; +import { sharedTestOrm } from '../../helpers/test-uow'; +import { + countRows, deleteRows, findRow, findRows, insertRow, insertRowIgnoringConflict, updateRows, upsertRow, +} from '../../helpers/factories/rows'; +import { SchoolHolidayCountries } from '../../../src/db/entities/SchoolHolidayCountries.entity'; +import { SchoolHolidayRegions } from '../../../src/db/entities/SchoolHolidayRegions.entity'; +import { Users } from '../../../src/db/entities/Users.entity'; +import { VacayCompanyHolidays } from '../../../src/db/entities/VacayCompanyHolidays.entity'; +import { VacayEntries } from '../../../src/db/entities/VacayEntries.entity'; +import { VacayHolidayCalendars } from '../../../src/db/entities/VacayHolidayCalendars.entity'; +import { VacayPlanMembers } from '../../../src/db/entities/VacayPlanMembers.entity'; +import { VacayPlans } from '../../../src/db/entities/VacayPlans.entity'; +import { VacayShares } from '../../../src/db/entities/VacayShares.entity'; +import { VacayUserColors } from '../../../src/db/entities/VacayUserColors.entity'; +import { VacayUserSettings } from '../../../src/db/entities/VacayUserSettings.entity'; +import { VacayUserYears } from '../../../src/db/entities/VacayUserYears.entity'; +import { VacayYears } from '../../../src/db/entities/VacayYears.entity'; + +const orm = () => sharedTestOrm(testDb); + +/** The stored row matching `where`, or undefined when there is none. */ +async function storedRow(entity: EntityClass, where: FilterQuery): Promise | undefined> { + return (await findRow(await orm(), entity, where)) ?? undefined; +} + +/** Only `cols` of a stored row, the way a SELECT of those columns returns it. */ +function pickColumns(row: EntityDTO, cols: string[]): Record { + const all = row as Record; + return Object.fromEntries(cols.map((col) => [col, all[col]])); +} + +/** Only `cols` of the stored row matching `where`, or undefined when there is none. */ +async function storedFields( + entity: EntityClass, + where: FilterQuery, + cols: string[], +): Promise | undefined> { + const row = await findRow(await orm(), entity, where); + return row ? pickColumns(row, cols) : undefined; +} + +/** The stored rows matching `where`, whole or cut down to `cols`. */ +async function storedRows( + entity: EntityClass, + where: FilterQuery, + orderBy?: FindOptions['orderBy'], + cols?: string[], +): Promise>> { + const rows = await findRows(await orm(), entity, where, orderBy); + return cols ? rows.map((row) => pickColumns(row, cols)) : (rows as Array>); +} // VACAY-SVC-001 through VACAY-SVC-066 moved 1:1 from the legacy // tests/unit/services/vacayService.test.ts (the named-function imports became @@ -80,10 +131,8 @@ afterAll(() => { // ── Helpers ─────────────────────────────────────────────────────────────────── /** Insert a vacay_plan_members row directly (no service factory for it). */ -function insertMember(planId: number, userId: number, status: 'pending' | 'accepted'): void { - testDb.prepare( - "INSERT INTO vacay_plan_members (plan_id, user_id, status) VALUES (?, ?, ?)" - ).run(planId, userId, status); +async function insertMember(planId: number, userId: number, status: 'pending' | 'accepted'): Promise { + await insertRow(await orm(), VacayPlanMembers, { plan: planId, user: userId, status }); } /** Fast helper: create a user and immediately materialise their own plan. */ @@ -97,8 +146,8 @@ async function setupUserWithPlan() { * Lift the (default-on) weekend blocking for tests whose dates are derived * from the current year and can land on any weekday. */ -function allowWeekends(planId: number) { - testDb.prepare('UPDATE vacay_plans SET block_weekends = 0 WHERE id = ?').run(planId); +async function allowWeekends(planId: number) { + await updateRows(await orm(), VacayPlans, { id: planId }, { block_weekends: 0 }); } // ── getOwnPlan ──────────────────────────────────────────────────────────────── @@ -126,9 +175,7 @@ describe('getOwnPlan', () => { const plan = await svc.getOwnPlan(user.id); const yr = new Date().getFullYear(); - const row = testDb - .prepare('SELECT * FROM vacay_years WHERE plan_id = ? AND year = ?') - .get(plan.id, yr); + const row = await storedRow(VacayYears, { plan: plan.id, year: yr }); expect(row).toBeDefined(); }); @@ -138,9 +185,7 @@ describe('getOwnPlan', () => { const plan = await svc.getOwnPlan(user.id); const yr = new Date().getFullYear(); - const row = testDb - .prepare('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?') - .get(user.id, plan.id, yr) as { vacation_days: number } | undefined; + const row = await storedRow(VacayUserYears, { user: user.id, plan: plan.id, year: yr }) as { vacation_days: number } | undefined; expect(row).toBeDefined(); expect(row!.vacation_days).toBe(30); @@ -164,7 +209,7 @@ describe('getActivePlan', () => { // Make sure member also has their own plan materialised first await svc.getOwnPlan(member.id); - insertMember(ownerPlan.id, member.id, 'accepted'); + await insertMember(ownerPlan.id, member.id, 'accepted'); const active = await svc.getActivePlan(member.id); expect(active.id).toBe(ownerPlan.id); @@ -175,7 +220,7 @@ describe('getActivePlan', () => { const { user: member } = createUser(testDb); await svc.getOwnPlan(member.id); - insertMember(ownerPlan.id, member.id, 'pending'); + await insertMember(ownerPlan.id, member.id, 'pending'); const active = await svc.getActivePlan(member.id); // Should still point to member's own plan @@ -197,7 +242,7 @@ describe('getPlanUsers', () => { it('VACAY-SVC-009: returns [owner, member] after an accepted membership is inserted', async () => { const { user: owner, plan } = await setupUserWithPlan(); const { user: member } = createUser(testDb); - insertMember(plan.id, member.id, 'accepted'); + await insertMember(plan.id, member.id, 'accepted'); const users = await svc.getPlanUsers(plan.id); @@ -209,7 +254,7 @@ describe('getPlanUsers', () => { it('VACAY-SVC-010: pending membership members are NOT included in plan users', async () => { const { plan } = await setupUserWithPlan(); const { user: pendingUser } = createUser(testDb); - insertMember(plan.id, pendingUser.id, 'pending'); + await insertMember(plan.id, pendingUser.id, 'pending'); const users = await svc.getPlanUsers(plan.id); expect(users.map(u => u.id)).not.toContain(pendingUser.id); @@ -230,9 +275,7 @@ describe('migrateHolidayCalendars', () => { await svc.migrateHolidayCalendars(plan.id, planRow); - const rows = testDb - .prepare('SELECT * FROM vacay_holiday_calendars WHERE plan_id = ?') - .all(plan.id); + const rows = await storedRows(VacayHolidayCalendars, { plan: plan.id }); expect(rows).toHaveLength(0); }); @@ -242,9 +285,7 @@ describe('migrateHolidayCalendars', () => { await svc.migrateHolidayCalendars(plan.id, planRow); - const rows = testDb - .prepare('SELECT * FROM vacay_holiday_calendars WHERE plan_id = ?') - .all(plan.id) as { region: string }[]; + const rows = await storedRows(VacayHolidayCalendars, { plan: plan.id }) as { region: string }[]; expect(rows).toHaveLength(1); expect(rows[0].region).toBe('DE'); }); @@ -257,9 +298,7 @@ describe('migrateHolidayCalendars', () => { // Call a second time — should NOT insert another row await svc.migrateHolidayCalendars(plan.id, planRow); - const rows = testDb - .prepare('SELECT * FROM vacay_holiday_calendars WHERE plan_id = ?') - .all(plan.id); + const rows = await storedRows(VacayHolidayCalendars, { plan: plan.id }); expect(rows).toHaveLength(1); }); }); @@ -272,9 +311,7 @@ describe('updatePlan', () => { await svc.updatePlan(plan.id, { block_weekends: true }, undefined); - const updated = testDb - .prepare('SELECT block_weekends FROM vacay_plans WHERE id = ?') - .get(plan.id) as { block_weekends: number }; + const updated = await storedFields(VacayPlans, { id: plan.id }, ['block_weekends']) as { block_weekends: number }; expect(updated.block_weekends).toBe(1); }); @@ -283,9 +320,7 @@ describe('updatePlan', () => { await svc.updatePlan(plan.id, { holidays_enabled: true }, undefined); - const updated = testDb - .prepare('SELECT holidays_enabled FROM vacay_plans WHERE id = ?') - .get(plan.id) as { holidays_enabled: number }; + const updated = await storedFields(VacayPlans, { id: plan.id }, ['holidays_enabled']) as { holidays_enabled: number }; expect(updated.holidays_enabled).toBe(1); }); @@ -303,15 +338,11 @@ describe('updatePlan', () => { const yr = new Date().getFullYear(); // Manually set a non-zero carried_over value - testDb - .prepare('UPDATE vacay_user_years SET carried_over = 5 WHERE user_id = ? AND plan_id = ? AND year = ?') - .run(user.id, plan.id, yr); + await updateRows(await orm(), VacayUserYears, { user: user.id, plan: plan.id, year: yr }, { carried_over: 5 }); await svc.updatePlan(plan.id, { carry_over_enabled: false }, undefined); - const row = testDb - .prepare('SELECT carried_over FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?') - .get(user.id, plan.id, yr) as { carried_over: number }; + const row = await storedFields(VacayUserYears, { user: user.id, plan: plan.id, year: yr }, ['carried_over']) as { carried_over: number }; expect(row.carried_over).toBe(0); }); }); @@ -321,14 +352,14 @@ describe('updatePlan', () => { describe('addHolidayCalendar', () => { it('validates manual region references for creates and updates', async () => { const { plan } = await setupUserWithPlan(); - testDb.prepare("INSERT INTO school_holiday_countries (code, name) VALUES ('US', 'USA')").run(); - const inserted = testDb.prepare("INSERT INTO school_holiday_regions (country, name) VALUES ('US', 'Seattle')").run(); - const code = `US-MANUAL-${inserted.lastInsertRowid}`; + await insertRow(await orm(), SchoolHolidayCountries, { code: 'US', name: 'USA' }); + const inserted = await insertRow(await orm(), SchoolHolidayRegions, { countryRef: 'US', name: 'Seattle' }); + const code = `US-MANUAL-${inserted}`; const calendar = await svc.addHolidayCalendar(plan.id, code, null, undefined, 0, undefined, 'school_holiday'); expect(calendar.region).toBe(code); expect((await svc.updateHolidayCalendar(calendar.id, plan.id, { label: 'School' }, undefined))?.region).toBe(code); await expect(svc.updateHolidayCalendar(calendar.id, plan.id, { type: 'public_holiday' }, undefined)).rejects.toThrow('Unknown manual'); - for (const region of ['US-MANUAL-0', 'US-MANUAL-999999', `CA-MANUAL-${inserted.lastInsertRowid}`]) { + for (const region of ['US-MANUAL-0', 'US-MANUAL-999999', `CA-MANUAL-${inserted}`]) { await expect(svc.addHolidayCalendar(plan.id, region, null, undefined, 0, undefined, 'school_holiday')).rejects.toThrow('Unknown manual'); } }); @@ -386,7 +417,7 @@ describe('deleteHolidayCalendar', () => { const result = await svc.deleteHolidayCalendar(cal.id, plan.id, undefined); expect(result).toBe(true); - const row = testDb.prepare('SELECT id FROM vacay_holiday_calendars WHERE id = ?').get(cal.id); + const row = await storedFields(VacayHolidayCalendars, { id: cal.id }, ['id']); expect(row).toBeUndefined(); }); @@ -407,9 +438,7 @@ describe('setUserColor', () => { await svc.setUserColor(user.id, plan.id, '#123456', undefined); - const row = testDb - .prepare('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?') - .get(user.id, plan.id) as { color: string } | undefined; + const row = await storedFields(VacayUserColors, { user: user.id, plan: plan.id }, ['color']) as { color: string } | undefined; expect(row?.color).toBe('#123456'); }); @@ -419,9 +448,7 @@ describe('setUserColor', () => { await svc.setUserColor(user.id, plan.id, '#bbbbbb', undefined); - const row = testDb - .prepare('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?') - .get(user.id, plan.id) as { color: string }; + const row = await storedFields(VacayUserColors, { user: user.id, plan: plan.id }, ['color']) as { color: string }; expect(row.color).toBe('#bbbbbb'); }); }); @@ -449,9 +476,7 @@ describe('addYear', () => { const years = await svc.listYears(plan.id); expect(years).toContain(newYear); - const userYear = testDb - .prepare('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?') - .get(user.id, plan.id, newYear) as { vacation_days: number } | undefined; + const userYear = await storedRow(VacayUserYears, { user: user.id, plan: plan.id, year: newYear }) as { vacation_days: number } | undefined; expect(userYear).toBeDefined(); expect(userYear!.vacation_days).toBe(30); }); @@ -462,23 +487,18 @@ describe('addYear', () => { const nextYear = currentYear + 1; // Enable carry-over and seed some entries for the current year - testDb.prepare('UPDATE vacay_plans SET carry_over_enabled = 1 WHERE id = ?').run(plan.id); + await updateRows(await orm(), VacayPlans, { id: plan.id }, { carry_over_enabled: 1 }); // Ensure current year row exists with 10 vacation days - testDb.prepare(` - INSERT OR REPLACE INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) - VALUES (?, ?, ?, 10, 0) - `).run(user.id, plan.id, currentYear); + await upsertRow(await orm(), VacayUserYears, { user: user.id, plan: plan.id, year: currentYear, vacation_days: 10, carried_over: 0 }); // Add 3 entries (used days) in the current year for (let day = 1; day <= 3; day++) { const dateStr = `${currentYear}-06-0${day}`; - testDb.prepare('INSERT OR IGNORE INTO vacay_entries (plan_id, user_id, date, note) VALUES (?, ?, ?, ?)').run(plan.id, user.id, dateStr, ''); + await insertRowIgnoringConflict(await orm(), VacayEntries, { plan: plan.id, user: user.id, date: dateStr, note: '' }); } await svc.addYear(plan.id, nextYear, undefined); - const userYear = testDb - .prepare('SELECT carried_over FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?') - .get(user.id, plan.id, nextYear) as { carried_over: number } | undefined; + const userYear = await storedFields(VacayUserYears, { user: user.id, plan: plan.id, year: nextYear }, ['carried_over']) as { carried_over: number } | undefined; // 10 vacation days - 3 used = 7 carried over expect(userYear?.carried_over).toBe(7); }); @@ -491,20 +511,14 @@ describe('deleteYear', () => { await svc.addYear(plan.id, targetYear, undefined); // Insert an entry for that year - testDb - .prepare('INSERT INTO vacay_entries (plan_id, user_id, date, note) VALUES (?, ?, ?, ?)') - .run(plan.id, user.id, `${targetYear}-07-15`, ''); + await insertRow(await orm(), VacayEntries, { plan: plan.id, user: user.id, date: `${targetYear}-07-15`, note: '' }); await svc.deleteYear(plan.id, targetYear, undefined); - const yearRow = testDb - .prepare('SELECT * FROM vacay_years WHERE plan_id = ? AND year = ?') - .get(plan.id, targetYear); + const yearRow = await storedRow(VacayYears, { plan: plan.id, year: targetYear }); expect(yearRow).toBeUndefined(); - const entries = testDb - .prepare("SELECT * FROM vacay_entries WHERE plan_id = ? AND date LIKE ?") - .all(plan.id, `${targetYear}-%`); + const entries = await storedRows(VacayEntries, { plan: plan.id, date: { $like: `${targetYear}-%` } }); expect(entries).toHaveLength(0); }); @@ -521,20 +535,20 @@ describe('deleteYear', () => { const { user: userA, plan } = await setupUserWithPlan(); const { user: userB } = createUser(testDb); await svc.getOwnPlan(userB.id); - insertMember(plan.id, userB.id, 'accepted'); + await insertMember(plan.id, userB.id, 'accepted'); await svc.updateYearSettings(userB.id, { year_type: 'fiscal', year_start_month: 4, year_start_day: 1 }); await svc.addYear(plan.id, 2026, undefined); - const insertEntry = (userId: number, date: string) => - testDb.prepare('INSERT INTO vacay_entries (plan_id, user_id, date, note) VALUES (?, ?, ?, ?)').run(plan.id, userId, date, ''); - insertEntry(userA.id, '2026-02-10'); // inside A's CALENDAR 2026 window [2026-01-01, 2027-01-01) — must be deleted - insertEntry(userA.id, '2025-11-01'); // inside A's CALENDAR 2025 window — must survive - insertEntry(userB.id, '2026-02-10'); // inside B's FISCAL 2025 window [2025-04-01, 2026-04-01) — must survive - insertEntry(userB.id, '2026-05-10'); // inside B's FISCAL 2026 window [2026-04-01, 2027-04-01) — must be deleted + const insertEntry = async (userId: number, date: string) => + await insertRow(await orm(), VacayEntries, { plan: plan.id, user: userId, date, note: '' }); + await insertEntry(userA.id, '2026-02-10'); // inside A's CALENDAR 2026 window [2026-01-01, 2027-01-01) — must be deleted + await insertEntry(userA.id, '2025-11-01'); // inside A's CALENDAR 2025 window — must survive + await insertEntry(userB.id, '2026-02-10'); // inside B's FISCAL 2025 window [2025-04-01, 2026-04-01) — must survive + await insertEntry(userB.id, '2026-05-10'); // inside B's FISCAL 2026 window [2026-04-01, 2027-04-01) — must be deleted await svc.deleteYear(plan.id, 2026, undefined); - const remaining = testDb.prepare('SELECT user_id, date FROM vacay_entries WHERE plan_id = ? ORDER BY user_id, date').all(plan.id); + const remaining = await storedRows(VacayEntries, { plan: plan.id }, { user: 'asc', date: 'asc' }, ['user_id', 'date']); expect(remaining).toEqual([ { user_id: userA.id, date: '2025-11-01' }, { user_id: userB.id, date: '2026-02-10' }, @@ -559,33 +573,33 @@ describe('shiftOwnerEntriesForTripWindow', () => { ['2026-06-10', '2026-06-20', '2026-06-03', '2026-06-15', '2026-06-08'], // -7 days ]; for (const [oldStart, oldEnd, newStart, entryDate, expectedShifted] of cases) { - testDb.prepare('DELETE FROM vacay_entries WHERE plan_id = ?').run(plan.id); - testDb.prepare('INSERT INTO vacay_entries (plan_id, user_id, date, note) VALUES (?, ?, ?, ?)').run(plan.id, user.id, entryDate, ''); + await deleteRows(await orm(), VacayEntries, { plan: plan.id }); + await insertRow(await orm(), VacayEntries, { plan: plan.id, user: user.id, date: entryDate, note: '' }); await svc.shiftOwnerEntriesForTripWindow(user.id, oldStart, oldEnd, newStart); - const row = testDb.prepare('SELECT date FROM vacay_entries WHERE plan_id = ? AND user_id = ?').get(plan.id, user.id) as { date: string }; + const row = await storedFields(VacayEntries, { plan: plan.id, user: user.id }, ['date']) as { date: string }; expect(row.date).toBe(expectedShifted); } }); it('VACAY-SVC-030c: a zero offset is a no-op (no write at all)', async () => { const { user, plan } = await setupUserWithPlan(); - testDb.prepare('INSERT INTO vacay_entries (plan_id, user_id, date, note) VALUES (?, ?, ?, ?)').run(plan.id, user.id, '2026-05-05', ''); + await insertRow(await orm(), VacayEntries, { plan: plan.id, user: user.id, date: '2026-05-05', note: '' }); await svc.shiftOwnerEntriesForTripWindow(user.id, '2026-05-01', '2026-05-10', '2026-05-01'); - const row = testDb.prepare('SELECT date FROM vacay_entries WHERE plan_id = ? AND user_id = ?').get(plan.id, user.id) as { date: string }; + const row = await storedFields(VacayEntries, { plan: plan.id, user: user.id }, ['date']) as { date: string }; expect(row.date).toBe('2026-05-05'); }); it('VACAY-SVC-030d (M2 parity): an unparseable start (garbage) resolves as a no-op, same as legacy\'s NULL-julianday offset of 0, instead of writing a NaN date', async () => { const { user, plan } = await setupUserWithPlan(); - testDb.prepare('INSERT INTO vacay_entries (plan_id, user_id, date, note) VALUES (?, ?, ?, ?)').run(plan.id, user.id, '2026-05-05', ''); + await insertRow(await orm(), VacayEntries, { plan: plan.id, user: user.id, date: '2026-05-05', note: '' }); await expect(svc.shiftOwnerEntriesForTripWindow(user.id, '2026-05-01', '2026-05-10', 'soon')).resolves.toBeUndefined(); - const row = testDb.prepare('SELECT date FROM vacay_entries WHERE plan_id = ? AND user_id = ?').get(plan.id, user.id) as { date: string }; + const row = await storedFields(VacayEntries, { plan: plan.id, user: user.id }, ['date']) as { date: string }; expect(row.date).toBe('2026-05-05'); }); @@ -594,11 +608,11 @@ describe('shiftOwnerEntriesForTripWindow', () => { // (2 days 23.5 hours, truncated toward zero) — a local-time Date.parse + // Math.round of the same pair computed 3 instead (task-7-review.md M2). const { user, plan } = await setupUserWithPlan(); - testDb.prepare('INSERT INTO vacay_entries (plan_id, user_id, date, note) VALUES (?, ?, ?, ?)').run(plan.id, user.id, '2025-06-15', ''); + await insertRow(await orm(), VacayEntries, { plan: plan.id, user: user.id, date: '2025-06-15', note: '' }); await svc.shiftOwnerEntriesForTripWindow(user.id, '2025-06-10', '2025-06-20', '2025-06-12T23:30'); - const row = testDb.prepare('SELECT date FROM vacay_entries WHERE plan_id = ? AND user_id = ?').get(plan.id, user.id) as { date: string }; + const row = await storedFields(VacayEntries, { plan: plan.id, user: user.id }, ['date']) as { date: string }; expect(row.date).toBe('2025-06-17'); }); @@ -610,27 +624,27 @@ describe('shiftOwnerEntriesForTripWindow', () => { // (task-7-review.md M1). Inserting the latest date first makes the id // order run opposite the date order, so the two orderings disagree. const { user, plan } = await setupUserWithPlan(); - const insertEntry = (date: string) => - testDb.prepare('INSERT INTO vacay_entries (plan_id, user_id, date, note) VALUES (?, ?, ?, ?)').run(plan.id, user.id, date, ''); - const readDates = () => - (testDb.prepare('SELECT date FROM vacay_entries WHERE plan_id = ? AND user_id = ? ORDER BY date').all(plan.id, user.id) as { date: string }[]).map( + const insertEntry = async (date: string) => + await insertRow(await orm(), VacayEntries, { plan: plan.id, user: user.id, date, note: '' }); + const readDates = async () => + (await storedRows(VacayEntries, { plan: plan.id, user: user.id }, { date: 'asc' }, ['date']) as { date: string }[]).map( (r) => r.date, ); - insertEntry('2026-07-12'); - insertEntry('2026-07-11'); - insertEntry('2026-07-10'); + await insertEntry('2026-07-12'); + await insertEntry('2026-07-11'); + await insertEntry('2026-07-10'); await svc.shiftOwnerEntriesForTripWindow(user.id, '2026-07-01', '2026-07-20', '2026-07-02'); // offset +1 - expect(readDates()).toEqual(['2026-07-11', '2026-07-12', '2026-07-13']); + expect((await readDates())).toEqual(['2026-07-11', '2026-07-12', '2026-07-13']); - testDb.prepare('DELETE FROM vacay_entries WHERE plan_id = ?').run(plan.id); - insertEntry('2026-07-12'); - insertEntry('2026-07-11'); - insertEntry('2026-07-10'); + await deleteRows(await orm(), VacayEntries, { plan: plan.id }); + await insertEntry('2026-07-12'); + await insertEntry('2026-07-11'); + await insertEntry('2026-07-10'); await svc.shiftOwnerEntriesForTripWindow(user.id, '2026-07-02', '2026-07-21', '2026-07-01'); // offset -1 - expect(readDates()).toEqual(['2026-07-09', '2026-07-11', '2026-07-12']); + expect((await readDates())).toEqual(['2026-07-09', '2026-07-11', '2026-07-12']); }); }); @@ -653,13 +667,13 @@ describe('getEntries', () => { // exercise the COALESCE(c.color, '#6366f1') fallback branch too — rule // 19's "fully seeded" bar for this read model. const { user: owner, plan } = await setupUserWithPlan(); - allowWeekends(plan.id); + await allowWeekends(plan.id); const { user: userB } = createUser(testDb); const { user: userC } = createUser(testDb); await svc.getOwnPlan(userB.id); await svc.getOwnPlan(userC.id); - insertMember(plan.id, userB.id, 'accepted'); - insertMember(plan.id, userC.id, 'accepted'); + await insertMember(plan.id, userB.id, 'accepted'); + await insertMember(plan.id, userC.id, 'accepted'); await svc.updateYearSettings(userB.id, { year_type: 'fiscal', year_start_month: 4, year_start_day: 1 }); await svc.updateYearSettings(userC.id, { year_type: 'anniversary', hire_date: '2020-06-15' }); await svc.setUserColor(owner.id, plan.id, '#111111', undefined); @@ -674,6 +688,7 @@ describe('getEntries', () => { const result = await svc.getEntries(plan.id, '2026', owner.id); + // test-sql-allow: legacy read-model oracle, recomputed from raw SQL on purpose. const legacy = testDb.prepare(` SELECT e.*, u.username as person_name, COALESCE(c.color, '#6366f1') as person_color FROM vacay_entries e @@ -695,9 +710,7 @@ describe('toggleEntry', () => { const result = await svc.toggleEntry(user.id, plan.id, '2025-08-01', undefined); expect(result.action).toBe('added'); - const row = testDb - .prepare('SELECT * FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date = ?') - .get(user.id, plan.id, '2025-08-01'); + const row = await storedRow(VacayEntries, { user: user.id, plan: plan.id, date: '2025-08-01' }); expect(row).toBeDefined(); }); @@ -708,9 +721,7 @@ describe('toggleEntry', () => { const result = await svc.toggleEntry(user.id, plan.id, '2025-08-04', undefined); expect(result.action).toBe('removed'); - const row = testDb - .prepare('SELECT * FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date = ?') - .get(user.id, plan.id, '2025-08-04'); + const row = await storedRow(VacayEntries, { user: user.id, plan: plan.id, date: '2025-08-04' }); expect(row).toBeUndefined(); }); @@ -720,9 +731,7 @@ describe('toggleEntry', () => { const result = await svc.toggleEntry(user.id, plan.id, '2025-08-05', 0.5); expect(result).toMatchObject({ action: 'added', fraction: 0.5 }); - const row = testDb - .prepare('SELECT fraction FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date = ?') - .get(user.id, plan.id, '2025-08-05') as { fraction: number }; + const row = await storedFields(VacayEntries, { user: user.id, plan: plan.id, date: '2025-08-05' }, ['fraction']) as { fraction: number }; expect(row.fraction).toBe(0.5); }); @@ -733,9 +742,7 @@ describe('toggleEntry', () => { const result = await svc.toggleEntry(user.id, plan.id, '2025-08-06', 0.5); expect(result).toMatchObject({ action: 'updated', fraction: 0.5 }); - const row = testDb - .prepare('SELECT fraction FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date = ?') - .get(user.id, plan.id, '2025-08-06') as { fraction: number }; + const row = await storedFields(VacayEntries, { user: user.id, plan: plan.id, date: '2025-08-06' }, ['fraction']) as { fraction: number }; expect(row.fraction).toBe(0.5); }); @@ -746,9 +753,7 @@ describe('toggleEntry', () => { const result = await svc.toggleEntry(user.id, plan.id, '2025-08-07', 0.5); expect(result.action).toBe('removed'); - const row = testDb - .prepare('SELECT id FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date = ?') - .get(user.id, plan.id, '2025-08-07'); + const row = await storedFields(VacayEntries, { user: user.id, plan: plan.id, date: '2025-08-07' }, ['id']); expect(row).toBeUndefined(); }); @@ -759,9 +764,7 @@ describe('toggleEntry', () => { const result = await svc.toggleEntry(user.id, plan.id, '2025-07-19', undefined); // Saturday expect(result).toEqual({ error: 'weekend_blocked' }); - const row = testDb - .prepare('SELECT id FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date = ?') - .get(user.id, plan.id, '2025-07-19'); + const row = await storedFields(VacayEntries, { user: user.id, plan: plan.id, date: '2025-07-19' }, ['id']); expect(row).toBeUndefined(); }); @@ -775,7 +778,7 @@ describe('toggleEntry', () => { it('VACAY-SVC-033f: accepts a weekend day when block_weekends is off', async () => { const { user, plan } = await setupUserWithPlan(); - testDb.prepare('UPDATE vacay_plans SET block_weekends = 0 WHERE id = ?').run(plan.id); + await updateRows(await orm(), VacayPlans, { id: plan.id }, { block_weekends: 0 }); const result = await svc.toggleEntry(user.id, plan.id, '2025-07-19', undefined); // Saturday @@ -784,7 +787,7 @@ describe('toggleEntry', () => { it('VACAY-SVC-033g: honours custom weekend_days (5,6 blocks Friday, frees Sunday)', async () => { const { user, plan } = await setupUserWithPlan(); - testDb.prepare("UPDATE vacay_plans SET weekend_days = '5,6' WHERE id = ?").run(plan.id); + await updateRows(await orm(), VacayPlans, { id: plan.id }, { weekend_days: '5,6' }); expect(await svc.toggleEntry(user.id, plan.id, '2025-07-18', undefined)).toEqual({ error: 'weekend_blocked' }); // Friday expect(await svc.toggleEntry(user.id, plan.id, '2025-07-20', undefined)).toMatchObject({ action: 'added' }); // Sunday @@ -792,38 +795,30 @@ describe('toggleEntry', () => { it('VACAY-SVC-033h: a NULL weekend_days column falls back to Sat/Sun', async () => { const { user, plan } = await setupUserWithPlan(); - testDb.prepare('UPDATE vacay_plans SET weekend_days = NULL WHERE id = ?').run(plan.id); + await updateRows(await orm(), VacayPlans, { id: plan.id }, { weekend_days: null }); expect(await svc.toggleEntry(user.id, plan.id, '2025-07-19', undefined)).toEqual({ error: 'weekend_blocked' }); // Saturday }); it('VACAY-SVC-033i: still removes an existing entry on a blocked day (stray-data cleanup)', async () => { const { user, plan } = await setupUserWithPlan(); - testDb - .prepare('INSERT INTO vacay_entries (plan_id, user_id, date, note, fraction, kind) VALUES (?, ?, ?, ?, ?, ?)') - .run(plan.id, user.id, '2025-07-19', '', 1, 'vacation'); + await insertRow(await orm(), VacayEntries, { plan: plan.id, user: user.id, date: '2025-07-19', note: '', fraction: 1, kind: 'vacation' }); const result = await svc.toggleEntry(user.id, plan.id, '2025-07-19', 1, 'vacation'); expect(result.action).toBe('removed'); - const row = testDb - .prepare('SELECT id FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date = ?') - .get(user.id, plan.id, '2025-07-19'); + const row = await storedFields(VacayEntries, { user: user.id, plan: plan.id, date: '2025-07-19' }, ['id']); expect(row).toBeUndefined(); }); it('VACAY-SVC-033j: refuses to convert an existing entry in place on a blocked day', async () => { const { user, plan } = await setupUserWithPlan(); - testDb - .prepare('INSERT INTO vacay_entries (plan_id, user_id, date, note, fraction, kind) VALUES (?, ?, ?, ?, ?, ?)') - .run(plan.id, user.id, '2025-07-19', '', 0.5, 'vacation'); + await insertRow(await orm(), VacayEntries, { plan: plan.id, user: user.id, date: '2025-07-19', note: '', fraction: 0.5, kind: 'vacation' }); const result = await svc.toggleEntry(user.id, plan.id, '2025-07-19', 1, 'vacation'); expect(result).toEqual({ error: 'weekend_blocked' }); - const row = testDb - .prepare('SELECT fraction FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date = ?') - .get(user.id, plan.id, '2025-07-19') as { fraction: number }; + const row = await storedFields(VacayEntries, { user: user.id, plan: plan.id, date: '2025-07-19' }, ['fraction']) as { fraction: number }; expect(row.fraction).toBe(0.5); }); }); @@ -837,9 +832,7 @@ describe('toggleCompanyHoliday', () => { const result = await svc.toggleCompanyHoliday(plan.id, '2025-12-25', 'Christmas', undefined); expect(result.action).toBe('added'); - const row = testDb - .prepare('SELECT * FROM vacay_company_holidays WHERE plan_id = ? AND date = ?') - .get(plan.id, '2025-12-25'); + const row = await storedRow(VacayCompanyHolidays, { plan: plan.id, date: '2025-12-25' }); expect(row).toBeDefined(); }); @@ -850,9 +843,7 @@ describe('toggleCompanyHoliday', () => { const result = await svc.toggleCompanyHoliday(plan.id, '2025-12-26', undefined, undefined); expect(result.action).toBe('removed'); - const row = testDb - .prepare('SELECT * FROM vacay_company_holidays WHERE plan_id = ? AND date = ?') - .get(plan.id, '2025-12-26'); + const row = await storedRow(VacayCompanyHolidays, { plan: plan.id, date: '2025-12-26' }); expect(row).toBeUndefined(); }); @@ -865,25 +856,23 @@ describe('toggleCompanyHoliday', () => { // Now declare it a company holiday — the personal entry should be wiped await svc.toggleCompanyHoliday(plan.id, '2025-05-01', 'Labour Day', undefined); - const personalEntry = testDb - .prepare('SELECT * FROM vacay_entries WHERE plan_id = ? AND date = ?') - .get(plan.id, '2025-05-01'); + const personalEntry = await storedRow(VacayEntries, { plan: plan.id, date: '2025-05-01' }); expect(personalEntry).toBeUndefined(); }); }); describe('half company holidays (#2439)', () => { - const entryOf = (planId: number, date: string) => - testDb.prepare('SELECT fraction FROM vacay_entries WHERE plan_id = ? AND date = ?').get(planId, date) as { fraction: number } | undefined; - const holidayOf = (planId: number, date: string) => - testDb.prepare('SELECT fraction FROM vacay_company_holidays WHERE plan_id = ? AND date = ?').get(planId, date) as { fraction: number } | undefined; + const entryOf = async (planId: number, date: string) => + await storedFields(VacayEntries, { plan: planId, date }, ['fraction']) as { fraction: number } | undefined; + const holidayOf = async (planId: number, date: string) => + await storedFields(VacayCompanyHolidays, { plan: planId, date }, ['fraction']) as { fraction: number } | undefined; it('VACAY-SVC-036b: a half company holiday halves a whole vacation day instead of wiping it', async () => { const { user, plan } = await setupUserWithPlan(); await svc.toggleEntry(user.id, plan.id, '2025-12-24', 1); expect(await svc.toggleCompanyHoliday(plan.id, '2025-12-24', 'Christmas Eve', undefined, 0.5)).toEqual({ action: 'added', fraction: 0.5 }); - expect(entryOf(plan.id, '2025-12-24')?.fraction).toBe(0.5); - expect(holidayOf(plan.id, '2025-12-24')?.fraction).toBe(0.5); + expect((await entryOf(plan.id, '2025-12-24'))?.fraction).toBe(0.5); + expect((await holidayOf(plan.id, '2025-12-24'))?.fraction).toBe(0.5); }); it('VACAY-SVC-036c: the other size converts the holiday, the same size clears it', async () => { @@ -891,14 +880,14 @@ describe('half company holidays (#2439)', () => { await svc.toggleCompanyHoliday(plan.id, '2025-12-31', undefined, undefined, 0.5); await svc.toggleEntry(user.id, plan.id, '2025-12-31', 0.5); expect(await svc.toggleCompanyHoliday(plan.id, '2025-12-31', undefined, undefined, 1)).toEqual({ action: 'updated', fraction: 1 }); - expect(entryOf(plan.id, '2025-12-31')).toBeUndefined(); + expect((await entryOf(plan.id, '2025-12-31'))).toBeUndefined(); expect(await svc.toggleCompanyHoliday(plan.id, '2025-12-31', undefined, undefined, 1)).toEqual({ action: 'removed' }); - expect(holidayOf(plan.id, '2025-12-31')).toBeUndefined(); + expect((await holidayOf(plan.id, '2025-12-31'))).toBeUndefined(); }); it('VACAY-SVC-036d: leave on a half company holiday is half a day, whatever was asked', async () => { const { user, plan } = await setupUserWithPlan(); - testDb.prepare('UPDATE vacay_plans SET company_holidays_enabled = 1 WHERE id = ?').run(plan.id); + await updateRows(await orm(), VacayPlans, { id: plan.id }, { company_holidays_enabled: 1 }); await svc.toggleCompanyHoliday(plan.id, '2025-12-24', undefined, undefined, 0.5); expect(await svc.toggleEntry(user.id, plan.id, '2025-12-24', 1)).toMatchObject({ action: 'added', fraction: 0.5 }); }); @@ -911,14 +900,12 @@ describe('acceptInvite', () => { const { user: owner, plan: ownerPlan } = await setupUserWithPlan(); const { user: invitee } = createUser(testDb); await svc.getOwnPlan(invitee.id); // ensure own plan exists for data migration path - insertMember(ownerPlan.id, invitee.id, 'pending'); + await insertMember(ownerPlan.id, invitee.id, 'pending'); const result = await svc.acceptInvite(invitee.id, ownerPlan.id, undefined); expect(result.error).toBeUndefined(); - const row = testDb - .prepare('SELECT status FROM vacay_plan_members WHERE plan_id = ? AND user_id = ?') - .get(ownerPlan.id, invitee.id) as { status: string } | undefined; + const row = await storedFields(VacayPlanMembers, { plan: ownerPlan.id, user: invitee.id }, ['status']) as { status: string } | undefined; expect(row?.status).toBe('accepted'); }); @@ -935,7 +922,7 @@ describe('acceptInvite', () => { const { user: owner, plan: ownerPlan } = await setupUserWithPlan(); const { user: invitee } = createUser(testDb); await svc.getOwnPlan(invitee.id); - insertMember(ownerPlan.id, invitee.id, 'pending'); + await insertMember(ownerPlan.id, invitee.id, 'pending'); await svc.acceptInvite(invitee.id, ownerPlan.id, undefined); @@ -948,13 +935,11 @@ describe('declineInvite', () => { it('VACAY-SVC-040: removes the pending invite row', async () => { const { user: owner, plan: ownerPlan } = await setupUserWithPlan(); const { user: invitee } = createUser(testDb); - insertMember(ownerPlan.id, invitee.id, 'pending'); + await insertMember(ownerPlan.id, invitee.id, 'pending'); await svc.declineInvite(invitee.id, ownerPlan.id, undefined); - const row = testDb - .prepare('SELECT * FROM vacay_plan_members WHERE plan_id = ? AND user_id = ?') - .get(ownerPlan.id, invitee.id); + const row = await storedRow(VacayPlanMembers, { plan: ownerPlan.id, user: invitee.id }); expect(row).toBeUndefined(); }); }); @@ -963,13 +948,11 @@ describe('cancelInvite', () => { it('VACAY-SVC-041: removes the pending invite when owner cancels it', async () => { const { user: owner, plan: ownerPlan } = await setupUserWithPlan(); const { user: target } = createUser(testDb); - insertMember(ownerPlan.id, target.id, 'pending'); + await insertMember(ownerPlan.id, target.id, 'pending'); await svc.cancelInvite(ownerPlan.id, target.id); - const row = testDb - .prepare('SELECT * FROM vacay_plan_members WHERE plan_id = ? AND user_id = ?') - .get(ownerPlan.id, target.id); + const row = await storedRow(VacayPlanMembers, { plan: ownerPlan.id, user: target.id }); expect(row).toBeUndefined(); }); }); @@ -993,7 +976,7 @@ describe('getAvailableUsers', () => { const { user: owner, plan } = await setupUserWithPlan(); const { user: alreadyFused } = createUser(testDb); const { plan: otherPlan } = await setupUserWithPlan(); - insertMember(otherPlan.id, alreadyFused.id, 'accepted'); + await insertMember(otherPlan.id, alreadyFused.id, 'accepted'); const available = await svc.getAvailableUsers(owner.id, plan.id) as { id: number }[]; @@ -1006,7 +989,7 @@ describe('getAvailableUsers', () => { it('VACAY-SVC-073: guest accounts are not offered in the plan invite picker', async () => { const { user: owner, plan } = await setupUserWithPlan(); const { user: guest } = createUser(testDb); - 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 available = await svc.getAvailableUsers(owner.id, plan.id) as { id: number }[]; @@ -1016,7 +999,7 @@ describe('getAvailableUsers', () => { it('VACAY-SVC-074: guest accounts are not offered in the shared-calendar picker', async () => { const { user: owner } = await setupUserWithPlan(); const { user: guest } = createUser(testDb); - 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 available = await svc.getShareAvailableUsers(owner.id) as { id: number }[]; @@ -1026,7 +1009,7 @@ describe('getAvailableUsers', () => { it('VACAY-SVC-075: a guest id sent straight to the write paths is refused', async () => { const { user: owner, plan } = await setupUserWithPlan(); const { user: guest } = createUser(testDb); - testDb.prepare('UPDATE users SET is_guest = 1 WHERE id = ?').run(guest.id); + await updateRows(await orm(), Users, { id: guest.id }, { is_guest: 1 }); // The picker is only a list. The id comes back from the client, and the MCP // tools reach the same two methods, so refusing has to happen here. @@ -1064,7 +1047,7 @@ describe('getStats', () => { // n` coercion without lying about the row on the wire. const { user, plan } = await setupUserWithPlan(); const yr = new Date().getFullYear(); - testDb.prepare('UPDATE vacay_user_years SET vacation_days = NULL, carried_over = NULL WHERE user_id = ? AND plan_id = ? AND year = ?').run(user.id, plan.id, yr); + await updateRows(await orm(), VacayUserYears, { user: user.id, plan: plan.id, year: yr }, { vacation_days: null, carried_over: null }); const stats = await svc.getStats(plan.id, yr); @@ -1078,7 +1061,7 @@ describe('getStats', () => { const { user, plan } = await setupUserWithPlan(); const yr = new Date().getFullYear(); - allowWeekends(plan.id); + await allowWeekends(plan.id); await svc.toggleEntry(user.id, plan.id, `${yr}-09-10`, undefined); await svc.toggleEntry(user.id, plan.id, `${yr}-09-11`, undefined); @@ -1092,7 +1075,7 @@ describe('getStats', () => { const { user, plan } = await setupUserWithPlan(); const yr = new Date().getFullYear(); - allowWeekends(plan.id); + await allowWeekends(plan.id); await svc.toggleEntry(user.id, plan.id, `${yr}-09-12`, 1); // full day await svc.toggleEntry(user.id, plan.id, `${yr}-09-13`, 0.5); // half day @@ -1106,7 +1089,7 @@ describe('getStats', () => { const { user, plan } = await setupUserWithPlan(); const yr = new Date().getFullYear(); - allowWeekends(plan.id); + await allowWeekends(plan.id); await svc.toggleEntry(user.id, plan.id, `${yr}-09-14`, 1, 'vacation'); await svc.toggleEntry(user.id, plan.id, `${yr}-09-15`, 1, 'comp'); @@ -1120,7 +1103,7 @@ describe('getStats', () => { const { user, plan } = await setupUserWithPlan(); const yr = new Date().getFullYear(); - allowWeekends(plan.id); + await allowWeekends(plan.id); await svc.toggleEntry(user.id, plan.id, `${yr}-09-16`, 0.5, 'comp'); const stats = await svc.getStats(plan.id, yr); @@ -1133,7 +1116,7 @@ describe('getStats', () => { const { user, plan } = await setupUserWithPlan(); const yr = new Date().getFullYear(); - allowWeekends(plan.id); + await allowWeekends(plan.id); await svc.toggleEntry(user.id, plan.id, `${yr}-09-17`, 1, 'comp'); await svc.toggleEntry(user.id, plan.id, `${yr}-09-18`, 0.5, 'comp'); await svc.toggleEntry(user.id, plan.id, `${yr}-09-19`, 1, 'vacation'); @@ -1148,7 +1131,7 @@ describe('getStats', () => { const { user, plan } = await setupUserWithPlan(); const yr = new Date().getFullYear(); - allowWeekends(plan.id); + await allowWeekends(plan.id); await svc.toggleEntry(user.id, plan.id, `${yr}-09-20`, 1, 'vacation'); expect((await svc.getStats(plan.id, yr))[0].used).toBe(1); @@ -1259,7 +1242,7 @@ describe('updateUserYearSettings', () => { const saved = await svc.updateYearSettings(user.id, { year_type: 'calendar' }); expect(saved.year_type).toBe('calendar'); - expect(testDb.prepare('SELECT COUNT(*) AS n FROM vacay_user_settings WHERE user_id = ?').get(user.id)).toEqual({ n: 1 }); + expect({ n: await countRows(await orm(), VacayUserSettings, { user: user.id }) }).toEqual({ n: 1 }); }); it('VACAY-SVC-045n: clamps an out-of-range month and day instead of storing them', async () => { @@ -1340,11 +1323,8 @@ describe('usage over a shifted window (#737)', () => { it('VACAY-SVC-045v: carry-over is computed over the previous period, not the previous calendar year', async () => { const { user, plan } = await setupUserWithPlan(); await svc.updateYearSettings(user.id, { year_type: 'fiscal', year_start_month: 7 }); - testDb.prepare('UPDATE vacay_plans SET carry_over_enabled = 1 WHERE id = ?').run(plan.id); - testDb.prepare(` - INSERT OR REPLACE INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) - VALUES (?, ?, 2030, 10, 0) - `).run(user.id, plan.id); + await updateRows(await orm(), VacayPlans, { id: plan.id }, { carry_over_enabled: 1 }); + await upsertRow(await orm(), VacayUserYears, { user: user.id, plan: plan.id, year: 2030, vacation_days: 10, carried_over: 0 }); // Two days inside the 2030 period (Jul 2030 – Jun 2031), one of them in 2031. await svc.toggleEntry(user.id, plan.id, '2030-09-02', 1, 'vacation'); @@ -1352,28 +1332,21 @@ describe('usage over a shifted window (#737)', () => { await svc.addYear(plan.id, 2031, undefined); - const row = testDb - .prepare('SELECT carried_over FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = 2031') - .get(user.id, plan.id) as { carried_over: number }; + const row = await storedFields(VacayUserYears, { user: user.id, plan: plan.id, year: 2031 }, ['carried_over']) as { carried_over: number }; expect(row.carried_over).toBe(8); }); it('VACAY-SVC-045w: comp days are excluded from the carry-over of a shifted period too', async () => { const { user, plan } = await setupUserWithPlan(); await svc.updateYearSettings(user.id, { year_type: 'fiscal', year_start_month: 7 }); - testDb.prepare('UPDATE vacay_plans SET carry_over_enabled = 1 WHERE id = ?').run(plan.id); - testDb.prepare(` - INSERT OR REPLACE INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) - VALUES (?, ?, 2030, 10, 0) - `).run(user.id, plan.id); + await updateRows(await orm(), VacayPlans, { id: plan.id }, { carry_over_enabled: 1 }); + await upsertRow(await orm(), VacayUserYears, { user: user.id, plan: plan.id, year: 2030, vacation_days: 10, carried_over: 0 }); await svc.toggleEntry(user.id, plan.id, '2031-02-03', 1, 'comp'); await svc.addYear(plan.id, 2031, undefined); - const row = testDb - .prepare('SELECT carried_over FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = 2031') - .get(user.id, plan.id) as { carried_over: number }; + const row = await storedFields(VacayUserYears, { user: user.id, plan: plan.id, year: 2031 }, ['carried_over']) as { carried_over: number }; expect(row.carried_over).toBe(10); }); @@ -1388,9 +1361,7 @@ describe('usage over a shifted window (#737)', () => { await svc.deleteYear(plan.id, 2026, undefined); - const left = testDb - .prepare('SELECT date FROM vacay_entries WHERE plan_id = ? ORDER BY date') - .all(plan.id) as { date: string }[]; + const left = await storedRows(VacayEntries, { plan: plan.id }, { date: 'asc' }, ['date']) as { date: string }[]; expect(left.map(r => r.date)).toEqual(['2026-06-30']); }); }); @@ -1450,14 +1421,12 @@ describe('applyHolidayCalendars', () => { const yr = new Date().getFullYear(); // Enable holidays and add a calendar - testDb.prepare('UPDATE vacay_plans SET holidays_enabled = 1 WHERE id = ?').run(plan.id); + await updateRows(await orm(), VacayPlans, { id: plan.id }, { holidays_enabled: 1 }); await svc.addHolidayCalendar(plan.id, 'DE', null, undefined, 0, undefined); // Add a vacay entry on the holiday date const holidayDate = `${yr}-01-01`; - testDb - .prepare('INSERT INTO vacay_entries (plan_id, user_id, date, note) VALUES (?, ?, ?, ?)') - .run(plan.id, user.id, holidayDate, ''); + await insertRow(await orm(), VacayEntries, { plan: plan.id, user: user.id, date: holidayDate, note: '' }); // Override fetch to return one global holiday matching that entry vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ @@ -1467,15 +1436,13 @@ describe('applyHolidayCalendars', () => { await svc.applyHolidayCalendars(plan.id); - const remaining = testDb - .prepare('SELECT * FROM vacay_entries WHERE plan_id = ? AND date = ?') - .all(plan.id, holidayDate); + const remaining = await storedRows(VacayEntries, { plan: plan.id, date: holidayDate }); expect(remaining).toHaveLength(0); }); it('VACAY-SVC-047b: a holiday API that does not answer is skipped, a failing delete is not', async () => { const { plan } = await setupUserWithPlan(); - testDb.prepare('UPDATE vacay_plans SET holidays_enabled = 1 WHERE id = ?').run(plan.id); + await updateRows(await orm(), VacayPlans, { id: plan.id }, { holidays_enabled: 1 }); await svc.addHolidayCalendar(plan.id, 'FR', null, undefined, 0, undefined); vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('fetch failed'))); @@ -1499,9 +1466,7 @@ describe('shareCalendar', () => { const result = await svc.shareCalendar(owner.id, owner.email, target.id); expect(result.error).toBeUndefined(); - const row = testDb - .prepare('SELECT * FROM vacay_shares WHERE owner_id = ? AND user_id = ?') - .get(owner.id, target.id); + const row = await storedRow(VacayShares, { owner: owner.id, user: target.id }); expect(row).toBeDefined(); }); @@ -1534,7 +1499,7 @@ describe('shareCalendar', () => { it('VACAY-SVC-052: returns 400 when the target is already a member of the owner plan', async () => { const { user: owner, plan } = await setupUserWithPlan(); const { user: member } = createUser(testDb); - insertMember(plan.id, member.id, 'accepted'); + await insertMember(plan.id, member.id, 'accepted'); const result = await svc.shareCalendar(owner.id, owner.email, member.id); @@ -1603,7 +1568,7 @@ describe('removeShare', () => { const shareId = (await svc.listShares(owner.id)).outgoing[0].id as number; expect(await svc.removeShare(shareId, owner.id)).toBe(true); - const row = testDb.prepare('SELECT id FROM vacay_shares WHERE id = ?').get(shareId); + const row = await storedFields(VacayShares, { id: shareId }, ['id']); expect(row).toBeUndefined(); }); @@ -1624,7 +1589,7 @@ describe('removeShare', () => { const shareId = (await svc.listShares(owner.id)).outgoing[0].id as number; expect(await svc.removeShare(shareId, stranger.id)).toBe(false); - const row = testDb.prepare('SELECT id FROM vacay_shares WHERE id = ?').get(shareId); + const row = await storedFields(VacayShares, { id: shareId }, ['id']); expect(row).toBeDefined(); expect(await svc.removeShare(99999, owner.id)).toBe(false); @@ -1639,12 +1604,12 @@ describe('setShareHidden', () => { const shareId = (await svc.listShares(viewer.id)).incoming[0].id; expect(await svc.setShareHidden(shareId, viewer.id, true)).toBe(true); - let row = testDb.prepare('SELECT hidden FROM vacay_shares WHERE id = ?').get(shareId) as { hidden: number }; + let row = await storedFields(VacayShares, { id: shareId }, ['hidden']) as { hidden: number }; expect(row.hidden).toBe(1); expect((await svc.listShares(viewer.id)).incoming[0].hidden).toBe(true); expect(await svc.setShareHidden(shareId, viewer.id, false)).toBe(true); - row = testDb.prepare('SELECT hidden FROM vacay_shares WHERE id = ?').get(shareId) as { hidden: number }; + row = await storedFields(VacayShares, { id: shareId }, ['hidden']) as { hidden: number }; expect(row.hidden).toBe(0); }); @@ -1655,7 +1620,7 @@ describe('setShareHidden', () => { const shareId = (await svc.listShares(owner.id)).outgoing[0].id as number; expect(await svc.setShareHidden(shareId, owner.id, true)).toBe(false); - const row = testDb.prepare('SELECT hidden FROM vacay_shares WHERE id = ?').get(shareId) as { hidden: number }; + const row = await storedFields(VacayShares, { id: shareId }, ['hidden']) as { hidden: number }; expect(row.hidden).toBe(0); }); }); @@ -1664,7 +1629,7 @@ describe('getShareAvailableUsers', () => { it('VACAY-SVC-061: excludes self, already-shared users and plan members', async () => { const { user: owner, plan } = await setupUserWithPlan(); const { user: member } = createUser(testDb); - insertMember(plan.id, member.id, 'accepted'); + await insertMember(plan.id, member.id, 'accepted'); const { user: shared } = createUser(testDb); await svc.shareCalendar(owner.id, owner.email, shared.id); const { user: unrelated } = createUser(testDb); @@ -1682,7 +1647,7 @@ describe('getSharedCalendars', () => { it('VACAY-SVC-062: returns only the owner entries of the shared plan, including fractions', async () => { const { user: owner, plan } = await setupUserWithPlan(); const { user: member } = createUser(testDb); - insertMember(plan.id, member.id, 'accepted'); + await insertMember(plan.id, member.id, 'accepted'); const { user: viewer } = await setupUserWithPlan(); await svc.toggleEntry(owner.id, plan.id, '2025-06-10', 1); await svc.toggleEntry(owner.id, plan.id, '2025-06-11', 0.5); @@ -1704,7 +1669,7 @@ describe('getSharedCalendars', () => { it('VACAY-SVC-063: company holidays stay hidden while the owner plan has them disabled', async () => { const { user: owner, plan } = await setupUserWithPlan(); const { user: viewer } = createUser(testDb); - testDb.prepare('UPDATE vacay_plans SET company_holidays_enabled = 0 WHERE id = ?').run(plan.id); + await updateRows(await orm(), VacayPlans, { id: plan.id }, { company_holidays_enabled: 0 }); await svc.toggleCompanyHoliday(plan.id, '2025-12-24', 'Christmas Eve', undefined); await svc.shareCalendar(owner.id, owner.email, viewer.id); @@ -1716,7 +1681,7 @@ describe('getSharedCalendars', () => { it('VACAY-SVC-064: company holidays appear once the owner plan enables them', async () => { const { user: owner, plan } = await setupUserWithPlan(); const { user: viewer } = createUser(testDb); - testDb.prepare('UPDATE vacay_plans SET company_holidays_enabled = 1 WHERE id = ?').run(plan.id); + await updateRows(await orm(), VacayPlans, { id: plan.id }, { company_holidays_enabled: 1 }); await svc.toggleCompanyHoliday(plan.id, '2025-12-24', 'Christmas Eve', undefined); await svc.shareCalendar(owner.id, owner.email, viewer.id); @@ -1728,14 +1693,14 @@ describe('getSharedCalendars', () => { it('VACAY-SVC-065: an owner without any plan yields empty arrays (no lazy creation)', async () => { const { user: owner } = createUser(testDb); // never touched vacay — no plan row const { user: viewer } = createUser(testDb); - testDb.prepare('INSERT INTO vacay_shares (owner_id, user_id) VALUES (?, ?)').run(owner.id, viewer.id); + await insertRow(await orm(), VacayShares, { owner: owner.id, user: viewer.id }); const calendars = await svc.getSharedCalendars(viewer.id, '2025'); expect(calendars).toHaveLength(1); expect(calendars[0].entries).toEqual([]); expect(calendars[0].companyHolidays).toEqual([]); - const plan = testDb.prepare('SELECT id FROM vacay_plans WHERE owner_id = ?').get(owner.id); + const plan = await storedFields(VacayPlans, { owner: owner.id }, ['id']); expect(plan).toBeUndefined(); }); @@ -1743,7 +1708,7 @@ describe('getSharedCalendars', () => { const { user: host, plan: hostPlan } = await setupUserWithPlan(); const { user: owner } = createUser(testDb); await svc.getOwnPlan(owner.id); - insertMember(hostPlan.id, owner.id, 'accepted'); + await insertMember(hostPlan.id, owner.id, 'accepted'); const { user: viewer } = createUser(testDb); await svc.toggleEntry(owner.id, hostPlan.id, '2025-03-03', 1); await svc.shareCalendar(owner.id, owner.email, viewer.id); @@ -1820,26 +1785,26 @@ describe('quirk fixes', () => { const { plan } = await setupUserWithPlan(); const { user: member } = createUser(testDb); await svc.getOwnPlan(member.id); - insertMember(plan.id, member.id, 'pending'); + await insertMember(plan.id, member.id, 'pending'); const broken = await failingService('INSERT OR IGNORE INTO vacay_user_years'); await expect(broken.acceptInvite(member.id, plan.id, undefined)).rejects.toThrow('boom'); - const row = testDb.prepare('SELECT status FROM vacay_plan_members WHERE plan_id = ? AND user_id = ?').get(plan.id, member.id) as { status: string }; + const row = await storedFields(VacayPlanMembers, { plan: plan.id, user: member.id }, ['status']) as { status: string }; expect(row.status).toBe('pending'); }); it('VACAY-SVC-069: deleteYear is atomic — a failure mid-flow keeps the year and its entries', async () => { const { user, plan } = await setupUserWithPlan(); const year = new Date().getFullYear(); - allowWeekends(plan.id); + await allowWeekends(plan.id); await svc.toggleEntry(user.id, plan.id, `${year}-03-03`, 1); const broken = await failingService('DELETE FROM vacay_user_years'); await expect(broken.deleteYear(plan.id, year, undefined)).rejects.toThrow('boom'); - expect(testDb.prepare('SELECT id FROM vacay_years WHERE plan_id = ? AND year = ?').get(plan.id, year)).toBeDefined(); - expect(testDb.prepare('SELECT id FROM vacay_entries WHERE plan_id = ?').get(plan.id)).toBeDefined(); + expect(await storedFields(VacayYears, { plan: plan.id, year }, ['id'])).toBeDefined(); + expect(await storedFields(VacayEntries, { plan: plan.id }, ['id'])).toBeDefined(); }); it('VACAY-SVC-070: getCountries surfaces an upstream non-2xx as the fetch error and caches nothing', async () => { @@ -1917,8 +1882,8 @@ describe('quirk fixes', () => { it('VACAY-SVC-071: applyHolidayCalendars honors the cache TTL', async () => { const { plan } = await setupUserWithPlan(); - testDb.prepare('UPDATE vacay_plans SET holidays_enabled = 1 WHERE id = ?').run(plan.id); - testDb.prepare("INSERT INTO vacay_holiday_calendars (plan_id, region) VALUES (?, 'DE')").run(plan.id); + await updateRows(await orm(), VacayPlans, { id: plan.id }, { holidays_enabled: 1 }); + await insertRow(await orm(), VacayHolidayCalendars, { plan: plan.id, region: 'DE' }); const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => [] }); vi.stubGlobal('fetch', fetchMock); const fresh = await freshVacayService(); @@ -1948,6 +1913,6 @@ describe('quirk fixes', () => { const broken = await failingService('INSERT OR IGNORE INTO vacay_user_years'); await expect(broken.addYear(plan.id, year + 1, undefined)).rejects.toThrow('boom'); // And atomically: the failed year was not half-added. - expect(testDb.prepare('SELECT id FROM vacay_years WHERE plan_id = ? AND year = ?').get(plan.id, year + 1)).toBeUndefined(); + expect(await storedFields(VacayYears, { plan: plan.id, year: year + 1 }, ['id'])).toBeUndefined(); }); });