From 2529255559f3bacceddd9a1e1406c5d0c51dc93c Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 8 Oct 2026 15:48:21 +0200 Subject: [PATCH] test(server): seed the websocket, push, api key and day specs through the factories Converts the raw prepare() writes and reads in the websocket connection suite, the web push and instance API key services, the days MCP spec and the packing visibility and place details cache repository specs to the ORM factories and readers. The websocket suite reads the app's own ORM; the password version bump that was a single relative UPDATE now reads the user and writes the next version. The packing visibility parity keeps the legacy fragment it is checked against behind an allow marker. --- .../PlaceDetailsCache.repository.test.ts | 32 ++--- .../_shared/packing-visibility.parity.test.ts | 35 +++-- server/tests/unit/mcp/tools-days.test.ts | 122 ++++++++++-------- .../notifications/web-push.service.test.ts | 34 +++-- .../nest/settings/instance-api-keys.test.ts | 29 ++--- server/tests/websocket/connection.test.ts | 22 +++- 6 files changed, 147 insertions(+), 127 deletions(-) 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)}`);