diff --git a/server/tests/unit/db/repositories/PlaceDetailsCache.repository.test.ts b/server/tests/unit/db/repositories/PlaceDetailsCache.repository.test.ts index 33110b107..f7ba3cd15 100644 --- a/server/tests/unit/db/repositories/PlaceDetailsCache.repository.test.ts +++ b/server/tests/unit/db/repositories/PlaceDetailsCache.repository.test.ts @@ -8,6 +8,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vites import { createSnapshotTestDb } from '../../../helpers/db-mock'; import { resetTestDb } from '../../../helpers/test-db'; import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; +import { findRow, findRows, insertRow, updateRows } from '../../../helpers/factories/rows'; import { PlaceDetailsCache } from '../../../../src/db/entities/PlaceDetailsCache.entity'; import type { PlaceDetailsCacheRepository } from '../../../../src/db/repositories/PlaceDetailsCache.repository'; @@ -22,27 +23,28 @@ beforeAll(async () => { beforeEach(() => { resetTestDb(testDb); t.clear(); }); afterAll(async () => { await t.close(); testDb.close(); }); -function rawRow(placeId: string, lang: string, expanded: number): unknown { - return testDb.prepare('SELECT * FROM place_details_cache WHERE place_id = ? AND lang = ? AND expanded = ?').get(placeId, lang, expanded); +function rawRow(placeId: string, lang: string, expanded: number) { + return findRow(t, PlaceDetailsCache, { place_id: placeId, lang, expanded }); +} + +function seedEntry(placeId: string, lang: string, expanded: number, payloadJson: string, fetchedAt: number) { + return insertRow(t, PlaceDetailsCache, { place_id: placeId, lang, expanded, payload_json: payloadJson, fetched_at: fetchedAt }); } describe('PlaceDetailsCacheRepository.findEntry', () => { it('PDCREPO-001: finds the entry for the exact (place_id, lang, expanded) triple', async () => { - testDb.prepare('INSERT INTO place_details_cache (place_id, lang, expanded, payload_json, fetched_at) VALUES (?, ?, ?, ?, ?)') - .run('p1', 'en', 0, '{"a":1}', 1000); + await seedEntry('p1', 'en', 0, '{"a":1}', 1000); expect(await cache.findEntry('p1', 'en', 0)).toEqual({ payload_json: '{"a":1}', fetched_at: 1000 }); }); it('PDCREPO-002: a different lang or expanded value is a miss, not a fuzzy match', async () => { - testDb.prepare('INSERT INTO place_details_cache (place_id, lang, expanded, payload_json, fetched_at) VALUES (?, ?, ?, ?, ?)') - .run('p1', 'en', 0, '{"a":1}', 1000); + await seedEntry('p1', 'en', 0, '{"a":1}', 1000); expect(await cache.findEntry('p1', 'fr', 0)).toBeNull(); expect(await cache.findEntry('p1', 'en', 2)).toBeNull(); }); it('PDCREPO-003: an empty-string lang (the "no lang requested" default) is its own distinct row', async () => { - testDb.prepare('INSERT INTO place_details_cache (place_id, lang, expanded, payload_json, fetched_at) VALUES (?, ?, ?, ?, ?)') - .run('p1', '', 2, '{"b":2}', 2000); + await seedEntry('p1', '', 2, '{"b":2}', 2000); expect(await cache.findEntry('p1', '', 2)).toEqual({ payload_json: '{"b":2}', fetched_at: 2000 }); expect(await cache.findEntry('p1', 'en', 2)).toBeNull(); }); @@ -55,7 +57,7 @@ describe('PlaceDetailsCacheRepository.findEntry', () => { describe('PlaceDetailsCacheRepository.upsertEntry — composite-key INSERT OR REPLACE parity', () => { it('PDCREPO-005: writes a fresh row for a new (place_id, lang, expanded) triple', async () => { await cache.upsertEntry({ place_id: 'p1', lang: '', expanded: 2, payload_json: '{"v":1}', fetched_at: 100 }); - expect(rawRow('p1', '', 2)).toMatchObject({ payload_json: '{"v":1}', fetched_at: 100 }); + expect(await rawRow('p1', '', 2)).toMatchObject({ payload_json: '{"v":1}', fetched_at: 100 }); }); // The composite-key proof the Task 1 brief requires: writes the SAME @@ -67,9 +69,9 @@ describe('PlaceDetailsCacheRepository.upsertEntry — composite-key INSERT OR RE await cache.upsertEntry({ place_id: 'p1', lang: 'en', expanded: 2, payload_json: '{"v":1}', fetched_at: 100 }); await cache.upsertEntry({ place_id: 'p1', lang: 'en', expanded: 2, payload_json: '{"v":2}', fetched_at: 200 }); - const rows = testDb.prepare('SELECT * FROM place_details_cache WHERE place_id = ? AND lang = ? AND expanded = ?').all('p1', 'en', 2); + const rows = await findRows(t, PlaceDetailsCache, { place_id: 'p1', lang: 'en', expanded: 2 }); expect(rows).toHaveLength(1); - expect(rawRow('p1', 'en', 2)).toMatchObject({ payload_json: '{"v":2}', fetched_at: 200 }); + expect(await rawRow('p1', 'en', 2)).toMatchObject({ payload_json: '{"v":2}', fetched_at: 200 }); }); it('PDCREPO-007: a different lang or expanded value for the same place_id is a SEPARATE row, not overwritten', async () => { @@ -77,7 +79,7 @@ describe('PlaceDetailsCacheRepository.upsertEntry — composite-key INSERT OR RE await cache.upsertEntry({ place_id: 'p1', lang: 'en', expanded: 2, payload_json: '{"kind":"enrichment"}', fetched_at: 200 }); await cache.upsertEntry({ place_id: 'p1', lang: 'fr', expanded: 2, payload_json: '{"kind":"enrichment-fr"}', fetched_at: 300 }); - const rows = testDb.prepare('SELECT lang, expanded, payload_json FROM place_details_cache WHERE place_id = ? ORDER BY lang, expanded').all('p1'); + const rows = await findRows(t, PlaceDetailsCache, { place_id: 'p1' }, { lang: 'asc', expanded: 'asc' }); expect(rows).toHaveLength(3); }); }); @@ -88,8 +90,7 @@ describe('PlaceDetailsCacheRepository.upsertEntry — composite-key INSERT OR RE // — a PK-only `findOne`, the class rule 20/21 targets directly. describe('PlaceDetailsCacheRepository — D-shape', () => { it('PDCREPO-008 (D-shape): a raw write after an unrelated identity-map read is visible in the next findEntry call, in one query (disableIdentityMap regression)', async () => { - testDb.prepare('INSERT INTO place_details_cache (place_id, lang, expanded, payload_json, fetched_at) VALUES (?, ?, ?, ?, ?)') - .run('p1', 'en', 2, '{"v":"old"}', 100); + await seedEntry('p1', 'en', 2, '{"v":"old"}', 100); // Task 9 fix wave (B-M3): rule 20 requires the FIRST, wider setup read to // opt out of the base default explicitly, SEPARATE from the method under // test — `findEntry` itself always uses the base's own @@ -97,8 +98,7 @@ describe('PlaceDetailsCacheRepository — D-shape', () => { // populate an entry for the later assertion to prove stale (the D-shape // claim would be vacuous, the same class of gap rule 20 exists to close). await t.repo(PlaceDetailsCache).findOne({ place_id: 'p1', lang: 'en', expanded: 2 }, { disableIdentityMap: false }); - testDb.prepare('UPDATE place_details_cache SET payload_json = ?, fetched_at = ? WHERE place_id = ? AND lang = ? AND expanded = ?') - .run('{"v":"new"}', 200, 'p1', 'en', 2); + await updateRows(t, PlaceDetailsCache, { place_id: 'p1', lang: 'en', expanded: 2 }, { payload_json: '{"v":"new"}', fetched_at: 200 }); const connection = t.orm.em.getConnection(); const spy = vi.spyOn(connection, 'execute'); diff --git a/server/tests/unit/db/repositories/_shared/packing-visibility.parity.test.ts b/server/tests/unit/db/repositories/_shared/packing-visibility.parity.test.ts index 0f84ea871..be9aee7d5 100644 --- a/server/tests/unit/db/repositories/_shared/packing-visibility.parity.test.ts +++ b/server/tests/unit/db/repositories/_shared/packing-visibility.parity.test.ts @@ -2,6 +2,8 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { createSnapshotTestDb } from '../../../../helpers/db-mock'; import { resetTestDb } from '../../../../helpers/test-db'; import { createTestOrm, type TestOrm } from '../../../../helpers/test-orm'; +import { insertRow } from '../../../../helpers/factories/rows'; +import { addPackingItemRecipients } from '../../../../helpers/factories/packing'; import { createTrip, createUser } from '../../../../helpers/factories'; import { PackingItems } from '../../../../../src/db/entities/PackingItems.entity'; import { @@ -41,22 +43,16 @@ type Actor = 'owner' | 'recipient' | 'stranger' | 'no-actor'; * item ids plus the owner/recipient/stranger user ids the 12-cell matrix * below runs against. */ -function seedFixture() { +async function seedFixture() { const { user: owner } = createUser(testDb); const { user: recipient } = createUser(testDb); const { user: stranger } = createUser(testDb); const trip = createTrip(testDb, owner.id); - const common = testDb - .prepare('INSERT INTO packing_items (trip_id, name, is_private, owner_id) VALUES (?, ?, 0, NULL)') - .run(trip.id, 'Common item').lastInsertRowid as number; - const personal = testDb - .prepare('INSERT INTO packing_items (trip_id, name, is_private, owner_id) VALUES (?, ?, 1, ?)') - .run(trip.id, 'Personal item', owner.id).lastInsertRowid as number; - const shared = testDb - .prepare('INSERT INTO packing_items (trip_id, name, is_private, owner_id) VALUES (?, ?, 1, ?)') - .run(trip.id, 'Shared item', owner.id).lastInsertRowid as number; - testDb.prepare('INSERT INTO packing_item_recipients (item_id, user_id) VALUES (?, ?)').run(shared, recipient.id); + const common = await insertRow(t, PackingItems, { trip: trip.id, name: 'Common item', is_private: 0, owner: null }); + const personal = await insertRow(t, PackingItems, { trip: trip.id, name: 'Personal item', is_private: 1, owner: owner.id }); + const shared = await insertRow(t, PackingItems, { trip: trip.id, name: 'Shared item', is_private: 1, owner: owner.id }); + await addPackingItemRecipients(t, shared, [recipient.id]); return { tripId: trip.id as number, @@ -72,6 +68,7 @@ function resolveActorId(actor: Actor, actorIds: Record { const rows = testDb + // test-sql-allow: the legacy fragment is this parity test's oracle and has to run as written. .prepare(`SELECT id FROM packing_items WHERE trip_id = ? AND ${VISIBLE_TO_ACTOR} ORDER BY id ASC`) .all(tripId, actorId ?? null, actorId ?? null) as { id: number }[]; return rows.map((r) => r.id); @@ -117,7 +114,7 @@ describe('packing-visibility parity — 12-cell matrix (4 actor types × 3 item for (const actor of actors) { it(`PKVIS-${actor}: legacy fragment, typed QB condition and typed Kysely expr all select the same tiers for a(n) ${actor} actor`, async () => { - const { tripId, itemIds, actorIds } = seedFixture(); + const { tripId, itemIds, actorIds } = await seedFixture(); const actorId = resolveActorId(actor, actorIds); const legacy = await legacyVisibleIds(tripId, actorId); @@ -133,12 +130,12 @@ describe('packing-visibility parity — 12-cell matrix (4 actor types × 3 item } it('PKVIS-mixed: every actor checked against the SAME seeded trip, plus a second trip that must never leak in', async () => { - const fixture = seedFixture(); + const fixture = await seedFixture(); // A second, unrelated trip's items must never leak into either actor's // result — the WHERE clause is trip-scoped independently of visibility. const { user: otherOwner } = createUser(testDb); const otherTrip = createTrip(testDb, otherOwner.id); - testDb.prepare('INSERT INTO packing_items (trip_id, name, is_private, owner_id) VALUES (?, ?, 0, NULL)').run(otherTrip.id, 'Other trip common'); + await insertRow(t, PackingItems, { trip: otherTrip.id, name: 'Other trip common', is_private: 0, owner: null }); for (const actor of ['owner', 'recipient', 'stranger', 'no-actor'] as const) { const actorId = resolveActorId(actor, fixture.actorIds); @@ -160,16 +157,16 @@ describe('packing-visibility parity — 12-cell matrix (4 actor types × 3 item // ANY item shares with them. Proven red, then restored — see this test's // own trailing comment for the exact mutation and the observed failure. it('PKVIS-mutation: a second, unrelated Shared item (recipient not invited) stays hidden from that recipient', async () => { - const { tripId, itemIds, actorIds } = seedFixture(); - const otherShared = testDb - .prepare('INSERT INTO packing_items (trip_id, name, is_private, owner_id) VALUES (?, ?, 1, ?)') - .run(tripId, 'Second shared item, different recipients', actorIds.owner).lastInsertRowid as number; + const { tripId, itemIds, actorIds } = await seedFixture(); + const otherShared = await insertRow(t, PackingItems, { + trip: tripId, name: 'Second shared item, different recipients', is_private: 1, owner: actorIds.owner, + }); // Deliberately NOT inviting `recipient` to this second shared item — // only `stranger` is invited here, so `recipient`'s own visibility must // stay exactly {common, shared} (itemIds.shared), never picking up // `otherShared` just because SOME row in packing_item_recipients names them // as a recipient of a DIFFERENT item. - testDb.prepare('INSERT INTO packing_item_recipients (item_id, user_id) VALUES (?, ?)').run(otherShared, actorIds.stranger); + await addPackingItemRecipients(t, otherShared, [actorIds.stranger]); const actorId = actorIds.recipient; const legacy = await legacyVisibleIds(tripId, actorId); diff --git a/server/tests/unit/mcp/tools-days.test.ts b/server/tests/unit/mcp/tools-days.test.ts index 8a93ea465..2440a48ee 100644 --- a/server/tests/unit/mcp/tools-days.test.ts +++ b/server/tests/unit/mcp/tools-days.test.ts @@ -27,6 +27,12 @@ import { } from '../../helpers/factories'; import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness'; import { DaysService } from '../../../src/nest/days/days.service'; +import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; +import { findRow, updateRows } from '../../helpers/factories/rows'; +import { readTripDays } from '../../helpers/factories/trips'; +import { DayAssignments } from '../../../src/db/entities/DayAssignments.entity'; +import { Days } from '../../../src/db/entities/Days.entity'; +import { Trips } from '../../../src/db/entities/Trips.entity'; beforeEach(() => { resetTestDb(testDb); @@ -34,7 +40,14 @@ beforeEach(() => { delete process.env.DEMO_MODE; }); -afterAll(() => { +let orm: TestOrm; + +beforeAll(async () => { + orm = await createTestOrm(testDb); +}); + +afterAll(async () => { + await orm.close(); testDb.close(); }); @@ -44,20 +57,19 @@ async function withHarness(userId: number, fn: (h: McpHarness) => Promise) } /** The stored day row, which is what a tool's echo can disagree with. */ -function dayRow(dayId: number) { - return testDb.prepare('SELECT title, notes, day_number, date FROM days WHERE id = ?').get(dayId) as - { title: string | null; notes: string | null; day_number: number; date: string | null }; +async function dayRow(dayId: number) { + const row = await findRow(orm, Days, { id: dayId }); + if (!row) throw new Error(`no day ${dayId}`); + return row; } /** Day ids of a trip in stored order, so a reorder can be read back positionally. */ -function dayIdsInOrder(tripId: number): number[] { - return (testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(tripId) as { id: number }[]) - .map(r => r.id); +async function dayIdsInOrder(tripId: number): Promise { + return (await readTripDays(orm, tripId)).map(r => r.id); } -function dayDatesInOrder(tripId: number): (string | null)[] { - return (testDb.prepare('SELECT date FROM days WHERE trip_id = ? ORDER BY day_number').all(tripId) as { date: string | null }[]) - .map(r => r.date); +async function dayDatesInOrder(tripId: number): Promise<(string | null)[]> { + return (await readTripDays(orm, tripId)).map(r => r.date ?? null); } // --------------------------------------------------------------------------- @@ -99,7 +111,7 @@ describe('Tool: update_day', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id); - testDb.prepare('UPDATE days SET notes = ? WHERE id = ?').run('Walking day', day.id); + await updateRows(orm, Days, { id: day.id }, { notes: 'Walking day' }); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ @@ -125,7 +137,7 @@ describe('Tool: update_day', () => { expect(data.day.notes).toBe('Ferry leaves at 08:00'); }); - expect(dayRow(day.id)).toMatchObject({ title: 'Arrival in Paris', notes: 'Ferry leaves at 08:00' }); + expect(await dayRow(day.id)).toMatchObject({ title: 'Arrival in Paris', notes: 'Ferry leaves at 08:00' }); }); it('sets a title and notes in one call', async () => { @@ -140,14 +152,14 @@ describe('Tool: update_day', () => { }); }); - expect(dayRow(day.id)).toMatchObject({ title: 'Free day', notes: 'Nothing booked' }); + expect(await dayRow(day.id)).toMatchObject({ title: 'Free day', notes: 'Nothing booked' }); }); it('clears the notes with an empty string', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id, { title: 'Arrival' }); - testDb.prepare('UPDATE days SET notes = ? WHERE id = ?').run('Ferry leaves at 08:00', day.id); + await updateRows(orm, Days, { id: day.id }, { notes: 'Ferry leaves at 08:00' }); await withHarness(user.id, async (h) => { await h.client.callTool({ @@ -156,7 +168,7 @@ describe('Tool: update_day', () => { }); }); - expect(dayRow(day.id)).toMatchObject({ title: 'Arrival', notes: null }); + expect(await dayRow(day.id)).toMatchObject({ title: 'Arrival', notes: null }); }); it('refuses a non-string notes value', async () => { @@ -173,7 +185,7 @@ describe('Tool: update_day', () => { expect((result.content as { text: string }[])[0].text).toContain('Invalid arguments'); }); - expect(dayRow(day.id).notes).toBeNull(); + expect((await dayRow(day.id)).notes).toBeNull(); }); it('broadcasts day:updated event', async () => { @@ -245,14 +257,14 @@ describe('Tool: create_day (position)', () => { insertedId = (parseToolResult(result) as { day: { id: number } }).day.id; }); - expect(dayIdsInOrder(trip.id)).toEqual([d1.id, insertedId, d2.id, d3.id]); - expect(dayRow(insertedId)).toMatchObject({ day_number: 2, date: null }); + expect(await dayIdsInOrder(trip.id)).toEqual([d1.id, insertedId, d2.id, d3.id]); + expect(await dayRow(insertedId)).toMatchObject({ day_number: 2, date: null }); }); it('re-pins the dates and extends the trip when inserting into a dated trip', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { start_date: '2025-06-01', end_date: '2025-06-03' }); - const [d1, d2, d3] = dayIdsInOrder(trip.id); + const [d1, d2, d3] = await dayIdsInOrder(trip.id); let insertedId = 0; await withHarness(user.id, async (h) => { @@ -264,10 +276,10 @@ describe('Tool: create_day (position)', () => { insertedId = (parseToolResult(result) as { day: { id: number } }).day.id; }); - expect(dayIdsInOrder(trip.id)).toEqual([d1, insertedId, d2, d3]); - expect(dayDatesInOrder(trip.id)).toEqual(['2025-06-01', '2025-06-02', '2025-06-03', '2025-06-04']); - expect(dayRow(insertedId)).toMatchObject({ date: '2025-06-02', notes: null }); - expect(testDb.prepare('SELECT end_date FROM trips WHERE id = ?').get(trip.id)).toEqual({ end_date: '2025-06-04' }); + expect(await dayIdsInOrder(trip.id)).toEqual([d1, insertedId, d2, d3]); + expect(await dayDatesInOrder(trip.id)).toEqual(['2025-06-01', '2025-06-02', '2025-06-03', '2025-06-04']); + expect(await dayRow(insertedId)).toMatchObject({ date: '2025-06-02', notes: null }); + expect((await findRow(orm, Trips, { id: trip.id }))?.end_date).toBe('2025-06-04'); }); it('appends when position is omitted and still honours date and notes', async () => { @@ -284,8 +296,8 @@ describe('Tool: create_day (position)', () => { appendedId = (parseToolResult(result) as { day: { id: number } }).day.id; }); - expect(dayIdsInOrder(trip.id)).toEqual([d1.id, appendedId]); - expect(dayRow(appendedId)).toMatchObject({ date: '2025-06-15', notes: 'Arrival day' }); + expect(await dayIdsInOrder(trip.id)).toEqual([d1.id, appendedId]); + expect(await dayRow(appendedId)).toMatchObject({ date: '2025-06-15', notes: 'Arrival day' }); }); it('broadcasts day:reordered for an insert and day:created for an append', async () => { @@ -318,13 +330,13 @@ describe('Tool: create_day (position)', () => { expect((result.content as { text: string }[])[0].text).toContain('Invalid arguments'); }); - expect(dayIdsInOrder(trip.id)).toHaveLength(1); + expect(await dayIdsInOrder(trip.id)).toHaveLength(1); }); it('refuses an insert that would invert a stay, leaving the trip untouched', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { start_date: '2025-06-01', end_date: '2025-06-03' }); - const [d1, d2, d3] = dayIdsInOrder(trip.id); + const [d1, d2, d3] = await dayIdsInOrder(trip.id); const place = createPlace(testDb, trip.id); // The stay ends on the last day, so pushing a day in front of its start // cannot invert it; anchoring it the other way round is what does. @@ -339,8 +351,8 @@ describe('Tool: create_day (position)', () => { expect((result.content as { text: string }[])[0].text).toContain('accommodation'); }); - expect(dayIdsInOrder(trip.id)).toEqual([d1, d2, d3]); - expect(dayDatesInOrder(trip.id)).toEqual(['2025-06-01', '2025-06-02', '2025-06-03']); + expect(await dayIdsInOrder(trip.id)).toEqual([d1, d2, d3]); + expect(await dayDatesInOrder(trip.id)).toEqual(['2025-06-01', '2025-06-02', '2025-06-03']); }); it('blocks demo user on an insert', async () => { @@ -352,7 +364,7 @@ describe('Tool: create_day (position)', () => { const result = await h.client.callTool({ name: 'create_day', arguments: { tripId: trip.id, position: 1 } }); expect(result.isError).toBe(true); }); - expect(dayIdsInOrder(trip.id)).toHaveLength(1); + expect(await dayIdsInOrder(trip.id)).toHaveLength(1); }); }); @@ -361,13 +373,12 @@ describe('Tool: create_day (position)', () => { // --------------------------------------------------------------------------- describe('Tool: create_day (dated)', () => { - const endDate = (tripId: number) => - (testDb.prepare('SELECT end_date FROM trips WHERE id = ?').get(tripId) as { end_date: string | null }).end_date; + const endDate = async (tripId: number) => (await findRow(orm, Trips, { id: tripId }))?.end_date; it('appends the next date behind the dated days, extends the trip and answers with both', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { start_date: '2025-06-01', end_date: '2025-06-02' }); - const [d1, d2] = dayIdsInOrder(trip.id); + const [d1, d2] = await dayIdsInOrder(trip.id); const spare = createDay(testDb, trip.id); let data = {} as { day: { id: number; date: string; notes: string | null }; trip: { end_date: string; day_count: number } }; @@ -381,9 +392,9 @@ describe('Tool: create_day (dated)', () => { expect(data.day).toMatchObject({ date: '2025-06-03', notes: 'Late checkout' }); expect(data.trip).toMatchObject({ end_date: '2025-06-03', day_count: 4 }); - expect(dayIdsInOrder(trip.id)).toEqual([d1, d2, data.day.id, spare.id]); - expect(dayDatesInOrder(trip.id)).toEqual(['2025-06-01', '2025-06-02', '2025-06-03', null]); - expect(endDate(trip.id)).toBe('2025-06-03'); + expect(await dayIdsInOrder(trip.id)).toEqual([d1, d2, data.day.id, spare.id]); + expect(await dayDatesInOrder(trip.id)).toEqual(['2025-06-01', '2025-06-02', '2025-06-03', null]); + expect(await endDate(trip.id)).toBe('2025-06-03'); // The insert shape of day:reordered, so collaborators refetch, then the trip. expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'day:reordered', expect.objectContaining({ day: expect.objectContaining({ id: data.day.id }) })); expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'trip:updated', expect.objectContaining({ trip: expect.objectContaining({ end_date: '2025-06-03' }) })); @@ -402,8 +413,8 @@ describe('Tool: create_day (dated)', () => { } }); - expect(dayIdsInOrder(trip.id)).toHaveLength(2); - expect(endDate(trip.id)).toBe('2025-06-02'); + expect(await dayIdsInOrder(trip.id)).toHaveLength(2); + expect(await endDate(trip.id)).toBe('2025-06-02'); expect(broadcastMock).not.toHaveBeenCalled(); }); @@ -418,7 +429,7 @@ describe('Tool: create_day (dated)', () => { expect((result.content as { text: string }[])[0].text).toContain('This trip has no dates'); }); - expect(dayDatesInOrder(trip.id)).toEqual([null]); + expect(await dayDatesInOrder(trip.id)).toEqual([null]); expect(broadcastMock).not.toHaveBeenCalled(); }); @@ -448,8 +459,8 @@ describe('Tool: create_day (dated)', () => { expect(result.isError).toBe(true); }); - expect(dayIdsInOrder(trip.id)).toHaveLength(2); - expect(endDate(trip.id)).toBe('2025-06-02'); + expect(await dayIdsInOrder(trip.id)).toHaveLength(2); + expect(await endDate(trip.id)).toBe('2025-06-02'); }); it('an insert at a position announces the grown trip as well', async () => { @@ -486,16 +497,15 @@ describe('Tool: reorder_days', () => { expect(parseToolResult(result)).toEqual({ success: true }); }); - expect(dayIdsInOrder(trip.id)).toEqual([d3.id, d1.id, d2.id]); - expect(dayRow(d3.id)).toMatchObject({ day_number: 1, title: 'Third' }); - expect(testDb.prepare('SELECT day_id FROM day_assignments WHERE id = ?').get(assignment.id)) - .toEqual({ day_id: d3.id }); + expect(await dayIdsInOrder(trip.id)).toEqual([d3.id, d1.id, d2.id]); + expect(await dayRow(d3.id)).toMatchObject({ day_number: 1, title: 'Third' }); + expect((await findRow(orm, DayAssignments, { id: assignment.id }))?.day_id).toBe(d3.id); }); it('keeps the dates pinned to their slots so the content moves across them', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { start_date: '2025-06-01', end_date: '2025-06-03' }); - const [d1, d2, d3] = dayIdsInOrder(trip.id); + const [d1, d2, d3] = await dayIdsInOrder(trip.id); await withHarness(user.id, async (h) => { await h.client.callTool({ @@ -504,9 +514,9 @@ describe('Tool: reorder_days', () => { }); }); - expect(dayIdsInOrder(trip.id)).toEqual([d3, d1, d2]); - expect(dayDatesInOrder(trip.id)).toEqual(['2025-06-01', '2025-06-02', '2025-06-03']); - expect(dayRow(d3).date).toBe('2025-06-01'); + expect(await dayIdsInOrder(trip.id)).toEqual([d3, d1, d2]); + expect(await dayDatesInOrder(trip.id)).toEqual(['2025-06-01', '2025-06-02', '2025-06-03']); + expect((await dayRow(d3)).date).toBe('2025-06-01'); }); it('broadcasts day:reordered with the ordered ids', async () => { @@ -542,7 +552,7 @@ describe('Tool: reorder_days', () => { expect((result.content as { text: string }[])[0].text).toContain('permutation'); }); - expect(dayIdsInOrder(trip.id)).toEqual([d1.id, d2.id, d3.id]); + expect(await dayIdsInOrder(trip.id)).toEqual([d1.id, d2.id, d3.id]); expect(broadcastMock).not.toHaveBeenCalled(); }); @@ -562,14 +572,14 @@ describe('Tool: reorder_days', () => { expect(result.isError).toBe(true); }); - expect(dayIdsInOrder(trip.id)).toEqual([d1.id, d2.id]); - expect(dayRow(foreign.id).day_number).toBe(1); + expect(await dayIdsInOrder(trip.id)).toEqual([d1.id, d2.id]); + expect((await dayRow(foreign.id)).day_number).toBe(1); }); it('refuses a move that would make a stay end before it starts, and rolls back', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { start_date: '2025-06-01', end_date: '2025-06-03' }); - const [d1, d2, d3] = dayIdsInOrder(trip.id); + const [d1, d2, d3] = await dayIdsInOrder(trip.id); const place = createPlace(testDb, trip.id); createDayAccommodation(testDb, trip.id, place.id, d1, d2); @@ -582,7 +592,7 @@ describe('Tool: reorder_days', () => { expect((result.content as { text: string }[])[0].text).toContain('accommodation'); }); - expect(dayIdsInOrder(trip.id)).toEqual([d1, d2, d3]); + expect(await dayIdsInOrder(trip.id)).toEqual([d1, d2, d3]); expect(broadcastMock).not.toHaveBeenCalled(); }); @@ -616,7 +626,7 @@ describe('Tool: reorder_days', () => { expect(result.isError).toBe(true); }); - expect(dayIdsInOrder(trip.id)).toEqual([d1.id, d2.id]); + expect(await dayIdsInOrder(trip.id)).toEqual([d1.id, d2.id]); }); it('blocks demo user', async () => { @@ -634,7 +644,7 @@ describe('Tool: reorder_days', () => { expect(result.isError).toBe(true); }); - expect(dayIdsInOrder(trip.id)).toEqual([d1.id, d2.id]); + expect(await dayIdsInOrder(trip.id)).toEqual([d1.id, d2.id]); }); }); diff --git a/server/tests/unit/nest/notifications/web-push.service.test.ts b/server/tests/unit/nest/notifications/web-push.service.test.ts index 72d1e13e6..d02facb53 100644 --- a/server/tests/unit/nest/notifications/web-push.service.test.ts +++ b/server/tests/unit/nest/notifications/web-push.service.test.ts @@ -51,6 +51,11 @@ vi.mock('../../../../src/utils/ssrfGuard', () => { import { db as testDb } from '../../../../src/db/database'; import { resetTestDb } from '../../../helpers/test-db'; import { createUser } from '../../../helpers/factories'; +import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; +import { findRows, updateRows } from '../../../helpers/factories/rows'; +import { readAppSetting } from '../../../helpers/factories/settings'; +import { AppSettings } from '../../../../src/db/entities/AppSettings.entity'; +import { PushSubscriptions } from '../../../../src/db/entities/PushSubscriptions.entity'; import { makePushSubscriptionsService, makeVapidKeysService } from '../../../helpers/notifications'; import { SsrfBlockedError } from '../../../../src/utils/ssrfGuard'; import type { PushSubscriptionsService } from '../../../../src/nest/notifications/push/push-subscriptions.service'; @@ -149,7 +154,14 @@ beforeEach(() => { logError.mockClear(); }); -afterAll(() => { +let orm: TestOrm; + +beforeAll(async () => { + orm = await createTestOrm(testDb); +}); + +afterAll(async () => { + await orm.close(); testDb.close(); }); @@ -299,7 +311,7 @@ describe('WebPushService delivery', () => { it('WPUSH-008: a row made for a previous server key is dropped without a request', async () => { const { user } = createUser(testDb); await subscribe(user.id, 'https://fcm.googleapis.com/fcm/send/old'); - testDb.prepare("UPDATE push_subscriptions SET vapid_public_key = 'previous-key'").run(); + await updateRows(orm, PushSubscriptions, {}, { vapid_public_key: 'previous-key' }); await expect(push.sendToUser(user.id, MSG)).resolves.toBe(false); expect(safeFetchFollow).not.toHaveBeenCalled(); expect(await rows(user.id)).toEqual([]); @@ -309,10 +321,8 @@ describe('WebPushService delivery', () => { const { user } = createUser(testDb); await subscribe(user.id, 'https://fcm.googleapis.com/fcm/send/x'); await subscribe(user.id, 'https://fcm.googleapis.com/fcm/send/y'); - testDb - .prepare("UPDATE push_subscriptions SET endpoint = 'https://attacker.example.test/collect' WHERE endpoint LIKE '%/x'") - .run(); - testDb.prepare("UPDATE push_subscriptions SET endpoint = 'not a url' WHERE endpoint LIKE '%/y'").run(); + await updateRows(orm, PushSubscriptions, { endpoint: { $like: '%/x' } }, { endpoint: 'https://attacker.example.test/collect' }); + await updateRows(orm, PushSubscriptions, { endpoint: { $like: '%/y' } }, { endpoint: 'not a url' }); await expect(push.sendToUser(user.id, MSG)).resolves.toBe(false); expect(safeFetchFollow).not.toHaveBeenCalled(); expect(await rows(user.id)).toEqual([]); @@ -325,7 +335,7 @@ describe('WebPushService delivery', () => { it('WPUSH-010: broken stored keys count as a failure for that row', async () => { const { user } = createUser(testDb); await subscribe(user.id, 'https://fcm.googleapis.com/fcm/send/x'); - testDb.prepare("UPDATE push_subscriptions SET auth = '***'").run(); + await updateRows(orm, PushSubscriptions, {}, { auth: '***' }); await expect(push.sendToUser(user.id, MSG)).resolves.toBe(false); expect(safeFetchFollow).not.toHaveBeenCalled(); expect((await rows(user.id))[0].failure_count).toBe(1); @@ -352,9 +362,7 @@ describe('WebPushService delivery', () => { await subscribe(user.id, 'https://fcm.googleapis.com/fcm/send/x'); const publicKey = await keys.getPublicKey(); // A restore under a different ENCRYPTION_KEY: the ciphertext no longer opens. - testDb - .prepare("UPDATE app_settings SET value = 'enc:v1:bm90IGEgY2lwaGVydGV4dA' WHERE key = ?") - .run(VAPID_PRIVATE_KEY_SETTING); + await updateRows(orm, AppSettings, { key: VAPID_PRIVATE_KEY_SETTING }, { value: 'enc:v1:bm90IGEgY2lwaGVydGV4dA' }); await expect(push.sendToUser(user.id, MSG)).resolves.toBe(false); @@ -385,7 +393,7 @@ describe('WebPushService delivery', () => { expect(await push.isAvailable()).toBe(false); expect(safeFetchFollow).not.toHaveBeenCalled(); - expect(testDb.prepare("SELECT key FROM app_settings WHERE key LIKE 'web_push_vapid_%'").all()).toEqual([]); + expect(await findRows(orm, AppSettings, { key: { $like: 'web_push_vapid_%' } })).toEqual([]); expect((await rows(user.id)).map((row) => row.vapid_public_key)).toEqual([pair.publicKey, pair.publicKey]); // The Secret fixed: the same devices receive again, nobody turns push on again. @@ -419,9 +427,7 @@ describe('WebPushService delivery', () => { expect(safeFetchFollow).not.toHaveBeenCalled(); // Signing with the stored pair would have dropped both rows; they stay on the environment key. expect((await rows(user.id)).map((row) => row.vapid_public_key)).toEqual([pair.publicKey, pair.publicKey]); - expect(testDb.prepare('SELECT value FROM app_settings WHERE key = ?').get(VAPID_PUBLIC_KEY_SETTING)).toEqual({ - value: storedKey, - }); + expect(await readAppSetting(orm, VAPID_PUBLIC_KEY_SETTING)).toBe(storedKey); // Browsers are not handed the stored key to subscribe again with either. let refusal: unknown = null; try { diff --git a/server/tests/unit/nest/settings/instance-api-keys.test.ts b/server/tests/unit/nest/settings/instance-api-keys.test.ts index b5087c09b..efad64eac 100644 --- a/server/tests/unit/nest/settings/instance-api-keys.test.ts +++ b/server/tests/unit/nest/settings/instance-api-keys.test.ts @@ -21,6 +21,7 @@ import { createSnapshotTestDb } from '../../../helpers/db-mock'; import { resetTestDb } from '../../../helpers/test-db'; import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; import { createUser, createAdmin } from '../../../helpers/factories'; +import { countRows, findRow, insertRow, updateRows } from '../../../helpers/factories/rows'; import { AppSettings } from '../../../../src/db/entities/AppSettings.entity'; import type { AppSettingsRepository } from '../../../../src/db/repositories/AppSettings.repository'; import { Users } from '../../../../src/db/entities/Users.entity'; @@ -54,37 +55,35 @@ afterAll(async () => { testDb.close(); }); -const storedValue = (key: string) => - (testDb.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined)?.value; +const storedValue = async (key: string) => (await findRow(t, AppSettings, { key }))?.value; describe('instance API keys', () => { it('INSTKEY-001: round-trips through encryption at rest', async () => { await writeInstanceApiKey(appSettings, 'maps_api_key', 'AIza-instance-key'); - expect(storedValue('maps_api_key')).toMatch(/^enc:v1:/); - expect(storedValue('maps_api_key')).not.toContain('AIza-instance-key'); + expect(await storedValue('maps_api_key')).toMatch(/^enc:v1:/); + expect(await storedValue('maps_api_key')).not.toContain('AIza-instance-key'); expect(await readInstanceApiKey(appSettings, 'maps_api_key')).toBe('AIza-instance-key'); }); it('INSTKEY-002: a second write of the same value replaces the row (no second one)', async () => { await writeInstanceApiKey(appSettings, 'maps_api_key', 'same-key'); - const first = storedValue('maps_api_key'); + const first = await storedValue('maps_api_key'); await writeInstanceApiKey(appSettings, 'maps_api_key', 'same-key'); // Same plaintext, different blob — the IV is random. That is exactly why // "did this change?" is never asked of the stored value. - expect(storedValue('maps_api_key')).not.toBe(first); + expect(await storedValue('maps_api_key')).not.toBe(first); expect(await readInstanceApiKey(appSettings, 'maps_api_key')).toBe('same-key'); - const rows = testDb.prepare("SELECT COUNT(*) AS n FROM app_settings WHERE key = 'maps_api_key'").get() as { n: number }; - expect(rows.n).toBe(1); + expect(await countRows(t, AppSettings, { key: 'maps_api_key' })).toBe(1); }); it('INSTKEY-003: a blank value reads back as unset but keeps the row', async () => { await writeInstanceApiKey(appSettings, 'unsplash_api_key', ' '); - expect(storedValue('unsplash_api_key')).toBe(''); + expect(await storedValue('unsplash_api_key')).toBe(''); expect(await readInstanceApiKey(appSettings, 'unsplash_api_key')).toBeNull(); }); it('INSTKEY-004: a legacy plaintext row still reads', async () => { - testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('maps_api_key', 'plain-old-key')").run(); + await insertRow(t, AppSettings, { key: 'maps_api_key', value: 'plain-old-key' }); expect(await readInstanceApiKey(appSettings, 'maps_api_key')).toBe('plain-old-key'); }); @@ -99,7 +98,7 @@ describe('instance API keys', () => { it('INSTKEY-006: the instance value wins over the caller own row', async () => { const { user } = createAdmin(testDb); - testDb.prepare('UPDATE users SET maps_api_key = ? WHERE id = ?').run('personal-key', user.id); + await updateRows(t, Users, { id: user.id }, { maps_api_key: 'personal-key' }); await writeInstanceApiKey(appSettings, 'maps_api_key', 'instance-key'); expect(await resolveApiKey(appSettings, users, 'maps_api_key', user.id, undefined)).toEqual({ key: 'instance-key', @@ -109,7 +108,7 @@ describe('instance API keys', () => { it("INSTKEY-007: without an instance value the caller's own row answers — and nobody else's (#1939)", async () => { const { user: admin } = createAdmin(testDb); - testDb.prepare('UPDATE users SET maps_api_key = ? WHERE id = ?').run('admins-own-key', admin.id); + await updateRows(t, Users, { id: admin.id }, { maps_api_key: 'admins-own-key' }); const { user: member } = createUser(testDb); // The admin gets theirs... @@ -124,7 +123,7 @@ describe('instance API keys', () => { it('INSTKEY-009: userId 0 asks about the instance only', async () => { const { user } = createAdmin(testDb); - testDb.prepare('UPDATE users SET maps_api_key = ? WHERE id = ?').run('personal-key', user.id); + await updateRows(t, Users, { id: user.id }, { maps_api_key: 'personal-key' }); // app-config is optional-auth: with nobody asking there is no own row, and // the answer must not be some other row that happens to be first. expect(await resolveApiKey(appSettings, users, 'maps_api_key', 0, undefined)).toEqual({ key: null, source: null }); @@ -137,13 +136,13 @@ describe('instance API keys', () => { it('INSTKEY-008: an empty instance value does not fall through to the own row', async () => { const { user } = createAdmin(testDb); - testDb.prepare('UPDATE users SET unsplash_api_key = ? WHERE id = ?').run('stale-personal', user.id); + await updateRows(t, Users, { id: user.id }, { unsplash_api_key: 'stale-personal' }); await writeInstanceApiKey(appSettings, 'unsplash_api_key', 'to-be-cleared'); await writeInstanceApiKey(appSettings, 'unsplash_api_key', ''); // The admin who cleared the field cleared their column in the same save, so // the fallback finding the old value would only happen on a row nobody // touched — here it must not resurrect a cleared instance key for them. - testDb.prepare('UPDATE users SET unsplash_api_key = NULL WHERE id = ?').run(user.id); + await updateRows(t, Users, { id: user.id }, { unsplash_api_key: null }); expect(await resolveApiKey(appSettings, users, 'unsplash_api_key', user.id, undefined)).toEqual({ key: null, source: null }); }); }); diff --git a/server/tests/websocket/connection.test.ts b/server/tests/websocket/connection.test.ts index d9322dcdb..5212f7b78 100644 --- a/server/tests/websocket/connection.test.ts +++ b/server/tests/websocket/connection.test.ts @@ -36,6 +36,12 @@ import { createEphemeralToken } from '../../src/nest/auth/ephemeral-tokens'; import { TokenService } from '../../src/nest/tokens/token.service'; import { EphemeralTokenService } from '../../src/nest/auth/ephemeral-token.service'; import { createTestMcpTokensRepo, createTestUsersRepo } from '../helpers/test-uow'; +import { MikroORM } from '@mikro-orm/core'; +import { Users } from '../../src/db/entities/Users.entity'; +import type { FactoryOrm } from '../helpers/factories/context'; +import { deleteRows, updateRows } from '../helpers/factories/rows'; +import { setAppSetting } from '../helpers/factories/settings'; +import { readUser } from '../helpers/factories/users'; // The gateway consumes ws-tokens through its injected TokenService; the // ephemeral store is module-scoped on purpose, so a directly-constructed @@ -52,6 +58,7 @@ const createWsToken = (...args: Parameters) => let server: http.Server; let wsUrl: string; let nestApp: INestApplication; +const orm = (): FactoryOrm => nestApp.get(MikroORM); beforeAll(async () => { tokenService = new TokenService(await createTestMcpTokensRepo(testDb), await createTestUsersRepo(testDb), new EphemeralTokenService()); @@ -453,7 +460,7 @@ describe('WS auth edge cases', () => { const { user } = createUser(testDb); const token = createEphemeralToken(user.id, 'ws')!; // Remove the user so the DB lookup returns undefined - testDb.prepare('DELETE FROM users WHERE id = ?').run(user.id); + await deleteRows(orm(), Users, { id: user.id }); const closeCode = await new Promise((resolve) => { const ws = new WebSocket(`${wsUrl}?token=${encodeURIComponent(token)}`); @@ -465,7 +472,7 @@ describe('WS auth edge cases', () => { it('WS-013 — MFA is enforced when require_mfa is enabled and user has no MFA', async () => { // Enable require_mfa in app_settings - testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('require_mfa', 'true')").run(); + await setAppSetting(orm(), 'require_mfa', 'true'); // Create a regular user without MFA const { user } = createUser(testDb); @@ -481,11 +488,11 @@ describe('WS auth edge cases', () => { it('WS-014 — MFA-enabled user connects successfully when require_mfa is enabled', async () => { // Enable require_mfa - testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('require_mfa', 'true')").run(); + await setAppSetting(orm(), 'require_mfa', 'true'); // Create a user with MFA enabled const { user } = createUser(testDb); - testDb.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ? WHERE id = ?').run('JBSWY3DPEHPK3PXP', user.id); + await updateRows(orm(), Users, { id: user.id }, { mfa_enabled: 1, mfa_secret: 'JBSWY3DPEHPK3PXP' }); const token = createEphemeralToken(user.id, 'ws')!; const client = await connectWs(token); @@ -504,7 +511,8 @@ describe('WS auth edge cases', () => { const token = result.token!; // Simulate a password reset bumping the version AFTER the token was issued. - testDb.prepare('UPDATE users SET password_version = password_version + 1 WHERE id = ?').run(user.id); + const { password_version: version } = await readUser(orm(), user.id); + await updateRows(orm(), Users, { id: user.id }, { password_version: version + 1 }); const closeCode = await new Promise((resolve) => { const ws = new WebSocket(`${wsUrl}?token=${encodeURIComponent(token)}`); @@ -517,7 +525,7 @@ describe('WS auth edge cases', () => { it('WS-028 — ws-token whose password_version still matches connects successfully', async () => { const { user } = createUser(testDb); // Bump the version first, THEN mint — the token captures the current pv. - testDb.prepare('UPDATE users SET password_version = 3 WHERE id = ?').run(user.id); + await updateRows(orm(), Users, { id: user.id }, { password_version: 3 }); const result = await createWsToken(user.id); const client = await connectWs(result.token!); try { @@ -532,7 +540,7 @@ describe('WS auth edge cases', () => { // Tokens minted via createEphemeralToken carry no pv (treated as version 0). const { user } = createUser(testDb); const token = createEphemeralToken(user.id, 'ws')!; - testDb.prepare('UPDATE users SET password_version = 1 WHERE id = ?').run(user.id); + await updateRows(orm(), Users, { id: user.id }, { password_version: 1 }); const closeCode = await new Promise((resolve) => { const ws = new WebSocket(`${wsUrl}?token=${encodeURIComponent(token)}`);