From 2860148df32bf026adc86cdb6d0d10c9a62af1bb Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 8 Oct 2026 16:04:51 +0200 Subject: [PATCH] test(server): seed the plugin discovery, permission, feed and stay specs through the factories Converts the raw prepare() fixtures and reads in the plugin discovery spec, the permissions service spec, the feeds and invite MCP spec and the day accommodations repository spec to the ORM factories and readers. The permission cache cases still write their override rows past savePermissions, now through setAppSetting, so the stale-cache behaviour they pin is unchanged. The day accommodations spec keeps the legacy statements it holds the repository to behind allow markers. --- .../DayAccommodations.repository.test.ts | 42 ++++--- .../tests/unit/mcp/tools-feeds-invite.test.ts | 118 ++++++++++-------- .../unit/nest/permissions.service.test.ts | 31 ++--- server/tests/unit/plugins/discovery.test.ts | 38 +++--- 4 files changed, 126 insertions(+), 103 deletions(-) diff --git a/server/tests/unit/db/repositories/DayAccommodations.repository.test.ts b/server/tests/unit/db/repositories/DayAccommodations.repository.test.ts index bcdc28ba5..26f3a8366 100644 --- a/server/tests/unit/db/repositories/DayAccommodations.repository.test.ts +++ b/server/tests/unit/db/repositories/DayAccommodations.repository.test.ts @@ -9,6 +9,9 @@ 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 { updateRows } from '../../../helpers/factories/rows'; +import { readTripDays } from '../../../helpers/factories/trips'; +import { Reservations } from '../../../../src/db/entities/Reservations.entity'; import { createDayAccommodation, createPlace, createReservation, createTrip, createUser } from '../../../helpers/factories'; import { DayAccommodations } from '../../../../src/db/entities/DayAccommodations.entity'; import type { DayAccommodationsRepository } from '../../../../src/db/repositories/DayAccommodations.repository'; @@ -28,41 +31,43 @@ describe('DayAccommodationsRepository — fully seeded world', () => { // One seeded world for every test below: a trip with two stays — // `named` (a place, a check-in/out, a linked live booking) and `bare` // (no place, no booking) — plus a second trip's stay, to prove scoping. - const seed = () => { + const seed = async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { start_date: '2026-09-01', end_date: '2026-09-05' }); const otherTrip = createTrip(testDb, user.id, { start_date: '2026-09-01', end_date: '2026-09-02' }); - const days = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number ASC').all(trip.id) as { id: number }[]; + const days = await readTripDays(t, trip.id); const place = createPlace(testDb, trip.id, { name: 'The Plaza', description: 'Fancy' }); const named = createDayAccommodation(testDb, trip.id, place.id, days[0].id, days[1].id, { check_in: '15:00', check_out: '11:00', confirmation: 'CONF-1' }); const booking = createReservation(testDb, trip.id, { title: 'Plaza Stay', type: 'hotel' }); - testDb.prepare('UPDATE reservations SET accommodation_id = ? WHERE id = ?').run(`${named.id}.0`, booking.id); + await updateRows(t, Reservations, { id: booking.id }, { accommodation_id: `${named.id}.0` }); const bare = createDayAccommodation(testDb, trip.id, place.id, days[2].id, days[3].id); - const otherDays = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number ASC').all(otherTrip.id) as { id: number }[]; + const otherDays = await readTripDays(t, otherTrip.id); const otherPlace = createPlace(testDb, otherTrip.id, { name: 'Elsewhere' }); const otherStay = createDayAccommodation(testDb, otherTrip.id, otherPlace.id, otherDays[0].id, otherDays[1].id); return { trip, otherTrip, days, place, named, booking, bare, otherStay }; }; it('AC2 findWithPlace — matches the legacy LEFT JOIN places statement, both with and without a place', async () => { - const { named, place } = seed(); + const { named, place } = await seed(); const legacySql = ` SELECT a.*, p.name as place_name, p.address as place_address, p.image_url as place_image, p.lat as place_lat, p.lng as place_lng FROM day_accommodations a LEFT JOIN places p ON a.place_id = p.id WHERE a.id = ?`; const typed = await repo.findWithPlace(named.id); + // test-sql-allow: the raw statement is the legacy oracle this parity test holds the repository to. const legacy = testDb.prepare(legacySql).get(named.id); expect(typed).toEqual(legacy); expect(typed?.place_name).toBe(place.name); }); it('AC3 listForTripWithPlaceAndBooking — matches the legacy LEFT JOIN places/reservations statement, fanning out one row per linked booking', async () => { - const { trip, named, bare, booking } = seed(); + const { trip, named, bare, booking } = await seed(); const legacySql = ` SELECT a.*, p.name as place_name, p.address as place_address, p.image_url as place_image, p.lat as place_lat, p.lng as place_lng, r.title as reservation_title FROM day_accommodations a LEFT JOIN places p ON a.place_id = p.id LEFT JOIN reservations r ON r.accommodation_id = a.id WHERE a.trip_id = ? ORDER BY a.created_at ASC`; const typed = await repo.listForTripWithPlaceAndBooking(trip.id); + // test-sql-allow: the raw statement is the legacy oracle this parity test holds the repository to. const legacy = testDb.prepare(legacySql).all(trip.id); expect(typed).toEqual(legacy); expect(typed.map((r) => r.id)).toEqual([named.id, bare.id]); @@ -71,14 +76,15 @@ describe('DayAccommodationsRepository — fully seeded world', () => { }); it('AC34 findInTrip — matches the legacy statement, scoped by trip (a foreign-trip id misses)', async () => { - const { trip, named, otherStay } = seed(); + const { trip, named, otherStay } = await seed(); + // test-sql-allow: the raw statement is the legacy oracle this parity test holds the repository to. const legacy = testDb.prepare('SELECT * FROM day_accommodations WHERE id = ? AND trip_id = ?').get(named.id, trip.id); expect(await repo.findInTrip(named.id, trip.id)).toEqual(legacy); expect(await repo.findInTrip(otherStay.id, trip.id)).toBeUndefined(); }); it('RS37/RS38/RS21 existsInTrip / getCheckIn / getTripId — match the legacy statements', async () => { - const { trip, named, otherStay } = seed(); + const { trip, named, otherStay } = await seed(); expect(await repo.existsInTrip(named.id, trip.id)).toBe(true); expect(await repo.existsInTrip(otherStay.id, trip.id)).toBe(false); expect(await repo.getCheckIn(named.id)).toBe('15:00'); @@ -87,7 +93,8 @@ describe('DayAccommodationsRepository — fully seeded world', () => { }); it('DY19 listStartEndDayNumbers — matches the legacy joined day_number statement', async () => { - const { trip, named, bare } = seed(); + const { trip, named, bare } = await seed(); + // test-sql-allow: the raw statement is the legacy oracle this parity test holds the repository to. const legacy = testDb.prepare(` SELECT a.id, s.day_number as start_no, e.day_number as end_no FROM day_accommodations a JOIN days s ON s.id = a.start_day_id JOIN days e ON e.id = a.end_day_id WHERE a.trip_id = ?`).all(trip.id); @@ -97,7 +104,8 @@ describe('DayAccommodationsRepository — fully seeded world', () => { }); it('DY20 listForResync — matches the legacy statement, both persist(false) mirror columns present', async () => { - const { trip, named } = seed(); + const { trip, named } = await seed(); + // test-sql-allow: the raw statement is the legacy oracle this parity test holds the repository to. const legacy = testDb.prepare('SELECT id, start_day_id, end_day_id, check_in FROM day_accommodations WHERE trip_id = ?').all(trip.id); const typed = await repo.listForResync(trip.id); expect(typed).toEqual(legacy); @@ -105,7 +113,8 @@ describe('DayAccommodationsRepository — fully seeded world', () => { }); it('DY41 listIdsCheckingInOrOutOn — matches the legacy statement: check-in day, check-out day, not a day only run across', async () => { - const { trip, days, named, bare } = seed(); + const { trip, days, named, bare } = await seed(); + // test-sql-allow: the raw statement is the legacy oracle this parity test holds the repository to. const legacy = (dayId: number) => (testDb.prepare( 'SELECT id FROM day_accommodations WHERE trip_id = ? AND (start_day_id = ? OR end_day_id = ?) ORDER BY id', ).all(trip.id, dayId, dayId) as { id: number }[]).map((r) => r.id); @@ -117,12 +126,13 @@ describe('DayAccommodationsRepository — fully seeded world', () => { }); it('DY41 listIdsCheckingInOrOutOn — scoped by trip (another trip\'s day matches nothing here)', async () => { - const { trip, otherStay } = seed(); + const { trip, otherStay } = await seed(); expect(await repo.listIdsCheckingInOrOutOn(trip.id, otherStay.start_day_id)).toEqual([]); }); it('RPL3 listRoadtripStays — matches the legacy LEFT JOIN places + MIN(reservation) statement, id order', async () => { - const { trip, named, bare, booking } = seed(); + const { trip, named, bare, booking } = await seed(); + // test-sql-allow: the raw statement is the legacy oracle this parity test holds the repository to. const legacy = testDb.prepare(` SELECT a.id, a.place_id, a.start_day_id, a.end_day_id, a.check_in, a.check_out, p.name AS place_name, p.lat AS place_lat, p.lng AS place_lng, @@ -136,7 +146,8 @@ describe('DayAccommodationsRepository — fully seeded world', () => { }); it('PL16 listForPlace — matches the legacy statement, scoped by trip AND place', async () => { - const { trip, place, named, bare } = seed(); + const { trip, place, named, bare } = await seed(); + // test-sql-allow: the raw statement is the legacy oracle this parity test holds the repository to. const legacy = testDb.prepare('SELECT id FROM day_accommodations WHERE trip_id = ? AND place_id = ?').all(trip.id, place.id); const typed = await repo.listForPlace(trip.id, place.id); expect(typed).toEqual(legacy); @@ -144,7 +155,8 @@ describe('DayAccommodationsRepository — fully seeded world', () => { }); it('TP55 listAllForTrip — matches the legacy SELECT *, no ORDER BY, scoped by trip', async () => { - const { trip, otherTrip, named, bare, otherStay } = seed(); + const { trip, otherTrip, named, bare, otherStay } = await seed(); + // test-sql-allow: the raw statement is the legacy oracle this parity test holds the repository to. const legacy = testDb.prepare('SELECT * FROM day_accommodations WHERE trip_id = ?').all(trip.id); const typed = await repo.listAllForTrip(trip.id); expect(typed).toEqual(legacy); diff --git a/server/tests/unit/mcp/tools-feeds-invite.test.ts b/server/tests/unit/mcp/tools-feeds-invite.test.ts index 9e5f9feac..4dfaa9b81 100644 --- a/server/tests/unit/mcp/tools-feeds-invite.test.ts +++ b/server/tests/unit/mcp/tools-feeds-invite.test.ts @@ -26,6 +26,13 @@ import { resetTestDb } from '../../helpers/test-db'; import { createUser, createTrip, addTripMember } from '../../helpers/factories'; import { invalidatePermissionsCache } from '../../../src/nest/permissions/permissions-cache'; import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness'; +import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; +import { countRows, findRow, insertRow, updateRows } from '../../helpers/factories/rows'; +import { AppSettings } from '../../../src/db/entities/AppSettings.entity'; +import { AuditLog } from '../../../src/db/entities/AuditLog.entity'; +import { TripInviteTokens } from '../../../src/db/entities/TripInviteTokens.entity'; +import { Trips } from '../../../src/db/entities/Trips.entity'; +import { Users } from '../../../src/db/entities/Users.entity'; const BASE = 'https://trek.example'; @@ -42,20 +49,23 @@ function textOf(result: ToolResult): string { return content.find((c) => c.type === 'text')?.text ?? ''; } -function tripToken(tripId: number): string | null { - const row = testDb.prepare('SELECT feed_token FROM trips WHERE id = ?').get(tripId) as { feed_token: string | null } | undefined; - return row?.feed_token ?? null; +let orm: TestOrm; + +beforeAll(async () => { + orm = await createTestOrm(testDb); +}); + +async function tripToken(tripId: number): Promise { + return (await findRow(orm, Trips, { id: tripId }))?.feed_token ?? null; } -function userToken(userId: number): string | null { - const row = testDb.prepare('SELECT feed_token FROM users WHERE id = ?').get(userId) as { feed_token: string | null } | undefined; - return row?.feed_token ?? null; +async function userToken(userId: number): Promise { + return (await findRow(orm, Users, { id: userId }))?.feed_token ?? null; } -function inviteRow(tripId: number) { - return testDb.prepare('SELECT token, expires_at, created_by FROM trip_invite_tokens WHERE trip_id = ?').get(tripId) as - | { token: string; expires_at: string | null; created_by: number } - | undefined; +/** The trip's stored invite link, undefined when it has none. */ +async function inviteRow(tripId: number) { + return (await findRow(orm, TripInviteTokens, { trip: tripId })) ?? undefined; } interface FeedResult { feed_url: string | null } @@ -76,8 +86,9 @@ beforeEach(async () => { process.env.APP_URL = BASE; }); -afterAll(() => { +afterAll(async () => { delete process.env.APP_URL; + await orm.close(); testDb.close(); }); @@ -100,7 +111,7 @@ describe('Tool: get_trip_calendar_feed', () => { await h.client.callTool({ name: 'enable_trip_calendar_feed', arguments: { tripId: trip.id } }); const on = payload(await h.client.callTool({ name: 'get_trip_calendar_feed', arguments: { tripId: trip.id } })); - expect(on.feed_url).toBe(`${BASE}/api/feed/trip/${tripToken(trip.id)}.ics`); + expect(on.feed_url).toBe(`${BASE}/api/feed/trip/${await tripToken(trip.id)}.ics`); }); }); @@ -109,13 +120,13 @@ describe('Tool: get_trip_calendar_feed', () => { const { user: member } = createUser(testDb); const trip = createTrip(testDb, owner.id); addTripMember(testDb, trip.id, member.id); - testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('perm_share_manage', 'trip_member')").run(); + await insertRow(orm, AppSettings, { key: 'perm_share_manage', value: 'trip_member' }); await withHarness(owner.id, async (h) => { await h.client.callTool({ name: 'enable_trip_calendar_feed', arguments: { tripId: trip.id } }); }); await withHarness(member.id, async (h) => { const result = payload(await h.client.callTool({ name: 'get_trip_calendar_feed', arguments: { tripId: trip.id } })); - expect(result.feed_url).toBe(`${BASE}/api/feed/trip/${tripToken(trip.id)}.ics`); + expect(result.feed_url).toBe(`${BASE}/api/feed/trip/${await tripToken(trip.id)}.ics`); }); }); }); @@ -126,13 +137,13 @@ describe('Tool: enable_trip_calendar_feed', () => { const trip = createTrip(testDb, user.id); await withHarness(user.id, async (h) => { const first = payload(await h.client.callTool({ name: 'enable_trip_calendar_feed', arguments: { tripId: trip.id } })); - const minted = tripToken(trip.id); + const minted = await tripToken(trip.id); expect(minted).toBeTruthy(); expect(first.feed_url).toBe(`${BASE}/api/feed/trip/${minted}.ics`); const second = payload(await h.client.callTool({ name: 'enable_trip_calendar_feed', arguments: { tripId: trip.id } })); expect(second.feed_url).toBe(first.feed_url); - expect(tripToken(trip.id)).toBe(minted); + expect(await tripToken(trip.id)).toBe(minted); }); }); }); @@ -143,9 +154,9 @@ describe('Tool: rotate_trip_calendar_feed', () => { const trip = createTrip(testDb, user.id); await withHarness(user.id, async (h) => { await h.client.callTool({ name: 'enable_trip_calendar_feed', arguments: { tripId: trip.id } }); - const before = tripToken(trip.id); + const before = await tripToken(trip.id); const rotated = payload(await h.client.callTool({ name: 'rotate_trip_calendar_feed', arguments: { tripId: trip.id } })); - const after = tripToken(trip.id); + const after = await tripToken(trip.id); expect(after).not.toBe(before); expect(rotated.feed_url).toBe(`${BASE}/api/feed/trip/${after}.ics`); }); @@ -160,7 +171,7 @@ describe('Tool: disable_trip_calendar_feed', () => { await h.client.callTool({ name: 'enable_trip_calendar_feed', arguments: { tripId: trip.id } }); const result = payload(await h.client.callTool({ name: 'disable_trip_calendar_feed', arguments: { tripId: trip.id } })); expect(result.feed_url).toBeNull(); - expect(tripToken(trip.id)).toBeNull(); + expect(await tripToken(trip.id)).toBeNull(); }); }); }); @@ -177,14 +188,14 @@ describe('trip calendar feed gates', () => { const { user } = createUser(testDb); const { user: other } = createUser(testDb); const foreign = createTrip(testDb, other.id); - testDb.prepare('UPDATE trips SET feed_token = ? WHERE id = ?').run('foreign-token', foreign.id); + await updateRows(orm, Trips, { id: foreign.id }, { feed_token: 'foreign-token' }); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name, arguments: { tripId: foreign.id } }); expect(result.isError).toBe(true); expect(textOf(result)).toBe('Trip not found or access denied.'); // The service scopes its own UPDATE, but the tool must refuse before it: // generate/rotate answer a URL for a token they never wrote. - expect(tripToken(foreign.id)).toBe('foreign-token'); + expect(await tripToken(foreign.id)).toBe('foreign-token'); }); }); @@ -202,12 +213,12 @@ describe('trip calendar feed gates', () => { const { user: member } = createUser(testDb); const trip = createTrip(testDb, owner.id); addTripMember(testDb, trip.id, member.id); - testDb.prepare('UPDATE trips SET feed_token = ? WHERE id = ?').run('owner-token', trip.id); + await updateRows(orm, Trips, { id: trip.id }, { feed_token: 'owner-token' }); await withHarness(member.id, async (h) => { const result = await h.client.callTool({ name, arguments: { tripId: trip.id } }); expect(result.isError).toBe(true); expect(textOf(result)).toBe('You do not have permission to perform this action on this trip.'); - expect(tripToken(trip.id)).toBe('owner-token'); + expect(await tripToken(trip.id)).toBe('owner-token'); }); }); @@ -217,12 +228,12 @@ describe('trip calendar feed gates', () => { process.env.DEMO_MODE = 'true'; const { user: demo } = createUser(testDb, { email: 'demo@trek.app' }); const trip = createTrip(testDb, demo.id); - testDb.prepare('UPDATE trips SET feed_token = ? WHERE id = ?').run('demo-token', trip.id); + await updateRows(orm, Trips, { id: trip.id }, { feed_token: 'demo-token' }); await withHarness(demo.id, async (h) => { const result = await h.client.callTool({ name, arguments: { tripId: trip.id } }); expect(result.isError).toBe(true); expect(textOf(result)).toBe('Write operations are disabled in demo mode.'); - expect(tripToken(trip.id)).toBe('demo-token'); + expect(await tripToken(trip.id)).toBe('demo-token'); }); }, ); @@ -241,7 +252,7 @@ describe('Tool: get_all_trips_calendar_feed', () => { await h.client.callTool({ name: 'enable_all_trips_calendar_feed', arguments: {} }); const on = payload(await h.client.callTool({ name: 'get_all_trips_calendar_feed', arguments: {} })); - expect(on.feed_url).toBe(`${BASE}/api/feed/user/${userToken(user.id)}.ics`); + expect(on.feed_url).toBe(`${BASE}/api/feed/user/${await userToken(user.id)}.ics`); }); }); }); @@ -252,13 +263,13 @@ describe('Tool: enable_all_trips_calendar_feed', () => { const { user: other } = createUser(testDb); await withHarness(user.id, async (h) => { const first = payload(await h.client.callTool({ name: 'enable_all_trips_calendar_feed', arguments: {} })); - const minted = userToken(user.id); + const minted = await userToken(user.id); expect(first.feed_url).toBe(`${BASE}/api/feed/user/${minted}.ics`); - expect(userToken(other.id)).toBeNull(); + expect(await userToken(other.id)).toBeNull(); const second = payload(await h.client.callTool({ name: 'enable_all_trips_calendar_feed', arguments: {} })); expect(second.feed_url).toBe(first.feed_url); - expect(userToken(user.id)).toBe(minted); + expect(await userToken(user.id)).toBe(minted); }); }); }); @@ -268,9 +279,9 @@ describe('Tool: rotate_all_trips_calendar_feed', () => { const { user } = createUser(testDb); await withHarness(user.id, async (h) => { await h.client.callTool({ name: 'enable_all_trips_calendar_feed', arguments: {} }); - const before = userToken(user.id); + const before = await userToken(user.id); const rotated = payload(await h.client.callTool({ name: 'rotate_all_trips_calendar_feed', arguments: {} })); - const after = userToken(user.id); + const after = await userToken(user.id); expect(after).not.toBe(before); expect(rotated.feed_url).toBe(`${BASE}/api/feed/user/${after}.ics`); }); @@ -284,7 +295,7 @@ describe('Tool: disable_all_trips_calendar_feed', () => { await h.client.callTool({ name: 'enable_all_trips_calendar_feed', arguments: {} }); const result = payload(await h.client.callTool({ name: 'disable_all_trips_calendar_feed', arguments: {} })); expect(result.feed_url).toBeNull(); - expect(userToken(user.id)).toBeNull(); + expect(await userToken(user.id)).toBeNull(); }); }); }); @@ -295,12 +306,12 @@ describe('all-trips calendar feed gates', () => { async (name) => { process.env.DEMO_MODE = 'true'; const { user: demo } = createUser(testDb, { email: 'demo@trek.app' }); - testDb.prepare('UPDATE users SET feed_token = ? WHERE id = ?').run('demo-user-token', demo.id); + await updateRows(orm, Users, { id: demo.id }, { feed_token: 'demo-user-token' }); await withHarness(demo.id, async (h) => { const result = await h.client.callTool({ name, arguments: {} }); expect(result.isError).toBe(true); expect(textOf(result)).toBe('Write operations are disabled in demo mode.'); - expect(userToken(demo.id)).toBe('demo-user-token'); + expect(await userToken(demo.id)).toBe('demo-user-token'); }); }, ); @@ -320,8 +331,8 @@ describe('Tool: get_trip_invite_link', () => { await h.client.callTool({ name: 'create_trip_invite_link', arguments: { tripId: trip.id } }); const link = payload(await h.client.callTool({ name: 'get_trip_invite_link', arguments: { tripId: trip.id } })); - expect(link.invite_link?.token).toBe(inviteRow(trip.id)?.token); - expect(link.invite_link?.url).toBe(`${BASE}/join/${inviteRow(trip.id)?.token}`); + expect(link.invite_link?.token).toBe((await inviteRow(trip.id))?.token); + expect(link.invite_link?.url).toBe(`${BASE}/join/${(await inviteRow(trip.id))?.token}`); expect(link.invite_link?.expires_at).toBeNull(); }); }); @@ -333,14 +344,12 @@ describe('Tool: create_trip_invite_link', () => { const trip = createTrip(testDb, user.id); await withHarness(user.id, async (h) => { const created = payload(await h.client.callTool({ name: 'create_trip_invite_link', arguments: { tripId: trip.id } })); - const row = inviteRow(trip.id); + const row = await inviteRow(trip.id); expect(row?.token).toBe(created.invite_link?.token); expect(row?.created_by).toBe(user.id); expect(row?.expires_at).toBeNull(); - const audit = testDb.prepare("SELECT user_id, resource, details FROM audit_log WHERE action = 'trip.invite_link_create'").get() as - | { user_id: number; resource: string; details: string | null } - | undefined; + const audit = await findRow(orm, AuditLog, { action: 'trip.invite_link_create' }); expect(audit?.user_id).toBe(user.id); expect(audit?.resource).toBe(String(trip.id)); }); @@ -356,7 +365,7 @@ describe('Tool: create_trip_invite_link', () => { }); const expected = Date.now() + 7 * 86400000; for (const trip of [numeric, stringy]) { - const at = inviteRow(trip.id)?.expires_at; + const at = (await inviteRow(trip.id))?.expires_at; expect(at).toBeTruthy(); expect(Math.abs(new Date(at as string).getTime() - expected)).toBeLessThan(60_000); } @@ -370,8 +379,8 @@ describe('Tool: create_trip_invite_link', () => { await h.client.callTool({ name: 'create_trip_invite_link', arguments: { tripId: zero.id, expires_in_days: 0 } }); await h.client.callTool({ name: 'create_trip_invite_link', arguments: { tripId: blank.id, expires_in_days: '' } }); }); - expect(inviteRow(zero.id)?.expires_at).toBeNull(); - expect(inviteRow(blank.id)?.expires_at).toBeNull(); + expect((await inviteRow(zero.id))?.expires_at).toBeNull(); + expect((await inviteRow(blank.id))?.expires_at).toBeNull(); }); it('rejects an expiry the shared contract does not admit', async () => { @@ -380,7 +389,7 @@ describe('Tool: create_trip_invite_link', () => { await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'create_trip_invite_link', arguments: { tripId: trip.id, expires_in_days: '7 days' } }); expect(result.isError).toBe(true); - expect(inviteRow(trip.id)).toBeUndefined(); + expect(await inviteRow(trip.id)).toBeUndefined(); }); }); @@ -391,9 +400,8 @@ describe('Tool: create_trip_invite_link', () => { const first = payload(await h.client.callTool({ name: 'create_trip_invite_link', arguments: { tripId: trip.id } })); const second = payload(await h.client.callTool({ name: 'create_trip_invite_link', arguments: { tripId: trip.id } })); expect(second.invite_link?.token).not.toBe(first.invite_link?.token); - const count = testDb.prepare('SELECT COUNT(*) AS n FROM trip_invite_tokens WHERE trip_id = ?').get(trip.id) as { n: number }; - expect(count.n).toBe(1); - expect(inviteRow(trip.id)?.token).toBe(second.invite_link?.token); + expect(await countRows(orm, TripInviteTokens, { trip: trip.id })).toBe(1); + expect((await inviteRow(trip.id))?.token).toBe(second.invite_link?.token); }); }); }); @@ -406,8 +414,8 @@ describe('Tool: delete_trip_invite_link', () => { await h.client.callTool({ name: 'create_trip_invite_link', arguments: { tripId: trip.id } }); const result = payload<{ success: boolean }>(await h.client.callTool({ name: 'delete_trip_invite_link', arguments: { tripId: trip.id } })); expect(result.success).toBe(true); - expect(inviteRow(trip.id)).toBeUndefined(); - const audit = testDb.prepare("SELECT resource FROM audit_log WHERE action = 'trip.invite_link_delete'").get() as { resource: string } | undefined; + expect(await inviteRow(trip.id)).toBeUndefined(); + const audit = await findRow(orm, AuditLog, { action: 'trip.invite_link_delete' }); expect(audit?.resource).toBe(String(trip.id)); }); }); @@ -423,13 +431,13 @@ describe('trip invite link gates', () => { await withHarness(other.id, async (h) => { await h.client.callTool({ name: 'create_trip_invite_link', arguments: { tripId: foreign.id } }); }); - const existing = inviteRow(foreign.id)?.token; + const existing = (await inviteRow(foreign.id))?.token; await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name, arguments: { tripId: foreign.id } }); expect(result.isError).toBe(true); expect(textOf(result)).toBe('Trip not found or access denied.'); }); - expect(inviteRow(foreign.id)?.token).toBe(existing); + expect((await inviteRow(foreign.id))?.token).toBe(existing); }); it.each(INVITE_TOOLS)('%s refuses a trip id that does not exist', async (name) => { @@ -449,26 +457,26 @@ describe('trip invite link gates', () => { await withHarness(owner.id, async (h) => { await h.client.callTool({ name: 'create_trip_invite_link', arguments: { tripId: trip.id } }); }); - const existing = inviteRow(trip.id)?.token; + const existing = (await inviteRow(trip.id))?.token; await withHarness(member.id, async (h) => { const result = await h.client.callTool({ name, arguments: { tripId: trip.id } }); expect(result.isError).toBe(true); expect(textOf(result)).toBe('You do not have permission to perform this action on this trip.'); }); - expect(inviteRow(trip.id)?.token).toBe(existing); + expect((await inviteRow(trip.id))?.token).toBe(existing); }); it.each(['create_trip_invite_link', 'delete_trip_invite_link'])('%s refuses a demo account', async (name) => { process.env.DEMO_MODE = 'true'; const { user: demo } = createUser(testDb, { email: 'demo@trek.app' }); const trip = createTrip(testDb, demo.id); - testDb.prepare('INSERT INTO trip_invite_tokens (trip_id, token, created_by) VALUES (?, ?, ?)').run(trip.id, 'seeded-token', demo.id); + await insertRow(orm, TripInviteTokens, { trip: trip.id, token: 'seeded-token', createdByRef: demo.id }); await withHarness(demo.id, async (h) => { const result = await h.client.callTool({ name, arguments: { tripId: trip.id } }); expect(result.isError).toBe(true); expect(textOf(result)).toBe('Write operations are disabled in demo mode.'); }); - expect(inviteRow(trip.id)?.token).toBe('seeded-token'); + expect((await inviteRow(trip.id))?.token).toBe('seeded-token'); }); }); diff --git a/server/tests/unit/nest/permissions.service.test.ts b/server/tests/unit/nest/permissions.service.test.ts index ab41d7dd0..27d65d368 100644 --- a/server/tests/unit/nest/permissions.service.test.ts +++ b/server/tests/unit/nest/permissions.service.test.ts @@ -52,6 +52,8 @@ import { } from '../../../src/nest/permissions/permissions-cache'; import { createTestUnitOfWork } from '../../helpers/test-uow'; import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; +import { deleteRows, findRows } from '../../helpers/factories/rows'; +import { readAppSetting, setAppSetting } from '../../helpers/factories/settings'; import { AppSettings } from '../../../src/db/entities/AppSettings.entity'; import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository'; @@ -67,7 +69,7 @@ beforeAll(async () => { beforeEach(async () => { vi.clearAllMocks(); - testDb.prepare("DELETE FROM app_settings WHERE key LIKE 'perm_%'").run(); + await deleteRows(t, AppSettings, { key: { $like: 'perm_%' } }); t.clear(); await svc.invalidatePermissionsCache(); }); @@ -155,7 +157,7 @@ describe('savePermissions — invalid input is silently skipped', () => { it('PERM-SVC-011: returns skipped array containing invalid action key, writes no row', async () => { const result = await svc.savePermissions({ nonexistent_action: 'trip_member' }); expect(result.skipped).toContain('nonexistent_action'); - const rows = testDb.prepare("SELECT key FROM app_settings WHERE key LIKE 'perm_%'").all(); + const rows = await findRows(t, AppSettings, { key: { $like: 'perm_%' } }); expect(rows).toEqual([]); }); @@ -163,15 +165,15 @@ describe('savePermissions — invalid input is silently skipped', () => { // trip_delete only allows ['admin', 'trip_owner'], so 'trip_member' is invalid const result = await svc.savePermissions({ trip_delete: 'trip_member' }); expect(result.skipped).toContain('trip_delete'); - const rows = testDb.prepare("SELECT key FROM app_settings WHERE key LIKE 'perm_%'").all(); + const rows = await findRows(t, AppSettings, { key: { $like: 'perm_%' } }); expect(rows).toEqual([]); }); }); describe('corrupt stored levels', () => { it('PERM-SVC-013: an unrecognized stored level is ignored — every reader falls back to the default', async () => { - testDb.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run('perm_trip_edit', 'unknown_level'); - testDb.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run('perm_trip_delete', ''); + await setAppSetting(t, 'perm_trip_edit', 'unknown_level'); + await setAppSetting(t, 'perm_trip_delete', ''); await svc.invalidatePermissionsCache(); // Since the quirk fix the corrupt rows never enter the cache, so // getPermissionLevel, getAllPermissions and checkPermission agree on the @@ -185,7 +187,7 @@ describe('corrupt stored levels', () => { it('PERM-SVC-021: a stored level outside the action\'s allowedLevels is ignored too', async () => { // trip_edit only allows trip_owner/trip_member — a raw 'everybody' row must not widen it. - testDb.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run('perm_trip_edit', 'everybody'); + await setAppSetting(t, 'perm_trip_edit', 'everybody'); await svc.invalidatePermissionsCache(); expect(await svc.getPermissionLevel('trip_edit')).toBe('trip_owner'); expect(await svc.checkPermission('trip_edit', 'user', 10, 30, false)).toBe(false); @@ -227,7 +229,7 @@ describe('load failures', () => { // The mock is exhausted after the one queued rejection — the next call // falls through to the real (now-working) repository read. - testDb.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run('perm_trip_edit', 'trip_member'); + await setAppSetting(t, 'perm_trip_edit', 'trip_member'); expect(await svc.getPermissionLevel('trip_edit')).toBe('trip_member'); expect(await getPermissionsCache()).not.toBe(null); spy.mockRestore(); @@ -256,7 +258,7 @@ describe('load failures', () => { it('PERM-SVC-023: an all-skipped save writes nothing and leaves the cache untouched', async () => { expect(await svc.getPermissionLevel('trip_edit')).toBe('trip_owner'); // prime the cache - testDb.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run('perm_trip_edit', 'trip_member'); + await setAppSetting(t, 'perm_trip_edit', 'trip_member'); const result = await svc.savePermissions({ bogus: 'trip_member', trip_delete: 'trip_member' }); expect(result.skipped).toEqual(['bogus', 'trip_delete']); // No valid entries → no transaction and no cache flush: the raw row above @@ -271,7 +273,7 @@ describe('load failures', () => { describe('stored overrides + cache', () => { it('PERM-SVC-014: stored perm_ row overrides the default after invalidation', async () => { - testDb.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run('perm_trip_edit', 'trip_member'); + await setAppSetting(t, 'perm_trip_edit', 'trip_member'); await svc.invalidatePermissionsCache(); expect(await svc.getPermissionLevel('trip_edit')).toBe('trip_member'); // A plain member now passes what defaults to a trip_owner-only action. @@ -283,16 +285,15 @@ describe('stored overrides + cache', () => { expect(await svc.getPermissionLevel('trip_edit')).toBe('trip_owner'); const result = await svc.savePermissions({ trip_edit: 'trip_member' }); expect(result.skipped).toEqual([]); - const row = testDb.prepare('SELECT value FROM app_settings WHERE key = ?').get('perm_trip_edit') as { value: string }; - expect(row.value).toBe('trip_member'); + expect(await readAppSetting(t, 'perm_trip_edit')).toBe('trip_member'); // No manual invalidation — savePermissions did it. expect(await svc.getPermissionLevel('trip_edit')).toBe('trip_member'); }); it('PERM-SVC-016: the cache memoizes until invalidated', async () => { expect(await svc.getPermissionLevel('trip_edit')).toBe('trip_owner'); - // Raw SQL write bypasses savePermissions' self-invalidation → stale value served. - testDb.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run('perm_trip_edit', 'trip_member'); + // A direct row write bypasses savePermissions' self-invalidation → stale value served. + await setAppSetting(t, 'perm_trip_edit', 'trip_member'); expect(await svc.getPermissionLevel('trip_edit')).toBe('trip_owner'); await svc.invalidatePermissionsCache(); expect(await svc.getPermissionLevel('trip_edit')).toBe('trip_member'); @@ -322,10 +323,10 @@ describe('module-scoped permissions cache', () => { // (checkPermission for a plain member flips with the stored level). await svc.savePermissions({ trip_edit: 'trip_member' }); expect(await secondInstance.checkPermission('trip_edit', 'user', 10, 20, true)).toBe(true); - // Raw SQL write, then invalidate through permissions-cache — the plain + // A direct row write, then invalidate through permissions-cache — the plain // function backup.impl.ts calls after a restore. Both service instances // must serve the fresh value afterwards. - testDb.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run('perm_trip_edit', 'trip_owner'); + await setAppSetting(t, 'perm_trip_edit', 'trip_owner'); expect(await svc.getPermissionLevel('trip_edit')).toBe('trip_member'); // still cached await invalidateSharedCache(); expect(await svc.getPermissionLevel('trip_edit')).toBe('trip_owner'); diff --git a/server/tests/unit/plugins/discovery.test.ts b/server/tests/unit/plugins/discovery.test.ts index ba5ea585b..1e4096a4a 100644 --- a/server/tests/unit/plugins/discovery.test.ts +++ b/server/tests/unit/plugins/discovery.test.ts @@ -21,6 +21,7 @@ import { PluginSettingsFields } from '../../../src/db/entities/PluginSettingsFie import { PluginErrorLog } from '../../../src/db/entities/PluginErrorLog.entity'; import { discoverPlugins, type DiscoveryRepos } from '../../../src/nest/plugins/install/discovery'; import { UnitOfWork } from '../../../src/nest/database/unit-of-work'; +import { countRows, findRow, findRows, insertRow } from '../../helpers/factories/rows'; const testDb = createSnapshotTestDb(); let t: TestOrm; @@ -66,12 +67,12 @@ describe('discoverPlugins', () => { const res = await discoverPlugins(repos); expect(res.discovered).toEqual(['flight-tracker']); - const row = testDb.prepare("SELECT status, type, permissions FROM plugins WHERE id='flight-tracker'").get() as { status: string; type: string; permissions: string }; - expect(row.status).toBe('inactive'); - expect(row.type).toBe('widget'); - expect(JSON.parse(row.permissions)).toEqual(['db:own']); + const row = await findRow(t, Plugins, { id: 'flight-tracker' }); + expect(row?.status).toBe('inactive'); + expect(row?.type).toBe('widget'); + expect(JSON.parse(String(row?.permissions))).toEqual(['db:own']); - const field = testDb.prepare("SELECT field_key, secret FROM plugin_settings_fields WHERE plugin_id='flight-tracker'").get() as { field_key: string; secret: number }; + const field = await findRow(t, PluginSettingsFields, { plugin_id: 'flight-tracker' }); expect(field).toMatchObject({ field_key: 'api_key', secret: 1 }); }); @@ -81,7 +82,8 @@ describe('discoverPlugins', () => { actions: [{ key: 'ping', label: 'Ping' }, { key: 'purge', label: 'Purge', scope: 'instance', danger: true }], }); await discoverPlugins(repos); - const rows = testDb.prepare("SELECT action_key, scope, danger FROM plugin_actions WHERE plugin_id='acts' ORDER BY sort_order").all(); + const rows = (await findRows(t, PluginActions, { plugin_id: 'acts' }, { sort_order: 'asc' })) + .map((r) => ({ action_key: r.action_key, scope: r.scope, danger: r.danger })); expect(rows).toEqual([ { action_key: 'ping', scope: 'user', danger: 0 }, { action_key: 'purge', scope: 'instance', danger: 1 }, @@ -89,13 +91,13 @@ describe('discoverPlugins', () => { }); it('keeps an existing plugin status + granted permissions on re-discovery', async () => { - testDb.prepare("INSERT INTO plugins (id, name, type, status, granted_permissions) VALUES ('keep','Keep','page','active','[\"db:own\"]')").run(); + await insertRow(t, Plugins, { id: 'keep', name: 'Keep', type: 'page', status: 'active', granted_permissions: '["db:own"]' }); writePlugin('keep', { name: 'Keep v2', type: 'page', version: '2.0.0' }); await discoverPlugins(repos); - const row = testDb.prepare("SELECT status, version, granted_permissions FROM plugins WHERE id='keep'").get() as { status: string; version: string; granted_permissions: string }; - expect(row.status).toBe('active'); // not downgraded - expect(row.version).toBe('2.0.0'); // metadata refreshed - expect(JSON.parse(row.granted_permissions)).toEqual(['db:own']); // grants preserved + const row = await findRow(t, Plugins, { id: 'keep' }); + expect(row?.status).toBe('active'); // not downgraded + expect(row?.version).toBe('2.0.0'); // metadata refreshed + expect(JSON.parse(String(row?.granted_permissions))).toEqual(['db:own']); // grants preserved }); it('tolerates a UTF-8 BOM in trek-plugin.json (Windows-authored plugins)', async () => { @@ -109,8 +111,8 @@ describe('discoverPlugins', () => { writePlugin('bad', { type: 'not-a-type' }); const res = await discoverPlugins(repos); expect(res.skipped).toEqual(['bad']); - expect(testDb.prepare("SELECT COUNT(*) c FROM plugins WHERE id='bad'").get()).toMatchObject({ c: 0 }); - expect((testDb.prepare("SELECT message FROM plugin_error_log WHERE plugin_id='bad'").get() as { message: string }).message).toContain('discovery'); + expect(await countRows(t, Plugins, { id: 'bad' })).toBe(0); + expect((await findRow(t, PluginErrorLog, { plugin_id: 'bad' }))?.message).toContain('discovery'); }); it('skips a plugin that ships native binaries', async () => { @@ -133,12 +135,12 @@ describe('discoverPlugins', () => { // Off (default): a stale dev-link symlink is not discovered or registered. delete process.env.TREK_PLUGINS_DEV_LINK; expect((await discoverPlugins(repos)).discovered).toEqual([]); - expect(testDb.prepare("SELECT status FROM plugins WHERE id='linked'").get()).toBeUndefined(); + expect(await findRow(t, Plugins, { id: 'linked' })).toBeNull(); // On: the dev-link is followed and registered inactive. process.env.TREK_PLUGINS_DEV_LINK = '1'; expect((await discoverPlugins(repos)).discovered).toEqual(['linked']); - expect(testDb.prepare("SELECT status FROM plugins WHERE id='linked'").get()).toMatchObject({ status: 'inactive' }); + expect(await findRow(t, Plugins, { id: 'linked' })).toMatchObject({ status: 'inactive' }); } finally { if (prev === undefined) delete process.env.TREK_PLUGINS_DEV_LINK; else process.env.TREK_PLUGINS_DEV_LINK = prev; fs.rmSync(srcRoot, { recursive: true, force: true }); @@ -154,7 +156,7 @@ describe('discoverPlugins', () => { it('persists the range and its lower bound', async () => { writePlugin('ranged', { trek: '>=3.2.0 <4.0.0' }); await discoverPlugins(repos); - expect(testDb.prepare("SELECT trek_range, min_trek_version FROM plugins WHERE id='ranged'").get()) + expect(await findRow(t, Plugins, { id: 'ranged' })) .toMatchObject({ trek_range: '>=3.2.0 <4.0.0', min_trek_version: '3.2.0' }); }); @@ -165,7 +167,7 @@ describe('discoverPlugins', () => { // registered with a null range and the activation gate refuses it (TREK_VERSION_UNKNOWN). writePlugin('rangeless', {}); expect((await discoverPlugins(repos)).discovered).toEqual(['rangeless']); - expect(testDb.prepare("SELECT trek_range FROM plugins WHERE id='rangeless'").get()).toMatchObject({ trek_range: null }); + expect(await findRow(t, Plugins, { id: 'rangeless' })).toMatchObject({ trek_range: null }); }); it('refreshes the range on re-discovery, so a plugin that narrowed its support is caught', async () => { @@ -173,7 +175,7 @@ describe('discoverPlugins', () => { await discoverPlugins(repos); writePlugin('shrink', { trek: '>=3.0.0 <3.1.0' }); await discoverPlugins(repos); - expect(testDb.prepare("SELECT trek_range FROM plugins WHERE id='shrink'").get()).toMatchObject({ trek_range: '>=3.0.0 <3.1.0' }); + expect(await findRow(t, Plugins, { id: 'shrink' })).toMatchObject({ trek_range: '>=3.0.0 <3.1.0' }); }); }); });