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' }); }); }); });