diff --git a/server/tests/e2e/google-quota.e2e.test.ts b/server/tests/e2e/google-quota.e2e.test.ts index e139aa973..b47a63e83 100644 --- a/server/tests/e2e/google-quota.e2e.test.ts +++ b/server/tests/e2e/google-quota.e2e.test.ts @@ -21,7 +21,14 @@ import { AuditService } from '../../src/nest/audit/audit.service'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; import { TestUnitOfWorkModule } from '../helpers/test-uow'; -import { createTestMikroOrmModule } from '../helpers/test-orm'; +import { todayUtc } from '@trek/shared'; +import { AppSettings } from '../../src/db/entities/AppSettings.entity'; +import { GoogleApiUsage } from '../../src/db/entities/GoogleApiUsage.entity'; +import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm'; +import { makeAdmin, makeUser } from '../helpers/factories/users'; +import { deleteRows, upsertRow } from '../helpers/factories/rows'; + +let orm: TestOrm; const USER = 1; const ADMIN = 2; @@ -45,20 +52,18 @@ describe('/api/admin/google-quota e2e (real guards + temp SQLite)', () => { } beforeAll(async () => { - // harness.ts's seedUser() omits password_hash, which the migrated schema - // requires NOT NULL, so the two accounts are inserted here directly. - const insertUser = db.prepare( - 'INSERT INTO users (id, username, email, password_hash, role, password_version) VALUES (?, ?, ?, ?, ?, 0)', - ); - insertUser.run(USER, 'e2e-user', 'e2e@example.test', 'x', 'user'); - insertUser.run(ADMIN, 'e2e-admin', 'admin@example.com', 'x', 'admin'); - db.prepare("DELETE FROM app_settings WHERE key = 'google_daily_limit'").run(); + orm = await createTestOrm(db); + // Pinned ids: sessionCookie(USER) and (ADMIN) sign for exactly these users. + await makeUser(orm, { id: USER, username: 'e2e-user', email: 'e2e@example.test' }); + await makeAdmin(orm, { id: ADMIN, username: 'e2e-admin', email: 'admin@example.com' }); + await deleteRows(orm, AppSettings, { key: 'google_daily_limit' }); app = await build(); server = app.getHttpServer(); }); afterAll(async () => { await app.close(); + await orm.close(); }); it('GQUOTA-E2E-001: 401 without a cookie and 403 for a non-admin', async () => { @@ -68,7 +73,7 @@ describe('/api/admin/google-quota e2e (real guards + temp SQLite)', () => { }); it('GQUOTA-E2E-002: stores the ceiling, reports today, and audits the change', async () => { - db.prepare("INSERT OR REPLACE INTO google_api_usage (day, calls) VALUES (date('now'), 7)").run(); + await upsertRow(orm, GoogleApiUsage, { day: todayUtc(), calls: 7 }); const put = await request(server).put('/api/admin/google-quota').set('Cookie', sessionCookie(ADMIN)).send({ daily_limit: 5 }); expect(put.status).toBe(200); expect(put.body).toEqual({ daily_limit: 5, used_today: 7, exhausted: true }); diff --git a/server/tests/e2e/notifications.e2e.test.ts b/server/tests/e2e/notifications.e2e.test.ts index 54ee037ae..a57a66919 100644 --- a/server/tests/e2e/notifications.e2e.test.ts +++ b/server/tests/e2e/notifications.e2e.test.ts @@ -1,7 +1,7 @@ /** * Notifications module e2e — exercises the migrated /api/notifications endpoints - * through the real JwtAuthGuard against a temp SQLite db. NotificationsService - * runs its real (DI-native) in-app SQL — notifications DDL below; the channel + * through the real JwtAuthGuard against a migrated temp SQLite db. NotificationsService + * runs its real (DI-native) in-app SQL; the channel * transports and the preference matrix (still plain services/* modules) are * mocked. Focuses on auth, the inline admin gate on /test-smtp, routing (the * /in-app/all ordering trap) and status/body shapes. @@ -11,47 +11,13 @@ import request from 'supertest'; import cookieParser from 'cookie-parser'; import type { Server } from 'http'; import { Test } from '@nestjs/testing'; -import { seedUser, sessionCookie } from './harness'; +import { sessionCookie } from './harness'; -const { db } = vi.hoisted(() => { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const Database = require('better-sqlite3'); - const tmp = new Database(':memory:'); - tmp.exec('PRAGMA journal_mode = WAL'); - tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL, - email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0, - avatar TEXT);`); - // NotificationsService runs its real SQL (DI-native since the notifications - // fold) — full notifications column set for listInApp/markRead/deleteAll. - tmp.exec(`CREATE TABLE notifications ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - type TEXT NOT NULL CHECK(type IN ('simple', 'boolean', 'navigate')), - scope TEXT NOT NULL CHECK(scope IN ('trip', 'user', 'admin')), - target INTEGER NOT NULL, - sender_id INTEGER REFERENCES users(id) ON DELETE SET NULL, - recipient_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - title_key TEXT NOT NULL, - title_params TEXT DEFAULT '{}', - text_key TEXT NOT NULL, - text_params TEXT DEFAULT '{}', - positive_text_key TEXT, - negative_text_key TEXT, - positive_callback TEXT, - negative_callback TEXT, - response TEXT CHECK(response IN ('positive', 'negative')), - navigate_text_key TEXT, - navigate_target TEXT, - is_read INTEGER DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - );`); - // StorageRegistryService (behind StorageModule, now in this module chain) reads - // this at onModuleInit. - tmp.exec('CREATE TABLE app_settings (key TEXT PRIMARY KEY, value TEXT);'); - return { db: tmp }; +vi.mock('../../src/db/database', async () => { + const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); + return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} })); - // The preference matrix and the channel transports are providers since the // fold — overridden at the container instead of mocked by module path; the // in-app store is real SQL. @@ -62,6 +28,8 @@ const { prefs, mailer, webhook, ntfy } = vi.hoisted(() => ({ ntfy: { testNtfy: vi.fn(), getUserNtfyConfig: vi.fn(), getAdminNtfyConfig: vi.fn() }, })); +import { db } from '../../src/db/database'; +import { Notifications } from '../../src/db/entities/Notifications.entity'; import { RealtimeModule } from '../../src/nest/realtime/realtime.module'; import { NotificationsModule } from '../../src/nest/notifications/notifications.module'; import { MailerService } from '../../src/nest/notifications/mailer/mailer.service'; @@ -70,17 +38,23 @@ import { NtfyService } from '../../src/nest/notifications/transports/ntfy.servic import { WebhookService } from '../../src/nest/notifications/transports/webhook.service'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; import { TestUnitOfWorkModule } from '../helpers/test-uow'; -import { createTestMikroOrmModule } from '../helpers/test-orm'; +import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm'; +import { makeAdmin, makeUser } from '../helpers/factories/users'; +import { makeNotification } from '../helpers/factories/notifications'; +import { deleteRows, findRows } from '../helpers/factories/rows'; -function seedNotification(recipientId: number, overrides: { is_read?: number } = {}): number { - const r = db.prepare( - `INSERT INTO notifications (type, scope, target, recipient_id, title_key, text_key, is_read) - VALUES ('simple', 'user', ?, ?, 'notif.test.title', 'notif.test.text', ?)` - ).run(recipientId, recipientId, overrides.is_read ?? 0); - return r.lastInsertRowid as number; +let orm: TestOrm; + +async function seedNotification(recipientId: number, overrides: { is_read?: number } = {}): Promise { + const row = await makeNotification(orm, recipientId, { + title_key: 'notif.test.title', + text_key: 'notif.test.text', + is_read: overrides.is_read ?? 0, + }); + return row.id; } -describe('Notifications e2e (real auth guard + temp SQLite)', () => { +describe('Notifications e2e (real auth guard + migrated temp SQLite)', () => { let server: Server; let app: Awaited>; @@ -101,8 +75,10 @@ describe('Notifications e2e (real auth guard + temp SQLite)', () => { } beforeAll(async () => { - seedUser(db as never, { id: 1, role: 'admin', email: 'admin@example.test' }); - seedUser(db as never, { id: 2, role: 'user', email: 'user@example.test' }); + orm = await createTestOrm(db); + // Pinned ids: sessionCookie(1) and (2) sign for exactly these users. + await makeAdmin(orm, { id: 1, email: 'admin@example.test' }); + await makeUser(orm, { id: 2, email: 'user@example.test' }); app = await build(); server = app.getHttpServer(); prefs.getPreferencesMatrix.mockReturnValue({ preferences: {}, available_channels: {}, event_types: [], implemented_combos: {} }); @@ -111,6 +87,7 @@ describe('Notifications e2e (real auth guard + temp SQLite)', () => { afterAll(async () => { await app.close(); + await orm.close(); }); it('401 without a session cookie', async () => { @@ -138,25 +115,25 @@ describe('Notifications e2e (real auth guard + temp SQLite)', () => { }); it('200 unread-count from the real notifications table', async () => { - db.prepare('DELETE FROM notifications').run(); - seedNotification(2); - seedNotification(2); - seedNotification(2, { is_read: 1 }); + await deleteRows(orm, Notifications); + await seedNotification(2); + await seedNotification(2); + await seedNotification(2, { is_read: 1 }); const res = await request(server).get('/api/notifications/in-app/unread-count').set('Cookie', sessionCookie(2)); expect(res.status).toBe(200); expect(res.body).toEqual({ count: 2 }); }); it('DELETE /in-app/all hits deleteAll, not the /:id handler', async () => { - db.prepare('DELETE FROM notifications').run(); - for (let i = 0; i < 4; i++) seedNotification(2); - const other = seedNotification(1); + await deleteRows(orm, Notifications); + for (let i = 0; i < 4; i++) await seedNotification(2); + const other = await seedNotification(1); const res = await request(server).delete('/api/notifications/in-app/all').set('Cookie', sessionCookie(2)); expect(res.status).toBe(200); expect(res.body).toEqual({ success: true, count: 4 }); // Only user 2's rows are gone — the static route deleted per-recipient, it // did not fall through to the /:id param handler (which would 400 on 'all'). - const rows = db.prepare('SELECT id FROM notifications').all() as { id: number }[]; + const rows = await findRows(orm, Notifications); expect(rows.map(r => r.id)).toEqual([other]); }); diff --git a/server/tests/integration/collections.test.ts b/server/tests/integration/collections.test.ts index d7745e563..433a1ef1e 100644 --- a/server/tests/integration/collections.test.ts +++ b/server/tests/integration/collections.test.ts @@ -29,37 +29,45 @@ vi.mock('../../src/config', () => ({ vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn(), getOnlineUserIds: vi.fn(() => []) })); import { db as testDb } from '../../src/db/database'; +import { MikroORM } from '@mikro-orm/core'; import { buildApp } from '../../src/bootstrap'; import { resetTestDb, resetRateLimits } from '../helpers/test-db'; +import type { FactoryOrm } from '../helpers/factories/context'; import { createUser } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; +import { insertRow, upsertRow } from '../helpers/factories/rows'; +import { Addons } from '../../src/db/entities/Addons.entity'; +import { Collections } from '../../src/db/entities/Collections.entity'; +import { CollectionPlaces } from '../../src/db/entities/CollectionPlaces.entity'; let nestApp: INestApplication; let app: Application; +let orm: FactoryOrm; const FIXTURE_IMG = path.join(__dirname, '../fixtures/small-image.jpg'); const coversDir = path.join(__dirname, '../../uploads/covers'); const placesDir = path.join(__dirname, '../../uploads/places'); -function createCollection(ownerId: number): number { - return Number(testDb.prepare("INSERT INTO collections (owner_id, name) VALUES (?, 'C')").run(ownerId).lastInsertRowid); +async function createCollection(ownerId: number): Promise { + return insertRow(orm, Collections, { owner: ownerId, name: 'C' }); } -function createCollectionPlace(collectionId: number, ownerId: number): number { - return Number(testDb.prepare("INSERT INTO collection_places (collection_id, owner_id, name) VALUES (?, ?, 'P')").run(collectionId, ownerId).lastInsertRowid); +async function createCollectionPlace(collectionId: number, ownerId: number): Promise { + return insertRow(orm, CollectionPlaces, { collection: collectionId, owner: ownerId, name: 'P' }); } beforeAll(async () => { nestApp = await buildApp(); app = nestApp.getHttpAdapter().getInstance(); + orm = nestApp.get(MikroORM); }); beforeEach(async () => { resetTestDb(testDb); await resetRateLimits(nestApp); // Enable the collections addon (the controller sits behind AddonGuard). - testDb.prepare( - "INSERT OR REPLACE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES ('collections', 'Collections', 'Saved places', 'global', 'Bookmark', 1, 40)" - ).run(); + await upsertRow(orm, Addons, { + id: 'collections', name: 'Collections', description: 'Saved places', type: 'global', icon: 'Bookmark', enabled: true, sort_order: 40, + }); }); afterAll(async () => { @@ -72,7 +80,7 @@ afterAll(async () => { describe('Collection cover upload', () => { it('COLL-P01 — cover upload stores /uploads/covers/ and writes the file', async () => { const { user } = createUser(testDb); - const collectionId = createCollection(user.id); + const collectionId = await createCollection(user.id); const res = await request(app) .post(`/api/addons/collections/${collectionId}/cover`) @@ -86,7 +94,7 @@ describe('Collection cover upload', () => { it('COLL-P02 — no file → 400 "No image uploaded"', async () => { const { user } = createUser(testDb); - const collectionId = createCollection(user.id); + const collectionId = await createCollection(user.id); const res = await request(app) .post(`/api/addons/collections/${collectionId}/cover`) @@ -97,7 +105,7 @@ describe('Collection cover upload', () => { it('COLL-P03 — non-image cover is 500 (plain-Error filter quirk — pinned, do not "fix")', async () => { const { user } = createUser(testDb); - const collectionId = createCollection(user.id); + const collectionId = await createCollection(user.id); const res = await request(app) .post(`/api/addons/collections/${collectionId}/cover`) @@ -110,8 +118,8 @@ describe('Collection cover upload', () => { describe('Collection place image upload', () => { it('COLL-P04 — place image upload stores /uploads/places/ and writes the file', async () => { const { user } = createUser(testDb); - const collectionId = createCollection(user.id); - const placeId = createCollectionPlace(collectionId, user.id); + const collectionId = await createCollection(user.id); + const placeId = await createCollectionPlace(collectionId, user.id); const res = await request(app) .post(`/api/addons/collections/places/${placeId}/image`) @@ -125,8 +133,8 @@ describe('Collection place image upload', () => { it('COLL-P05 — non-image place upload is 400 with the bespoke message', async () => { const { user } = createUser(testDb); - const collectionId = createCollection(user.id); - const placeId = createCollectionPlace(collectionId, user.id); + const collectionId = await createCollection(user.id); + const placeId = await createCollectionPlace(collectionId, user.id); const res = await request(app) .post(`/api/addons/collections/places/${placeId}/image`) diff --git a/server/tests/integration/dayReorder.test.ts b/server/tests/integration/dayReorder.test.ts index 7a9d1e7aa..898690fa0 100644 --- a/server/tests/integration/dayReorder.test.ts +++ b/server/tests/integration/dayReorder.test.ts @@ -33,7 +33,13 @@ import { createTestDayAccommodationsRepo, createTestRoadtripViasRepo, createTestRoadtripDayBoundariesRepo, + sharedTestOrm, } from '../helpers/test-uow'; +import { findRow, findRows, updateRows } from '../helpers/factories/rows'; +import { readTripDays } from '../helpers/factories/trips'; +import { DayAssignments } from '../../src/db/entities/DayAssignments.entity'; +import { Reservations } from '../../src/db/entities/Reservations.entity'; +import { Trips } from '../../src/db/entities/Trips.entity'; let svc: DaysService; beforeAll(async () => { @@ -65,50 +71,48 @@ beforeEach(() => { afterAll(() => testDb.close()); -const orderedDays = (tripId: number) => - testDb.prepare('SELECT id, day_number, date FROM days WHERE trip_id = ? ORDER BY day_number').all(tripId) as - { id: number; day_number: number; date: string | null }[]; +const orderedDays = async (tripId: number) => readTripDays(await sharedTestOrm(testDb), tripId); describe('reorderDays', () => { it('permutes positions, pins dates to slots, and content rides along by id', async () => { const trip = createTrip(testDb, userId, { start_date: '2026-03-01', end_date: '2026-03-03' }); - const [d1, d2, d3] = orderedDays(trip.id); + const [d1, d2, d3] = await orderedDays(trip.id); const place = createPlace(testDb, trip.id); createDayAssignment(testDb, d2.id, place.id); // place sits on day 2 // Move day 2 to the front: [d2, d1, d3] await reorderDays(trip.id, [d2.id, d1.id, d3.id]); - const after = orderedDays(trip.id); + const after = await orderedDays(trip.id); expect(after.map(d => d.id)).toEqual([d2.id, d1.id, d3.id]); // Dates stay pinned to their calendar slots expect(after.map(d => d.date)).toEqual(['2026-03-01', '2026-03-02', '2026-03-03']); // The place rides along with its day row (still attached to d2.id, now at slot 1) - const onD2 = testDb.prepare('SELECT * FROM day_assignments WHERE day_id = ?').all(d2.id); + const onD2 = await findRows(await sharedTestOrm(testDb), DayAssignments, { day: d2.id }); expect(onD2).toHaveLength(1); }); it('re-stamps a booking\'s date onto its day\'s new date, keeping the time', async () => { const trip = createTrip(testDb, userId, { start_date: '2026-03-01', end_date: '2026-03-03' }); - const [d1, d2, d3] = orderedDays(trip.id); + const [d1, d2, d3] = await orderedDays(trip.id); const res = createReservation(testDb, trip.id, { day_id: d2.id, type: 'restaurant' }); - testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?').run('2026-03-02T19:00', res.id); + await updateRows(await sharedTestOrm(testDb), Reservations, { id: res.id }, { reservation_time: '2026-03-02T19:00' }); await reorderDays(trip.id, [d2.id, d1.id, d3.id]); // d2 moves to the 2026-03-01 slot - const r = testDb.prepare('SELECT reservation_time FROM reservations WHERE id = ?').get(res.id) as { reservation_time: string }; - expect(r.reservation_time).toBe('2026-03-01T19:00'); + const r = await findRow(await sharedTestOrm(testDb), Reservations, { id: res.id }); + expect(r?.reservation_time).toBe('2026-03-01T19:00'); }); it('rejects an orderedIds list that is not a permutation of the trip days', async () => { const trip = createTrip(testDb, userId, { start_date: '2026-03-01', end_date: '2026-03-03' }); - const [d1, d2] = orderedDays(trip.id); + const [d1, d2] = await orderedDays(trip.id); await expect(reorderDays(trip.id, [d1.id, d2.id])).rejects.toThrow(DayReorderError); }); it('blocks a move that would make an accommodation end before it starts, and rolls back', async () => { const trip = createTrip(testDb, userId, { start_date: '2026-03-01', end_date: '2026-03-03' }); - const [d1, d2, d3] = orderedDays(trip.id); + const [d1, d2, d3] = await orderedDays(trip.id); const place = createPlace(testDb, trip.id); createDayAccommodation(testDb, trip.id, place.id, d1.id, d2.id); // stay spans day 1 -> day 2 @@ -116,7 +120,7 @@ describe('reorderDays', () => { await expect(reorderDays(trip.id, [d2.id, d3.id, d1.id])).rejects.toThrow(DayReorderError); // Transaction rolled back: original order intact - expect(orderedDays(trip.id).map(d => d.id)).toEqual([d1.id, d2.id, d3.id]); + expect((await orderedDays(trip.id)).map(d => d.id)).toEqual([d1.id, d2.id, d3.id]); }); }); @@ -129,7 +133,7 @@ describe('insertDay', () => { const created = await insertDay(trip.id, 1); - const after = orderedDays(trip.id); + const after = await orderedDays(trip.id); expect(after).toHaveLength(4); expect(after[0].id).toBe(created.id); expect(after[0].date).toBeNull(); @@ -138,18 +142,18 @@ describe('insertDay', () => { it('inserts at the front of a dated trip: dates stay contiguous and the trip extends', async () => { const trip = createTrip(testDb, userId, { start_date: '2026-03-01', end_date: '2026-03-03' }); - const [d1, d2, d3] = orderedDays(trip.id); + const [d1, d2, d3] = await orderedDays(trip.id); const created = await insertDay(trip.id, 1); - const after = orderedDays(trip.id); + const after = await orderedDays(trip.id); expect(after).toHaveLength(4); expect(after[0].id).toBe(created.id); expect(after.map(d => d.date)).toEqual(['2026-03-01', '2026-03-02', '2026-03-03', '2026-03-04']); // Old content shifted down a slot expect(after.slice(1).map(d => d.id)).toEqual([d1.id, d2.id, d3.id]); // Trip range extended by one day - const t = testDb.prepare('SELECT end_date FROM trips WHERE id = ?').get(trip.id) as { end_date: string }; - expect(t.end_date).toBe('2026-03-04'); + const stored = await findRow(await sharedTestOrm(testDb), Trips, { id: trip.id }); + expect(stored?.end_date).toBe('2026-03-04'); }); }); diff --git a/server/tests/integration/leg-mode-incoming.test.ts b/server/tests/integration/leg-mode-incoming.test.ts index 60a2ceac3..2b3503270 100644 --- a/server/tests/integration/leg-mode-incoming.test.ts +++ b/server/tests/integration/leg-mode-incoming.test.ts @@ -29,18 +29,24 @@ vi.mock('../../src/config', () => ({ vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); import { db as testDb2 } from '../../src/db/database'; +import { MikroORM } from '@mikro-orm/core'; import { buildApp } from '../../src/bootstrap'; import { resetTestDb, resetRateLimits } from '../helpers/test-db'; +import type { FactoryOrm } from '../helpers/factories/context'; import { createUser, createTrip, createDay, createPlace } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; +import { findRow, updateRows } from '../helpers/factories/rows'; +import { DayAssignments } from '../../src/db/entities/DayAssignments.entity'; describe('incoming_leg_transport_mode read-path parity', () => { let nestApp: INestApplication; let app: Application; + let orm: FactoryOrm; beforeAll(async () => { nestApp = await buildApp(); app = nestApp.getHttpAdapter().getInstance(); + orm = nestApp.get(MikroORM); }); beforeEach(async () => { @@ -66,7 +72,7 @@ describe('incoming_leg_transport_mode read-path parity', () => { expect(create.status).toBe(201); const assignmentId = create.body.assignment.id; - testDb2.prepare('UPDATE day_assignments SET incoming_leg_transport_mode = ? WHERE id = ?').run('transit', assignmentId); + await updateRows(orm, DayAssignments, { id: assignmentId }, { incoming_leg_transport_mode: 'transit' }); const res = await request(app) .get(`/api/trips/${trip.id}/days`) @@ -100,9 +106,9 @@ describe('incoming_leg_transport_mode read-path parity', () => { .send({ transport_mode: 'cycling' }); expect(res.status).toBe(200); - const row = testDb2.prepare('SELECT leg_transport_mode, incoming_leg_transport_mode FROM day_assignments WHERE id = ?').get(assignmentId) as { leg_transport_mode: string | null; incoming_leg_transport_mode: string | null }; - expect(row.leg_transport_mode).toBe('cycling'); - expect(row.incoming_leg_transport_mode).toBeNull(); + const row = await findRow(orm, DayAssignments, { id: assignmentId }); + expect(row?.leg_transport_mode).toBe('cycling'); + expect(row?.incoming_leg_transport_mode).toBeNull(); }); it("direction: 'incoming' writes the incoming column", async () => { @@ -123,8 +129,8 @@ describe('incoming_leg_transport_mode read-path parity', () => { .send({ transport_mode: 'transit', direction: 'incoming' }); expect(res.status).toBe(200); - const row = testDb2.prepare('SELECT incoming_leg_transport_mode FROM day_assignments WHERE id = ?').get(assignmentId) as { incoming_leg_transport_mode: string | null }; - expect(row.incoming_leg_transport_mode).toBe('transit'); + const row = await findRow(orm, DayAssignments, { id: assignmentId }); + expect(row?.incoming_leg_transport_mode).toBe('transit'); }); it('rejects an invalid direction with 400', async () => { @@ -170,9 +176,9 @@ describe('incoming_leg_transport_mode read-path parity', () => { .send({ transport_mode: 'transit', direction: 'incoming' }) .expect(200); - const row = testDb2.prepare('SELECT leg_transport_mode, incoming_leg_transport_mode FROM day_assignments WHERE id = ?').get(assignmentId) as { leg_transport_mode: string | null; incoming_leg_transport_mode: string | null }; - expect(row.leg_transport_mode).toBe('cycling'); - expect(row.incoming_leg_transport_mode).toBe('transit'); + const row = await findRow(orm, DayAssignments, { id: assignmentId }); + expect(row?.leg_transport_mode).toBe('cycling'); + expect(row?.incoming_leg_transport_mode).toBe('transit'); const res = await request(app) .get(`/api/trips/${trip.id}/days`) diff --git a/server/tests/integration/packing.test.ts b/server/tests/integration/packing.test.ts index 46fc37259..409af8c86 100644 --- a/server/tests/integration/packing.test.ts +++ b/server/tests/integration/packing.test.ts @@ -23,17 +23,26 @@ vi.mock('../../src/config', () => ({ vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); import { db as testDb } from '../../src/db/database'; +import { MikroORM } from '@mikro-orm/core'; import { buildApp } from '../../src/bootstrap'; import { resetTestDb, resetRateLimits } from '../helpers/test-db'; +import type { FactoryOrm } from '../helpers/factories/context'; import { createUser, createTrip, createPackingItem, addTripMember } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; +import { findRows, insertRow } from '../helpers/factories/rows'; +import { PackingItems } from '../../src/db/entities/PackingItems.entity'; +import { PackingTemplates } from '../../src/db/entities/PackingTemplates.entity'; +import { PackingTemplateCategories } from '../../src/db/entities/PackingTemplateCategories.entity'; +import { PackingTemplateItems } from '../../src/db/entities/PackingTemplateItems.entity'; let nestApp: INestApplication; let app: Application; +let orm: FactoryOrm; beforeAll(async () => { nestApp = await buildApp(); app = nestApp.getHttpAdapter().getInstance(); + orm = nestApp.get(MikroORM); }); beforeEach(async () => { @@ -352,9 +361,7 @@ describe('Reorder packing items', () => { expect(res.status).toBe(200); expect(res.body.success).toBe(true); - const rows = testDb - .prepare('SELECT id, sort_order FROM packing_items WHERE trip_id = ? ORDER BY sort_order') - .all(trip.id) as Array<{ id: number; sort_order: number }>; + const rows = await findRows(orm, PackingItems, { trip: trip.id }, { sort_order: 'asc' }); expect(rows[0].id).toBe(i2.id); expect(rows[1].id).toBe(i1.id); }); @@ -549,10 +556,9 @@ describe('Packing — apply-template, bag members, save-as-template', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const tpl = testDb.prepare("INSERT INTO packing_templates (name, created_by) VALUES ('Beach', ?)").run(user.id); - const cat = testDb.prepare("INSERT INTO packing_template_categories (template_id, name, sort_order) VALUES (?, 'Essentials', 0)").run(tpl.lastInsertRowid); - testDb.prepare("INSERT INTO packing_template_items (category_id, name, sort_order) VALUES (?, 'Sunscreen', 0)").run(cat.lastInsertRowid); - const templateId = tpl.lastInsertRowid; + const templateId = await insertRow(orm, PackingTemplates, { name: 'Beach', createdByRef: user.id }); + const categoryId = await insertRow(orm, PackingTemplateCategories, { template: templateId, name: 'Essentials', sort_order: 0 }); + await insertRow(orm, PackingTemplateItems, { category: categoryId, name: 'Sunscreen', sort_order: 0 }); const res = await request(app) .post(`/api/trips/${trip.id}/packing/apply-template/${templateId}`) @@ -569,8 +575,7 @@ describe('Packing — apply-template, bag members, save-as-template', () => { const trip = createTrip(testDb, user.id); // Template with no items - const tpl = testDb.prepare("INSERT INTO packing_templates (name, created_by) VALUES ('Empty', ?)").run(user.id); - const emptyTemplateId = tpl.lastInsertRowid; + const emptyTemplateId = await insertRow(orm, PackingTemplates, { name: 'Empty', createdByRef: user.id }); const res = await request(app) .post(`/api/trips/${trip.id}/packing/apply-template/${emptyTemplateId}`) diff --git a/server/tests/integration/trip-access-primitives.test.ts b/server/tests/integration/trip-access-primitives.test.ts index 8639e212e..91c1f9eed 100644 --- a/server/tests/integration/trip-access-primitives.test.ts +++ b/server/tests/integration/trip-access-primitives.test.ts @@ -42,18 +42,26 @@ vi.mock('../../src/config', () => ({ vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); import { db as testDb } from '../../src/db/database'; +import { MikroORM } from '@mikro-orm/core'; import { buildApp } from '../../src/bootstrap'; import { resetTestDb, resetRateLimits } from '../helpers/test-db'; +import type { FactoryOrm } from '../helpers/factories/context'; import { createUser, createTrip, createDay, createPlace, addTripMember, createDayAssignment } from '../helpers/factories'; import { authCookie, generateToken } from '../helpers/auth'; import { closeMcpSessions } from '../../src/mcp/index'; +import { findRow, updateRows } from '../helpers/factories/rows'; +import { Addons } from '../../src/db/entities/Addons.entity'; +import { Categories } from '../../src/db/entities/Categories.entity'; +import { Trips } from '../../src/db/entities/Trips.entity'; let nestApp: INestApplication; let app: Application; +let orm: FactoryOrm; beforeAll(async () => { nestApp = await buildApp(); app = nestApp.getHttpAdapter().getInstance(); + orm = nestApp.get(MikroORM); }); beforeEach(async () => { resetTestDb(testDb); @@ -137,8 +145,8 @@ describe('isOwner (async) — the delete_trip MCP tool', () => { return (JSON.parse(line.slice('data:'.length).trim()) as { result?: { isError?: boolean; content?: { type: string; text: string }[] } }).result ?? {}; } - beforeEach(() => { - testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'mcp'").run(); + beforeEach(async () => { + await updateRows(orm, Addons, { id: 'mcp' }, { enabled: true }); }); it('PRIM-ISOWN-001 — owner: 200, deletes the trip', async () => { @@ -157,7 +165,7 @@ describe('isOwner (async) — the delete_trip MCP tool', () => { const result = toolResult(res.text); expect(result.isError).toBeFalsy(); expect(JSON.parse(result.content?.[0]?.text ?? '{}')).toEqual({ success: true, tripId: trip.id }); - expect(testDb.prepare('SELECT id FROM trips WHERE id = ?').get(trip.id)).toBeUndefined(); + expect(await findRow(orm, Trips, { id: trip.id })).toBeNull(); }); /** @@ -215,7 +223,7 @@ describe('isOwner (async) — the delete_trip MCP tool', () => { const result = toolResult(res.text); expect(result.isError).toBe(true); expect(result.content?.[0]?.text).toBe('Trip not found or access denied.'); - expect(testDb.prepare('SELECT id FROM trips WHERE id = ?').get(trip.id)).toBeDefined(); + expect(await findRow(orm, Trips, { id: trip.id })).not.toBeNull(); }); it('PRIM-ISOWN-003 — non-member/stranger: the identical refusal isOwner gives a member, trip untouched', async () => { @@ -235,7 +243,7 @@ describe('isOwner (async) — the delete_trip MCP tool', () => { const result = toolResult(res.text); expect(result.isError).toBe(true); expect(result.content?.[0]?.text).toBe('Trip not found or access denied.'); - expect(testDb.prepare('SELECT id FROM trips WHERE id = ?').get(trip.id)).toBeDefined(); + expect(await findRow(orm, Trips, { id: trip.id })).not.toBeNull(); }); it('PRIM-ISOWN-004 — anonymous: 401, no session established', async () => { @@ -333,8 +341,9 @@ describe('getPlaceWithTags (async) — GET /api/trips/:tripId/places/:id', () => // actually assigned rather than assuming none, so this pins the real // production shape instead of a factory default that happens to be null. const place = createPlace(testDb, trip.id, { name: 'Eiffel Tower' }); - const expectedCategory = place.category_id - ? testDb.prepare('SELECT id, name, color, icon FROM categories WHERE id = ?').get(place.category_id) + const category = place.category_id ? await findRow(orm, Categories, { id: place.category_id }) : null; + const expectedCategory = category + ? { id: category.id, name: category.name, color: category.color, icon: category.icon } : null; const res = await request(app).get(`/api/trips/${trip.id}/places/${place.id}`).set('Cookie', authCookie(owner.id)); diff --git a/server/tests/integration/uploads-photo-request-context.test.ts b/server/tests/integration/uploads-photo-request-context.test.ts index 4c944ad14..058975cdf 100644 --- a/server/tests/integration/uploads-photo-request-context.test.ts +++ b/server/tests/integration/uploads-photo-request-context.test.ts @@ -55,6 +55,9 @@ import { generateToken } from '../helpers/auth'; import { applyPlatformUploads } from '../../src/nest/platform/platform.routes'; import { StorageService } from '../../src/nest/storage/storage.service'; import { Users } from '../../src/db/entities/Users.entity'; +import { Photos } from '../../src/db/entities/Photos.entity'; +import { insertRow } from '../helpers/factories/rows'; +import { makeShareToken } from '../helpers/factories/trips'; let nestApp: INestApplication; let app: Application; @@ -136,8 +139,8 @@ describe('GET /uploads/photos/:filename — request context (Plan 3b Task 0, D6) it('PHOTOCTX-004 (R1): a valid share token serves the SAME photo bytes as the JWT path, byte-identical', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - testDb.prepare('INSERT INTO photos (trip_id, filename, original_name) VALUES (?, ?, ?)').run(trip.id, photoName, photoName); - testDb.prepare('INSERT INTO share_tokens (trip_id, token, created_by) VALUES (?, ?, ?)').run(trip.id, 'ratchet-valid-token', user.id); + await insertRow(nestApp.get(MikroORM), Photos, { trip: trip.id, filename: photoName, original_name: photoName }); + await makeShareToken(nestApp.get(MikroORM), trip.id, user.id, { token: 'ratchet-valid-token' }); const res = await request(app).get(`/uploads/photos/${photoName}?token=ratchet-valid-token`); @@ -148,7 +151,7 @@ describe('GET /uploads/photos/:filename — request context (Plan 3b Task 0, D6) it('PHOTOCTX-005 (R1): an invalid/unknown share token answers the legacy 401 — never `cannotUseGlobalContext`', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - testDb.prepare('INSERT INTO photos (trip_id, filename, original_name) VALUES (?, ?, ?)').run(trip.id, photoName, photoName); + await insertRow(nestApp.get(MikroORM), Photos, { trip: trip.id, filename: photoName, original_name: photoName }); // No matching share_tokens row for this token at all. const res = await request(app).get(`/uploads/photos/${photoName}?token=ratchet-unknown-token`); diff --git a/server/tests/unit/db/entities/primary-relations.spike.test.ts b/server/tests/unit/db/entities/primary-relations.spike.test.ts index f89e30082..5aaccb7ce 100644 --- a/server/tests/unit/db/entities/primary-relations.spike.test.ts +++ b/server/tests/unit/db/entities/primary-relations.spike.test.ts @@ -54,6 +54,7 @@ describe('primary-relation entities (Task 1 spike)', () => { t.em.clear(); const rawInsert = testDb + // test-sql-allow: the raw row is the oracle the ORM's toObject() is held against. .prepare('SELECT * FROM budget_category_order WHERE trip_id = ? AND category = ?') .get(trip.id, 'Food'); expect(rawInsert).toEqual({ trip_id: trip.id, category: 'Food', sort_order: 2 }); @@ -81,6 +82,7 @@ describe('primary-relation entities (Task 1 spike)', () => { await t.em.persist(created).flush(); t.em.clear(); + // test-sql-allow: the raw row is the oracle the ORM's toObject() is held against. const rawInsert = testDb.prepare('SELECT * FROM vacay_user_settings WHERE user_id = ?').get(user.id); expect(rawInsert).toEqual({ user_id: user.id, @@ -168,6 +170,7 @@ describe('primary-relation entities (Task 1 spike)', () => { expect(found?.key).toBe('spike_setting'); expect(found?.value).toBe('on'); + // test-sql-allow: the raw row is the oracle the ORM's toObject() is held against. const raw = testDb.prepare('SELECT * FROM app_settings WHERE key = ?').get('spike_setting'); expect(wrap(found).toObject()).toStrictEqual(raw); }); diff --git a/server/tests/unit/db/repositories/BudgetItems.repository.test.ts b/server/tests/unit/db/repositories/BudgetItems.repository.test.ts index 443c49a39..7d30eb469 100644 --- a/server/tests/unit/db/repositories/BudgetItems.repository.test.ts +++ b/server/tests/unit/db/repositories/BudgetItems.repository.test.ts @@ -13,18 +13,25 @@ import { createSnapshotTestDb } from '../../../helpers/db-mock'; import { resetTestDb } from '../../../helpers/test-db'; import { createBudgetItem, createPlace, createReservation, createTrip, createUser } from '../../../helpers/factories'; import { createTestBudgetItemsRepo } from '../../../helpers/files-repos'; +import { sharedTestOrm } from '../../../helpers/test-uow'; +import type { TestOrm } from '../../../helpers/test-orm'; +import { findRow, findRows, updateRows } from '../../../helpers/factories/rows'; +import { BudgetItems } from '../../../../src/db/entities/BudgetItems.entity'; import type { BudgetItemsRepository } from '../../../../src/db/repositories/BudgetItems.repository'; const testDb = createSnapshotTestDb(); let budgetItemsRepo: BudgetItemsRepository; +let orm: TestOrm; beforeAll(async () => { budgetItemsRepo = await createTestBudgetItemsRepo(testDb); + orm = await sharedTestOrm(testDb); }); beforeEach(() => resetTestDb(testDb)); afterAll(() => testDb.close()); function legacyPublicForShare(tripId: number): unknown { + // test-sql-allow: the raw SELECT is the legacy oracle this parity test holds the repository to. return testDb.prepare('SELECT * FROM budget_items WHERE trip_id = ? ORDER BY category ASC').all(tripId); } @@ -38,18 +45,18 @@ describe('BudgetItemsRepository — share.service.ts SH14 read', () => { // Every nullable column set — sorts second by category. const withDetails = createBudgetItem(testDb, trip.id, { name: 'Hotel', category: 'Lodging', total_price: 250 }); - testDb.prepare(` - UPDATE budget_items SET persons = 2, days = 3, note = 'non-refundable', sort_order = 4, - paid_by_user_id = ?, expense_date = '2026-09-02', reservation_id = ?, currency = 'EUR', - exchange_rate = 0.92, ticket_json = '{"seat":"12A"}', place_id = ? - WHERE id = ?`).run(user.id, reservation.id, place.id, withDetails.id); + await updateRows(orm, BudgetItems, { id: withDetails.id }, { + persons: 2, days: 3, note: 'non-refundable', sort_order: 4, + paidByUser: user.id, expense_date: '2026-09-02', reservation: reservation.id, currency: 'EUR', + exchange_rate: 0.92, ticket_json: '{"seat":"12A"}', place: place.id, + }); // Every nullable column left null — sorts first by category. const bare = createBudgetItem(testDb, trip.id, { name: 'Snacks', category: 'Food', total_price: 12.5 }); - testDb.prepare(` - UPDATE budget_items SET persons = NULL, days = NULL, note = NULL, paid_by_user_id = NULL, - expense_date = NULL, reservation_id = NULL, currency = NULL, place_id = NULL - WHERE id = ?`).run(bare.id); + await updateRows(orm, BudgetItems, { id: bare.id }, { + persons: null, days: null, note: null, paidByUser: null, + expense_date: null, reservation: null, currency: null, place: null, + }); // A budget item on a different trip must never leak in. createBudgetItem(testDb, other.id, { category: 'Other' }); @@ -76,7 +83,7 @@ describe('BudgetItemsRepository — reservations.service.ts RS49 delete', () => const kept = createBudgetItem(testDb, trip.id, { name: 'Hotel' }); await budgetItemsRepo.deleteByIds([first.id, second.id]); - expect((testDb.prepare('SELECT id FROM budget_items WHERE trip_id = ?').all(trip.id) as { id: number }[]).map((r) => r.id)) + expect((await findRows(orm, BudgetItems, { trip: trip.id })).map((r) => r.id)) .toEqual([kept.id]); }); @@ -85,6 +92,6 @@ describe('BudgetItemsRepository — reservations.service.ts RS49 delete', () => const trip = createTrip(testDb, user.id); const kept = createBudgetItem(testDb, trip.id); await budgetItemsRepo.deleteByIds([]); - expect(testDb.prepare('SELECT id FROM budget_items WHERE id = ?').get(kept.id)).toEqual({ id: kept.id }); + expect(await findRow(orm, BudgetItems, { id: kept.id })).toMatchObject({ id: kept.id }); }); }); diff --git a/server/tests/unit/db/repositories/DawarichConnections.repository.test.ts b/server/tests/unit/db/repositories/DawarichConnections.repository.test.ts index b591bc91b..034953ff8 100644 --- a/server/tests/unit/db/repositories/DawarichConnections.repository.test.ts +++ b/server/tests/unit/db/repositories/DawarichConnections.repository.test.ts @@ -3,6 +3,8 @@ import { createSnapshotTestDb } from '../../../helpers/db-mock'; import { resetTestDb } from '../../../helpers/test-db'; import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; import { createUser } from '../../../helpers/factories'; +import { countRows, findRow, insertRow, upsertRow } from '../../../helpers/factories/rows'; +import { DawarichVisitSuggestions } from '../../../../src/db/entities/DawarichVisitSuggestions.entity'; import { DawarichConnections } from '../../../../src/db/entities/DawarichConnections.entity'; import type { DawarichConnectionsRepository } from '../../../../src/db/repositories/DawarichConnections.repository'; @@ -24,7 +26,7 @@ afterAll(async () => { testDb.close(); }); -function seed(userId: number, over: Partial<{ +async function seed(userId: number, over: Partial<{ url: string | null; api_key: string | null; allow_insecure_tls: number; @@ -33,28 +35,22 @@ function seed(userId: number, over: Partial<{ last_sync_state: string; last_sync_error: string | null; capabilities: string | null; -}> = {}): void { - testDb - .prepare( - `INSERT OR REPLACE INTO dawarich_connections - (user_id, url, api_key, allow_insecure_tls, sync_enabled, last_sync_at, last_sync_state, last_sync_error, capabilities) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ) - .run( - userId, - over.url === undefined ? 'https://dawarich.example' : over.url, - over.api_key === undefined ? 'enc:v1:cipher' : over.api_key, - over.allow_insecure_tls ?? 0, - over.sync_enabled ?? 1, - over.last_sync_at ?? null, - over.last_sync_state ?? 'never', - over.last_sync_error ?? null, - over.capabilities ?? null, - ); +}> = {}): Promise { + await upsertRow(t, DawarichConnections, { + user: userId, + url: over.url === undefined ? 'https://dawarich.example' : over.url, + api_key: over.api_key === undefined ? 'enc:v1:cipher' : over.api_key, + allow_insecure_tls: over.allow_insecure_tls ?? 0, + sync_enabled: over.sync_enabled ?? 1, + last_sync_at: over.last_sync_at ?? null, + last_sync_state: over.last_sync_state ?? 'never', + last_sync_error: over.last_sync_error ?? null, + capabilities: over.capabilities ?? null, + }); } -function raw(userId: number): Record | undefined { - return testDb.prepare('SELECT * FROM dawarich_connections WHERE user_id = ?').get(userId) as Record | undefined; +function raw(userId: number) { + return findRow(t, DawarichConnections, { user: userId }); } describe('DawarichConnectionsRepository', () => { @@ -66,7 +62,7 @@ describe('DawarichConnectionsRepository', () => { it('CONNREPO-002: a fully-seeded row comes back byte-identical to a legacy SELECT of the same 9 columns', async () => { const { user } = createUser(testDb); - seed(user.id, { + await seed(user.id, { url: 'https://dawarich.example/api', api_key: 'enc:v1:abcdef', allow_insecure_tls: 1, @@ -78,7 +74,8 @@ describe('DawarichConnectionsRepository', () => { }); const row = await connections.findRow(user.id); - const legacy = raw(user.id)!; + // test-sql-allow: the legacy SELECT of the same 9 columns is the oracle this case holds findRow to. + const legacy = testDb.prepare('SELECT * FROM dawarich_connections WHERE user_id = ?').get(user.id) as Record; expect(row).toEqual({ user_id: legacy.user_id, @@ -102,11 +99,11 @@ describe('DawarichConnectionsRepository', () => { const blankUrl = createUser(testDb).user.id; const noKey = createUser(testDb).user.id; - seed(syncable); - seed(pollOff, { sync_enabled: 0 }); - seed(noUrl, { url: null }); - seed(blankUrl, { url: '' }); - seed(noKey, { api_key: null }); + await seed(syncable); + await seed(pollOff, { sync_enabled: 0 }); + await seed(noUrl, { url: null }); + await seed(blankUrl, { url: '' }); + await seed(noKey, { api_key: null }); expect(await connections.listSyncableUserIds()).toEqual([syncable]); }); @@ -116,9 +113,9 @@ describe('DawarichConnectionsRepository', () => { it('CONNREPO-020: the stored url, or null for no row / a null column', async () => { const { user } = createUser(testDb); expect(await connections.getUrl(user.id)).toBeNull(); - seed(user.id, { url: 'https://d.example' }); + await seed(user.id, { url: 'https://d.example' }); expect(await connections.getUrl(user.id)).toBe('https://d.example'); - seed(user.id, { url: null }); + await seed(user.id, { url: null }); expect(await connections.getUrl(user.id)).toBeNull(); }); }); @@ -128,18 +125,18 @@ describe('DawarichConnectionsRepository', () => { const { user } = createUser(testDb); await connections.upsertConnection(user.id, { url: 'https://d.example', allowInsecureTls: true, syncEnabled: false }); - const row = raw(user.id)!; + const row = (await raw(user.id))!; expect(row).toMatchObject({ url: 'https://d.example', allow_insecure_tls: 1, sync_enabled: 0, api_key: null, capabilities: null, last_sync_state: 'never' }); expect(row.updated_at).toBeTruthy(); }); it('CONNREPO-031: on conflict merges ONLY url/allow_insecure_tls/sync_enabled/updated_at — api_key and capabilities survive untouched', async () => { const { user } = createUser(testDb); - seed(user.id, { url: 'https://old.example', api_key: 'enc:v1:stored', capabilities: '{"visits":true}', last_sync_state: 'ok' }); + await seed(user.id, { url: 'https://old.example', api_key: 'enc:v1:stored', capabilities: '{"visits":true}', last_sync_state: 'ok' }); await connections.upsertConnection(user.id, { url: 'https://new.example', allowInsecureTls: true, syncEnabled: true }); - const row = raw(user.id)!; + const row = (await raw(user.id))!; expect(row.url).toBe('https://new.example'); expect(row.allow_insecure_tls).toBe(1); expect(row.api_key).toBe('enc:v1:stored'); @@ -154,31 +151,31 @@ describe('DawarichConnectionsRepository', () => { await connections.upsertConnection(user.id, { url: 'https://d.example', allowInsecureTls: false, syncEnabled: false }); - expect(raw(user.id)!.updated_at).not.toBe('2000-01-01 00:00:00'); + expect((await raw(user.id))!.updated_at).not.toBe('2000-01-01 00:00:00'); }); }); describe('setApiKey (DWC5/DWC7)', () => { it('CONNREPO-040: writes the given ciphertext, and null scrubs it', async () => { const { user } = createUser(testDb); - seed(user.id, { api_key: 'enc:v1:old' }); + await seed(user.id, { api_key: 'enc:v1:old' }); await connections.setApiKey(user.id, 'enc:v1:new'); - expect(raw(user.id)!.api_key).toBe('enc:v1:new'); + expect((await raw(user.id))!.api_key).toBe('enc:v1:new'); await connections.setApiKey(user.id, null); - expect(raw(user.id)!.api_key).toBeNull(); + expect((await raw(user.id))!.api_key).toBeNull(); }); }); describe('resetSyncState (DWC6)', () => { it('CONNREPO-050: clears capabilities/last_sync_* without touching api_key', async () => { const { user } = createUser(testDb); - seed(user.id, { api_key: 'enc:v1:kept', capabilities: '{"a":1}', last_sync_state: 'ok', last_sync_error: 'x', last_sync_at: '2026-01-01T00:00:00Z' }); + await seed(user.id, { api_key: 'enc:v1:kept', capabilities: '{"a":1}', last_sync_state: 'ok', last_sync_error: 'x', last_sync_at: '2026-01-01T00:00:00Z' }); await connections.resetSyncState(user.id); - const row = raw(user.id)!; + const row = (await raw(user.id))!; expect(row).toMatchObject({ api_key: 'enc:v1:kept', capabilities: null, last_sync_state: 'never', last_sync_error: null, last_sync_at: null }); }); }); @@ -186,11 +183,11 @@ describe('DawarichConnectionsRepository', () => { describe('clearForRemovedUrl (DWC8)', () => { it('CONNREPO-060: clears api_key together with capabilities/last_sync_* in one statement', async () => { const { user } = createUser(testDb); - seed(user.id, { api_key: 'enc:v1:gone', capabilities: '{"a":1}', last_sync_state: 'ok', last_sync_error: 'x', last_sync_at: '2026-01-01T00:00:00Z' }); + await seed(user.id, { api_key: 'enc:v1:gone', capabilities: '{"a":1}', last_sync_state: 'ok', last_sync_error: 'x', last_sync_at: '2026-01-01T00:00:00Z' }); await connections.clearForRemovedUrl(user.id); - expect(raw(user.id)!).toMatchObject({ api_key: null, capabilities: null, last_sync_state: 'never', last_sync_error: null, last_sync_at: null }); + expect((await raw(user.id))!).toMatchObject({ api_key: null, capabilities: null, last_sync_state: 'never', last_sync_error: null, last_sync_at: null }); }); }); @@ -198,30 +195,27 @@ describe('DawarichConnectionsRepository', () => { it('CONNREPO-070: removes only the named user\'s row, and does not cascade to suggestions (no FK relation exists for it to cascade through)', async () => { const { user } = createUser(testDb); const other = createUser(testDb).user.id; - seed(user.id); - seed(other); - testDb - .prepare( - `INSERT INTO dawarich_visit_suggestions - (user_id, source_visit_id, name, lat, lng, started_at, ended_at, local_date, source_hash) - VALUES (?, 'v1', 'Cafe', 1, 1, '2026-01-01T00:00:00Z', '2026-01-01T01:00:00Z', '2026-01-01', 'hash')`, - ) - .run(user.id); + await seed(user.id); + await seed(other); + await insertRow(t, DawarichVisitSuggestions, { + user: user.id, source_visit_id: 'v1', name: 'Cafe', lat: 1, lng: 1, + started_at: '2026-01-01T00:00:00Z', ended_at: '2026-01-01T01:00:00Z', local_date: '2026-01-01', source_hash: 'hash', + }); await connections.disconnect(user.id); - expect(raw(user.id)).toBeUndefined(); - expect(raw(other)).toBeDefined(); - expect(testDb.prepare('SELECT COUNT(*) AS n FROM dawarich_visit_suggestions WHERE user_id = ?').get(user.id)).toEqual({ n: 1 }); + expect(await raw(user.id)).toBeNull(); + expect(await raw(other)).not.toBeNull(); + expect(await countRows(t, DawarichVisitSuggestions, { user: user.id })).toBe(1); }); }); describe('storeCapabilities (DWC10)', () => { it('CONNREPO-080: writes the JSON blob and stamps updated_at', async () => { const { user } = createUser(testDb); - seed(user.id); + await seed(user.id); await connections.storeCapabilities(user.id, '{"visits":true,"tracks":false}'); - expect(raw(user.id)!.capabilities).toBe('{"visits":true,"tracks":false}'); + expect((await raw(user.id))!.capabilities).toBe('{"visits":true,"tracks":false}'); }); }); @@ -229,13 +223,13 @@ describe('DawarichConnectionsRepository', () => { it('CONNREPO-090: writes all four columns for the named user only', async () => { const { user } = createUser(testDb); const other = createUser(testDb).user.id; - seed(user.id); - seed(other, { last_sync_state: 'ok' }); + await seed(user.id); + await seed(other, { last_sync_state: 'ok' }); await connections.recordSyncResult(user.id, { lastSyncAt: '2026-09-12T04:15:00.000Z', state: 'failed', error: 'unreachable' }); - expect(raw(user.id)!).toMatchObject({ last_sync_at: '2026-09-12T04:15:00.000Z', last_sync_state: 'failed', last_sync_error: 'unreachable' }); - expect(raw(other)!.last_sync_state).toBe('ok'); + expect((await raw(user.id))!).toMatchObject({ last_sync_at: '2026-09-12T04:15:00.000Z', last_sync_state: 'failed', last_sync_error: 'unreachable' }); + expect((await raw(other))!.last_sync_state).toBe('ok'); }); }); @@ -243,7 +237,7 @@ describe('DawarichConnectionsRepository', () => { it('CONNREPO-100: the stored state, or null with no row at all', async () => { const { user } = createUser(testDb); expect(await connections.getLastSyncState(user.id)).toBeNull(); - seed(user.id, { last_sync_state: 'partial' }); + await seed(user.id, { last_sync_state: 'partial' }); expect(await connections.getLastSyncState(user.id)).toBe('partial'); }); }); diff --git a/server/tests/unit/db/repositories/PasswordResetTokens.repository.test.ts b/server/tests/unit/db/repositories/PasswordResetTokens.repository.test.ts index 8fe3bfff5..2c5d4675b 100644 --- a/server/tests/unit/db/repositories/PasswordResetTokens.repository.test.ts +++ b/server/tests/unit/db/repositories/PasswordResetTokens.repository.test.ts @@ -4,6 +4,7 @@ import { createSnapshotTestDb } from '../../../helpers/db-mock'; import { resetTestDb } from '../../../helpers/test-db'; import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; import { createUser } from '../../../helpers/factories'; +import { findRow, insertRow } from '../../../helpers/factories/rows'; import { PasswordResetTokens } from '../../../../src/db/entities/PasswordResetTokens.entity'; import type { PasswordResetTokensRepository } from '../../../../src/db/repositories/PasswordResetTokens.repository'; import { UnitOfWork } from '../../../../src/nest/database/unit-of-work'; @@ -29,16 +30,21 @@ function hash(raw: string): string { return createHash('sha256').update(raw).digest('hex'); } -function rawRow(id: number): { id: number; user_id: number; token_hash: string; expires_at: string; consumed_at: string | null; created_ip: string | null } { - return testDb.prepare('SELECT id, user_id, token_hash, expires_at, consumed_at, created_ip FROM password_reset_tokens WHERE id = ?').get(id) as never; +async function rawRow(id: number) { + const row = await findRow(t, PasswordResetTokens, { id }); + if (!row) throw new Error(`no password reset token ${id}`); + return row; } -function seedToken(userId: number, tokenHash: string, opts: { expires_at?: string; consumed_at?: string | null; created_ip?: string | null } = {}): number { +async function seedToken(userId: number, tokenHash: string, opts: { expires_at?: string; consumed_at?: string | null; created_ip?: string | null } = {}): Promise { const expiresAt = opts.expires_at ?? new Date(Date.now() + 60 * 60 * 1000).toISOString(); - const result = testDb - .prepare('INSERT INTO password_reset_tokens (user_id, token_hash, expires_at, consumed_at, created_ip) VALUES (?, ?, ?, ?, ?)') - .run(userId, tokenHash, expiresAt, opts.consumed_at ?? null, opts.created_ip ?? null); - return Number(result.lastInsertRowid); + return insertRow(t, PasswordResetTokens, { + user: userId, + token_hash: tokenHash, + expires_at: expiresAt, + consumed_at: opts.consumed_at ?? null, + created_ip: opts.created_ip ?? null, + }); } describe('PasswordResetTokensRepository', () => { @@ -50,8 +56,8 @@ describe('PasswordResetTokensRepository', () => { await tokens.insertToken({ user_id: user.id, token_hash: tokenHash, expires_at: expiresAt, created_ip: '1.2.3.4' }); - const row = testDb.prepare('SELECT user_id, token_hash, expires_at, consumed_at, created_ip FROM password_reset_tokens WHERE token_hash = ?').get(tokenHash); - expect(row).toEqual({ user_id: user.id, token_hash: tokenHash, expires_at: expiresAt, consumed_at: null, created_ip: '1.2.3.4' }); + const row = await findRow(t, PasswordResetTokens, { token_hash: tokenHash }); + expect(row).toMatchObject({ user_id: user.id, token_hash: tokenHash, expires_at: expiresAt, consumed_at: null, created_ip: '1.2.3.4' }); }); it('PWDRESETREPO-002: writes a NULL created_ip', async () => { @@ -59,8 +65,8 @@ describe('PasswordResetTokensRepository', () => { const tokenHash = hash('no-ip-token'); await tokens.insertToken({ user_id: user.id, token_hash: tokenHash, expires_at: new Date().toISOString(), created_ip: null }); - const row = testDb.prepare('SELECT created_ip FROM password_reset_tokens WHERE token_hash = ?').get(tokenHash) as { created_ip: string | null }; - expect(row.created_ip).toBeNull(); + const row = await findRow(t, PasswordResetTokens, { token_hash: tokenHash }); + expect(row?.created_ip).toBeNull(); }); }); @@ -68,7 +74,7 @@ describe('PasswordResetTokensRepository', () => { it('PWDRESETREPO-003: finds a live token by its hash', async () => { const { user } = createUser(testDb); const tokenHash = hash('lookup-me'); - const id = seedToken(user.id, tokenHash); + const id = await seedToken(user.id, tokenHash); const found = await tokens.findByTokenHash(tokenHash); @@ -78,7 +84,7 @@ describe('PasswordResetTokensRepository', () => { it('PWDRESETREPO-004: a consumed token is still found (consumed_at is checked by the caller, not filtered here)', async () => { const { user } = createUser(testDb); const tokenHash = hash('already-used'); - seedToken(user.id, tokenHash, { consumed_at: '2026-01-01T00:00:00.000Z' }); + await seedToken(user.id, tokenHash, { consumed_at: '2026-01-01T00:00:00.000Z' }); const found = await tokens.findByTokenHash(tokenHash); @@ -93,54 +99,54 @@ describe('PasswordResetTokensRepository', () => { describe('markConsumed', () => { it('PWDRESETREPO-006: stamps consumed_at on the target row only', async () => { const { user } = createUser(testDb); - const target = seedToken(user.id, hash('mark-me')); - const other = seedToken(user.id, hash('leave-me')); + const target = await seedToken(user.id, hash('mark-me')); + const other = await seedToken(user.id, hash('leave-me')); await tokens.markConsumed(target); - expect(rawRow(target).consumed_at).not.toBeNull(); - expect(rawRow(other).consumed_at).toBeNull(); + expect((await rawRow(target)).consumed_at).not.toBeNull(); + expect((await rawRow(other)).consumed_at).toBeNull(); }); }); describe('consumeAllLiveForUser', () => { it('PWDRESETREPO-007: without an exclusion, burns every live token for the user', async () => { const { user } = createUser(testDb); - const a = seedToken(user.id, hash('a')); - const b = seedToken(user.id, hash('b')); + const a = await seedToken(user.id, hash('a')); + const b = await seedToken(user.id, hash('b')); await tokens.consumeAllLiveForUser(user.id); - expect(rawRow(a).consumed_at).not.toBeNull(); - expect(rawRow(b).consumed_at).not.toBeNull(); + expect((await rawRow(a)).consumed_at).not.toBeNull(); + expect((await rawRow(b)).consumed_at).not.toBeNull(); }); it('PWDRESETREPO-008: with an exclusion, burns every OTHER live token but leaves the excluded id alone', async () => { const { user } = createUser(testDb); - const keep = seedToken(user.id, hash('keep-me')); - const burn1 = seedToken(user.id, hash('burn-1')); - const burn2 = seedToken(user.id, hash('burn-2')); + const keep = await seedToken(user.id, hash('keep-me')); + const burn1 = await seedToken(user.id, hash('burn-1')); + const burn2 = await seedToken(user.id, hash('burn-2')); await tokens.consumeAllLiveForUser(user.id, keep); - expect(rawRow(keep).consumed_at).toBeNull(); - expect(rawRow(burn1).consumed_at).not.toBeNull(); - expect(rawRow(burn2).consumed_at).not.toBeNull(); + expect((await rawRow(keep)).consumed_at).toBeNull(); + expect((await rawRow(burn1)).consumed_at).not.toBeNull(); + expect((await rawRow(burn2)).consumed_at).not.toBeNull(); }); it('PWDRESETREPO-009: never touches another user\'s tokens or an already-consumed row', async () => { const { user: victim } = createUser(testDb); const { user: bystander } = createUser(testDb); - const already = seedToken(victim.id, hash('already-consumed'), { consumed_at: '2026-01-01T00:00:00.000Z' }); - const bystanderToken = seedToken(bystander.id, hash('bystander')); + const already = await seedToken(victim.id, hash('already-consumed'), { consumed_at: '2026-01-01T00:00:00.000Z' }); + const bystanderToken = await seedToken(bystander.id, hash('bystander')); await tokens.consumeAllLiveForUser(victim.id); // The already-consumed row's consumed_at is untouched (still the original // stamp, not overwritten by this call — the WHERE only matches consumed_at // IS NULL rows). - expect(rawRow(already).consumed_at).toBe('2026-01-01T00:00:00.000Z'); - expect(rawRow(bystanderToken).consumed_at).toBeNull(); + expect((await rawRow(already)).consumed_at).toBe('2026-01-01T00:00:00.000Z'); + expect((await rawRow(bystanderToken)).consumed_at).toBeNull(); }); }); @@ -176,7 +182,7 @@ describe('PasswordResetTokensRepository', () => { describe('identity-map isolation', () => { it('PWDRESETREPO-010: findByTokenHash never leaves a managed entity behind', async () => { const { user } = createUser(testDb); - const id = seedToken(user.id, hash('isolated')); + const id = await seedToken(user.id, hash('isolated')); await tokens.findByTokenHash(hash('isolated')); @@ -185,7 +191,7 @@ describe('PasswordResetTokensRepository', () => { it('PWDRESETREPO-011: findByTokenHash then markConsumed inside uow.transactional survives the closing flush (cannot fail by construction — see PWDRESETREPO-012 for the mutation-proved guard)', async () => { const { user } = createUser(testDb); - const id = seedToken(user.id, hash('survives')); + const id = await seedToken(user.id, hash('survives')); const uow = new UnitOfWork(t.em); await uow.transactional(async () => { @@ -194,12 +200,12 @@ describe('PasswordResetTokensRepository', () => { await tokens.markConsumed(id); }); - expect(rawRow(id).consumed_at).not.toBeNull(); + expect((await rawRow(id)).consumed_at).not.toBeNull(); }); it('PWDRESETREPO-012: findByTokenHash is the FIRST, wider read (it carries consumed_at); a narrower managed read follows; markConsumed still lands', async () => { const { user } = createUser(testDb); - const id = seedToken(user.id, hash('dshape')); + const id = await seedToken(user.id, hash('dshape')); const uow = new UnitOfWork(t.em); await uow.transactional(async () => { @@ -208,12 +214,12 @@ describe('PasswordResetTokensRepository', () => { await tokens.markConsumed(id); }); - expect(rawRow(id).consumed_at).not.toBeNull(); + expect((await rawRow(id)).consumed_at).not.toBeNull(); }); it('PWDRESETREPO-013: same D-shape guard for consumeAllLiveForUser', async () => { const { user } = createUser(testDb); - const id = seedToken(user.id, hash('dshape-consume-all')); + const id = await seedToken(user.id, hash('dshape-consume-all')); const uow = new UnitOfWork(t.em); await uow.transactional(async () => { @@ -222,7 +228,7 @@ describe('PasswordResetTokensRepository', () => { await tokens.consumeAllLiveForUser(user.id); }); - expect(rawRow(id).consumed_at).not.toBeNull(); + expect((await rawRow(id)).consumed_at).not.toBeNull(); }); }); }); diff --git a/server/tests/unit/db/repositories/TodoItems.repository.test.ts b/server/tests/unit/db/repositories/TodoItems.repository.test.ts index b2c1526cb..592e4d65b 100644 --- a/server/tests/unit/db/repositories/TodoItems.repository.test.ts +++ b/server/tests/unit/db/repositories/TodoItems.repository.test.ts @@ -16,6 +16,7 @@ import { createSnapshotTestDb } from '../../../helpers/db-mock'; import { resetTestDb } from '../../../helpers/test-db'; import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; import { createUser, createTrip } from '../../../helpers/factories'; +import { findRow, insertRow } from '../../../helpers/factories/rows'; import { TodoItems } from '../../../../src/db/entities/TodoItems.entity'; import type { TodoItemsRepository } from '../../../../src/db/repositories/TodoItems.repository'; @@ -62,11 +63,12 @@ describe('listDueForReminder', () => { '', ]; for (const s of shapes) { - testDb.prepare('INSERT INTO todo_items (trip_id, name, due_date, checked) VALUES (?, ?, ?, 0)').run(trip.id, 'n:' + s, s); + await insertRow(t, TodoItems, { trip: trip.id, name: 'n:' + s, due_date: s, checked: 0 }); } const legacy = ( testDb + // test-sql-allow: the legacy statement is this parity test's oracle and has to run as written. .prepare( `SELECT ti.id FROM todo_items ti JOIN trips t ON t.id = ti.trip_id WHERE ti.checked = 0 AND ti.due_date IS NOT NULL AND ti.due_date <> '' @@ -79,9 +81,16 @@ describe('listDueForReminder', () => { ).map((r) => r.id); const head = (await repo.listDueForReminder(iso(t0), iso(plus(LEAD)))).map((r) => r.id).sort((a, b) => a - b); - const names = (ids: number[]) => ids.map((i) => (testDb.prepare('SELECT name FROM todo_items WHERE id=?').get(i) as { name: string }).name); + const names = (ids: number[]) => + Promise.all( + ids.map(async (i) => { + const row = await findRow(t, TodoItems, { id: i }); + if (!row) throw new Error(`no todo item ${i}`); + return row.name; + }), + ); - expect(names(head)).toEqual(names(legacy)); + expect(await names(head)).toEqual(await names(legacy)); expect(head.length).toBeGreaterThan(0); }); }); diff --git a/server/tests/unit/db/repositories/VacayCompanyHolidays.repository.test.ts b/server/tests/unit/db/repositories/VacayCompanyHolidays.repository.test.ts index a7e8302dc..189fef410 100644 --- a/server/tests/unit/db/repositories/VacayCompanyHolidays.repository.test.ts +++ b/server/tests/unit/db/repositories/VacayCompanyHolidays.repository.test.ts @@ -8,6 +8,8 @@ import { createSnapshotTestDb } from '../../../helpers/db-mock'; import { resetTestDb } from '../../../helpers/test-db'; import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; import { createUser } from '../../../helpers/factories'; +import { insertRows } from '../../../helpers/factories/rows'; +import { makeVacayPlan } from '../../../helpers/factories/vacay'; import { VacayCompanyHolidays } from '../../../../src/db/entities/VacayCompanyHolidays.entity'; import type { VacayCompanyHolidaysRepository } from '../../../../src/db/repositories/VacayCompanyHolidays.repository'; @@ -22,19 +24,22 @@ beforeAll(async () => { beforeEach(() => { resetTestDb(testDb); t.clear(); }); afterAll(async () => { await t.close(); testDb.close(); }); -function insertPlan(ownerId: number): number { - return Number(testDb.prepare('INSERT INTO vacay_plans (owner_id) VALUES (?)').run(ownerId).lastInsertRowid); +async function insertPlan(ownerId: number): Promise { + return (await makeVacayPlan(t, ownerId)).id; } describe('VacayCompanyHolidaysRepository.listForPlan (VC29/67)', () => { it('VACAYCOHOLREPO-001: matches SELECT date, note, fraction FROM vacay_company_holidays WHERE plan_id = ? run raw, note both NULL and SET', async () => { const { user } = createUser(testDb); - const planId = insertPlan(user.id); - const other = insertPlan(createUser(testDb).user.id); - testDb.prepare('INSERT INTO vacay_company_holidays (plan_id, date, note) VALUES (?, ?, ?)').run(planId, '2026-12-25', 'Christmas'); - testDb.prepare('INSERT INTO vacay_company_holidays (plan_id, date, note) VALUES (?, ?, ?)').run(planId, '2026-01-01', null); - testDb.prepare('INSERT INTO vacay_company_holidays (plan_id, date, note) VALUES (?, ?, ?)').run(other, '2026-07-04', 'Not mine'); + const planId = await insertPlan(user.id); + const other = await insertPlan(createUser(testDb).user.id); + await insertRows(t, VacayCompanyHolidays, [ + { plan: planId, date: '2026-12-25', note: 'Christmas' }, + { plan: planId, date: '2026-01-01', note: null }, + { plan: other, date: '2026-07-04', note: 'Not mine' }, + ]); + // test-sql-allow: the raw SELECT is the legacy oracle this parity test holds the repository to. const legacy = testDb.prepare('SELECT date, note, fraction FROM vacay_company_holidays WHERE plan_id = ?').all(planId); const rows = await repo.listForPlan(planId); @@ -44,7 +49,7 @@ describe('VacayCompanyHolidaysRepository.listForPlan (VC29/67)', () => { it('VACAYCOHOLREPO-002: empty array for a plan with none', async () => { const { user } = createUser(testDb); - const planId = insertPlan(user.id); + const planId = await insertPlan(user.id); expect(await repo.listForPlan(planId)).toEqual([]); }); }); diff --git a/server/tests/unit/db/repositories/VacayHolidayCalendars.repository.test.ts b/server/tests/unit/db/repositories/VacayHolidayCalendars.repository.test.ts index 0271dd5fd..e6c36bd72 100644 --- a/server/tests/unit/db/repositories/VacayHolidayCalendars.repository.test.ts +++ b/server/tests/unit/db/repositories/VacayHolidayCalendars.repository.test.ts @@ -10,6 +10,8 @@ import { createSnapshotTestDb } from '../../../helpers/db-mock'; import { resetTestDb } from '../../../helpers/test-db'; import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; import { createUser } from '../../../helpers/factories'; +import { insertRow } from '../../../helpers/factories/rows'; +import { makeVacayPlan } from '../../../helpers/factories/vacay'; import { VacayHolidayCalendars } from '../../../../src/db/entities/VacayHolidayCalendars.entity'; import type { VacayHolidayCalendarsRepository } from '../../../../src/db/repositories/VacayHolidayCalendars.repository'; @@ -24,33 +26,31 @@ beforeAll(async () => { beforeEach(() => { resetTestDb(testDb); t.clear(); }); afterAll(async () => { await t.close(); testDb.close(); }); -function insertPlan(ownerId: number): number { - return Number(testDb.prepare('INSERT INTO vacay_plans (owner_id) VALUES (?)').run(ownerId).lastInsertRowid); +async function insertPlan(ownerId: number): Promise { + return (await makeVacayPlan(t, ownerId)).id; } -function insertCalendar(planId: number, overrides: Partial<{ type: string; region: string; label: string | null; color: string; sort_order: number }> = {}): number { - const result = testDb.prepare( - 'INSERT INTO vacay_holiday_calendars (plan_id, type, region, label, color, sort_order) VALUES (?, ?, ?, ?, ?, ?)', - ).run( - planId, - overrides.type ?? 'public_holiday', - overrides.region ?? 'DE', - overrides.label === undefined ? null : overrides.label, - overrides.color ?? '#fecaca', - overrides.sort_order ?? 0, - ); - return Number(result.lastInsertRowid); +async function insertCalendar(planId: number, overrides: Partial<{ type: string; region: string; label: string | null; color: string; sort_order: number }> = {}): Promise { + return insertRow(t, VacayHolidayCalendars, { + plan: planId, + type: overrides.type ?? 'public_holiday', + region: overrides.region ?? 'DE', + label: overrides.label === undefined ? null : overrides.label, + color: overrides.color ?? '#fecaca', + sort_order: overrides.sort_order ?? 0, + }); } describe('VacayHolidayCalendarsRepository.listForPlan (VC37/131, getPlanData)', () => { it('VACAYHOLCALREPO-001: matches SELECT * FROM vacay_holiday_calendars WHERE plan_id = ? ORDER BY sort_order, id run raw, label both NULL and SET', async () => { const { user } = createUser(testDb); - const planId = insertPlan(user.id); - const other = insertPlan(createUser(testDb).user.id); - const second = insertCalendar(planId, { region: 'DE', sort_order: 1, label: 'Bavaria' }); - const first = insertCalendar(planId, { region: 'FR', sort_order: 0, label: null }); - insertCalendar(other, { region: 'US' }); + const planId = await insertPlan(user.id); + const other = await insertPlan(createUser(testDb).user.id); + const second = await insertCalendar(planId, { region: 'DE', sort_order: 1, label: 'Bavaria' }); + const first = await insertCalendar(planId, { region: 'FR', sort_order: 0, label: null }); + await insertCalendar(other, { region: 'US' }); + // test-sql-allow: the raw SELECT is the legacy oracle this parity test holds the repository to. const legacy = testDb.prepare('SELECT * FROM vacay_holiday_calendars WHERE plan_id = ? ORDER BY sort_order ASC, id ASC').all(planId); const rows = await repo.listForPlan(planId); @@ -60,7 +60,7 @@ describe('VacayHolidayCalendarsRepository.listForPlan (VC37/131, getPlanData)', it('VACAYHOLCALREPO-002: empty array for a plan with none', async () => { const { user } = createUser(testDb); - const planId = insertPlan(user.id); + const planId = await insertPlan(user.id); expect(await repo.listForPlan(planId)).toEqual([]); }); }); @@ -68,9 +68,10 @@ describe('VacayHolidayCalendarsRepository.listForPlan (VC37/131, getPlanData)', describe('VacayHolidayCalendarsRepository.findById (VC39/42)', () => { it('VACAYHOLCALREPO-003: matches SELECT * FROM vacay_holiday_calendars WHERE id = ? run raw', async () => { const { user } = createUser(testDb); - const planId = insertPlan(user.id); - const id = insertCalendar(planId, { type: 'school_holiday', region: 'DE-BY', label: 'Summer' }); + const planId = await insertPlan(user.id); + const id = await insertCalendar(planId, { type: 'school_holiday', region: 'DE-BY', label: 'Summer' }); + // test-sql-allow: the raw SELECT is the legacy oracle this parity test holds the repository to. const legacy = testDb.prepare('SELECT * FROM vacay_holiday_calendars WHERE id = ?').get(id); expect(await repo.findById(id)).toEqual(legacy); }); diff --git a/server/tests/unit/db/repositories/WebauthnChallenges.repository.test.ts b/server/tests/unit/db/repositories/WebauthnChallenges.repository.test.ts index dc184f7d8..2e698b3b7 100644 --- a/server/tests/unit/db/repositories/WebauthnChallenges.repository.test.ts +++ b/server/tests/unit/db/repositories/WebauthnChallenges.repository.test.ts @@ -3,6 +3,7 @@ import { createSnapshotTestDb } from '../../../helpers/db-mock'; import { resetTestDb } from '../../../helpers/test-db'; import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; import { createUser } from '../../../helpers/factories'; +import { countRows, findRow, findRows, insertRow } from '../../../helpers/factories/rows'; import { WebauthnChallenges } from '../../../../src/db/entities/WebauthnChallenges.entity'; import type { WebauthnChallengesRepository } from '../../../../src/db/repositories/WebauthnChallenges.repository'; @@ -17,12 +18,12 @@ beforeAll(async () => { beforeEach(() => { resetTestDb(testDb); t.clear(); }); afterAll(async () => { await t.close(); testDb.close(); }); -function seedChallenge(challenge: string, userId: number | null, type: string, expiresAt: number): void { - testDb.prepare('INSERT INTO webauthn_challenges (challenge, user_id, type, expires_at) VALUES (?, ?, ?, ?)').run(challenge, userId, type, expiresAt); +async function seedChallenge(challenge: string, userId: number | null, type: string, expiresAt: number): Promise { + await insertRow(t, WebauthnChallenges, { challenge, user: userId, type, expires_at: expiresAt }); } -function challengeCount(): number { - return (testDb.prepare('SELECT COUNT(*) AS n FROM webauthn_challenges').get() as { n: number }).n; +function challengeCount(): Promise { + return countRows(t, WebauthnChallenges); } describe('WebauthnChallengesRepository', () => { @@ -30,20 +31,20 @@ describe('WebauthnChallengesRepository', () => { it('WEBAUTHN-CHAL-REPO-001: deletes only rows past their expiry, live rows survive', async () => { const { user } = createUser(testDb); const now = Date.now(); - seedChallenge('expired-1', user.id, 'registration', now - 1000); - seedChallenge('expired-2', null, 'authentication', now - 1); - seedChallenge('live', user.id, 'registration', now + 60_000); + await seedChallenge('expired-1', user.id, 'registration', now - 1000); + await seedChallenge('expired-2', null, 'authentication', now - 1); + await seedChallenge('live', user.id, 'registration', now + 60_000); await challenges.purgeExpired(now); - expect(challengeCount()).toBe(1); - const remaining = testDb.prepare('SELECT challenge FROM webauthn_challenges').all() as Array<{ challenge: string }>; - expect(remaining).toEqual([{ challenge: 'live' }]); + expect(await challengeCount()).toBe(1); + const remaining = await findRows(t, WebauthnChallenges); + expect(remaining.map((r) => ({ challenge: r.challenge }))).toEqual([{ challenge: 'live' }]); }); it('WEBAUTHN-CHAL-REPO-002: an empty table is a no-op', async () => { await challenges.purgeExpired(Date.now()); - expect(challengeCount()).toBe(0); + expect(await challengeCount()).toBe(0); }); }); @@ -53,16 +54,16 @@ describe('WebauthnChallengesRepository', () => { const expiresAt = Date.now() + 300_000; await challenges.insertChallenge({ challenge: 'reg-chal', user_id: user.id, type: 'registration', expires_at: expiresAt }); - const row = testDb.prepare('SELECT challenge, user_id, type, expires_at FROM webauthn_challenges WHERE challenge = ?').get('reg-chal'); - expect(row).toEqual({ challenge: 'reg-chal', user_id: user.id, type: 'registration', expires_at: expiresAt }); + const row = await findRow(t, WebauthnChallenges, { challenge: 'reg-chal' }); + expect(row).toMatchObject({ challenge: 'reg-chal', user_id: user.id, type: 'registration', expires_at: expiresAt }); }); it('WEBAUTHN-CHAL-REPO-004: writes a NULL user_id (anonymous authentication challenge)', async () => { const expiresAt = Date.now() + 300_000; await challenges.insertChallenge({ challenge: 'auth-chal', user_id: null, type: 'authentication', expires_at: expiresAt }); - const row = testDb.prepare('SELECT user_id, type FROM webauthn_challenges WHERE challenge = ?').get('auth-chal'); - expect(row).toEqual({ user_id: null, type: 'authentication' }); + const row = await findRow(t, WebauthnChallenges, { challenge: 'auth-chal' }); + expect(row).toMatchObject({ user_id: null, type: 'authentication' }); }); }); @@ -70,17 +71,17 @@ describe('WebauthnChallengesRepository', () => { it('WEBAUTHN-CHAL-REPO-005: claims a live, matching row and returns its user_id', async () => { const { user } = createUser(testDb); const now = Date.now(); - seedChallenge('claim-me', user.id, 'registration', now + 60_000); + await seedChallenge('claim-me', user.id, 'registration', now + 60_000); const result = await challenges.claimChallenge('claim-me', 'registration', now); expect(result).toEqual({ user_id: user.id }); - expect(challengeCount()).toBe(0); // spent + expect(await challengeCount()).toBe(0); // spent }); it('WEBAUTHN-CHAL-REPO-006: claims an anonymous (null user_id) row', async () => { const now = Date.now(); - seedChallenge('anon-claim', null, 'authentication', now + 60_000); + await seedChallenge('anon-claim', null, 'authentication', now + 60_000); expect(await challenges.claimChallenge('anon-claim', 'authentication', now)).toEqual({ user_id: null }); }); @@ -91,26 +92,26 @@ describe('WebauthnChallengesRepository', () => { it('WEBAUTHN-CHAL-REPO-008: an expired row is null and stays in the table (purgeExpired\'s job, not claimChallenge\'s)', async () => { const now = Date.now(); - seedChallenge('stale', null, 'registration', now - 1); + await seedChallenge('stale', null, 'registration', now - 1); expect(await challenges.claimChallenge('stale', 'registration', now)).toBeNull(); - expect(challengeCount()).toBe(1); // still there — claimChallenge only deletes a MATCHING row + expect(await challengeCount()).toBe(1); // still there — claimChallenge only deletes a MATCHING row }); it('WEBAUTHN-CHAL-REPO-009: a type mismatch is null, the row survives for its real type', async () => { const { user } = createUser(testDb); const now = Date.now(); - seedChallenge('wrong-type', user.id, 'registration', now + 60_000); + await seedChallenge('wrong-type', user.id, 'registration', now + 60_000); expect(await challenges.claimChallenge('wrong-type', 'authentication', now)).toBeNull(); - expect(challengeCount()).toBe(1); + expect(await challengeCount()).toBe(1); expect(await challenges.claimChallenge('wrong-type', 'registration', now)).toEqual({ user_id: user.id }); }); it('WEBAUTHN-CHAL-REPO-010: single-use — a second claim of the same challenge is null', async () => { const { user } = createUser(testDb); const now = Date.now(); - seedChallenge('once-only', user.id, 'registration', now + 60_000); + await seedChallenge('once-only', user.id, 'registration', now + 60_000); expect(await challenges.claimChallenge('once-only', 'registration', now)).toEqual({ user_id: user.id }); expect(await challenges.claimChallenge('once-only', 'registration', now)).toBeNull(); @@ -119,7 +120,7 @@ describe('WebauthnChallengesRepository', () => { it('WEBAUTHN-CHAL-REPO-011: two concurrent claims of the same challenge — exactly one winner', async () => { const { user } = createUser(testDb); const now = Date.now(); - seedChallenge('race-me', user.id, 'registration', now + 60_000); + await seedChallenge('race-me', user.id, 'registration', now + 60_000); const [a, b] = await Promise.all([ challenges.claimChallenge('race-me', 'registration', now), @@ -129,7 +130,7 @@ describe('WebauthnChallengesRepository', () => { const winners = [a, b].filter((r) => r !== null); expect(winners).toHaveLength(1); expect(winners[0]).toEqual({ user_id: user.id }); - expect(challengeCount()).toBe(0); + expect(await challengeCount()).toBe(0); }); }); }); diff --git a/server/tests/unit/mcp/tools-help-addons.test.ts b/server/tests/unit/mcp/tools-help-addons.test.ts index 9f9c15c39..6c73aa57a 100644 --- a/server/tests/unit/mcp/tools-help-addons.test.ts +++ b/server/tests/unit/mcp/tools-help-addons.test.ts @@ -42,10 +42,15 @@ const { wiki } = vi.hoisted(() => { vi.mock('../../../src/nest/help/wiki', () => wiki); import { resetTestDb, setAddonEnabled } from '../../helpers/test-db'; +import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; import { createUser } from '../../helpers/factories'; import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness'; import { createTestAddonsService } from '../../helpers/test-addons'; import { ADDON_IDS } from '../../../src/addons'; +import { findRow, updateRows } from '../../helpers/factories/rows'; +import { setAppSetting } from '../../helpers/factories/settings'; +import { Addons } from '../../../src/db/entities/Addons.entity'; +import { PhotoProviders } from '../../../src/db/entities/PhotoProviders.entity'; const SECTIONS = [ { title: 'Getting Started', pages: [{ title: 'Quick Start', slug: 'Quick-Start' }] }, @@ -128,7 +133,14 @@ beforeEach(() => { } }); -afterAll(() => { +let orm: TestOrm; + +beforeAll(async () => { + orm = await createTestOrm(testDb); +}); + +afterAll(async () => { + await orm.close(); testDb.close(); }); @@ -340,10 +352,7 @@ describe('Tool: search_help', () => { describe('Tool: list_addons', () => { it('lists the enabled addons with the collab sub-features and bag tracking', async () => { const { user } = createUser(testDb); - const row = testDb.prepare('SELECT name, type FROM addons WHERE id = ?').get('budget') as { - name: string; - type: string; - }; + const row = await findRow(orm, Addons, { id: 'budget' }); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'list_addons', arguments: {} }); expect(result.isError).toBeFalsy(); @@ -352,8 +361,8 @@ describe('Tool: list_addons', () => { // on a photo provider) has to be absent rather than merely unasserted. expect(data.addons).toContainEqual({ id: 'budget', - name: row.name, - type: row.type, + name: row?.name, + type: row?.type, enabled: true, }); expect(data.collabFeatures).toEqual({ chat: true, notes: true, links: true, polls: true, whatsnext: true }); @@ -367,16 +376,14 @@ describe('Tool: list_addons', () => { setAddonEnabled(testDb, ADDON_IDS.JOURNEY, true); // PhotoProviderSeeder seeds immich disabled by default (enabled: 0) — flip // it on the way the admin panel would, same idiom as setAddonEnabled above. - testDb.prepare("UPDATE photo_providers SET enabled = 1 WHERE id = 'immich'").run(); - const row = testDb.prepare('SELECT name FROM photo_providers WHERE id = ?').get('immich') as { - name: string; - }; + await updateRows(orm, PhotoProviders, { id: 'immich' }, { enabled: 1 }); + const row = await findRow(orm, PhotoProviders, { id: 'immich' }); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'list_addons', arguments: {} }); const data = parseToolResult(result) as AddonsPayload; expect(data.addons).toContainEqual({ id: 'immich', - name: row.name, + name: row?.name, type: 'photo_provider', enabled: true, }); @@ -418,9 +425,7 @@ describe('Tool: list_addons', () => { }); it('reports bag tracking once it is switched on', async () => { - testDb - .prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('bag_tracking_enabled', 'true')") - .run(); + await setAppSetting(orm, 'bag_tracking_enabled', 'true'); const { user } = createUser(testDb); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'list_addons', arguments: {} }); diff --git a/server/tests/unit/mcp/tools-journey.test.ts b/server/tests/unit/mcp/tools-journey.test.ts index dca63049a..bf9e41311 100644 --- a/server/tests/unit/mcp/tools-journey.test.ts +++ b/server/tests/unit/mcp/tools-journey.test.ts @@ -38,6 +38,11 @@ vi.mock('../../../src/nest/atlas/atlas-geo', async (importOriginal) => ({ })); import { resetTestDb } from '../../helpers/test-db'; +import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; +import { createRow, findRow } from '../../helpers/factories/rows'; +import { addJourneyContributor } from '../../helpers/factories/journeys'; +import { Journeys } from '../../../src/db/entities/Journeys.entity'; +import { JourneyEntries } from '../../../src/db/entities/JourneyEntries.entity'; import { createUser, createTrip } from '../../helpers/factories'; import { setAddonEnabled } from '../../helpers/test-db'; import { ADDON_IDS } from '../../../src/addons'; @@ -52,7 +57,14 @@ beforeEach(() => { delete process.env.DEMO_MODE; }); -afterAll(() => { +let orm: TestOrm; + +beforeAll(async () => { + orm = await createTestOrm(testDb); +}); + +afterAll(async () => { + await orm.close(); testDb.close(); }); @@ -164,12 +176,12 @@ describe('Tool: update_journey_preferences', () => { // --------------------------------------------------------------------------- /** A journey owned by someone else — every access check must refuse it. */ -function foreignJourney() { +async function foreignJourney() { const { user: other } = createUser(testDb); - const j = testDb.prepare( - 'INSERT INTO journeys (user_id, title, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?)', - ).run(other.id, 'Not yours', 'draft', Date.now(), Date.now()); - return { otherId: other.id, journeyId: Number(j.lastInsertRowid) }; + const j = await createRow(orm, Journeys, { + user: other.id, title: 'Not yours', status: 'draft', created_at: Date.now(), updated_at: Date.now(), + }); + return { otherId: other.id, journeyId: j.id }; } async function seedJourney(h: McpHarness, title = 'J') { @@ -202,7 +214,7 @@ describe('journey read tools', () => { it('get_journey refuses a journey the caller cannot see', async () => { const { user } = createUser(testDb); - const { journeyId } = foreignJourney(); + const { journeyId } = await foreignJourney(); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'get_journey', arguments: { journeyId } }); expect(result.isError).toBe(true); @@ -225,7 +237,7 @@ describe('journey read tools', () => { it('list_journey_entries refuses a foreign journey', async () => { const { user } = createUser(testDb); - const { journeyId } = foreignJourney(); + const { journeyId } = await foreignJourney(); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'list_journey_entries', arguments: { journeyId } }); expect(result.isError).toBe(true); @@ -245,7 +257,7 @@ describe('journey read tools', () => { it('list_journey_contributors refuses a foreign journey', async () => { const { user } = createUser(testDb); - const { journeyId } = foreignJourney(); + const { journeyId } = await foreignJourney(); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'list_journey_contributors', arguments: { journeyId } }); expect(result.isError).toBe(true); @@ -266,7 +278,7 @@ describe('journey read tools', () => { describe('journey write tools', () => { it('update_journey applies the change and refuses a foreign journey', async () => { const { user } = createUser(testDb); - const { journeyId } = foreignJourney(); + const { journeyId } = await foreignJourney(); await withHarness(user.id, async (h) => { const journey = await seedJourney(h); const updated = parseToolResult(await h.client.callTool({ @@ -280,7 +292,7 @@ describe('journey write tools', () => { it('delete_journey removes it and refuses a foreign journey', async () => { const { user } = createUser(testDb); - const { journeyId } = foreignJourney(); + const { journeyId } = await foreignJourney(); await withHarness(user.id, async (h) => { const journey = await seedJourney(h); const gone = parseToolResult(await h.client.callTool({ @@ -311,7 +323,7 @@ describe('journey write tools', () => { it('add_journey_trip refuses a foreign journey; remove_journey_trip is idempotent but refuses one', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const { journeyId } = foreignJourney(); + const { journeyId } = await foreignJourney(); await withHarness(user.id, async (h) => { const journey = await seedJourney(h); expect((await h.client.callTool({ @@ -330,7 +342,7 @@ describe('journey write tools', () => { it('create_journey_entry refuses a foreign journey', async () => { const { user } = createUser(testDb); - const { journeyId } = foreignJourney(); + const { journeyId } = await foreignJourney(); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'create_journey_entry', arguments: { journeyId, entry_date: '2026-07-01' }, @@ -401,7 +413,7 @@ describe('journey write tools', () => { it('contributor tools add, re-role and remove; each refuses a foreign journey', async () => { const { user } = createUser(testDb); const { user: guest } = createUser(testDb); - const { journeyId } = foreignJourney(); + const { journeyId } = await foreignJourney(); await withHarness(user.id, async (h) => { const journey = await seedJourney(h); expect((parseToolResult(await h.client.callTool({ @@ -427,7 +439,7 @@ describe('journey write tools', () => { it('update_journey_preferences refuses a foreign journey', async () => { const { user } = createUser(testDb); - const { journeyId } = foreignJourney(); + const { journeyId } = await foreignJourney(); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'update_journey_preferences', arguments: { journeyId, hide_skeletons: true }, @@ -446,8 +458,10 @@ describe('journey write tools', () => { // could not be corrected. Asserted against the row, not the tool's echo. // --------------------------------------------------------------------------- -function entryRow(id: number) { - return testDb.prepare('SELECT * FROM journey_entries WHERE id = ?').get(id) as any; +async function entryRow(id: number) { + const row = await findRow(orm, JourneyEntries, { id }); + if (!row) throw new Error(`no journey entry ${id}`); + return row; } async function seedEntry(h: McpHarness, journeyId: number, args: Record = {}) { @@ -474,13 +488,13 @@ describe('journey entry fields', () => { type: 'checkin', }); - const row = entryRow(entry.id); + const row = await entryRow(entry.id); expect(row.location_name).toBe('Hallgrimskirkja'); expect(row.location_lat).toBeCloseTo(64.1418, 4); expect(row.location_lng).toBeCloseTo(-21.9266, 4); expect(row.weather).toBe('overcast, 9C'); - expect(JSON.parse(row.tags)).toEqual(['church', 'view']); - expect(JSON.parse(row.pros_cons)).toEqual({ pros: ['the tower'], cons: ['the queue'] }); + expect(JSON.parse(String(row.tags))).toEqual(['church', 'view']); + expect(JSON.parse(String(row.pros_cons))).toEqual({ pros: ['the tower'], cons: ['the queue'] }); expect(row.visibility).toBe('public'); expect(row.type).toBe('checkin'); }); @@ -491,7 +505,7 @@ describe('journey entry fields', () => { await withHarness(user.id, async (h) => { const journey = await seedJourney(h); const entry = await seedEntry(h, journey.id, { pros_cons: { pros: ['cheap'] } }); - expect(JSON.parse(entryRow(entry.id).pros_cons)).toEqual({ pros: ['cheap'], cons: [] }); + expect(JSON.parse(String((await entryRow(entry.id)).pros_cons))).toEqual({ pros: ['cheap'], cons: [] }); }); }); @@ -499,7 +513,7 @@ describe('journey entry fields', () => { const { user } = createUser(testDb); await withHarness(user.id, async (h) => { const journey = await seedJourney(h); - const row = entryRow((await seedEntry(h, journey.id)).id); + const row = await entryRow((await seedEntry(h, journey.id)).id); expect(row.visibility).toBe('private'); expect(row.type).toBe('entry'); }); @@ -536,7 +550,7 @@ describe('journey entry fields', () => { }, }); - const row = entryRow(entry.id); + const row = await entryRow(entry.id); expect(row.location_name).toBe('Hallgrimskirkja'); expect(row.location_lat).toBeCloseTo(64.1418, 4); expect(row.location_lng).toBeCloseTo(-21.9266, 4); @@ -561,10 +575,10 @@ describe('journey entry fields', () => { }, })) as any; - const row = entryRow(entry.id); + const row = await entryRow(entry.id); expect(row.weather).toBe('rain all day'); - expect(JSON.parse(row.tags)).toEqual(['museum']); - expect(JSON.parse(row.pros_cons)).toEqual({ pros: ['free on Sundays'], cons: ['packed'] }); + expect(JSON.parse(String(row.tags))).toEqual(['museum']); + expect(JSON.parse(String(row.pros_cons))).toEqual({ pros: ['free on Sundays'], cons: ['packed'] }); expect(row.visibility).toBe('shared'); expect(row.sort_order).toBe(3); // The enrichment parses both back out, so the caller sees the objects. @@ -578,10 +592,10 @@ describe('journey entry fields', () => { await withHarness(user.id, async (h) => { const journey = await seedJourney(h); const entry = await seedEntry(h, journey.id, { type: 'skeleton', title: 'Blue Lagoon' }); - expect(entryRow(entry.id).type).toBe('skeleton'); + expect((await entryRow(entry.id)).type).toBe('skeleton'); await h.client.callTool({ name: 'update_journey_entry', arguments: { entryId: entry.id, type: 'entry' } }); - expect(entryRow(entry.id).type).toBe('entry'); + expect((await entryRow(entry.id)).type).toBe('entry'); }); }); @@ -613,7 +627,7 @@ describe('journey entry fields', () => { }, }); - const row = entryRow(entry.id); + const row = await entryRow(entry.id); expect(row.entry_time).toBeNull(); expect(row.location_lat).toBeNull(); expect(row.location_lng).toBeNull(); @@ -644,7 +658,7 @@ describe('journey entry fields', () => { const data = parseToolResult(await h.client.callTool({ name: 'update_journey_entry', arguments: { entryId: airport.id, stats_excluded: true }, })) as any; - expect(entryRow(airport.id).stats_excluded).toBe(1); + expect((await entryRow(airport.id)).stats_excluded).toBe(1); // The echo carries the boolean, as the REST answer does. expect(data.entry.stats_excluded).toBe(true); @@ -661,7 +675,7 @@ describe('journey entry fields', () => { const on = parseToolResult(await h.client.callTool({ name: 'get_journey_stats', arguments: { journeyId: journey.id }, })) as any; - expect(entryRow(airport.id).stats_excluded).toBe(0); + expect((await entryRow(airport.id)).stats_excluded).toBe(0); expect(on.stats.steps).toBe(2); expect(on.stats.excluded).toEqual([]); }); @@ -675,7 +689,7 @@ describe('journey entry fields', () => { expect((await h.client.callTool({ name: 'update_journey_entry', arguments: { entryId: entry.id, stats_excluded: 'yes' }, })).isError).toBe(true); - expect(entryRow(entry.id).stats_excluded).toBe(0); + expect((await entryRow(entry.id)).stats_excluded).toBe(0); }); }); @@ -690,7 +704,7 @@ describe('journey entry fields', () => { })).isError).toBe(true); } // And none of the refusals touched the row. - const row = entryRow(entry.id); + const row = await entryRow(entry.id); expect(row.visibility).toBe('private'); expect(row.location_lng).toBeNull(); }); @@ -752,7 +766,7 @@ describe('Tool: get_journey_stats', () => { it('refuses a journey the caller cannot see', async () => { const { user } = createUser(testDb); - const { journeyId } = foreignJourney(); + const { journeyId } = await foreignJourney(); await withHarness(user.id, async (h) => { expect((await h.client.callTool({ name: 'get_journey_stats', arguments: { journeyId }, @@ -817,9 +831,7 @@ describe('journey share-link tools', () => { journeyId = journey.id; await h.client.callTool({ name: 'create_journey_share_link', arguments: { journeyId } }); }); - testDb.prepare( - 'INSERT INTO journey_contributors (journey_id, user_id, role, added_at) VALUES (?, ?, ?, ?)', - ).run(journeyId, guest.id, 'viewer', Date.now()); + await addJourneyContributor(orm, journeyId, guest.id, 'viewer'); await withHarness(guest.id, async (h) => { // The token is the whole credential: a contributor must not be able to // read out a link that keeps working after they are removed. @@ -831,7 +843,7 @@ describe('journey share-link tools', () => { it('every share tool refuses a foreign journey', async () => { const { user } = createUser(testDb); - const { journeyId } = foreignJourney(); + const { journeyId } = await foreignJourney(); await withHarness(user.id, async (h) => { for (const name of ['get_journey_share_link', 'create_journey_share_link', 'delete_journey_share_link']) { expect((await h.client.callTool({ name, arguments: { journeyId } })).isError).toBe(true); diff --git a/server/tests/unit/mcp/tools-settings.test.ts b/server/tests/unit/mcp/tools-settings.test.ts index f3bdc9aad..48477a9c9 100644 --- a/server/tests/unit/mcp/tools-settings.test.ts +++ b/server/tests/unit/mcp/tools-settings.test.ts @@ -26,8 +26,13 @@ vi.mock('../../../src/config', () => ({ vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() })); import { resetTestDb } from '../../helpers/test-db'; +import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; import { createUser } from '../../helpers/factories'; import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness'; +import { countRows, findRow, insertRow } from '../../helpers/factories/rows'; +import { setUserSetting } from '../../helpers/factories/settings'; +import { Settings } from '../../../src/db/entities/Settings.entity'; +import { AppSettings } from '../../../src/db/entities/AppSettings.entity'; import { encrypt_api_key } from '../../../src/nest/common/crypto/apiKeyCrypto'; import { MASKED_SETTING_VALUE } from '@trek/shared'; import { DISPLAY_PREFERENCE_KEYS } from '../../../src/nest/settings/settings.mcp'; @@ -39,7 +44,14 @@ beforeEach(() => { delete process.env.DEMO_MODE; }); -afterAll(() => { +let orm: TestOrm; + +beforeAll(async () => { + orm = await createTestOrm(testDb); +}); + +afterAll(async () => { + await orm.close(); testDb.close(); }); @@ -47,22 +59,17 @@ afterAll(() => { // Helpers // --------------------------------------------------------------------------- -function setSetting(userId: number, key: string, value: string): void { - testDb.prepare( - 'INSERT INTO settings (user_id, key, value) VALUES (?, ?, ?) ' + - 'ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value', - ).run(userId, key, value); +async function setSetting(userId: number, key: string, value: string): Promise { + await setUserSetting(orm, userId, key, value); } -function readSetting(userId: number, key: string): string | undefined { - const row = testDb.prepare('SELECT value FROM settings WHERE user_id = ? AND key = ?').get(userId, key) as - | { value: string } - | undefined; +async function readSetting(userId: number, key: string): Promise { + const row = await findRow(orm, Settings, { user: userId, key }); return row?.value; } -function countSettings(userId: number): number { - return (testDb.prepare('SELECT COUNT(*) as c FROM settings WHERE user_id = ?').get(userId) as { c: number }).c; +function countSettings(userId: number): Promise { + return countRows(orm, Settings, { user: userId }); } async function withHarness(userId: number, fn: (h: McpHarness) => Promise) { @@ -100,13 +107,13 @@ describe('Tool: get_display_settings', () => { it('returns the display preferences the user has stored', async () => { const { user } = createUser(testDb); - setSetting(user.id, 'temperature_unit', '"fahrenheit"'); - setSetting(user.id, 'distance_unit', '"imperial"'); - setSetting(user.id, 'time_format', '"12h"'); - setSetting(user.id, 'language', '"de"'); - setSetting(user.id, 'default_currency', '"USD"'); - setSetting(user.id, 'start_page', '"active_trip"'); - setSetting(user.id, 'blur_booking_codes', 'true'); + await setSetting(user.id, 'temperature_unit', '"fahrenheit"'); + await setSetting(user.id, 'distance_unit', '"imperial"'); + await setSetting(user.id, 'time_format', '"12h"'); + await setSetting(user.id, 'language', '"de"'); + await setSetting(user.id, 'default_currency', '"USD"'); + await setSetting(user.id, 'start_page', '"active_trip"'); + await setSetting(user.id, 'blur_booking_codes', 'true'); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'get_display_settings', arguments: {} }); @@ -125,8 +132,7 @@ describe('Tool: get_display_settings', () => { it('falls back to the admin-set instance default for a key the user has not set', async () => { const { user } = createUser(testDb); - testDb.prepare('INSERT INTO app_settings (key, value) VALUES (?, ?)') - .run('default_user_setting_temperature_unit', '"fahrenheit"'); + await insertRow(orm, AppSettings, { key: 'default_user_setting_temperature_unit', value: '"fahrenheit"' }); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'get_display_settings', arguments: {} }); @@ -141,12 +147,12 @@ describe('Tool: get_display_settings', () => { // getUserSettings hands them back in cleartext. That is the leak this // surface must not have: store them the way the real write path does and // assert they are nowhere in the result. - setSetting(user.id, 'mapbox_access_token', String(encrypt_api_key('pk.super-secret-token'))); - setSetting(user.id, 'carto_api_key', String(encrypt_api_key('carto-secret'))); - setSetting(user.id, 'llm_api_key', String(encrypt_api_key('sk-secret'))); - setSetting(user.id, 'ntfy_token', String(encrypt_api_key('ntfy-secret'))); - setSetting(user.id, 'webhook_url', String(encrypt_api_key('https://hook.example/secret'))); - setSetting(user.id, 'temperature_unit', '"celsius"'); + await setSetting(user.id, 'mapbox_access_token', String(encrypt_api_key('pk.super-secret-token'))); + await setSetting(user.id, 'carto_api_key', String(encrypt_api_key('carto-secret'))); + await setSetting(user.id, 'llm_api_key', String(encrypt_api_key('sk-secret'))); + await setSetting(user.id, 'ntfy_token', String(encrypt_api_key('ntfy-secret'))); + await setSetting(user.id, 'webhook_url', String(encrypt_api_key('https://hook.example/secret'))); + await setSetting(user.id, 'temperature_unit', '"celsius"'); await withScopedHarness(user.id, ['settings:read'], async (h) => { const result = await h.client.callTool({ name: 'get_display_settings', arguments: {} }); @@ -166,9 +172,9 @@ describe('Tool: get_display_settings', () => { it('leaves out settings that are neither display preferences nor credentials', async () => { const { user } = createUser(testDb); - setSetting(user.id, 'map_tile_url', '"https://tiles.example/{z}/{x}/{y}.png"'); - setSetting(user.id, 'dashboard_fx_from', '"EUR"'); - setSetting(user.id, 'time_format', '"24h"'); + await setSetting(user.id, 'map_tile_url', '"https://tiles.example/{z}/{x}/{y}.png"'); + await setSetting(user.id, 'dashboard_fx_from', '"EUR"'); + await setSetting(user.id, 'time_format', '"24h"'); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'get_display_settings', arguments: {} }); @@ -180,7 +186,7 @@ describe('Tool: get_display_settings', () => { it('does not leak another user\'s preferences', async () => { const { user: mine } = createUser(testDb); const { user: theirs } = createUser(testDb); - setSetting(theirs.id, 'temperature_unit', '"fahrenheit"'); + await setSetting(theirs.id, 'temperature_unit', '"fahrenheit"'); await withHarness(mine.id, async (h) => { const result = await h.client.callTool({ name: 'get_display_settings', arguments: {} }); @@ -203,17 +209,17 @@ describe('Tool: update_display_settings', () => { expect(data.success).toBe(true); expect(data.updated).toBe(2); expect(data.settings).toEqual({ temperature_unit: 'fahrenheit', distance_unit: 'imperial' }); - expect(readSetting(user.id, 'temperature_unit')).toBe('fahrenheit'); - expect(readSetting(user.id, 'distance_unit')).toBe('imperial'); + expect(await readSetting(user.id, 'temperature_unit')).toBe('fahrenheit'); + expect(await readSetting(user.id, 'distance_unit')).toBe('imperial'); }); }); it('leaves untouched keys alone', async () => { const { user } = createUser(testDb); - setSetting(user.id, 'time_format', '"12h"'); + await setSetting(user.id, 'time_format', '"12h"'); await withHarness(user.id, async (h) => { await update(h, { temperature_unit: 'celsius' }); - expect(readSetting(user.id, 'time_format')).toBe('"12h"'); + expect(await readSetting(user.id, 'time_format')).toBe('"12h"'); }); }); @@ -245,7 +251,7 @@ describe('Tool: update_display_settings', () => { expect(set.settings.place_language).toBe('en'); const refused = await update(h, { place_language: 'klingon' }); expect(refused.isError).toBe(true); - expect(readSetting(user.id, 'place_language')).toBe('en'); + expect(await readSetting(user.id, 'place_language')).toBe('en'); const cleared = parseToolResult(await update(h, { place_language: '' })) as any; expect(cleared.settings.place_language).toBe(''); }); @@ -258,7 +264,7 @@ describe('Tool: update_display_settings', () => { expect(ok.settings.week_start).toBe('sunday'); const refused = await update(h, { week_start: 'friday' }); expect(refused.isError).toBe(true); - expect(readSetting(user.id, 'week_start')).toBe('sunday'); + expect(await readSetting(user.id, 'week_start')).toBe('sunday'); }); }); @@ -290,25 +296,24 @@ describe('Tool: update_display_settings', () => { const result = await update(h, { dark_mode: false }); const data = parseToolResult(result) as any; expect(data.settings.dark_mode).toBe(false); - expect(readSetting(user.id, 'dark_mode')).toBe('false'); + expect(await readSetting(user.id, 'dark_mode')).toBe('false'); }); }); it('accepts an empty default_currency, which falls back to each trip\'s own', async () => { const { user } = createUser(testDb); - setSetting(user.id, 'default_currency', '"USD"'); + await setSetting(user.id, 'default_currency', '"USD"'); await withHarness(user.id, async (h) => { const result = await update(h, { default_currency: '' }); const data = parseToolResult(result) as any; expect(data.success).toBe(true); - expect(readSetting(user.id, 'default_currency')).toBe(''); + expect(await readSetting(user.id, 'default_currency')).toBe(''); }); }); it('reads back the admin default rather than echoing the input', async () => { const { user } = createUser(testDb); - testDb.prepare('INSERT INTO app_settings (key, value) VALUES (?, ?)') - .run('default_user_setting_distance_unit', '"imperial"'); + await insertRow(orm, AppSettings, { key: 'default_user_setting_distance_unit', value: '"imperial"' }); await withHarness(user.id, async (h) => { const result = await update(h, { temperature_unit: 'celsius' }); const data = parseToolResult(result) as any; @@ -328,7 +333,7 @@ describe('Tool: update_display_settings, refusals', () => { const result = await update(h, { mapbox_access_token: 'pk.attacker-token' }); expect(result.isError).toBe(true); expect(errorText(result)).toContain('mapbox_access_token'); - expect(countSettings(user.id)).toBe(0); + expect(await countSettings(user.id)).toBe(0); }); }); @@ -337,7 +342,7 @@ describe('Tool: update_display_settings, refusals', () => { await withScopedHarness(user.id, ['settings:write'], async (h) => { const result = await update(h, { llm_api_key: 'sk-attacker' }); expect(result.isError).toBe(true); - expect(countSettings(user.id)).toBe(0); + expect(await countSettings(user.id)).toBe(0); }); }); @@ -348,7 +353,7 @@ describe('Tool: update_display_settings, refusals', () => { const result = await update(h, settings); expect(result.isError).toBe(true); } - expect(countSettings(user.id)).toBe(0); + expect(await countSettings(user.id)).toBe(0); }); }); @@ -358,8 +363,8 @@ describe('Tool: update_display_settings, refusals', () => { const result = await update(h, { made_up_preference: 'whatever' }); expect(result.isError).toBe(true); expect(errorText(result)).toContain('made_up_preference'); - expect(countSettings(user.id)).toBe(0); - expect(readSetting(user.id, 'made_up_preference')).toBeUndefined(); + expect(await countSettings(user.id)).toBe(0); + expect(await readSetting(user.id, 'made_up_preference')).toBeUndefined(); }); }); @@ -368,7 +373,7 @@ describe('Tool: update_display_settings, refusals', () => { await withHarness(user.id, async (h) => { const result = await update(h, { toString: 'gotcha' }); expect(result.isError).toBe(true); - expect(countSettings(user.id)).toBe(0); + expect(await countSettings(user.id)).toBe(0); }); }); @@ -377,7 +382,7 @@ describe('Tool: update_display_settings, refusals', () => { await withHarness(user.id, async (h) => { const result = await update(h, { temperature_unit: 'fahrenheit', llm_api_key: 'sk-attacker' }); expect(result.isError).toBe(true); - expect(countSettings(user.id)).toBe(0); + expect(await countSettings(user.id)).toBe(0); }); }); @@ -386,7 +391,7 @@ describe('Tool: update_display_settings, refusals', () => { await withHarness(user.id, async (h) => { const result = await update(h, { temperature_unit: 'fahrenheit', time_format: '36h' }); expect(result.isError).toBe(true); - expect(countSettings(user.id)).toBe(0); + expect(await countSettings(user.id)).toBe(0); }); }); @@ -396,7 +401,7 @@ describe('Tool: update_display_settings, refusals', () => { const result = await update(h, { temperature_unit: 'kelvin' }); expect(result.isError).toBe(true); expect(errorText(result)).toContain('temperature_unit'); - expect(readSetting(user.id, 'temperature_unit')).toBeUndefined(); + expect(await readSetting(user.id, 'temperature_unit')).toBeUndefined(); }); }); @@ -407,7 +412,7 @@ describe('Tool: update_display_settings, refusals', () => { expect(bad.isError).toBe(true); const good = await update(h, { language: 'fr' }); expect(good.isError).toBeFalsy(); - expect(readSetting(user.id, 'language')).toBe('fr'); + expect(await readSetting(user.id, 'language')).toBe('fr'); }); }); @@ -418,7 +423,7 @@ describe('Tool: update_display_settings, refusals', () => { const result = await update(h, { default_currency: value }); expect(result.isError).toBe(true); } - expect(countSettings(user.id)).toBe(0); + expect(await countSettings(user.id)).toBe(0); }); }); @@ -427,7 +432,7 @@ describe('Tool: update_display_settings, refusals', () => { await withHarness(user.id, async (h) => { const result = await update(h, { blur_booking_codes: 'yes' }); expect(result.isError).toBe(true); - expect(countSettings(user.id)).toBe(0); + expect(await countSettings(user.id)).toBe(0); }); }); @@ -436,7 +441,7 @@ describe('Tool: update_display_settings, refusals', () => { await withHarness(user.id, async (h) => { expect((await update(h, { start_trip_tab: '' })).isError).toBe(true); expect((await update(h, { start_trip_tab: 'x'.repeat(65) })).isError).toBe(true); - expect(countSettings(user.id)).toBe(0); + expect(await countSettings(user.id)).toBe(0); }); }); @@ -446,7 +451,7 @@ describe('Tool: update_display_settings, refusals', () => { const result = await update(h, { start_trip_tab: MASKED_SETTING_VALUE }); expect(result.isError).toBe(true); expect(errorText(result)).toContain('start_trip_tab'); - expect(countSettings(user.id)).toBe(0); + expect(await countSettings(user.id)).toBe(0); }); }); @@ -456,7 +461,7 @@ describe('Tool: update_display_settings, refusals', () => { const result = await update(h, {}); expect(result.isError).toBe(true); expect(errorText(result)).toContain('temperature_unit'); - expect(countSettings(user.id)).toBe(0); + expect(await countSettings(user.id)).toBe(0); }); }); @@ -466,14 +471,14 @@ describe('Tool: update_display_settings, refusals', () => { await withHarness(user.id, async (h) => { const result = await update(h, { temperature_unit: 'fahrenheit' }); expect(result.isError).toBe(true); - expect(countSettings(user.id)).toBe(0); + expect(await countSettings(user.id)).toBe(0); }); }); it('lets the demo user read', async () => { process.env.DEMO_MODE = 'true'; const { user } = createUser(testDb, { email: 'demo@trek.app' }); - setSetting(user.id, 'temperature_unit', '"celsius"'); + await setSetting(user.id, 'temperature_unit', '"celsius"'); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'get_display_settings', arguments: {} }); const data = parseToolResult(result) as any; diff --git a/server/tests/unit/nest/feeds.service.test.ts b/server/tests/unit/nest/feeds.service.test.ts index 9ab43137b..470b648ba 100644 --- a/server/tests/unit/nest/feeds.service.test.ts +++ b/server/tests/unit/nest/feeds.service.test.ts @@ -25,6 +25,7 @@ import { createSnapshotTestDb } from '../../helpers/db-mock'; import { resetTestDb } from '../../helpers/test-db'; import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; import { createUser, createTrip, addTripMember } from '../../helpers/factories'; +import { findRow, updateRows } from '../../helpers/factories/rows'; import { Trips } from '../../../src/db/entities/Trips.entity'; import { Users } from '../../../src/db/entities/Users.entity'; import type { TripsRepository } from '../../../src/db/repositories/Trips.repository'; @@ -86,16 +87,16 @@ afterAll(async () => { testDb.close(); }); -function seedTrip(token?: string) { +async function seedTrip(token?: string) { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - if (token) testDb.prepare('UPDATE trips SET feed_token = ? WHERE id = ?').run(token, trip.id); + if (token) await updateRows(t, Trips, { id: trip.id }, { feed_token: token }); return { user, trip }; } -function seedUserWithToken(token: string, overrides: Partial<{ username: string }> = {}) { +async function seedUserWithToken(token: string, overrides: Partial<{ username: string }> = {}) { const { user } = createUser(testDb, overrides); - testDb.prepare('UPDATE users SET feed_token = ? WHERE id = ?').run(token, user.id); + await updateRows(t, Users, { id: user.id }, { feed_token: token }); return user; } @@ -103,13 +104,13 @@ function seedUserWithToken(token: string, overrides: Partial<{ username: string describe('trip feed token lifecycle', () => { it('FEED-SVC-001: reports no URL while the trip has no token', async () => { - const { user, trip } = seedTrip(); + const { user, trip } = await seedTrip(); expect(await svc.getTripToken(trip.id, user.id, BASE)).toEqual({ feed_url: null }); }); it('FEED-SVC-002: reports the absolute feed URL once a token exists', async () => { - const { user, trip } = seedTrip('tok-trip'); + const { user, trip } = await seedTrip('tok-trip'); expect(await svc.getTripToken(trip.id, user.id, BASE)).toEqual({ feed_url: `${BASE}/api/feed/trip/tok-trip.ics`, @@ -119,7 +120,7 @@ describe('trip feed token lifecycle', () => { it('FEED-SVC-003: a trailing slash on the base is stripped, never doubled into //api', async () => { // APP_URL is user-supplied config; pasted with a trailing slash it would // otherwise produce https://host//api/feed/... which some clients reject. - const { user, trip } = seedTrip('tok-trip'); + const { user, trip } = await seedTrip('tok-trip'); expect((await svc.getTripToken(trip.id, user.id, `${BASE}/`)).feed_url).toBe( `${BASE}/api/feed/trip/tok-trip.ics`, @@ -129,7 +130,7 @@ describe('trip feed token lifecycle', () => { it('FEED-SVC-004: a user without access gets null, not the token of a foreign trip', async () => { // The token is the credential for the public feed, so leaking it through the // authenticated GET would hand a stranger the whole trip. - const { trip } = seedTrip('tok-trip'); + const { trip } = await seedTrip('tok-trip'); const { user: outsider } = createUser(testDb); expect(await svc.getTripToken(trip.id, outsider.id, BASE)).toEqual({ feed_url: null }); @@ -139,7 +140,7 @@ describe('trip feed token lifecycle', () => { // caller may manage the credential at all is decided one layer up, by // TripAccessGuard + @RequirePermission('share_manage') on the controller. it('FEED-SVC-005: a trip shared with the user as a member resolves too', async () => { - const { trip } = seedTrip('tok-trip'); + const { trip } = await seedTrip('tok-trip'); const { user: member } = createUser(testDb); addTripMember(testDb, trip.id, member.id); @@ -151,7 +152,7 @@ describe('trip feed token lifecycle', () => { it('FEED-SVC-006: generate mints a token once and stays idempotent', async () => { // Enabling twice must not invalidate a URL the user already handed to their // calendar client — that is what rotate is for. - const { user, trip } = seedTrip(); + const { user, trip } = await seedTrip(); const first = await svc.generateTripToken(trip.id, user.id, BASE); const second = await svc.generateTripToken(trip.id, user.id, BASE); @@ -161,7 +162,7 @@ describe('trip feed token lifecycle', () => { }); it('FEED-SVC-007: rotate issues a fresh token and the previous URL stops resolving', async () => { - const { user, trip } = seedTrip(); + const { user, trip } = await seedTrip(); const before = (await svc.generateTripToken(trip.id, user.id, BASE)).feed_url; const oldToken = before.match(/trip\/([0-9a-f-]+)\.ics$/)![1]; @@ -172,7 +173,7 @@ describe('trip feed token lifecycle', () => { }); it('FEED-SVC-008: disable clears the column so the public URL dies', async () => { - const { user, trip } = seedTrip(); + const { user, trip } = await seedTrip(); const url = (await svc.generateTripToken(trip.id, user.id, BASE)).feed_url; const token = url.match(/trip\/([0-9a-f-]+)\.ics$/)![1]; @@ -186,7 +187,7 @@ describe('trip feed token lifecycle', () => { // The route guard is what enforces share_manage; this is the second lock, so // a caller reaching the service another way cannot mint or clear a token on // a trip id it merely guessed. - const { user, trip } = seedTrip(); + const { user, trip } = await seedTrip(); const { user: outsider } = createUser(testDb); const mine = (await svc.generateTripToken(trip.id, user.id, BASE)).feed_url; const myToken = mine.match(/trip\/([0-9a-f-]+)\.ics$/)![1]; @@ -205,7 +206,7 @@ describe('trip feed token lifecycle', () => { // Named for the hole, per the task brief; see the task report for the // one-line fix proposal (compare the affected count and 404/refuse on 0). it('R4 HOLE — generateTripToken returns a feed_url for a token it never stored, for a trip the caller cannot reach', async () => { - const { trip } = seedTrip(); // no token yet + const { trip } = await seedTrip(); // no token yet const { user: stranger } = createUser(testDb); const result = await svc.generateTripToken(trip.id, stranger.id, BASE); @@ -215,7 +216,7 @@ describe('trip feed token lifecycle', () => { // ...but the column was never written (setFeedTokenIfReachable affected 0 // rows: the stranger fails REACHABLE), so the URL 404s for anyone who tries it. const mintedToken = result.feed_url.match(/trip\/([0-9a-f-]+)\.ics$/)![1]; - expect((testDb.prepare('SELECT feed_token FROM trips WHERE id = ?').get(trip.id) as { feed_token: string | null }).feed_token).toBeNull(); + expect((await findRow(t, Trips, { id: trip.id }))?.feed_token).toBeNull(); expect(await svc.buildTripIcs(mintedToken)).toBeNull(); // Proven directly at the repository too — the affected count IS the 0-row signal. expect(await tripsRepo.setFeedTokenIfReachable(trip.id, stranger.id, mintedToken)).toBe(0); @@ -230,7 +231,7 @@ describe('trip feed token lifecycle', () => { // one of the two minted URLs ever resolves. Pinning today's actual outcome // (same class as `roadtrip.service.test.ts`'s R7 vias pin), not a fix. it('R7: two concurrent generateTripToken calls both mint, but only the last write survives — the other caller\'s URL never resolves (unserialized FD1-then-FD2)', async () => { - const { user, trip } = seedTrip(); // no token yet + const { user, trip } = await seedTrip(); // no token yet const [r1, r2] = await Promise.all([ svc.generateTripToken(trip.id, user.id, BASE), @@ -242,7 +243,7 @@ describe('trip feed token lifecycle', () => { // FD1 saw no token for either caller, so two distinct tokens were minted. expect(token1).not.toBe(token2); - const stored = (testDb.prepare('SELECT feed_token FROM trips WHERE id = ?').get(trip.id) as { feed_token: string | null }).feed_token; + const stored = (await findRow(t, Trips, { id: trip.id }))?.feed_token; expect([token1, token2]).toContain(stored); const loser = stored === token1 ? token2 : token1; // The loser's URL is well-formed, but resolves nothing. @@ -301,7 +302,7 @@ describe('user feed token lifecycle', () => { describe('buildTripIcs', () => { it('FEED-SVC-013: an unknown token yields null without asking the calendar', async () => { - seedTrip('tok-trip'); + await seedTrip('tok-trip'); expect(await svc.buildTripIcs('00000000-0000-0000-0000-000000000000')).toBeNull(); expect(buildTripCalendar).not.toHaveBeenCalled(); @@ -311,7 +312,7 @@ describe('buildTripIcs', () => { // The public feed is unauthenticated: a trip the calendar cannot render (a row // deleted mid-request, unparseable data) has to come back as a 404, not a 500 // that a subscribing client retries hourly forever. - seedTrip('tok-trip'); + await seedTrip('tok-trip'); buildTripCalendar.mockImplementation(() => { throw new Error('calendar exploded'); }); @@ -324,7 +325,7 @@ describe('buildTripIcs', () => { // before the first component, and clients that scan only the preamble stop // re-fetching if they slip behind a VTIMEZONE. The document is concatenated from // the calendar's parts now, so the order is an assembly decision, not a given. - const { trip } = seedTrip('tok-trip'); + const { trip } = await seedTrip('tok-trip'); buildTripCalendar.mockImplementation(() => calendarParts({ calName: 'Golden Trip', @@ -351,7 +352,7 @@ describe('buildTripIcs', () => { describe('buildUserIcs', () => { it('FEED-SVC-016: an unknown token yields null without asking the calendar', async () => { - seedUserWithToken('tok-user'); + await seedUserWithToken('tok-user'); expect(await svc.buildUserIcs('00000000-0000-0000-0000-000000000000')).toBeNull(); expect(buildTripCalendar).not.toHaveBeenCalled(); @@ -360,7 +361,7 @@ describe('buildUserIcs', () => { it('FEED-SVC-017: a trip whose calendar throws is skipped, the rest are still emitted', async () => { // One unrenderable trip must not take the whole all-trips subscription down — // the user would silently lose every calendar entry because of a single bad row. - const user = seedUserWithToken('tok-user'); + const user = await seedUserWithToken('tok-user'); const good = createTrip(testDb, user.id, { start_date: '2026-01-01' }); const broken = createTrip(testDb, user.id, { start_date: '2026-02-01' }); const alsoGood = createTrip(testDb, user.id, { start_date: '2026-03-01' }); @@ -382,7 +383,7 @@ describe('buildUserIcs', () => { // Two VTIMEZONE blocks with the same TZID make the document invalid and clients // drop the events referencing it; a block emitted after the VEVENT that uses it // does not resolve either (#1453). First definition wins. - const user = seedUserWithToken('tok-user'); + const user = await seedUserWithToken('tok-user'); const first = createTrip(testDb, user.id, { start_date: '2026-01-01' }); createTrip(testDb, user.id, { start_date: '2026-02-01' }); buildTripCalendar.mockImplementation((id: number) => ({ @@ -408,7 +409,7 @@ describe('buildUserIcs', () => { // render as a truncated calendar title. The body still has to fold — RFC 5545 // caps a content line at 75 octets. const username = 'Ferdinand-Bartholomew-'.repeat(5); - const user = seedUserWithToken('tok-user', { username }); + const user = await seedUserWithToken('tok-user', { username }); createTrip(testDb, user.id, { start_date: '2026-01-01' }); const longSummary = 'A'.repeat(120); buildTripCalendar.mockImplementation(() => calendarParts({ events: [vevent(longSummary)] })); @@ -428,7 +429,7 @@ describe('buildUserIcs', () => { // An unescaped ; or , ends the property value early, so the calendar shows up // under a truncated name. The returned calName feeds the HTTP layer, not ICS, // and must stay verbatim. - const user = seedUserWithToken('tok-user', { username: 'Alice; Bob, Co\\Ltd' }); + const user = await seedUserWithToken('tok-user', { username: 'Alice; Bob, Co\\Ltd' }); createTrip(testDb, user.id, { start_date: '2026-01-01' }); const { ics, calName } = (await svc.buildUserIcs('tok-user'))!; diff --git a/server/tests/unit/nest/platform.controller.test.ts b/server/tests/unit/nest/platform.controller.test.ts index 5627c3218..a572ab7b8 100644 --- a/server/tests/unit/nest/platform.controller.test.ts +++ b/server/tests/unit/nest/platform.controller.test.ts @@ -26,6 +26,10 @@ import type { StorageService } from '../../../src/nest/storage/storage.service'; import { createSnapshotTestDb } from '../../helpers/db-mock'; import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; import { createUser, createTrip } from '../../helpers/factories'; +import { makeShareToken } from '../../helpers/factories/trips'; +import { deleteRows, insertRow } from '../../helpers/factories/rows'; +import { Photos } from '../../../src/db/entities/Photos.entity'; +import { ShareTokens } from '../../../src/db/entities/ShareTokens.entity'; // The serving swap addresses files as (category, name) on the injected facade; // these unit tests only assert routing/auth/error mapping, so a two-method stub @@ -212,24 +216,22 @@ describe('applyPlatformUploads', () => { // every case here seeds REAL rows in `uploadsTestDb` (bound to // `uploadsOrm.orm`) rather than mocking `db.prepare`'s return value; the // `h.dbPrepare` stub is dead for this whole describe block now. - function insertShareToken(tripId: number, userId: number, token: string, expiresAt: string | null = null) { - uploadsTestDb.prepare('INSERT INTO share_tokens (trip_id, token, created_by, expires_at) VALUES (?, ?, ?, ?)') - .run(tripId, token, userId, expiresAt); + async function insertShareToken(tripId: number, userId: number, token: string, expiresAt: string | null = null) { + await makeShareToken(uploadsOrm, tripId, userId, { token, expires_at: expiresAt }); } - function insertPhoto(tripId: number, filename: string) { - uploadsTestDb.prepare('INSERT INTO photos (trip_id, filename, original_name) VALUES (?, ?, ?)') - .run(tripId, filename, filename); + async function insertPhoto(tripId: number, filename: string) { + await insertRow(uploadsOrm, Photos, { trip: tripId, filename, original_name: filename }); } it('401 when a share token does not cover the photo trip', async () => { const { user } = createUser(uploadsTestDb); const photoTrip = createTrip(uploadsTestDb, user.id); const otherTrip = createTrip(uploadsTestDb, user.id); - insertShareToken(otherTrip.id, user.id, 'share-mismatch'); + await insertShareToken(otherTrip.id, user.id, 'share-mismatch'); h.exists.mockResolvedValue(true); h.verifyJwtAndLoadUser.mockReturnValue(null); - insertPhoto(photoTrip.id, 'photo-mismatch.jpg'); + await insertPhoto(photoTrip.id, 'photo-mismatch.jpg'); const res = makeRes(); await photoHandler()({ params: { filename: 'photo-mismatch.jpg' }, headers: {}, query: { token: 'share-mismatch' } }, res, next); expect(res.statusCode).toBe(401); @@ -240,7 +242,7 @@ describe('applyPlatformUploads', () => { const photoTrip = createTrip(uploadsTestDb, user.id); h.exists.mockResolvedValue(true); h.verifyJwtAndLoadUser.mockReturnValue(null); - insertPhoto(photoTrip.id, 'photo-unknown-token.jpg'); + await insertPhoto(photoTrip.id, 'photo-unknown-token.jpg'); const res = makeRes(); await photoHandler()({ params: { filename: 'photo-unknown-token.jpg' }, headers: {}, query: { token: 'never-issued' } }, res, next); expect(res.statusCode).toBe(401); @@ -249,11 +251,11 @@ describe('applyPlatformUploads', () => { it('R5: 401 when the token is revoked (deleted, not merely unknown)', async () => { const { user } = createUser(uploadsTestDb); const photoTrip = createTrip(uploadsTestDb, user.id); - insertShareToken(photoTrip.id, user.id, 'share-revoked'); - uploadsTestDb.prepare('DELETE FROM share_tokens WHERE token = ?').run('share-revoked'); + await insertShareToken(photoTrip.id, user.id, 'share-revoked'); + await deleteRows(uploadsOrm, ShareTokens, { token: 'share-revoked' }); h.exists.mockResolvedValue(true); h.verifyJwtAndLoadUser.mockReturnValue(null); - insertPhoto(photoTrip.id, 'photo-revoked.jpg'); + await insertPhoto(photoTrip.id, 'photo-revoked.jpg'); const res = makeRes(); await photoHandler()({ params: { filename: 'photo-revoked.jpg' }, headers: {}, query: { token: 'share-revoked' } }, res, next); expect(res.statusCode).toBe(401); @@ -262,10 +264,10 @@ describe('applyPlatformUploads', () => { it('R5: 401 when the token is expired', async () => { const { user } = createUser(uploadsTestDb); const photoTrip = createTrip(uploadsTestDb, user.id); - insertShareToken(photoTrip.id, user.id, 'share-expired', '2020-01-01 00:00:00'); + await insertShareToken(photoTrip.id, user.id, 'share-expired', '2020-01-01 00:00:00'); h.exists.mockResolvedValue(true); h.verifyJwtAndLoadUser.mockReturnValue(null); - insertPhoto(photoTrip.id, 'photo-expired.jpg'); + await insertPhoto(photoTrip.id, 'photo-expired.jpg'); const res = makeRes(); await photoHandler()({ params: { filename: 'photo-expired.jpg' }, headers: {}, query: { token: 'share-expired' } }, res, next); expect(res.statusCode).toBe(401); @@ -274,10 +276,10 @@ describe('applyPlatformUploads', () => { it('R5: 401 for a wrong-case token — no case-folding is introduced', async () => { const { user } = createUser(uploadsTestDb); const photoTrip = createTrip(uploadsTestDb, user.id); - insertShareToken(photoTrip.id, user.id, 'share-CaseSensitive'); + await insertShareToken(photoTrip.id, user.id, 'share-CaseSensitive'); h.exists.mockResolvedValue(true); h.verifyJwtAndLoadUser.mockReturnValue(null); - insertPhoto(photoTrip.id, 'photo-case.jpg'); + await insertPhoto(photoTrip.id, 'photo-case.jpg'); const res = makeRes(); await photoHandler()({ params: { filename: 'photo-case.jpg' }, headers: {}, query: { token: 'share-casesensitive' } }, res, next); expect(res.statusCode).toBe(401); @@ -286,10 +288,10 @@ describe('applyPlatformUploads', () => { it('R5: 401 for a token with an embedded NUL byte', async () => { const { user } = createUser(uploadsTestDb); const photoTrip = createTrip(uploadsTestDb, user.id); - insertShareToken(photoTrip.id, user.id, 'share-nul'); + await insertShareToken(photoTrip.id, user.id, 'share-nul'); h.exists.mockResolvedValue(true); h.verifyJwtAndLoadUser.mockReturnValue(null); - insertPhoto(photoTrip.id, 'photo-nul.jpg'); + await insertPhoto(photoTrip.id, 'photo-nul.jpg'); const res = makeRes(); await photoHandler()({ params: { filename: 'photo-nul.jpg' }, headers: {}, query: { token: 'share-nul\0extra' } }, res, next); expect(res.statusCode).toBe(401); @@ -298,11 +300,11 @@ describe('applyPlatformUploads', () => { it('serves the file when the share token covers the photo trip (R1 valid-token case)', async () => { const { user } = createUser(uploadsTestDb); const photoTrip = createTrip(uploadsTestDb, user.id); - insertShareToken(photoTrip.id, user.id, 'share-valid'); + await insertShareToken(photoTrip.id, user.id, 'share-valid'); h.exists.mockResolvedValue(true); h.sendToResponse.mockResolvedValue(undefined); h.verifyJwtAndLoadUser.mockReturnValue(null); - insertPhoto(photoTrip.id, 'photo-valid.jpg'); + await insertPhoto(photoTrip.id, 'photo-valid.jpg'); const res = makeRes(); await photoHandler()( { params: { filename: 'photo-valid.jpg' }, headers: { authorization: 'Bearer share-valid' }, query: {} }, diff --git a/server/tests/unit/nest/query-helpers.service.test.ts b/server/tests/unit/nest/query-helpers.service.test.ts index d01bf9f22..fc0787908 100644 --- a/server/tests/unit/nest/query-helpers.service.test.ts +++ b/server/tests/unit/nest/query-helpers.service.test.ts @@ -16,6 +16,9 @@ import type { PlaceRatingsRepository } from '../../../src/db/repositories/PlaceR import { AssignmentParticipants } from '../../../src/db/entities/AssignmentParticipants.entity'; import type { AssignmentParticipantsRepository } from '../../../src/db/repositories/AssignmentParticipants.repository'; import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service'; +import { Users } from '../../../src/db/entities/Users.entity'; +import { insertRow, updateRows } from '../../helpers/factories/rows'; +import { tagPlace } from '../../helpers/factories/places'; const testDb = createSnapshotTestDb(); let t: TestOrm; @@ -31,16 +34,16 @@ beforeAll(async () => { beforeEach(() => { resetTestDb(testDb); t.clear(); }); afterAll(async () => { await t.close(); testDb.close(); }); -function attachTag(tagId: number, placeId: number): void { - testDb.prepare('INSERT INTO place_tags (tag_id, place_id) VALUES (?, ?)').run(tagId, placeId); +async function attachTag(tagId: number, placeId: number): Promise { + await tagPlace(t, placeId, [tagId]); } -function rate(placeId: number, userId: number, rating: number): void { - testDb.prepare('INSERT INTO place_ratings (place_id, user_id, rating) VALUES (?, ?, ?)').run(placeId, userId, rating); +async function rate(placeId: number, userId: number, rating: number): Promise { + await insertRow(t, PlaceRatings, { place: placeId, user: userId, rating }); } -function addParticipant(assignmentId: number, userId: number): void { - testDb.prepare('INSERT INTO assignment_participants (assignment_id, user_id) VALUES (?, ?)').run(assignmentId, userId); +async function addParticipant(assignmentId: number, userId: number): Promise { + await insertRow(t, AssignmentParticipants, { assignment: assignmentId, user: userId }); } describe('QueryHelpersService.loadTagsByPlaceIds', () => { @@ -53,7 +56,7 @@ describe('QueryHelpersService.loadTagsByPlaceIds', () => { const trip = createTrip(testDb, user.id); const place = createPlace(testDb, trip.id); const tag = createTag(testDb, user.id, { name: 'Beach', color: '#ff0000' }); - attachTag(tag.id, place.id); + await attachTag(tag.id, place.id); const byPlace = await svc.loadTagsByPlaceIds([place.id]); expect(byPlace[place.id]).toHaveLength(1); @@ -66,7 +69,7 @@ describe('QueryHelpersService.loadTagsByPlaceIds', () => { const trip = createTrip(testDb, user.id); const place = createPlace(testDb, trip.id); const tag = createTag(testDb, user.id, { name: 'Compact' }); - attachTag(tag.id, place.id); + await attachTag(tag.id, place.id); const byPlace = await svc.loadTagsByPlaceIds([place.id], { compact: true }); expect(byPlace[place.id][0]).toEqual({ id: tag.id, name: 'Compact', color: tag.color, created_at: expect.any(String) }); @@ -79,8 +82,8 @@ describe('QueryHelpersService.loadTagsByPlaceIds', () => { const placeB = createPlace(testDb, trip.id, { name: 'B' }); const placeC = createPlace(testDb, trip.id, { name: 'C' }); const tag = createTag(testDb, user.id); - attachTag(tag.id, placeA.id); - attachTag(tag.id, placeB.id); + await attachTag(tag.id, placeA.id); + await attachTag(tag.id, placeB.id); const byPlace = await svc.loadTagsByPlaceIds([placeA.id, placeB.id, placeC.id]); expect(Object.keys(byPlace).map(Number).sort()).toEqual([placeA.id, placeB.id].sort()); @@ -93,8 +96,8 @@ describe('QueryHelpersService.loadTagsByPlaceIds', () => { const place = createPlace(testDb, trip.id); const tagA = createTag(testDb, user.id, { name: 'First' }); const tagB = createTag(testDb, user.id, { name: 'Second' }); - attachTag(tagA.id, place.id); - attachTag(tagB.id, place.id); + await attachTag(tagA.id, place.id); + await attachTag(tagB.id, place.id); const byPlace = await svc.loadTagsByPlaceIds([place.id]); expect(byPlace[place.id].map((t) => t.name).sort()).toEqual(['First', 'Second']); @@ -111,7 +114,7 @@ describe('QueryHelpersService.loadRatingsByPlaceIds', () => { const { user: voter } = createUser(testDb, { username: 'rater1' }); const trip = createTrip(testDb, owner.id); const place = createPlace(testDb, trip.id); - rate(place.id, voter.id, 4); + await rate(place.id, voter.id, 4); const byPlace = await svc.loadRatingsByPlaceIds([place.id]); expect(byPlace[place.id]).toEqual([{ user_id: voter.id, username: 'rater1', avatar: null, rating: 4 }]); @@ -130,8 +133,8 @@ describe('QueryHelpersService.loadRatingsByPlaceIds', () => { const { user: voterB } = createUser(testDb, { username: 'voter-b' }); const trip = createTrip(testDb, owner.id); const place = createPlace(testDb, trip.id); - rate(place.id, voterA.id, 5); - rate(place.id, voterB.id, 2); + await rate(place.id, voterA.id, 5); + await rate(place.id, voterB.id, 2); const byPlace = await svc.loadRatingsByPlaceIds([place.id]); expect(byPlace[place.id].map((r) => r.username).sort()).toEqual(['voter-a', 'voter-b']); @@ -146,12 +149,12 @@ describe('QueryHelpersService.loadParticipantsByAssignmentIds', () => { it('QH-009: indexes participants by assignment id, with NO COALESCE(display_name, username) — raw username only', async () => { const { user: owner } = createUser(testDb); const { user: participant } = createUser(testDb, { username: 'raw-username' }); - testDb.prepare('UPDATE users SET display_name = ? WHERE id = ?').run('Ignored Display Name', participant.id); + await updateRows(t, Users, { id: participant.id }, { display_name: 'Ignored Display Name' }); const trip = createTrip(testDb, owner.id); const day = createDay(testDb, trip.id); const place = createPlace(testDb, trip.id); const assignment = createDayAssignment(testDb, day.id, place.id); - addParticipant(assignment.id, participant.id); + await addParticipant(assignment.id, participant.id); const byAssignment = await svc.loadParticipantsByAssignmentIds([assignment.id]); expect(byAssignment[assignment.id]).toEqual([{ user_id: participant.id, username: 'raw-username', avatar: null }]); @@ -174,8 +177,8 @@ describe('QueryHelpersService.loadParticipantsByAssignmentIds', () => { const day = createDay(testDb, trip.id); const place = createPlace(testDb, trip.id); const assignment = createDayAssignment(testDb, day.id, place.id); - addParticipant(assignment.id, p1.id); - addParticipant(assignment.id, p2.id); + await addParticipant(assignment.id, p1.id); + await addParticipant(assignment.id, p2.id); const byAssignment = await svc.loadParticipantsByAssignmentIds([assignment.id]); expect(byAssignment[assignment.id].map((p) => p.username).sort()).toEqual(['p1', 'p2']); diff --git a/server/tests/unit/nest/storage/storage-stats.service.test.ts b/server/tests/unit/nest/storage/storage-stats.service.test.ts index a6bcf371a..378e2a1f8 100644 --- a/server/tests/unit/nest/storage/storage-stats.service.test.ts +++ b/server/tests/unit/nest/storage/storage-stats.service.test.ts @@ -19,6 +19,9 @@ import { StorageRegistryService } from '../../../../src/nest/storage/storage-reg import { StorageService } from '../../../../src/nest/storage/storage.service'; import { StatsBusyError, StorageStatsService } from '../../../../src/nest/storage/storage-stats.service'; import { createTestUnitOfWork, createTestAppSettingsRepo, sharedTestOrm } from '../../../helpers/test-uow'; +import { deleteRows } from '../../../helpers/factories/rows'; +import { readAppSetting, setAppSetting } from '../../../helpers/factories/settings'; +import { AppSettings } from '../../../../src/db/entities/AppSettings.entity'; const tmpDirs: string[] = []; function makeTmpDir(): string { @@ -26,11 +29,11 @@ function makeTmpDir(): string { tmpDirs.push(dir); return dir; } -function setSetting(key: string, value: string): void { - testDb.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run(key, value); +async function setSetting(key: string, value: string): Promise { + await setAppSetting(await sharedTestOrm(testDb), key, value); } -beforeEach(() => { - testDb.prepare("DELETE FROM app_settings WHERE key LIKE 'storage.%'").run(); +beforeEach(async () => { + await deleteRows(await sharedTestOrm(testDb), AppSettings, { key: { $like: 'storage.%' } }); }); afterEach(() => { vi.restoreAllMocks(); @@ -40,7 +43,7 @@ afterEach(() => { async function makeWorld() { const uploadsRoot = makeTmpDir(); const backupsRoot = makeTmpDir(); - setSetting( + await setSetting( 'storage.backends', JSON.stringify([ { name: 'uploads-local', type: 'local', options: { root: uploadsRoot } }, @@ -77,8 +80,8 @@ describe('StorageStatsService', () => { expect(usage.computedAt).toBeGreaterThan(0); // Persisted round-trip: expect(await stats.readUsage()).toEqual(usage); - const raw = testDb.prepare("SELECT value FROM app_settings WHERE key = 'storage.usage'").get() as { value: string }; - expect(JSON.parse(raw.value)).toEqual(usage); + const raw = await readAppSetting(await sharedTestOrm(testDb), 'storage.usage'); + expect(JSON.parse(raw ?? 'null')).toEqual(usage); }); it('STATS-002 photos-google/photos-trek nested content is NOT double-counted into legacy photos', async () => { @@ -100,7 +103,7 @@ describe('StorageStatsService', () => { it('STATS-004 readUsage returns null on absent or unparseable rows', async () => { const { stats } = await makeWorld(); expect(await stats.readUsage()).toBeNull(); - setSetting('storage.usage', 'not json'); + await setSetting('storage.usage', 'not json'); expect(await stats.readUsage()).toBeNull(); }); }); diff --git a/server/tests/unit/plugins/operator-egress.test.ts b/server/tests/unit/plugins/operator-egress.test.ts index 68723e843..43590d4ef 100644 --- a/server/tests/unit/plugins/operator-egress.test.ts +++ b/server/tests/unit/plugins/operator-egress.test.ts @@ -28,6 +28,7 @@ import { parseManifest, ManifestError } from '../../../src/nest/plugins/install/ import { makeHostAllow } from '../../../src/nest/plugins/runtime/egress-policy'; import { createTestAddonsService } from '../../helpers/test-addons'; import { sharedTestOrm } from '../../helpers/test-uow'; +import { deleteRows, upsertRow } from '../../helpers/factories/rows'; import { Plugins } from '../../../src/db/entities/Plugins.entity'; import { PluginErrorLog } from '../../../src/db/entities/PluginErrorLog.entity'; import { PluginEgressHosts } from '../../../src/db/entities/PluginEgressHosts.entity'; @@ -36,32 +37,41 @@ import { PluginActions } from '../../../src/db/entities/PluginActions.entity'; import { PluginUserConfig } from '../../../src/db/entities/PluginUserConfig.entity'; import { PluginCapabilityAudit } from '../../../src/db/entities/PluginCapabilityAudit.entity'; -function install(id: string, operatorEgress: boolean, perms: string[] = ['http:outbound:gotify.net']) { - testDb.prepare( - `INSERT OR REPLACE INTO plugins (id, name, status, enabled, version, permissions, granted_permissions, capabilities, config, operator_egress) - VALUES (?, ?, 'inactive', 0, '1.0.0', ?, ?, '{}', '{}', ?)`, - ).run(id, id, JSON.stringify(perms), JSON.stringify(perms), operatorEgress ? 1 : 0); +async function install(id: string, operatorEgress: boolean, perms: string[] = ['http:outbound:gotify.net']) { + await upsertRow(await sharedTestOrm(testDb), Plugins, { + id, + name: id, + status: 'inactive', + enabled: 0, + version: '1.0.0', + permissions: JSON.stringify(perms), + granted_permissions: JSON.stringify(perms), + capabilities: '{}', + config: '{}', + operator_egress: operatorEgress ? 1 : 0, + }); } let rt: PluginRuntimeService; beforeEach(async () => { - testDb.prepare('DELETE FROM plugins').run(); - testDb.prepare('DELETE FROM plugin_egress_hosts').run(); - testDb.prepare('DELETE FROM plugin_actions').run(); + const orm = await sharedTestOrm(testDb); + await deleteRows(orm, Plugins); + await deleteRows(orm, PluginEgressHosts); + await deleteRows(orm, PluginActions); rt = await createPluginRuntime(testDb); }); describe('operator-supplied egress hosts', () => { it('OEG-001 — an admin can add hosts to a plugin that declared operatorEgress', async () => { - install('gotify', true); + await install('gotify', true); expect(await rt.wantsOperatorEgress('gotify')).toBe(true); expect(await rt.setOperatorEgressHosts('gotify', ['gotify.mydomain.com'])).toEqual(['gotify.mydomain.com']); expect(await rt.operatorEgressHosts('gotify')).toEqual(['gotify.mydomain.com']); }); it('OEG-002 — a plugin that did NOT declare it can never have hosts added', async () => { - install('sneaky', false); + await install('sneaky', false); expect(await rt.wantsOperatorEgress('sneaky')).toBe(false); // This is the load-bearing check: without it an admin could silently widen egress for // ANY plugin, and the install-time consent would stop bounding what's possible. @@ -70,7 +80,7 @@ describe('operator-supplied egress hosts', () => { }); it('OEG-003 — hosts are validated exactly like manifest egress', async () => { - install('gotify', true); + await install('gotify', true); for (const bad of ['*', '*.com', 'https://gotify.example.com', 'has space', 'a/b']) { await expect(rt.setOperatorEgressHosts('gotify', [bad])).rejects.toThrow(/invalid host/); } @@ -79,14 +89,14 @@ describe('operator-supplied egress hosts', () => { }); it('OEG-004 — hosts are normalized and de-duplicated', async () => { - install('gotify', true); + await install('gotify', true); expect(await rt.setOperatorEgressHosts('gotify', ['Gotify.MyDomain.com', 'gotify.mydomain.com.', ' ', ''])).toEqual([ 'gotify.mydomain.com', ]); }); it('OEG-005 — setting the list replaces it (a removed host is really gone)', async () => { - install('gotify', true); + await install('gotify', true); await rt.setOperatorEgressHosts('gotify', ['a.example.com', 'b.example.com']); await rt.setOperatorEgressHosts('gotify', ['b.example.com']); expect(await rt.operatorEgressHosts('gotify')).toEqual(['b.example.com']); @@ -115,13 +125,13 @@ describe('operator-supplied egress hosts', () => { it('OEG-010 — an operatorEgress plugin with no configured hosts still reaches nothing', async () => { // It ACTIVATES (it may have useful offline features), but the child's allow-list is the // union of its http:outbound: grants and the admin's hosts — both empty here. - install('gotify', true, ['http:outbound']); + await install('gotify', true, ['http:outbound']); expect(await rt.operatorEgressHosts('gotify')).toEqual([]); expect(makeHostAllow([])('gotify.mydomain.com')).toBe(false); }); it('OEG-007 — uninstalling drops the admin’s host consent with the plugin', async () => { - install('gotify', true); + await install('gotify', true); await rt.setOperatorEgressHosts('gotify', ['gotify.mydomain.com']); await rt.uninstall('gotify', false); // A LATER plugin reusing this id must not silently inherit hosts approved for another. @@ -130,22 +140,23 @@ describe('operator-supplied egress hosts', () => { }); describe('settings-page actions (runtime)', () => { - function declareAction(id: string, key: string, scope: 'user' | 'instance' = 'user') { - testDb.prepare('INSERT OR REPLACE INTO plugin_actions (plugin_id, action_key, label, hint, danger, scope, sort_order) VALUES (?, ?, ?, NULL, 0, ?, 0)') - .run(id, key, key, scope); + async function declareAction(id: string, key: string, scope: 'user' | 'instance' = 'user') { + await upsertRow(await sharedTestOrm(testDb), PluginActions, { + plugin_id: id, action_key: key, label: key, hint: null, danger: 0, scope, sort_order: 0, + }); } it('ACT-001 — actionsOf returns the declared descriptors of ONE scope', async () => { - install('p', false); - declareAction('p', 'testConnection'); - declareAction('p', 'purge', 'instance'); + await install('p', false); + await declareAction('p', 'testConnection'); + await declareAction('p', 'purge', 'instance'); expect(await rt.actionsOf('p', 'user')).toEqual([{ key: 'testConnection', label: 'testConnection', hint: undefined, danger: false, scope: 'user' }]); expect(await rt.actionsOf('p', 'instance')).toEqual([{ key: 'purge', label: 'purge', hint: undefined, danger: false, scope: 'instance' }]); }); it('ACT-002 — invoking an action the plugin never declared is REFUSED', async () => { - install('p', false); - declareAction('p', 'testConnection'); + await install('p', false); + await declareAction('p', 'testConnection'); // The key is caller-supplied (it comes off the URL), so the host must check it // against the manifest rather than forwarding whatever it is handed to the child. await expect(rt.invokeAction('p', 'somethingElse', 1, 'user')).rejects.toThrow(/did not declare action/); @@ -153,14 +164,14 @@ describe('settings-page actions (runtime)', () => { }); it('ACT-003 — a plugin with no actions can never be invoked', async () => { - install('p', false); + await install('p', false); await expect(rt.invokeAction('p', 'testConnection', 1, 'user')).rejects.toThrow(/did not declare action/); }); it('ACT-004 — a key declared in the OTHER scope is refused (the user route cannot fire an admin button)', async () => { - install('p', false); - declareAction('p', 'purge', 'instance'); - declareAction('p', 'testConnection', 'user'); + await install('p', false); + await declareAction('p', 'purge', 'instance'); + await declareAction('p', 'testConnection', 'user'); await expect(rt.invokeAction('p', 'purge', 1, 'user')).rejects.toThrow(/did not declare action "purge" in scope user/); await expect(rt.invokeAction('p', 'testConnection', 1, 'instance')).rejects.toThrow(/did not declare action "testConnection" in scope instance/); }); @@ -170,8 +181,8 @@ describe('the admin list surfaces operator egress (so the chip can be shown)', ( it('OEG-008 — reports operatorEgress + the host count', async () => { const { PluginsService } = await import('../../../src/nest/plugins/plugins.service'); process.env.TREK_PLUGINS_ENABLED = 'true'; - install('gotify', true); - install('plain', false); + await install('gotify', true); + await install('plain', false); // list() resolves required-addon dependencies through AddonsService, so it gets a real // one over the same DB. These fixtures declare no dependencies, so it is never consulted. diff --git a/server/tests/unit/plugins/plugin-pois.test.ts b/server/tests/unit/plugins/plugin-pois.test.ts index c89782598..85fb9b690 100644 --- a/server/tests/unit/plugins/plugin-pois.test.ts +++ b/server/tests/unit/plugins/plugin-pois.test.ts @@ -22,6 +22,9 @@ vi.mock('../../../src/db/database', async () => { import { db as testDb } from '../../../src/db/database'; import type { PluginsRepository } from '../../../src/db/repositories/Plugins.repository'; import { createTestPluginsRepo } from '../../helpers/share-repos'; +import { sharedTestOrm } from '../../helpers/test-uow'; +import { deleteRows, insertRows, updateRows } from '../../helpers/factories/rows'; +import { Plugins } from '../../../src/db/entities/Plugins.entity'; import type { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service'; import { PluginPoisService } from '../../../src/nest/plugins/contributions/plugin-pois.service'; import { PluginPoisController, parsePluginPoiQuery } from '../../../src/nest/plugins/contributions/plugin-pois.controller'; @@ -39,17 +42,21 @@ const hit = (over: Record = {}) => ({ id: 'th-1', name: 'Trailh // The plugin rows every case reads, re-seeded before each one on the migrated // snapshot the ORM repository is bound to. -function seedPlugins(): void { - testDb.prepare('DELETE FROM plugins').run(); - const insert = testDb.prepare('INSERT INTO plugins (id, name, status, sort_order, capabilities) VALUES (?, ?, ?, ?, ?)'); - insert.run('trail-finder', 'Trail Finder', 'active', 1, JSON.stringify({ poiCategories: [trailheads, { ...trailheads, id: 'huts', label: 'Huts', labels: undefined, icon: 'Tent' }] })); - insert.run('water-map', 'Water Map', 'active', 0, JSON.stringify({ poiCategories: [{ id: 'taps', label: 'Taps', icon: 'Droplet', color: '#2b6cb0' }] })); - insert.run('quiet', 'Quiet', 'active', 2, JSON.stringify({ poiCategories: [{ id: 'benches', label: 'Benches', icon: 'Info', color: '#000000' }] })); +async function seedPlugins(): Promise { + const orm = await sharedTestOrm(testDb); + await deleteRows(orm, Plugins); + await insertRows(orm, Plugins, [ + { id: 'trail-finder', name: 'Trail Finder', status: 'active', sort_order: 1, capabilities: JSON.stringify({ poiCategories: [trailheads, { ...trailheads, id: 'huts', label: 'Huts', labels: undefined, icon: 'Tent' }] }) }, + { id: 'water-map', name: 'Water Map', status: 'active', sort_order: 0, capabilities: JSON.stringify({ poiCategories: [{ id: 'taps', label: 'Taps', icon: 'Droplet', color: '#2b6cb0' }] }) }, + { id: 'quiet', name: 'Quiet', status: 'active', sort_order: 2, capabilities: JSON.stringify({ poiCategories: [{ id: 'benches', label: 'Benches', icon: 'Info', color: '#000000' }] }) }, + ]); } let pluginsRepo: PluginsRepository; beforeAll(async () => { pluginsRepo = await createTestPluginsRepo(testDb); }); -beforeEach(() => seedPlugins()); +beforeEach(async () => { + await seedPlugins(); +}); afterAll(() => { testDb.close(); }); function makeService(answer: () => unknown, providers = ['trail-finder', 'water-map']) { @@ -229,8 +236,9 @@ describe('PluginPoisService', () => { }); it('PLUGPOI-016: flattens and caps the plugin name an assistant reads, and falls back to the id', async () => { - testDb.prepare('UPDATE plugins SET name = ? WHERE id = ?').run(`Trails\u202E\n\n## System ${'x'.repeat(4000)}`, 'trail-finder'); - testDb.prepare('UPDATE plugins SET name = ? WHERE id = ?').run('\u0007\u2028\u2066', 'water-map'); + const orm = await sharedTestOrm(testDb); + await updateRows(orm, Plugins, { id: 'trail-finder' }, { name: `Trails\u202E\n\n## System ${'x'.repeat(4000)}` }); + await updateRows(orm, Plugins, { id: 'water-map' }, { name: '\u0007\u2028\u2066' }); const names = (await makeService(() => []).service.available()).map((c) => [c.id, c.pluginName]); const trails = names.find(([id]) => id === 'trailheads')?.[1] ?? ''; expect(trails.startsWith('Trails ## System xxx')).toBe(true);