diff --git a/server/tests/e2e/place-shadow.e2e.test.ts b/server/tests/e2e/place-shadow.e2e.test.ts index 03585eedc..7a477ff23 100644 --- a/server/tests/e2e/place-shadow.e2e.test.ts +++ b/server/tests/e2e/place-shadow.e2e.test.ts @@ -1,6 +1,6 @@ /** * /api/place-shadow e2e — the real JwtAuthGuard, the real AdminGuard and the - * real Zod pipe against a temp SQLite db. + * real Zod pipe against a migrated temp SQLite db. * * The three things worth booting a server for: that a non-admin cannot read * other people's searches, that a switched-off log answers 200 instead of an @@ -14,33 +14,24 @@ import type { Server } from 'http'; import { Test } from '@nestjs/testing'; import { APP_PIPE } from '@nestjs/core'; import { ZodValidationPipe } from 'nestjs-zod'; -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(`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);`); - tmp.exec('CREATE TABLE app_settings (key TEXT PRIMARY KEY, value TEXT);'); - tmp.exec(`CREATE TABLE place_shadow_picks ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - query TEXT NOT NULL, lang TEXT, bias_lat REAL, bias_lng REAL, source TEXT NOT NULL, - live_rank INTEGER NOT NULL, live_count INTEGER NOT NULL, - picked_name TEXT NOT NULL, picked_lat REAL NOT NULL, picked_lng REAL NOT NULL, - picked_place_id 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, canAccessTrip: vi.fn(), isOwner: vi.fn(), getPlaceWithTags: vi.fn(), closeDb: () => {}, reinitialize: () => {}, -})); - +import { db } from '../../src/db/database'; +import { PlaceShadowPicks } from '../../src/db/entities/PlaceShadowPicks.entity'; import { PlaceShadowModule } from '../../src/nest/place-shadow/place-shadow.module'; 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 { countRows, deleteRows } from '../helpers/factories/rows'; +import { setAppSetting } from '../helpers/factories/settings'; + +let orm: TestOrm; const PICK = { query: 'kaffee bar am dobi', @@ -56,21 +47,18 @@ const PICK = { const USER = 1; const ADMIN = 2; -function enable(on: boolean) { - db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)') - .run('place_shadow_enabled', on ? 'true' : 'false'); +async function enable(on: boolean): Promise { + await setAppSetting(orm, 'place_shadow_enabled', on ? 'true' : 'false'); } -describe('/api/place-shadow e2e (real guards + temp SQLite)', () => { +describe('/api/place-shadow e2e (real guards + migrated temp SQLite)', () => { let server: Server; let app: Awaited>; async function build() { - // JwtAuthGuard (Plan 3b Task 1) injects EntityManager — needs + // JwtAuthGuard (Plan 3b Task 1) injects EntityManager, so it needs // MikroOrmModule.forRoot in the graph, same as every other e2e harness - // guarding a route with it; this suite's minimal hand-rolled `users` - // table already carries the five columns - // `findByIdWithPasswordVersion` selects. + // guarding a route with it. const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), PlaceShadowModule], providers: [{ provide: APP_PIPE, useClass: ZodValidationPipe }], @@ -83,19 +71,22 @@ describe('/api/place-shadow e2e (real guards + temp SQLite)', () => { } beforeAll(async () => { - seedUser(db as never, { id: USER }); - seedUser(db as never, { id: ADMIN, email: 'admin@example.com', role: 'admin' }); + orm = await createTestOrm(db); + // Pinned ids: sessionCookie(USER) and (ADMIN) sign for exactly these users. + await makeUser(orm, { id: USER, email: 'e2e@example.test' }); + await makeAdmin(orm, { id: ADMIN, email: 'admin@example.com' }); app = await build(); server = app.getHttpServer(); }); afterAll(async () => { await app.close(); + await orm.close(); }); - beforeEach(() => { - db.prepare('DELETE FROM place_shadow_picks').run(); - enable(true); + beforeEach(async () => { + await deleteRows(orm, PlaceShadowPicks); + await enable(true); }); describe('POST pick', () => { @@ -107,15 +98,15 @@ describe('/api/place-shadow e2e (real guards + temp SQLite)', () => { const res = await request(server).post('/api/place-shadow/pick').set('Cookie', sessionCookie(USER)).send(PICK); expect(res.status).toBe(200); expect(res.body).toEqual({ recorded: true }); - expect(db.prepare('SELECT COUNT(*) AS n FROM place_shadow_picks').get()).toEqual({ n: 1 }); + expect(await countRows(orm, PlaceShadowPicks)).toBe(1); }); it('200 { recorded: false } while the log is off, not an error status', async () => { - enable(false); + await enable(false); const res = await request(server).post('/api/place-shadow/pick').set('Cookie', sessionCookie(USER)).send(PICK); expect(res.status).toBe(200); expect(res.body).toEqual({ recorded: false }); - expect(db.prepare('SELECT COUNT(*) AS n FROM place_shadow_picks').get()).toEqual({ n: 0 }); + expect(await countRows(orm, PlaceShadowPicks)).toBe(0); }); it('400 from the pipe on a malformed body, and nothing is written', async () => { @@ -129,7 +120,7 @@ describe('/api/place-shadow e2e (real guards + temp SQLite)', () => { const res = await request(server).post('/api/place-shadow/pick').set('Cookie', sessionCookie(USER)).send(bad); expect(res.status, JSON.stringify(bad).slice(0, 60)).toBe(400); } - expect(db.prepare('SELECT COUNT(*) AS n FROM place_shadow_picks').get()).toEqual({ n: 0 }); + expect(await countRows(orm, PlaceShadowPicks)).toBe(0); }); }); @@ -169,7 +160,7 @@ describe('/api/place-shadow e2e (real guards + temp SQLite)', () => { const wiped = await request(server).delete('/api/place-shadow').set('Cookie', sessionCookie(ADMIN)); expect(wiped.status).toBe(200); expect(wiped.body).toEqual({ removed: 1 }); - expect(db.prepare('SELECT COUNT(*) AS n FROM place_shadow_picks').get()).toEqual({ n: 0 }); + expect(await countRows(orm, PlaceShadowPicks)).toBe(0); }); }); }); diff --git a/server/tests/e2e/share.e2e.test.ts b/server/tests/e2e/share.e2e.test.ts index 3ec4e9393..35df0fa2e 100644 --- a/server/tests/e2e/share.e2e.test.ts +++ b/server/tests/e2e/share.e2e.test.ts @@ -3,7 +3,7 @@ * public /api/shared/:token endpoints through the real JwtAuthGuard against a * temp SQLite db. ShareService runs its real SQL via DatabaseModule (the * DATABASE_CONNECTION factory picks up the mocked db singleton); trip access - * resolves through a real-SQL canAccessTrip over the temp db. Only the + * resolves through the real repositories over the temp db. Only the * permission check and the photo-cache path lookup stay mocked. Focuses on * auth, trip-access 404, permission 403, the create-201-vs-update-200 split * and the unguarded public read. @@ -20,14 +20,6 @@ vi.mock('../../src/db/database', async () => { const db = createSnapshotTestDb(); return { db, - // Real-SQL trip access over the temp db — ShareService.verifyTripAccess and - // DatabaseModule both read the mocked singleton. - canAccessTrip: (tripId: number | string, userId: number) => - db.prepare(` - SELECT t.id, t.user_id FROM trips t - LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? - WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL) - `).get(userId, tripId, userId), isOwner: () => false, getPlaceWithTags: () => null, closeDb: () => {}, @@ -53,7 +45,14 @@ import { PlacePhotoCacheService } from '../../src/nest/place-photos/place-photo- 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 { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm'; +import { makeUser } from '../helpers/factories/users'; +import { makeTrip } from '../helpers/factories/trips'; +import { findRow, insertRow } from '../helpers/factories/rows'; +import { Places } from '../../src/db/entities/Places.entity'; +import { ShareTokens } from '../../src/db/entities/ShareTokens.entity'; + +let orm: TestOrm; describe('Share-link e2e (real auth guard + real SQL over temp SQLite)', () => { let server: Server; @@ -75,31 +74,31 @@ describe('Share-link e2e (real auth guard + real SQL over temp SQLite)', () => { } beforeAll(async () => { - // seedUser() omits password_hash, which the real schema requires NOT NULL. - db.prepare( - "INSERT INTO users (id, username, email, password_hash, role) VALUES (1, 'e2e-user', 'e2e@example.test', 'x', 'user')", - ).run(); + orm = await createTestOrm(db); + // Pinned id: sessionCookie(1) signs for exactly this user. + await makeUser(orm, { id: 1, username: 'e2e-user', email: 'e2e@example.test' }); app = await build(); checkPermission = vi.spyOn(app.get(PermissionsService), 'checkPermission'); server = app.getHttpServer(); }); - beforeEach(() => { + beforeEach(async () => { db.exec('DELETE FROM share_tokens'); db.exec('DELETE FROM places'); db.exec('DELETE FROM trip_members'); db.exec('DELETE FROM trips'); - tripId = Number(db.prepare('INSERT INTO trips (user_id, title) VALUES (1, ?)').run('Trip').lastInsertRowid); + tripId = (await makeTrip(orm, 1, { title: 'Trip' })).id; checkPermission.mockReturnValue(true); serveKey.mockReset(); }); afterAll(async () => { await app.close(); + await orm.close(); }); function tokenRow() { - return db.prepare('SELECT * FROM share_tokens WHERE trip_id = ?').get(tripId) as any; + return findRow(orm, ShareTokens, { trip: tripId }); } it('401 without a session cookie', async () => { @@ -109,7 +108,7 @@ describe('Share-link e2e (real auth guard + real SQL over temp SQLite)', () => { it('201 on first create, 200 on a subsequent update', async () => { const created = await request(server).post(`/api/trips/${tripId}/share-link`).set('Cookie', sessionCookie(1)).send({ share_map: true }); expect(created.status).toBe(201); - expect(created.body).toEqual({ token: tokenRow().token }); + expect(created.body).toEqual({ token: (await tokenRow())?.token }); const updated = await request(server).post(`/api/trips/${tripId}/share-link`).set('Cookie', sessionCookie(1)).send({}); expect(updated.status).toBe(200); @@ -121,7 +120,7 @@ describe('Share-link e2e (real auth guard + real SQL over temp SQLite)', () => { const res = await request(server).post(`/api/trips/${tripId}/share-link`).set('Cookie', sessionCookie(1)).send({}); expect(res.status).toBe(403); expect(res.body).toEqual({ error: 'No permission' }); - expect(tokenRow()).toBeUndefined(); + expect(await tokenRow()).toBeNull(); }); it('404 when the trip is not accessible', async () => { @@ -134,12 +133,12 @@ describe('Share-link e2e (real auth guard + real SQL over temp SQLite)', () => { await request(server).post(`/api/trips/${tripId}/share-link`).set('Cookie', sessionCookie(1)).send({ share_budget: true }); const info = await request(server).get(`/api/trips/${tripId}/share-link`).set('Cookie', sessionCookie(1)); expect(info.status).toBe(200); - expect(info.body).toEqual(expect.objectContaining({ token: tokenRow().token, share_budget: true, share_packing: false })); + expect(info.body).toEqual(expect.objectContaining({ token: (await tokenRow())?.token, share_budget: true, share_packing: false })); const removed = await request(server).delete(`/api/trips/${tripId}/share-link`).set('Cookie', sessionCookie(1)); expect(removed.status).toBe(200); expect(removed.body).toEqual({ success: true }); - expect(tokenRow()).toBeUndefined(); + expect(await tokenRow()).toBeNull(); const after = await request(server).get(`/api/trips/${tripId}/share-link`).set('Cookie', sessionCookie(1)); expect(after.body).toEqual({ token: null }); @@ -186,8 +185,7 @@ describe('Share-link e2e (real auth guard + real SQL over temp SQLite)', () => { it('streams cached bytes with no cookie (unguarded) for a valid token + place', async () => { const created = await request(server).post(`/api/trips/${tripId}/share-link`).set('Cookie', sessionCookie(1)).send({}); - db.prepare('INSERT INTO places (trip_id, name, image_url) VALUES (?, ?, ?)') - .run(tripId, 'Louvre', '/api/maps/place-photo/ChIJabc/bytes'); + await insertRow(orm, Places, { trip: tripId, name: 'Louvre', image_url: '/api/maps/place-photo/ChIJabc/bytes' }); serveKey.mockReturnValueOnce(photoName); const res = await request(server).get(`/api/shared/${created.body.token}/place-photo/ChIJabc/bytes`); expect(res.status).toBe(200); diff --git a/server/tests/unit/db/repositories/AssignmentParticipants.repository.test.ts b/server/tests/unit/db/repositories/AssignmentParticipants.repository.test.ts index e534d01eb..6bae3e4d5 100644 --- a/server/tests/unit/db/repositories/AssignmentParticipants.repository.test.ts +++ b/server/tests/unit/db/repositories/AssignmentParticipants.repository.test.ts @@ -15,6 +15,8 @@ import { AssignmentParticipants } from '../../../../src/db/entities/AssignmentPa import type { AssignmentParticipantsRepository } from '../../../../src/db/repositories/AssignmentParticipants.repository'; import { UnitOfWork } from '../../../../src/nest/database/unit-of-work'; import { withRequestContext } from '../../../../src/nest/database/request-context'; +import { Users } from '../../../../src/db/entities/Users.entity'; +import { findRows, insertRow, updateRows } from '../../../helpers/factories/rows'; const testDb = createSnapshotTestDb(); let t: TestOrm; @@ -29,8 +31,12 @@ beforeAll(async () => { beforeEach(() => { resetTestDb(testDb); t.clear(); }); afterAll(async () => { await t.close(); testDb.close(); }); -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 }); +} + +async function participantUserIds(assignmentId: number) { + return (await findRows(t, AssignmentParticipants, { assignment: assignmentId })).map((r) => ({ user_id: r.user_id })); } async function withQueryCount(fn: () => Promise): Promise<{ value: T; queries: number }> { @@ -54,12 +60,12 @@ describe('AssignmentParticipantsRepository.listForAssignments', () => { it('ASSIGNPARTREPO-002: joins the user\'s username/avatar, 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('A Display Name', participant.id); + await updateRows(t, Users, { id: participant.id }, { display_name: 'A 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 rows = await participants.listForAssignments([assignment.id]); expect(rows).toEqual([{ assignment_id: assignment.id, user_id: participant.id, username: 'raw-username', avatar: null }]); @@ -75,8 +81,8 @@ describe('AssignmentParticipantsRepository.listForAssignments', () => { const placeB = createPlace(testDb, trip.id, { name: 'B' }); const a1 = createDayAssignment(testDb, day.id, placeA.id); const a2 = createDayAssignment(testDb, day.id, placeB.id); - addParticipant(a1.id, p1.id); - addParticipant(a2.id, p2.id); + await addParticipant(a1.id, p1.id); + await addParticipant(a2.id, p2.id); const rows = await participants.listForAssignments([a1.id, a2.id]); expect(rows.map((r) => r.assignment_id).sort()).toEqual([a1.id, a2.id].sort()); @@ -110,7 +116,7 @@ describe('AssignmentParticipantsRepository.listForAssignments', () => { // nothing here, but the mechanical fix is applied uniformly rather than // singled out. await t.repo(AssignmentParticipants).find({}, { disableIdentityMap: false }); // populate the identity map with an unrelated (empty) read - addParticipant(assignment.id, participant.id); + await addParticipant(assignment.id, participant.id); const rows = await participants.listForAssignments([assignment.id]); expect(rows).toEqual([{ assignment_id: assignment.id, user_id: participant.id, username: 'fresh-participant', avatar: null }]); }); @@ -125,6 +131,7 @@ describe('AssignmentParticipantsRepository.listForAssignments', () => { /** The AS2/AS15/AS31 statement, run raw — the parity oracle every assertion below is checked against. */ function legacyParticipantRows(assignmentId: number): unknown { + // test-sql-allow: the legacy statement is this parity test's oracle and has to run as written. return testDb.prepare(` SELECT ap.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar FROM assignment_participants ap @@ -141,7 +148,7 @@ describe('AssignmentParticipantsRepository.listWithDisplayName (AS2/AS15/AS31)', 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 rows = await participants.listWithDisplayName(assignment.id); expect(rows).toStrictEqual(legacyParticipantRows(assignment.id)); @@ -151,14 +158,14 @@ describe('AssignmentParticipantsRepository.listWithDisplayName (AS2/AS15/AS31)', it('ASSIGNPARTREPO-007: username COALESCEs display_name over username, when set', async () => { const { user: owner } = createUser(testDb); const { user: withDisplayName } = createUser(testDb, { username: 'raw-username' }); - testDb.prepare('UPDATE users SET display_name = ? WHERE id = ?').run('Fancy Name', withDisplayName.id); + await updateRows(t, Users, { id: withDisplayName.id }, { display_name: 'Fancy Name' }); const { user: withoutDisplayName } = createUser(testDb, { username: 'plain-username' }); 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, withDisplayName.id); - addParticipant(assignment.id, withoutDisplayName.id); + await addParticipant(assignment.id, withDisplayName.id); + await addParticipant(assignment.id, withoutDisplayName.id); const rows = await participants.listWithDisplayName(assignment.id); expect(rows).toEqual(expect.arrayContaining([ @@ -189,7 +196,7 @@ describe('AssignmentParticipantsRepository.listWithDisplayName (AS2/AS15/AS31)', const place = createPlace(testDb, trip.id); const assignment = createDayAssignment(testDb, day.id, place.id); await t.repo(AssignmentParticipants).find({}, { disableIdentityMap: false }); - addParticipant(assignment.id, participant.id); + await addParticipant(assignment.id, participant.id); const rows = await participants.listWithDisplayName(assignment.id); expect(rows).toEqual([{ user_id: participant.id, username: 'fresh-participant-2', avatar: null }]); }); @@ -205,8 +212,8 @@ describe('AssignmentParticipantsRepository.deleteForAssignment / insertIgnore (A const place = createPlace(testDb, trip.id); const a1 = createDayAssignment(testDb, day.id, place.id); const a2 = createDayAssignment(testDb, day.id, place.id); - addParticipant(a1.id, p1.id); - addParticipant(a2.id, p2.id); + await addParticipant(a1.id, p1.id); + await addParticipant(a2.id, p2.id); await withRequestContext(t.orm, async () => { await participants.deleteForAssignment(a1.id); @@ -228,7 +235,7 @@ describe('AssignmentParticipantsRepository.deleteForAssignment / insertIgnore (A await participants.insertIgnore(assignment.id, [p1.id, p1.id]); }); - const rows = testDb.prepare('SELECT user_id FROM assignment_participants WHERE assignment_id = ?').all(assignment.id); + const rows = await participantUserIds(assignment.id); expect(rows).toEqual([{ user_id: p1.id }]); }); @@ -240,7 +247,7 @@ describe('AssignmentParticipantsRepository.deleteForAssignment / insertIgnore (A const place = createPlace(testDb, trip.id); const a1 = createDayAssignment(testDb, day.id, place.id); const a2 = createDayAssignment(testDb, day.id, place.id); - addParticipant(a1.id, shared.id); + await addParticipant(a1.id, shared.id); await withRequestContext(t.orm, async () => { await participants.insertIgnore(a2.id, [shared.id]); @@ -271,7 +278,7 @@ describe('AssignmentParticipantsRepository.deleteForAssignment / insertIgnore (A 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); + await addParticipant(assignment.id, p1.id); let caught: unknown; try { @@ -285,7 +292,7 @@ describe('AssignmentParticipantsRepository.deleteForAssignment / insertIgnore (A } catch (e) { caught = e; } expect((caught as Error).message).toBe('force rollback'); - const rows = testDb.prepare('SELECT user_id FROM assignment_participants WHERE assignment_id = ?').all(assignment.id); + const rows = await participantUserIds(assignment.id); expect(rows).toEqual([{ user_id: p1.id }]); }); }); @@ -302,13 +309,13 @@ describe('AssignmentParticipantsRepository.listForTrip (TP53)', () => { 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 otherDay = createDay(testDb, other.id); const otherPlace = createPlace(testDb, other.id); const otherAssignment = createDayAssignment(testDb, otherDay.id, otherPlace.id); - addParticipant(otherAssignment.id, p1.id); + await addParticipant(otherAssignment.id, p1.id); const rows = await participants.listForTrip(trip.id); expect(rows.map((r) => r.user_id).sort((a, b) => a - b)).toEqual([p1.id, p2.id].sort((a, b) => a - b)); diff --git a/server/tests/unit/db/seeds-admin.test.ts b/server/tests/unit/db/seeds-admin.test.ts index 6dacaa0f4..5940e7eed 100644 --- a/server/tests/unit/db/seeds-admin.test.ts +++ b/server/tests/unit/db/seeds-admin.test.ts @@ -13,20 +13,25 @@ import { AdminSeeder } from '../../../src/db/seeders/AdminSeeder'; import { createSnapshotTestDb } from '../../helpers/db-mock'; import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; +import { countRows, findRow } from '../../helpers/factories/rows'; +import { makeAdmin } from '../../helpers/factories/users'; +import { Users } from '../../../src/db/entities/Users.entity'; import type Database from 'better-sqlite3'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; const ENV_KEYS = ['ADMIN_EMAIL', 'ADMIN_PASSWORD', 'DEMO_MODE', 'OIDC_ONLY', 'OIDC_ISSUER', 'OIDC_CLIENT_ID']; -function countUsers(db: Database.Database): number { - return (db.prepare('SELECT COUNT(*) as c FROM users').get() as { c: number }).c; +function countUsers(orm: TestOrm): Promise { + return countRows(orm, Users); } -function insertExistingUser(db: Database.Database): void { - db.prepare( - "INSERT INTO users (username, email, password_hash, role) VALUES ('admin', 'admin@trek.local', 'x', 'admin')", - ).run(); +async function insertExistingUser(orm: TestOrm): Promise { + await makeAdmin(orm, { username: 'admin', email: 'admin@trek.local' }); +} + +function userByEmail(orm: TestOrm, email: string) { + return findRow(orm, Users, { email }); } describe('AdminSeeder — first-run admin', () => { @@ -61,36 +66,34 @@ describe('AdminSeeder — first-run admin', () => { await new AdminSeeder().run(t.em); - const user = db - .prepare('SELECT email, role, must_change_password FROM users WHERE email = ?') - .get('me@example.com') as { email: string; role: string; must_change_password: number } | undefined; - expect(user).toBeDefined(); + const user = await userByEmail(t, 'me@example.com'); + expect(user).not.toBeNull(); expect(user!.role).toBe('admin'); expect(user!.must_change_password).toBe(1); expect(warn).not.toHaveBeenCalled(); }); it('warns and creates nothing when ADMIN_* is set but a user already exists', async () => { - insertExistingUser(db); + await insertExistingUser(t); process.env.ADMIN_EMAIL = 'new@example.com'; process.env.ADMIN_PASSWORD = 'whatever'; const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); await new AdminSeeder().run(t.em); - expect(countUsers(db)).toBe(1); - expect(db.prepare('SELECT 1 FROM users WHERE email = ?').get('new@example.com')).toBeUndefined(); + expect(await countUsers(t)).toBe(1); + expect(await userByEmail(t, 'new@example.com')).toBeNull(); const msg = warn.mock.calls.map((c) => c.join(' ')).join('\n'); expect(msg).toContain('only apply on first run'); }); it('stays silent when no admin env is set and a user already exists', async () => { - insertExistingUser(db); + await insertExistingUser(t); const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); await new AdminSeeder().run(t.em); - expect(countUsers(db)).toBe(1); + expect(await countUsers(t)).toBe(1); expect(warn).not.toHaveBeenCalled(); }); @@ -101,8 +104,8 @@ describe('AdminSeeder — first-run admin', () => { await new AdminSeeder().run(t.em); // Falls back to the default local admin, NOT the provided email. - expect(db.prepare('SELECT 1 FROM users WHERE email = ?').get('admin@trek.local')).toBeDefined(); - expect(db.prepare('SELECT 1 FROM users WHERE email = ?').get('me@example.com')).toBeUndefined(); + expect(await userByEmail(t, 'admin@trek.local')).not.toBeNull(); + expect(await userByEmail(t, 'me@example.com')).toBeNull(); const msg = warn.mock.calls.map((c) => c.join(' ')).join('\n'); expect(msg).toContain('Only one of ADMIN_EMAIL/ADMIN_PASSWORD'); }); diff --git a/server/tests/unit/mcp/tools-memories.test.ts b/server/tests/unit/mcp/tools-memories.test.ts index 73abeb987..5089ae97e 100644 --- a/server/tests/unit/mcp/tools-memories.test.ts +++ b/server/tests/unit/mcp/tools-memories.test.ts @@ -31,6 +31,12 @@ import { createMcpHarness, parseToolResult, type McpHarness } from '../../helper import { ImmichService } from '../../../src/nest/memories/immich.service'; import { SynologyService } from '../../../src/nest/memories/synology.service'; import { PhotoCaptureBackfillService } from '../../../src/nest/memories/photo-capture-backfill.service'; +import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; +import { countRows, deleteRows, findRow, findRows, upsertRow } from '../../helpers/factories/rows'; +import { JourneyEntryPhotos } from '../../../src/db/entities/JourneyEntryPhotos.entity'; +import { JourneyPhotos } from '../../../src/db/entities/JourneyPhotos.entity'; +import { PhotoProviders } from '../../../src/db/entities/PhotoProviders.entity'; +import { TrekPhotos } from '../../../src/db/entities/TrekPhotos.entity'; const immichSearch = vi.spyOn(ImmichService.prototype, 'searchPhotos'); const immichAlbums = vi.spyOn(ImmichService.prototype, 'listAlbums'); @@ -50,21 +56,19 @@ const SYNOLOGY_ASSET = { id: 's1', takenAt: '2026-07-02T10:00:00.000Z', lat: 48. * photo_providers is seed data, so resetTestDb leaves it alone and a toggle * leaks into the next case. Every case states what it needs. */ -function setProviderEnabled(id: string, enabled: boolean): void { - testDb.prepare( - 'INSERT INTO photo_providers (id, name, enabled) VALUES (?, ?, ?) ON CONFLICT(id) DO UPDATE SET enabled = excluded.enabled', - ).run(id, id, enabled ? 1 : 0); +async function setProviderEnabled(id: string, enabled: boolean): Promise { + await upsertRow(orm, PhotoProviders, { id, name: id, enabled: enabled ? 1 : 0 }, ['enabled']); } -function removeProvider(id: string): void { - testDb.prepare('DELETE FROM photo_providers WHERE id = ?').run(id); +async function removeProvider(id: string): Promise { + await deleteRows(orm, PhotoProviders, { id }); } -beforeEach(() => { +beforeEach(async () => { resetTestDb(testDb); setAddonEnabled(testDb, ADDON_IDS.JOURNEY, true); - setProviderEnabled('immich', true); - setProviderEnabled('synologyphotos', true); + await setProviderEnabled('immich', true); + await setProviderEnabled('synologyphotos', true); broadcastMock.mockClear(); delete process.env.DEMO_MODE; @@ -77,7 +81,14 @@ beforeEach(() => { backfillRun.mockClear(); }); -afterAll(() => { +let orm: TestOrm; + +beforeAll(async () => { + orm = await createTestOrm(testDb); +}); + +afterAll(async () => { + await orm.close(); testDb.close(); }); @@ -146,7 +157,7 @@ describe('Tool: search_provider_photos', () => { it('refuses a provider the admin has switched off, without calling it', async () => { const { user } = createUser(testDb); - setProviderEnabled('synologyphotos', false); + await setProviderEnabled('synologyphotos', false); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'search_provider_photos', @@ -160,7 +171,7 @@ describe('Tool: search_provider_photos', () => { it('refuses a provider that is not in the provider table at all', async () => { const { user } = createUser(testDb); - removeProvider('synologyphotos'); + await removeProvider('synologyphotos'); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'search_provider_photos', @@ -207,8 +218,8 @@ describe('Tool: search_provider_photos', () => { it('is not registered when every photo provider is switched off', async () => { const { user } = createUser(testDb); - setProviderEnabled('immich', false); - setProviderEnabled('synologyphotos', false); + await setProviderEnabled('immich', false); + await setProviderEnabled('synologyphotos', false); await withHarness(user.id, async (h) => { const names = (await h.client.listTools()).tools.map(t => t.name); expect(names).not.toContain('search_provider_photos'); @@ -245,8 +256,8 @@ describe('Tool: search_provider_photos', () => { it('is registered again as soon as one provider is on', async () => { const { user } = createUser(testDb); - setProviderEnabled('immich', true); - setProviderEnabled('synologyphotos', false); + await setProviderEnabled('immich', true); + await setProviderEnabled('synologyphotos', false); await withHarness(user.id, async (h) => { expect((await h.client.listTools()).tools.map(t => t.name)).toContain('search_provider_photos'); }); @@ -288,7 +299,7 @@ describe('Tool: list_provider_albums', () => { it('refuses a disabled provider', async () => { const { user } = createUser(testDb); - setProviderEnabled('immich', false); + await setProviderEnabled('immich', false); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'list_provider_albums', arguments: { provider: 'immich' } }); expect(result.isError).toBe(true); @@ -337,7 +348,7 @@ describe('Tool: list_provider_album_photos', () => { it('refuses a disabled provider', async () => { const { user } = createUser(testDb); - setProviderEnabled('synologyphotos', false); + await setProviderEnabled('synologyphotos', false); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'list_provider_album_photos', arguments: { provider: 'synologyphotos', album_id: '7' }, @@ -364,15 +375,29 @@ describe('Tool: list_provider_album_photos', () => { // add_journey_provider_photos // --------------------------------------------------------------------------- -function entryPhotoRows(entryId: number) { - return testDb.prepare(` - SELECT tp.provider, tp.asset_id, tp.owner_id, tp.media_type, gp.journey_id, gp.caption - FROM journey_entry_photos jep - JOIN journey_photos gp ON gp.id = jep.journey_photo_id - JOIN trek_photos tp ON tp.id = gp.photo_id - WHERE jep.entry_id = ? - ORDER BY tp.asset_id - `).all(entryId) as Array>; +/** The provider photo behind each gallery row, the way the journey_photos to trek_photos join reads it. */ +async function withTrekPhotos(galleryRows: Array<{ photo_id?: number; journey_id?: number; caption?: string | null }>) { + const rows = []; + for (const gp of galleryRows) { + const tp = await findRow(orm, TrekPhotos, { id: gp.photo_id }); + if (tp) rows.push({ gp, tp }); + } + return rows; +} + +async function entryPhotoRows(entryId: number) { + const links = await findRows(orm, JourneyEntryPhotos, { entry: entryId }); + const gallery = []; + for (const link of links) { + const gp = await findRow(orm, JourneyPhotos, { id: link.journey_photo_id }); + if (gp) gallery.push(gp); + } + return (await withTrekPhotos(gallery)) + .map(({ gp, tp }) => ({ + provider: tp.provider, asset_id: tp.asset_id, owner_id: tp.owner_id, media_type: tp.media_type, + journey_id: gp.journey_id, caption: gp.caption, + })) + .sort((a, b) => ((a.asset_id ?? '') < (b.asset_id ?? '') ? -1 : (a.asset_id ?? '') > (b.asset_id ?? '') ? 1 : 0)); } describe('Tool: add_journey_provider_photos', () => { @@ -393,7 +418,7 @@ describe('Tool: add_journey_provider_photos', () => { expect(data.added).toBe(2); expect(data.skipped).toBe(0); - const rows = entryPhotoRows(entry.id); + const rows = await entryPhotoRows(entry.id); expect(rows).toHaveLength(2); expect(rows.map(r => r.asset_id)).toEqual(['a1', 'a2']); expect(rows.every(r => r.provider === 'immich' && r.owner_id === user.id)).toBe(true); @@ -414,11 +439,10 @@ describe('Tool: add_journey_provider_photos', () => { })) as any; expect(data.added).toBe(1); - const gallery = testDb.prepare( - 'SELECT tp.asset_id, tp.provider FROM journey_photos gp JOIN trek_photos tp ON tp.id = gp.photo_id WHERE gp.journey_id = ?', - ).all(journey.id) as Array>; + const gallery = (await withTrekPhotos(await findRows(orm, JourneyPhotos, { journey: journey.id }))) + .map(({ tp }) => ({ asset_id: tp.asset_id, provider: tp.provider })); expect(gallery).toEqual([{ asset_id: 'g1', provider: 'synologyphotos' }]); - expect(entryPhotoRows(entry.id)).toHaveLength(0); + expect(await entryPhotoRows(entry.id)).toHaveLength(0); }); }); @@ -432,7 +456,7 @@ describe('Tool: add_journey_provider_photos', () => { const second = parseToolResult(await h.client.callTool({ name: 'add_journey_provider_photos', arguments: args })) as any; expect(second.added).toBe(0); expect(second.skipped).toBe(1); - expect(entryPhotoRows(entry.id)).toHaveLength(1); + expect(await entryPhotoRows(entry.id)).toHaveLength(1); }); }); @@ -445,7 +469,7 @@ describe('Tool: add_journey_provider_photos', () => { name: 'add_journey_provider_photos', arguments: { journeyId: journey.id, entryId: entry.id, provider: 'immich', asset_ids: ['bf-1'] }, }); - const photoId = (testDb.prepare('SELECT id FROM trek_photos WHERE asset_id = ? AND owner_id = ?').get('bf-1', user.id) as { id: number }).id; + const photoId = (await findRow(orm, TrekPhotos, { asset_id: 'bf-1', owner: user.id }))?.id; expect(backfillRun).toHaveBeenCalledWith([photoId], user.id); }); }); @@ -481,7 +505,7 @@ describe('Tool: add_journey_provider_photos', () => { }); expect(result.isError).toBe(true); expect((result as any).content[0].text).toBe('Journey not found or access denied.'); - expect(entryPhotoRows(entry.id)).toHaveLength(0); + expect(await entryPhotoRows(entry.id)).toHaveLength(0); }); }); @@ -509,7 +533,7 @@ describe('Tool: add_journey_provider_photos', () => { }); expect(result.isError).toBe(true); expect((result as any).content[0].text).toBe('Entry not found in this journey.'); - expect(entryPhotoRows(foreignEntry.id)).toHaveLength(0); + expect(await entryPhotoRows(foreignEntry.id)).toHaveLength(0); }); }); @@ -524,7 +548,7 @@ describe('Tool: add_journey_provider_photos', () => { arguments: { journeyId: journey.id, entryId: entry.id, provider: 'immich', asset_ids: ['x1'] }, }); expect(result.isError).toBe(true); - expect(entryPhotoRows(entry.id)).toHaveLength(0); + expect(await entryPhotoRows(entry.id)).toHaveLength(0); }); }); @@ -541,7 +565,7 @@ describe('Tool: add_journey_provider_photos', () => { }, }); expect(result.isError).toBe(true); - expect(entryPhotoRows(entry.id)).toHaveLength(0); + expect(await entryPhotoRows(entry.id)).toHaveLength(0); }); }); @@ -554,7 +578,7 @@ describe('Tool: add_journey_provider_photos', () => { arguments: { journeyId: journey.id, provider: 'local', asset_ids: ['x1'] }, }); expect(result.isError).toBe(true); - expect(testDb.prepare('SELECT COUNT(*) c FROM journey_photos').get()).toEqual({ c: 0 }); + expect(await countRows(orm, JourneyPhotos)).toBe(0); }); }); diff --git a/server/tests/unit/nest/google-quota.service.test.ts b/server/tests/unit/nest/google-quota.service.test.ts index 54a6ec01c..ea8e8c692 100644 --- a/server/tests/unit/nest/google-quota.service.test.ts +++ b/server/tests/unit/nest/google-quota.service.test.ts @@ -11,6 +11,12 @@ import { GoogleApiUsage } from '../../../src/db/entities/GoogleApiUsage.entity'; import type { GoogleApiUsageRepository } from '../../../src/db/repositories/GoogleApiUsage.repository'; import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository'; import { createTestAppSettingsRepo, sharedTestOrm } from '../../helpers/test-uow'; +import { countRows, deleteRows, findRow, insertRow, insertRows } from '../../helpers/factories/rows'; +import { readAppSetting } from '../../helpers/factories/settings'; +import { AppSettings } from '../../../src/db/entities/AppSettings.entity'; + +/** The UTC calendar day `days` before today, the text SQLite's date('now', '-N days') gives. */ +const utcDaysAgo = (days: number): string => new Date(Date.now() - days * 86_400_000).toISOString().slice(0, 10); /** GQUOTA-001..007 — the daily ceiling on Google API calls (#1582). */ @@ -24,9 +30,9 @@ describe('GoogleQuotaService', () => { usage = (await sharedTestOrm(db)).repo(GoogleApiUsage); }); - beforeEach(() => { + beforeEach(async () => { db.exec('DELETE FROM google_api_usage'); - db.prepare('DELETE FROM app_settings WHERE key = ?').run(GOOGLE_DAILY_LIMIT_SETTING); + await deleteRows(await sharedTestOrm(db), AppSettings, { key: GOOGLE_DAILY_LIMIT_SETTING }); // A fresh service per case, so the once-a-day warning state starts clean. quota = new GoogleQuotaService(appSettings, usage); }); @@ -57,29 +63,32 @@ describe('GoogleQuotaService', () => { it('GQUOTA-003: yesterday does not count against today', async () => { await quota.setDailyLimit(1); - db.prepare("INSERT INTO google_api_usage (day, calls) VALUES (date('now', '-1 day'), 50)").run(); + await insertRow(await sharedTestOrm(db), GoogleApiUsage, { day: utcDaysAgo(1), calls: 50 }); expect(await quota.usedToday()).toBe(0); expect(await quota.exhausted()).toBe(false); }); it('GQUOTA-004: 0 and null remove the ceiling', async () => { await quota.setDailyLimit(10); - expect(db.prepare('SELECT value FROM app_settings WHERE key = ?').get(GOOGLE_DAILY_LIMIT_SETTING)).toEqual({ value: '10' }); + expect(await readAppSetting(await sharedTestOrm(db), GOOGLE_DAILY_LIMIT_SETTING)).toBe('10'); expect((await quota.setDailyLimit(0)).daily_limit).toBeNull(); await quota.setDailyLimit(10); expect((await quota.setDailyLimit(null)).daily_limit).toBeNull(); - expect(db.prepare('SELECT value FROM app_settings WHERE key = ?').get(GOOGLE_DAILY_LIMIT_SETTING)).toBeUndefined(); + expect(await findRow(await sharedTestOrm(db), AppSettings, { key: GOOGLE_DAILY_LIMIT_SETTING })).toBeNull(); }); it('GQUOTA-005: a stored value that is not a positive number means no ceiling', async () => { - db.prepare('INSERT INTO app_settings (key, value) VALUES (?, ?)').run(GOOGLE_DAILY_LIMIT_SETTING, 'lots'); + await insertRow(await sharedTestOrm(db), AppSettings, { key: GOOGLE_DAILY_LIMIT_SETTING, value: 'lots' }); expect(await quota.dailyLimit()).toBeNull(); }); it('GQUOTA-006: the status prunes days past the retention window', async () => { - db.prepare("INSERT INTO google_api_usage (day, calls) VALUES (date('now', '-500 days'), 9), (date('now', '-10 days'), 4)").run(); + await insertRows(await sharedTestOrm(db), GoogleApiUsage, [ + { day: utcDaysAgo(500), calls: 9 }, + { day: utcDaysAgo(10), calls: 4 }, + ]); await quota.status(); - expect(db.prepare('SELECT COUNT(*) AS n FROM google_api_usage').get()).toEqual({ n: 1 }); + expect(await countRows(await sharedTestOrm(db), GoogleApiUsage)).toBe(1); }); it('GQUOTA-007: raising the ceiling reopens the day at once', async () => { diff --git a/server/tests/unit/nest/mcp-shared.service.test.ts b/server/tests/unit/nest/mcp-shared.service.test.ts index cb5ed8daa..818ea3874 100644 --- a/server/tests/unit/nest/mcp-shared.service.test.ts +++ b/server/tests/unit/nest/mcp-shared.service.test.ts @@ -21,7 +21,12 @@ import { McpToolGuardsService } from '../../../src/nest/mcp-shared/mcp-tool-guar import { McpSharedModule } from '../../../src/nest/mcp-shared/mcp-shared.module'; import { PermissionsService } from '../../../src/nest/permissions/permissions.service'; import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; -import { createTestUnitOfWork, createTestAppSettingsRepo, createTestTripsRepo, createTestUsersRepo } from '../../helpers/test-uow'; +import { createTestUnitOfWork, createTestAppSettingsRepo, createTestTripsRepo, createTestUsersRepo, sharedTestOrm } from '../../helpers/test-uow'; +import { deleteRows, updateRows } from '../../helpers/factories/rows'; +import { makeTrip } from '../../helpers/factories/trips'; +import { TripMembers } from '../../../src/db/entities/TripMembers.entity'; +import { Trips } from '../../../src/db/entities/Trips.entity'; +import { Users } from '../../../src/db/entities/Users.entity'; let svc: McpToolGuardsService; beforeAll(async () => { @@ -35,16 +40,16 @@ beforeAll(async () => { ); }); -function createTrip(ownerId: number): number { - const r = testDb.prepare("INSERT INTO trips (user_id, title) VALUES (?, 'T')").run(ownerId); - return Number(r.lastInsertRowid); +async function createTrip(ownerId: number): Promise { + return (await makeTrip(await sharedTestOrm(testDb), ownerId, { title: 'T' })).id; } -beforeEach(() => { +beforeEach(async () => { vi.clearAllMocks(); - testDb.prepare('DELETE FROM trip_members').run(); - testDb.prepare('DELETE FROM trips').run(); - testDb.prepare('DELETE FROM users').run(); + const orm = await sharedTestOrm(testDb); + await deleteRows(orm, TripMembers); + await deleteRows(orm, Trips); + await deleteRows(orm, Users); }); describe('hasTripPermission', () => { @@ -56,22 +61,22 @@ describe('hasTripPermission', () => { it('GRD-002: the owner passes owner-level actions; a stranger does not', async () => { const { user: owner } = createUser(testDb); const { user: stranger } = createUser(testDb); - const tripId = createTrip(owner.id); + const tripId = await createTrip(owner.id); expect(await svc.hasTripPermission('trip_delete', tripId, owner.id)).toBe(true); expect(await svc.hasTripPermission('trip_delete', tripId, stranger.id)).toBe(false); }); it('GRD-003: an unknown user falls back to the plain user role', async () => { const { user: owner } = createUser(testDb); - const tripId = createTrip(owner.id); + const tripId = await createTrip(owner.id); expect(await svc.hasTripPermission('trip_delete', tripId, 424242)).toBe(false); }); it('GRD-004: a global admin passes regardless of membership', async () => { const { user: owner } = createUser(testDb); const { user: admin } = createUser(testDb); - testDb.prepare("UPDATE users SET role = 'admin' WHERE id = ?").run(admin.id); - const tripId = createTrip(owner.id); + await updateRows(await sharedTestOrm(testDb), Users, { id: admin.id }, { role: 'admin' }); + const tripId = await createTrip(owner.id); expect(await svc.hasTripPermission('trip_delete', tripId, admin.id)).toBe(true); }); }); @@ -80,7 +85,7 @@ describe('isAdminUser', () => { it('GRD-010: reflects the users.role column and is false for unknown ids', async () => { const { user } = createUser(testDb); expect(await svc.isAdminUser(user.id)).toBe(false); - testDb.prepare("UPDATE users SET role = 'admin' WHERE id = ?").run(user.id); + await updateRows(await sharedTestOrm(testDb), Users, { id: user.id }, { role: 'admin' }); expect(await svc.isAdminUser(user.id)).toBe(true); expect(await svc.isAdminUser(424242)).toBe(false); }); diff --git a/server/tests/unit/nest/travel-stats.service.test.ts b/server/tests/unit/nest/travel-stats.service.test.ts index e3c55a418..217867f24 100644 --- a/server/tests/unit/nest/travel-stats.service.test.ts +++ b/server/tests/unit/nest/travel-stats.service.test.ts @@ -27,6 +27,11 @@ import { resetTestDb } from '../../helpers/test-db'; import { createUser, createTrip, createPlace, createReservation, addTripMember } from '../../helpers/factories'; import { AtlasService } from '../../../src/nest/atlas/atlas.service'; import { createTestUnitOfWork, createTestTripsRepo, createTestPlacesRepo, createTestReservationEndpointsRepo, sharedTestOrm } from '../../helpers/test-uow'; +import { insertRow, upsertRow } from '../../helpers/factories/rows'; +import { addReservationTraveler, makeReservationEndpoint } from '../../helpers/factories/reservations'; +import { PlaceRegions } from '../../../src/db/entities/PlaceRegions.entity'; +import { Places } from '../../../src/db/entities/Places.entity'; +import { VisitedCountries } from '../../../src/db/entities/VisitedCountries.entity'; import { createTestBucketListRepo, createTestHiddenCountriesRepo, @@ -63,7 +68,7 @@ afterAll(() => { testDb.close(); }); // ── getTravelStats — dashboard passport card ──────────────────────────────── describe('getTravelStats', () => { - function endpoint( + async function endpoint( reservationId: number, role: 'from' | 'to' | 'stop', sequence: number, @@ -73,9 +78,9 @@ describe('getTravelStats', () => { localDate: string | null = null, localTime: string | null = null ) { - testDb.prepare( - 'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, lat, lng, code, local_date, local_time) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)' - ).run(reservationId, role, sequence, `Endpoint ${sequence}`, lat, lng, code, localDate, localTime); + await makeReservationEndpoint(await sharedTestOrm(testDb), reservationId, { + role, sequence, name: `Endpoint ${sequence}`, lat, lng, code, local_date: localDate, local_time: localTime, + }); } // Every trip below is dated in the past: since #1048 the passport card only counts @@ -88,8 +93,8 @@ describe('getTravelStats', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Tokyo Trip', start_date: PAST_START, end_date: PAST_END }); const res = createReservation(testDb, trip.id, { type: 'flight' }); - endpoint(res.id, 'from', 0, 50.9014, 4.4844); // Brussels - endpoint(res.id, 'to', 1, 35.6762, 139.6503); // Tokyo + await endpoint(res.id, 'from', 0, 50.9014, 4.4844); // Brussels + await endpoint(res.id, 'to', 1, 35.6762, 139.6503); // Tokyo const stats = await atlas.getTravelStats(user.id); expect(stats.countries).toContain('BE'); @@ -102,9 +107,9 @@ describe('getTravelStats', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Connection Trip', start_date: PAST_START, end_date: PAST_END }); const res = createReservation(testDb, trip.id, { type: 'flight' }); - endpoint(res.id, 'from', 0, 50.9014, 4.4844); // Brussels - endpoint(res.id, 'stop', 1, 35.6762, 139.6503); // Tokyo — never leaves the airport - endpoint(res.id, 'to', 2, -33.8688, 151.2093); // Sydney + await endpoint(res.id, 'from', 0, 50.9014, 4.4844); // Brussels + await endpoint(res.id, 'stop', 1, 35.6762, 139.6503); // Tokyo — never leaves the airport + await endpoint(res.id, 'to', 2, -33.8688, 151.2093); // Sydney const stats = await atlas.getTravelStats(user.id); expect(stats.countries).toContain('BE'); @@ -116,8 +121,8 @@ describe('getTravelStats', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Tokyo Trip', start_date: PAST_START, end_date: PAST_END }); const res = createReservation(testDb, trip.id, { type: 'flight' }); - endpoint(res.id, 'from', 0, 50.9014, 4.4844); - endpoint(res.id, 'to', 1, 35.6762, 139.6503); + await endpoint(res.id, 'from', 0, 50.9014, 4.4844); + await endpoint(res.id, 'to', 1, 35.6762, 139.6503); expect((await atlas.getTravelStats(user.id)).countries).toContain('JP'); @@ -132,19 +137,19 @@ describe('getTravelStats', () => { // Relative offsets, not literal dates — a hardcoded "future" date expires. const iso = (days: number) => new Date(Date.now() + days * 86_400_000).toISOString().slice(0, 10); - function placeInRegion(tripId: number, countryCode: string, regionCode: string) { + async function placeInRegion(tripId: number, countryCode: string, regionCode: string) { const place = createPlace(testDb, tripId, { name: `Place in ${countryCode}` }); - testDb - .prepare('INSERT OR REPLACE INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, ?, ?, ?)') - .run(place.id, countryCode, regionCode, regionCode); + await upsertRow(await sharedTestOrm(testDb), PlaceRegions, { + place: place.id, country_code: countryCode, region_code: regionCode, region_name: regionCode, + }); } it('AUTH-DB-094: #1048 a place in a future trip does not stamp its country; a past trip does', async () => { const { user } = createUser(testDb); const past = createTrip(testDb, user.id, { title: 'Paris, last month', start_date: iso(-40), end_date: iso(-30) }); const future = createTrip(testDb, user.id, { title: 'Tokyo, next month', start_date: iso(30), end_date: iso(40) }); - placeInRegion(past.id, 'FR', 'FR-75'); - placeInRegion(future.id, 'JP', 'JP-13'); + await placeInRegion(past.id, 'FR', 'FR-75'); + await placeInRegion(future.id, 'JP', 'JP-13'); const stats = await atlas.getTravelStats(user.id); @@ -155,9 +160,9 @@ describe('getTravelStats', () => { it('AUTH-DB-095: #1048 a trip with no dates at all stamps nothing', async () => { const { user } = createUser(testDb); const dateless = createTrip(testDb, user.id, { title: 'Someday: Japan' }); - placeInRegion(dateless.id, 'JP', 'JP-13'); + await placeInRegion(dateless.id, 'JP', 'JP-13'); const res = createReservation(testDb, dateless.id, { type: 'flight' }); - endpoint(res.id, 'from', 0, 50.9014, 4.4844); // Brussels + await endpoint(res.id, 'from', 0, 50.9014, 4.4844); // Brussels const stats = await atlas.getTravelStats(user.id); @@ -168,8 +173,8 @@ describe('getTravelStats', () => { const { user } = createUser(testDb); const future = createTrip(testDb, user.id, { title: 'Tokyo, next month', start_date: iso(30), end_date: iso(40) }); const res = createReservation(testDb, future.id, { type: 'flight' }); - endpoint(res.id, 'from', 0, 50.9014, 4.4844); // Brussels - endpoint(res.id, 'to', 1, 35.6762, 139.6503); // Tokyo + await endpoint(res.id, 'from', 0, 50.9014, 4.4844); // Brussels + await endpoint(res.id, 'to', 1, 35.6762, 139.6503); // Tokyo const stats = await atlas.getTravelStats(user.id); @@ -182,19 +187,19 @@ describe('getTravelStats', () => { // same pairing: a hub stored as the 'to' of one booking and the 'from' of the next // slips past the #1486 role filter. Brussels → Helsinki → New York. - function splitChainThroughHelsinki(tripId: number, onwardDate: string, onwardTime: string) { + async function splitChainThroughHelsinki(tripId: number, onwardDate: string, onwardTime: string) { const inbound = createReservation(testDb, tripId, { type: 'flight', title: 'BRU-HEL' }); - endpoint(inbound.id, 'from', 0, 50.9014, 4.4844, 'BRU', '2023-05-01', '07:00'); - endpoint(inbound.id, 'to', 1, 60.3172, 24.9633, 'HEL', '2023-05-01', '09:30'); + await endpoint(inbound.id, 'from', 0, 50.9014, 4.4844, 'BRU', '2023-05-01', '07:00'); + await endpoint(inbound.id, 'to', 1, 60.3172, 24.9633, 'HEL', '2023-05-01', '09:30'); const onward = createReservation(testDb, tripId, { type: 'flight', title: 'HEL-JFK' }); - endpoint(onward.id, 'from', 0, 60.3172, 24.9633, 'HEL', onwardDate, onwardTime); - endpoint(onward.id, 'to', 1, 40.6413, -73.7781, 'JFK', onwardDate, '15:00'); + await endpoint(onward.id, 'from', 0, 60.3172, 24.9633, 'HEL', onwardDate, onwardTime); + await endpoint(onward.id, 'to', 1, 40.6413, -73.7781, 'JFK', onwardDate, '15:00'); } it('AUTH-DB-099: #1535 a plane change booked as two flights does not stamp the hub country', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'New York via Helsinki', start_date: PAST_START, end_date: PAST_END }); - splitChainThroughHelsinki(trip.id, '2023-05-01', '11:00'); + await splitChainThroughHelsinki(trip.id, '2023-05-01', '11:00'); const stats = await atlas.getTravelStats(user.id); expect(stats.countries).toContain('BE'); @@ -205,7 +210,7 @@ describe('getTravelStats', () => { it('AUTH-DB-100: #1535 a stopover of two days still stamps the hub country', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Helsinki stopover', start_date: PAST_START, end_date: PAST_END }); - splitChainThroughHelsinki(trip.id, '2023-05-03', '11:00'); + await splitChainThroughHelsinki(trip.id, '2023-05-03', '11:00'); expect((await atlas.getTravelStats(user.id)).countries).toContain('FI'); }); @@ -213,7 +218,7 @@ describe('getTravelStats', () => { it('AUTH-DB-101: #1490 removing a country still subtracts it around the layover pairing', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'New York via Helsinki', start_date: PAST_START, end_date: PAST_END }); - splitChainThroughHelsinki(trip.id, '2023-05-01', '11:00'); + await splitChainThroughHelsinki(trip.id, '2023-05-01', '11:00'); await atlas.unmarkCountry(user.id, 'BE'); @@ -227,8 +232,8 @@ describe('getTravelStats', () => { // filter must not reach it. const { user } = createUser(testDb); const future = createTrip(testDb, user.id, { title: 'Tokyo, next month', start_date: iso(30), end_date: iso(40) }); - placeInRegion(future.id, 'JP', 'JP-13'); - testDb.prepare('INSERT INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(user.id, 'JP'); + await placeInRegion(future.id, 'JP', 'JP-13'); + await insertRow(await sharedTestOrm(testDb), VisitedCountries, { user: user.id, country_code: 'JP' }); const stats = await atlas.getTravelStats(user.id); @@ -240,7 +245,7 @@ describe('travel-stats quirk fixes', () => { it('AUTH-DB-089: getTravelStats keeps a place at lat 0 / lng 0 (equator, prime meridian)', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Null Island' }); - testDb.prepare('INSERT INTO places (trip_id, name, lat, lng) VALUES (?, ?, ?, ?)').run(trip.id, 'Null Island', 0, 0); + await insertRow(await sharedTestOrm(testDb), Places, { trip: trip.id, name: 'Null Island', lat: 0, lng: 0 }); const stats = await atlas.getTravelStats(user.id); expect(stats.coords).toContainEqual({ lat: 0, lng: 0 }); }); @@ -264,36 +269,36 @@ describe('personal figures on a shared trip (#1966)', () => { const PAST_START = '2023-05-01'; const PAST_END = '2023-05-10'; - const endpoint = (reservationId: number, role: 'from' | 'to', sequence: number, lat: number, lng: number, code: string) => - testDb.prepare( - 'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, lat, lng, code) VALUES (?, ?, ?, ?, ?, ?, ?)' - ).run(reservationId, role, sequence, `Endpoint ${sequence}`, lat, lng, code); + const endpoint = async (reservationId: number, role: 'from' | 'to', sequence: number, lat: number, lng: number, code: string) => + makeReservationEndpoint(await sharedTestOrm(testDb), reservationId, { + role, sequence, name: `Endpoint ${sequence}`, lat, lng, code, + }); - const assignTo = (reservationId: number, userId: number) => - testDb.prepare('INSERT INTO reservation_travelers (reservation_id, user_id) VALUES (?, ?)').run(reservationId, userId); + const assignTo = async (reservationId: number, userId: number) => + addReservationTraveler(await sharedTestOrm(testDb), reservationId, userId); /** A shared trip: A flies Rome→New York, B flies Mexico City→New York. */ - function sharedTrip() { + async function sharedTrip() { const { user: alice } = createUser(testDb); const { user: bob } = createUser(testDb); const trip = createTrip(testDb, alice.id, { title: 'Shared', start_date: PAST_START, end_date: PAST_END }); addTripMember(testDb, trip.id, bob.id); const fromRome = createReservation(testDb, trip.id, { type: 'flight', title: 'FCO-JFK' }); - endpoint(fromRome.id, 'from', 0, 41.8003, 12.2389, 'FCO'); - endpoint(fromRome.id, 'to', 1, 40.6413, -73.7781, 'JFK'); + await endpoint(fromRome.id, 'from', 0, 41.8003, 12.2389, 'FCO'); + await endpoint(fromRome.id, 'to', 1, 40.6413, -73.7781, 'JFK'); const fromMexico = createReservation(testDb, trip.id, { type: 'flight', title: 'MEX-JFK' }); - endpoint(fromMexico.id, 'from', 0, 19.4363, -99.0721, 'MEX'); - endpoint(fromMexico.id, 'to', 1, 40.6413, -73.7781, 'JFK'); + await endpoint(fromMexico.id, 'from', 0, 19.4363, -99.0721, 'MEX'); + await endpoint(fromMexico.id, 'to', 1, 40.6413, -73.7781, 'JFK'); return { alice, bob, trip, fromRome, fromMexico }; } it('credits each traveler with their own flight only', async () => { - const { alice, bob, fromRome, fromMexico } = sharedTrip(); - assignTo(fromRome.id, alice.id); - assignTo(fromMexico.id, bob.id); + const { alice, bob, fromRome, fromMexico } = await sharedTrip(); + await assignTo(fromRome.id, alice.id); + await assignTo(fromMexico.id, bob.id); const a = await atlas.getTravelStats(alice.id); const b = await atlas.getTravelStats(bob.id); @@ -306,9 +311,9 @@ describe('personal figures on a shared trip (#1966)', () => { }); it('does not stamp another traveler s departure country on your passport', async () => { - const { alice, bob, fromRome, fromMexico } = sharedTrip(); - assignTo(fromRome.id, alice.id); - assignTo(fromMexico.id, bob.id); + const { alice, bob, fromRome, fromMexico } = await sharedTrip(); + await assignTo(fromRome.id, alice.id); + await assignTo(fromMexico.id, bob.id); // Alice never went through Mexico. expect((await atlas.getTravelStats(alice.id)).countries).not.toContain('MX'); @@ -320,7 +325,7 @@ describe('personal figures on a shared trip (#1966)', () => { * is empty on every install upgrading from 3.4.1. */ it('still counts a booking nobody is named on, for everyone on the trip', async () => { - const { alice, bob } = sharedTrip(); + const { alice, bob } = await sharedTrip(); const a = await atlas.getTravelStats(alice.id); const b = await atlas.getTravelStats(bob.id); @@ -329,11 +334,11 @@ describe('personal figures on a shared trip (#1966)', () => { }); it('counts a booking they are both named on, once for each of them', async () => { - const { alice, bob, fromRome, fromMexico } = sharedTrip(); - assignTo(fromRome.id, alice.id); - assignTo(fromRome.id, bob.id); - assignTo(fromMexico.id, alice.id); - assignTo(fromMexico.id, bob.id); + const { alice, bob, fromRome, fromMexico } = await sharedTrip(); + await assignTo(fromRome.id, alice.id); + await assignTo(fromRome.id, bob.id); + await assignTo(fromMexico.id, alice.id); + await assignTo(fromMexico.id, bob.id); const a = await atlas.getTravelStats(alice.id); const b = await atlas.getTravelStats(bob.id); @@ -342,8 +347,8 @@ describe('personal figures on a shared trip (#1966)', () => { }); it('leaves a traveler assigned nothing with the unassigned bookings only', async () => { - const { alice, bob, fromRome } = sharedTrip(); - assignTo(fromRome.id, bob.id); + const { alice, bob, fromRome } = await sharedTrip(); + await assignTo(fromRome.id, bob.id); // Alice keeps the Mexico flight (nobody named) but loses the Rome one. const a = await atlas.getTravelStats(alice.id);