diff --git a/server/tests/integration/airtrail-sync-request-context.test.ts b/server/tests/integration/airtrail-sync-request-context.test.ts index a03ffd17b..455f2f0bd 100644 --- a/server/tests/integration/airtrail-sync-request-context.test.ts +++ b/server/tests/integration/airtrail-sync-request-context.test.ts @@ -53,6 +53,8 @@ import { AirtrailClient, type AirtrailFlightRaw } from '../../src/nest/integrati import { ReservationsService } from '../../src/nest/reservations/reservations.service'; import { Users } from '../../src/db/entities/Users.entity'; import { createTrip, createUser } from '../helpers/factories'; +import { deleteRows, insertRow } from '../helpers/factories/rows'; +import { Reservations } from '../../src/db/entities/Reservations.entity'; describe('airtrail-sync cron tick runs inside a request context', () => { let app: INestApplication; @@ -72,13 +74,10 @@ describe('airtrail-sync cron tick runs inside a request context', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const result = testDb - .prepare( - `INSERT INTO reservations (trip_id, title, type, status, external_source, external_id, external_owner_user_id, sync_enabled, external_hash) - VALUES (?, 'AirTrail Flight', 'flight', 'confirmed', 'airtrail', '999', ?, 1, 'stale-hash')`, - ) - .run(trip.id, user.id); - const reservationId = result.lastInsertRowid as number; + const reservationId = await insertRow(orm, Reservations, { + trip: trip.id, title: 'AirTrail Flight', type: 'flight', status: 'confirmed', external_source: 'airtrail', + external_id: '999', external_owner_user_id: user.id, sync_enabled: 1, external_hash: 'stale-hash', + }); const flight: AirtrailFlightRaw = { id: 999, @@ -165,7 +164,7 @@ describe('airtrail-sync cron tick runs inside a request context', () => { flightsSpy.mockRestore(); credsSpy.mockRestore(); syncEnabledSpy.mockRestore(); - testDb.prepare('DELETE FROM reservations WHERE id = ?').run(reservationId); + await deleteRows(orm, Reservations, { id: reservationId }); } }); }); diff --git a/server/tests/integration/assignments.test.ts b/server/tests/integration/assignments.test.ts index 194e36888..0c3ca28e2 100644 --- a/server/tests/integration/assignments.test.ts +++ b/server/tests/integration/assignments.test.ts @@ -27,13 +27,20 @@ import { buildApp } from '../../src/bootstrap'; import { resetTestDb, resetRateLimits } from '../helpers/test-db'; import { createUser, createTrip, createDay, createPlace, addTripMember, createTag } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; +import { MikroORM } from '@mikro-orm/core'; +import { countRows, findRows, updateRows } from '../helpers/factories/rows'; +import { tagPlace } from '../helpers/factories/places'; +import { DayAssignments } from '../../src/db/entities/DayAssignments.entity'; +import { Places } from '../../src/db/entities/Places.entity'; let nestApp: INestApplication; let app: Application; +let orm: MikroORM; beforeAll(async () => { nestApp = await buildApp(); app = nestApp.getHttpAdapter().getInstance(); + orm = nestApp.get(MikroORM); }); beforeEach(async () => { @@ -66,7 +73,7 @@ describe('Create assignment', () => { .set('Cookie', authCookie(user.id)).send({ place_id: place.id }); const id = created.body.assignment.id; const url = `/api/trips/${trip.id}/assignments/${id}/end-day`; - testDb.prepare('UPDATE day_assignments SET assignment_time = ? WHERE id = ?').run('07:00', id); + await updateRows(orm, DayAssignments, { id }, { assignment_time: '07:00' }); const changed = await request(app).put(url).set('Cookie', authCookie(user.id)).send({ end_day: true }).expect(200); expect(changed.body.assignment).toMatchObject({ end_day: true, assignment_time: '07:00' }); const listed = await request(app).get(`/api/trips/${trip.id}/days`).set('Cookie', authCookie(user.id)).expect(200); @@ -195,7 +202,7 @@ describe('List assignments', () => { it('ASSIGN-003 — the embedded place carries osm_id so the day-plan thumbnail can auto-fetch (#1136)', async () => { const { user } = createUser(testDb); const { trip, day, place } = setupAssignmentFixtures(user.id); - testDb.prepare('UPDATE places SET osm_id = ? WHERE id = ?').run('node:42', place.id); + await updateRows(orm, Places, { id: place.id }, { osm_id: 'node:42' }); await request(app) .post(`/api/trips/${trip.id}/days/${day.id}/assignments`) @@ -295,9 +302,7 @@ describe('Reorder assignments', () => { expect(reorder.status).toBe(200); expect(reorder.body.success).toBe(true); - const rows = testDb - .prepare('SELECT id, order_index FROM day_assignments WHERE day_id = ? ORDER BY order_index') - .all(day.id) as Array<{ id: number; order_index: number }>; + const rows = await findRows(orm, DayAssignments, { day: day.id }, { order_index: 'asc' }); expect(rows[0].id).toBe(a2.body.assignment.id); expect(rows[1].id).toBe(a1.body.assignment.id); }); @@ -368,7 +373,7 @@ describe('Assignment participants', () => { // Attach a tag to the place const tag = createTag(testDb, user.id, { name: 'Must See' }); - testDb.prepare('INSERT INTO place_tags (place_id, tag_id) VALUES (?, ?)').run(place.id, tag.id); + await tagPlace(orm, place.id, [tag.id]); // Create the assignment via API const create = await request(app) @@ -436,6 +441,6 @@ describe('H1 — trip id parsed once at the gate (rule 21)', () => { .send({ place_id: place.id }); expect(res.status).toBe(404); expect(res.body).toEqual({ error: 'Day not found' }); - expect(testDb.prepare('SELECT COUNT(*) AS n FROM day_assignments WHERE day_id = ?').get(day.id)).toEqual({ n: 0 }); + expect(await countRows(orm, DayAssignments, { day: day.id })).toBe(0); }); }); diff --git a/server/tests/integration/auth.test.ts b/server/tests/integration/auth.test.ts index c17c80ccc..95ef6fea7 100644 --- a/server/tests/integration/auth.test.ts +++ b/server/tests/integration/auth.test.ts @@ -33,15 +33,62 @@ import { buildApp } from '../../src/bootstrap'; import { resetTestDb, resetRateLimits } from '../helpers/test-db'; import { createUser, createAdmin, createUserWithMfa, createInviteToken, createTrip, createBudgetItem, createJourney, createJourneyEntry, addJourneyContributor, addTripPhoto, createCategory, createTag, createTodoItem, createMcpToken, createBucketListItem, createVisitedCountry, createCollabNote, addTripMember } from '../helpers/factories'; import { authCookie, authHeader } from '../helpers/auth'; +import type { EntityClass, FilterQuery } from '@mikro-orm/core'; +import { MikroORM } from '@mikro-orm/core'; +import { dbNow } from '../../src/db/types'; +import { findRow, insertRow, insertRowIgnoringConflict, updateRows } from '../helpers/factories/rows'; +import { setAppSetting, setUserSetting } from '../helpers/factories/settings'; +import { makeShareToken } from '../helpers/factories/trips'; +import { AuditLog } from '../../src/db/entities/AuditLog.entity'; +import { BucketList } from '../../src/db/entities/BucketList.entity'; +import { BudgetItems } from '../../src/db/entities/BudgetItems.entity'; +import { Categories } from '../../src/db/entities/Categories.entity'; +import { CollabNotes } from '../../src/db/entities/CollabNotes.entity'; +import { InviteTokens } from '../../src/db/entities/InviteTokens.entity'; +import { JourneyContributors } from '../../src/db/entities/JourneyContributors.entity'; +import { JourneyEntries } from '../../src/db/entities/JourneyEntries.entity'; +import { JourneyShareTokens } from '../../src/db/entities/JourneyShareTokens.entity'; +import { Journeys } from '../../src/db/entities/Journeys.entity'; +import { McpTokens } from '../../src/db/entities/McpTokens.entity'; +import { NotificationChannelPreferences } from '../../src/db/entities/NotificationChannelPreferences.entity'; +import { Notifications } from '../../src/db/entities/Notifications.entity'; +import { OauthClients } from '../../src/db/entities/OauthClients.entity'; +import { OauthConsents } from '../../src/db/entities/OauthConsents.entity'; +import { OauthTokens } from '../../src/db/entities/OauthTokens.entity'; +import { PackingBags } from '../../src/db/entities/PackingBags.entity'; +import { PackingTemplates } from '../../src/db/entities/PackingTemplates.entity'; +import { PasswordResetTokens } from '../../src/db/entities/PasswordResetTokens.entity'; +import { Settings } from '../../src/db/entities/Settings.entity'; +import { ShareTokens } from '../../src/db/entities/ShareTokens.entity'; +import { Tags } from '../../src/db/entities/Tags.entity'; +import { TodoItems } from '../../src/db/entities/TodoItems.entity'; +import { TrekPhotos } from '../../src/db/entities/TrekPhotos.entity'; +import { TripFiles } from '../../src/db/entities/TripFiles.entity'; +import { TripMembers } from '../../src/db/entities/TripMembers.entity'; +import { TripPhotos } from '../../src/db/entities/TripPhotos.entity'; +import { Trips } from '../../src/db/entities/Trips.entity'; +import { UserNoticeDismissals } from '../../src/db/entities/UserNoticeDismissals.entity'; +import { Users } from '../../src/db/entities/Users.entity'; +import { VacayPlanMembers } from '../../src/db/entities/VacayPlanMembers.entity'; +import { VacayPlans } from '../../src/db/entities/VacayPlans.entity'; +import { VisitedCountries } from '../../src/db/entities/VisitedCountries.entity'; +import { VisitedRegions } from '../../src/db/entities/VisitedRegions.entity'; let nestApp: INestApplication; let app: Application; +let orm: MikroORM; beforeAll(async () => { nestApp = await buildApp(); app = nestApp.getHttpAdapter().getInstance(); + orm = nestApp.get(MikroORM); }); +/** The row matching `where`, or null once it is gone. */ +function rowOf(entity: EntityClass, where: FilterQuery) { + return findRow(orm, entity, where); +} + beforeEach(async () => { resetTestDb(testDb); // Reset rate limiter state between tests so they don't interfere @@ -148,7 +195,7 @@ describe('Registration', () => { it('AUTH-009 — registration disabled by admin returns 403', async () => { createUser(testDb); - testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run(); + await setAppSetting(orm, 'allow_registration', 'false'); const res = await request(app).post('/api/auth/register').send({ username: 'blocked', email: 'blocked@example.com', @@ -160,7 +207,7 @@ describe('Registration', () => { it('AUTH-010 — registration with valid invite token succeeds even when registration disabled', async () => { const { user: admin } = createAdmin(testDb); - testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run(); + await setAppSetting(orm, 'allow_registration', 'false'); const invite = createInviteToken(testDb, { max_uses: 1, created_by: admin.id }); const res = await request(app).post('/api/auth/register').send({ @@ -171,7 +218,7 @@ describe('Registration', () => { }); expect(res.status).toBe(201); - const row = testDb.prepare('SELECT used_count FROM invite_tokens WHERE id = ?').get(invite.id) as { used_count: number }; + const row = (await rowOf(InviteTokens, { id: invite.id }))!; expect(row.used_count).toBe(1); }); @@ -189,7 +236,7 @@ describe('Registration', () => { const { user: admin } = createAdmin(testDb); const invite = createInviteToken(testDb, { max_uses: 1, created_by: admin.id }); // Mark as exhausted - testDb.prepare('UPDATE invite_tokens SET used_count = 1 WHERE id = ?').run(invite.id); + await updateRows(orm, InviteTokens, { id: invite.id }, { used_count: 1 }); const res = await request(app).get(`/api/auth/invite/${invite.token}`); expect(res.status).toBe(410); @@ -199,7 +246,7 @@ describe('Registration', () => { it('AUTH-013 — GET /api/auth/invite/:token for a never-expiring invite keeps expires_at present and null on the wire (rule 16, task-5-review F1/T1)', async () => { const { user: admin } = createAdmin(testDb); const invite = createInviteToken(testDb, { max_uses: 3, created_by: admin.id }); - testDb.prepare('UPDATE invite_tokens SET used_count = 1 WHERE id = ?').run(invite.id); + await updateRows(orm, InviteTokens, { id: invite.id }, { used_count: 1 }); const res = await request(app).get(`/api/auth/invite/${invite.token}`); expect(res.status).toBe(200); @@ -221,7 +268,7 @@ describe('Registration — whitespace normalization', () => { password: 'Str0ng!Pass', }); expect(res.status).toBe(201); - const row = testDb.prepare('SELECT username FROM users WHERE email = ?').get('trimmed@example.com') as { username: string }; + const row = (await rowOf(Users, { email: 'trimmed@example.com' }))!; expect(row.username).toBe('trimmeduser'); }); @@ -232,7 +279,7 @@ describe('Registration — whitespace normalization', () => { password: 'Str0ng!Pass', }); expect(res.status).toBe(201); - const row = testDb.prepare('SELECT email FROM users WHERE username = ?').get('emailtrimuser') as { email: string }; + const row = (await rowOf(Users, { username: 'emailtrimuser' }))!; expect(row.email).toBe('emailtrim@example.com'); }); @@ -326,7 +373,7 @@ describe('Session', () => { it('AUTH-021 — user with must_change_password=1 sees the flag in their profile', async () => { const { user } = createUser(testDb); - testDb.prepare('UPDATE users SET must_change_password = 1 WHERE id = ?').run(user.id); + await updateRows(orm, Users, { id: user.id }, { must_change_password: 1 }); const res = await request(app).get('/api/auth/me').set('Cookie', authCookie(user.id)); expect(res.status).toBe(200); @@ -351,7 +398,7 @@ describe('App config', () => { it('AUTH-028 — allow_registration is false after admin disables it', async () => { createUser(testDb); - testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run(); + await setAppSetting(orm, 'allow_registration', 'false'); const res = await request(app).get('/api/auth/app-config'); expect(res.status).toBe(200); expect(res.body.allow_registration).toBe(false); @@ -370,9 +417,7 @@ describe('Demo login', () => { }); it('AUTH-022 — POST /api/auth/demo-login with DEMO_MODE and demo user returns 200 + cookie', async () => { - testDb.prepare( - "INSERT INTO users (username, email, password_hash, role) VALUES ('demo', 'demo@trek.app', 'x', 'user')" - ).run(); + await insertRow(orm, Users, { username: 'demo', email: 'demo@trek.app', password_hash: 'x', role: 'user' }); process.env.DEMO_MODE = 'true'; try { const res = await request(app).post('/api/auth/demo-login'); @@ -478,7 +523,7 @@ describe('MFA', () => { describe('Forced MFA policy', () => { it('AUTH-020 — non-MFA user is blocked (403 MFA_REQUIRED) when require_mfa is true', async () => { const { user } = createUser(testDb); - testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('require_mfa', 'true')").run(); + await setAppSetting(orm, 'require_mfa', 'true'); // mfaPolicy checks Authorization: Bearer header const res = await request(app).get('/api/trips').set(authHeader(user.id)); @@ -488,7 +533,7 @@ describe('Forced MFA policy', () => { it('AUTH-020 — /api/auth/me and MFA setup endpoints are exempt from require_mfa', async () => { const { user } = createUser(testDb); - testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('require_mfa', 'true')").run(); + await setAppSetting(orm, 'require_mfa', 'true'); const meRes = await request(app).get('/api/auth/me').set(authHeader(user.id)); expect(meRes.status).toBe(200); @@ -499,7 +544,7 @@ describe('Forced MFA policy', () => { it('AUTH-020 — MFA-enabled user passes through require_mfa policy', async () => { const { user } = createUserWithMfa(testDb); - testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('require_mfa', 'true')").run(); + await setAppSetting(orm, 'require_mfa', 'true'); const res = await request(app).get('/api/trips').set(authHeader(user.id)); expect(res.status).toBe(200); @@ -512,7 +557,7 @@ describe('Forced MFA policy', () => { // leave every addon endpoint reachable without MFA. const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('require_mfa', 'true')").run(); + await setAppSetting(orm, 'require_mfa', 'true'); for (const path of [`/api/trips/${trip.id}/budget`, `/api/trips/${trip.id}/packing`, `/api/trips/${trip.id}/todo`]) { const res = await request(app).get(path).set(authHeader(user.id)); @@ -524,7 +569,7 @@ describe('Forced MFA policy', () => { it('AUTH-020 — MFA-enabled user reaches nested Nest addon controllers under require_mfa', async () => { const { user } = createUserWithMfa(testDb); const trip = createTrip(testDb, user.id); - testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('require_mfa', 'true')").run(); + await setAppSetting(orm, 'require_mfa', 'true'); const res = await request(app).get(`/api/trips/${trip.id}/budget`).set(authHeader(user.id)); expect(res.status).toBe(200); @@ -589,8 +634,7 @@ describe('Extended auth scenarios', () => { // Generate and store backup codes on the MFA-enabled user const backupCodes = generateBackupCodes(); const backupHashes = backupCodes.map(hashBackupCode); - testDb.prepare('UPDATE users SET mfa_backup_codes = ? WHERE id = ?') - .run(JSON.stringify(backupHashes), user.id); + await updateRows(orm, Users, { id: user.id }, { mfa_backup_codes: JSON.stringify(backupHashes) }); // Step 1: login to get mfa_token const loginRes = await request(app) @@ -633,14 +677,14 @@ describe('Account deletion', () => { // trip_members.invited_by: target invited thirdUser to otherUser's trip // (trip survives deletion; only invited_by should become NULL) const otherTrip = createTrip(testDb, otherUser.id); - testDb.prepare('INSERT INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)').run(otherTrip.id, thirdUser.id, target.id); + await insertRow(orm, TripMembers, { trip: otherTrip.id, user: thirdUser.id, invitedByRef: target.id }); // share_tokens.created_by: target created a share token for otherUser's trip - testDb.prepare("INSERT INTO share_tokens (trip_id, token, created_by) VALUES (?, 'tok-auth-test', ?)").run(otherTrip.id, target.id); + await makeShareToken(orm, otherTrip.id, target.id, { token: 'tok-auth-test' }); // budget_items.paid_by_user_id: target paid for an expense on otherUser's trip const budgetItem = createBudgetItem(testDb, otherTrip.id); - testDb.prepare('UPDATE budget_items SET paid_by_user_id = ? WHERE id = ?').run(target.id, budgetItem.id); + await updateRows(orm, BudgetItems, { id: budgetItem.id }, { paidByUser: target.id }); // journey_contributors: target is a contributor on otherUser's journey const otherJourney = createJourney(testDb, otherUser.id); @@ -650,35 +694,31 @@ describe('Account deletion', () => { createJourneyEntry(testDb, otherJourney.id, target.id); // journey_share_tokens: target created a share token for otherUser's journey - testDb.prepare("INSERT INTO journey_share_tokens (journey_id, token, created_by) VALUES (?, 'jst-auth-test', ?)").run(otherJourney.id, target.id); + await insertRow(orm, JourneyShareTokens, { journey: otherJourney.id, token: 'jst-auth-test', createdByRef: target.id }); // notifications.sender_id (SET NULL): target sent a notification to otherUser - const sentNotif = testDb.prepare( - "INSERT INTO notifications (type, scope, target, sender_id, recipient_id, title_key, text_key) VALUES ('simple', 'trip', ?, ?, ?, 'k', 'k')" - ).run(otherTrip.id, target.id, otherUser.id); + const sentNotifId = await insertRow(orm, Notifications, { + type: 'simple', scope: 'trip', target: otherTrip.id, sender: target.id, recipient: otherUser.id, title_key: 'k', text_key: 'k', + }); // notifications.recipient_id (CASCADE): otherUser sent a notification to target - testDb.prepare( - "INSERT INTO notifications (type, scope, target, sender_id, recipient_id, title_key, text_key) VALUES ('simple', 'trip', ?, ?, ?, 'k', 'k')" - ).run(otherTrip.id, otherUser.id, target.id); + await insertRow(orm, Notifications, { + type: 'simple', scope: 'trip', target: otherTrip.id, sender: otherUser.id, recipient: target.id, title_key: 'k', text_key: 'k', + }); // user_notice_dismissals (CASCADE): target dismissed a notice - testDb.prepare( - "INSERT INTO user_notice_dismissals (user_id, notice_id, dismissed_at) VALUES (?, 'test-notice', ?)" - ).run(target.id, Date.now()); + await insertRow(orm, UserNoticeDismissals, { user: target.id, notice_id: 'test-notice', dismissed_at: Date.now() }); // owned journey: target owns a journey with an entry (cascade-deletes on journey deletion) const ownedJourney = createJourney(testDb, target.id); createJourneyEntry(testDb, ownedJourney.id, target.id); // trip_files.uploaded_by (SET NULL): target uploaded a file to otherUser's trip - const fileRow = testDb.prepare( - "INSERT INTO trip_files (trip_id, filename, original_name, uploaded_by) VALUES (?, 'f.pdf', 'file.pdf', ?)" - ).run(otherTrip.id, target.id); + const fileId = await insertRow(orm, TripFiles, { + trip: otherTrip.id, filename: 'f.pdf', original_name: 'file.pdf', uploadedByRef: target.id, + }); // trek_photos.owner_id (SET NULL): target owns a photo in the central registry - const trekPhotoRow = testDb.prepare( - "INSERT INTO trek_photos (provider, asset_id, owner_id) VALUES ('immich', 'asset-auth-test', ?)" - ).run(target.id); + const trekPhotoId = await insertRow(orm, TrekPhotos, { provider: 'immich', asset_id: 'asset-auth-test', owner: target.id }); // trip_photos.user_id (CASCADE): target added a photo to otherUser's trip addTripPhoto(testDb, otherTrip.id, target.id, 'asset-tp-auth', 'immich'); @@ -697,33 +737,31 @@ describe('Account deletion', () => { // todo_items.assigned_user_id (SET NULL): target is assigned to a todo on otherUser's trip const todoItem = createTodoItem(testDb, otherTrip.id); - testDb.prepare('UPDATE todo_items SET assigned_user_id = ? WHERE id = ?').run(target.id, todoItem.id); + await updateRows(orm, TodoItems, { id: todoItem.id }, { assignedUser: target.id }); // packing_bags.user_id (SET NULL): target owns a packing bag on otherUser's trip - const packBagRow = testDb.prepare( - "INSERT INTO packing_bags (trip_id, name, color, user_id) VALUES (?, 'Bag', '#ff0000', ?)" - ).run(otherTrip.id, target.id); + const packBagId = await insertRow(orm, PackingBags, { trip: otherTrip.id, name: 'Bag', color: '#ff0000', user: target.id }); // mcp_tokens.user_id (CASCADE): target has an MCP API token createMcpToken(testDb, target.id); // oauth_tokens/consents.user_id (CASCADE): target has tokens from otherUser's OAuth client - testDb.prepare( - "INSERT INTO oauth_clients (id, user_id, name, client_id, client_secret_hash) VALUES ('cl-auth-test', ?, 'App', 'cid-auth-test', 'h')" - ).run(otherUser.id); - testDb.prepare( - "INSERT INTO oauth_tokens (client_id, user_id, access_token_hash, refresh_token_hash, access_token_expires_at, refresh_token_expires_at) VALUES ('cid-auth-test', ?, 'ath-auth', 'rth-auth', datetime('now','+1 hour'), datetime('now','+30 days'))" - ).run(target.id); - testDb.prepare( - "INSERT INTO oauth_consents (client_id, user_id) VALUES ('cid-auth-test', ?)" - ).run(target.id); + await insertRow(orm, OauthClients, { + id: 'cl-auth-test', user: otherUser.id, name: 'App', client_id: 'cid-auth-test', client_secret_hash: 'h', + }); + await insertRow(orm, OauthTokens, { + client: 'cid-auth-test', user: target.id, access_token_hash: 'ath-auth', refresh_token_hash: 'rth-auth', + access_token_expires_at: dbNow(new Date(Date.now() + 3600_000)), + refresh_token_expires_at: dbNow(new Date(Date.now() + 30 * 86_400_000)), + }); + await insertRow(orm, OauthConsents, { client: 'cid-auth-test', user: target.id }); // vacay_plans.owner_id (CASCADE): target owns a vacation plan - const vacayPlanRow = testDb.prepare("INSERT INTO vacay_plans (owner_id) VALUES (?)").run(target.id); + const vacayPlanId = await insertRow(orm, VacayPlans, { owner: target.id }); // vacay_plan_members.user_id (CASCADE): target is a member of otherUser's vacay plan - const otherVacayPlanRow = testDb.prepare("INSERT INTO vacay_plans (owner_id) VALUES (?)").run(otherUser.id); - testDb.prepare("INSERT INTO vacay_plan_members (plan_id, user_id) VALUES (?, ?)").run(otherVacayPlanRow.lastInsertRowid, target.id); + const otherVacayPlanId = await insertRow(orm, VacayPlans, { owner: otherUser.id }); + await insertRow(orm, VacayPlanMembers, { plan: otherVacayPlanId, user: target.id }); // bucket_list.user_id (CASCADE): target has a bucket list item createBucketListItem(testDb, target.id); @@ -732,14 +770,10 @@ describe('Account deletion', () => { createVisitedCountry(testDb, target.id, 'JP'); // visited_regions.user_id (CASCADE): target has visited a region - testDb.prepare( - "INSERT INTO visited_regions (user_id, region_code, region_name, country_code) VALUES (?, 'JP-13', 'Tokyo', 'JP')" - ).run(target.id); + await insertRow(orm, VisitedRegions, { user: target.id, region_code: 'JP-13', region_name: 'Tokyo', country_code: 'JP' }); // packing_templates.created_by (CASCADE): target created a packing template - const packTemplateRow = testDb.prepare( - "INSERT INTO packing_templates (name, created_by) VALUES ('My Template', ?)" - ).run(target.id); + const packTemplateId = await insertRow(orm, PackingTemplates, { name: 'My Template', createdByRef: target.id }); // invite_tokens.created_by (CASCADE): target created an invite token createInviteToken(testDb, { created_by: target.id }); @@ -748,20 +782,18 @@ describe('Account deletion', () => { createCollabNote(testDb, otherTrip.id, target.id); // settings.user_id (CASCADE): target has a user setting - testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'theme', 'dark')").run(target.id); + await setUserSetting(orm, target.id, 'theme', 'dark'); // password_reset_tokens.user_id (CASCADE): target has a pending password reset - testDb.prepare( - "INSERT INTO password_reset_tokens (user_id, token_hash, expires_at) VALUES (?, 'prt-hash-auth', datetime('now','+1 hour'))" - ).run(target.id); + await insertRow(orm, PasswordResetTokens, { + user: target.id, token_hash: 'prt-hash-auth', expires_at: dbNow(new Date(Date.now() + 3600_000)), + }); // audit_log.user_id (SET NULL): target performed an audited action - const auditRow = testDb.prepare( - "INSERT INTO audit_log (user_id, action, ip) VALUES (?, 'test.action', '127.0.0.1')" - ).run(target.id); + const auditId = await insertRow(orm, AuditLog, { user: target.id, action: 'test.action', ip: '127.0.0.1' }); // notification_channel_preferences.user_id (CASCADE): target has notification preferences - testDb.prepare("INSERT OR IGNORE INTO notification_channel_preferences (user_id, event_type, channel) VALUES (?, 'trip_invite', 'email')").run(target.id); + await insertRowIgnoringConflict(orm, NotificationChannelPreferences, { user: target.id, event_type: 'trip_invite', channel: 'email' }); // admin exists to ensure target (non-admin user) passes the last-admin guard void admin; @@ -772,69 +804,69 @@ describe('Account deletion', () => { expect(res.status).toBe(200); expect(res.body.success).toBe(true); - expect(testDb.prepare('SELECT id FROM users WHERE id = ?').get(target.id)).toBeUndefined(); + expect(await rowOf(Users, { id: target.id })).toBeNull(); // trip_members row survives but invited_by is now NULL - expect((testDb.prepare('SELECT invited_by FROM trip_members WHERE trip_id = ? AND user_id = ?').get(otherTrip.id, thirdUser.id) as any).invited_by).toBeNull(); - expect(testDb.prepare('SELECT id FROM share_tokens WHERE created_by = ?').get(target.id)).toBeUndefined(); - expect((testDb.prepare('SELECT paid_by_user_id FROM budget_items WHERE id = ?').get(budgetItem.id) as any).paid_by_user_id).toBeNull(); - expect(testDb.prepare('SELECT user_id FROM journey_contributors WHERE journey_id = ? AND user_id = ?').get(otherJourney.id, target.id)).toBeUndefined(); - expect(testDb.prepare('SELECT id FROM journey_entries WHERE author_id = ?').get(target.id)).toBeUndefined(); - expect(testDb.prepare('SELECT id FROM journey_share_tokens WHERE created_by = ?').get(target.id)).toBeUndefined(); + expect((await rowOf(TripMembers, { trip: otherTrip.id, user: thirdUser.id }))!.invited_by).toBeNull(); + expect(await rowOf(ShareTokens, { createdByRef: target.id })).toBeNull(); + expect((await rowOf(BudgetItems, { id: budgetItem.id }))!.paid_by_user_id).toBeNull(); + expect(await rowOf(JourneyContributors, { journey: otherJourney.id, user: target.id })).toBeNull(); + expect(await rowOf(JourneyEntries, { author: target.id })).toBeNull(); + expect(await rowOf(JourneyShareTokens, { createdByRef: target.id })).toBeNull(); // sent notification survives but sender_id becomes NULL - expect((testDb.prepare('SELECT sender_id FROM notifications WHERE id = ?').get(sentNotif.lastInsertRowid) as any).sender_id).toBeNull(); + expect((await rowOf(Notifications, { id: sentNotifId }))!.sender_id).toBeNull(); // received notification is cascade-deleted - expect(testDb.prepare('SELECT id FROM notifications WHERE recipient_id = ?').get(target.id)).toBeUndefined(); + expect(await rowOf(Notifications, { recipient: target.id })).toBeNull(); // notice dismissals are cascade-deleted - expect(testDb.prepare("SELECT user_id FROM user_notice_dismissals WHERE user_id = ? AND notice_id = 'test-notice'").get(target.id)).toBeUndefined(); + expect(await rowOf(UserNoticeDismissals, { user: target.id, notice_id: 'test-notice' })).toBeNull(); // owned journey and its entries are cascade-deleted - expect(testDb.prepare('SELECT id FROM journeys WHERE user_id = ?').get(target.id)).toBeUndefined(); - expect(testDb.prepare('SELECT id FROM journey_entries WHERE journey_id = ?').get(ownedJourney.id)).toBeUndefined(); + expect(await rowOf(Journeys, { user: target.id })).toBeNull(); + expect(await rowOf(JourneyEntries, { journey: ownedJourney.id })).toBeNull(); // uploaded file survives but uploaded_by is now NULL - expect((testDb.prepare('SELECT uploaded_by FROM trip_files WHERE id = ?').get(fileRow.lastInsertRowid) as any).uploaded_by).toBeNull(); + expect((await rowOf(TripFiles, { id: fileId }))!.uploaded_by).toBeNull(); // trek_photos row survives but owner_id is now NULL - expect((testDb.prepare('SELECT owner_id FROM trek_photos WHERE id = ?').get(trekPhotoRow.lastInsertRowid) as any).owner_id).toBeNull(); + expect((await rowOf(TrekPhotos, { id: trekPhotoId }))!.owner_id).toBeNull(); // trip_photos row for target is cascade-deleted - expect(testDb.prepare("SELECT id FROM trip_photos WHERE trip_id = ? AND user_id = ?").get(otherTrip.id, target.id)).toBeUndefined(); + expect(await rowOf(TripPhotos, { trip: otherTrip.id, user: target.id })).toBeNull(); // owned trip is cascade-deleted - expect(testDb.prepare('SELECT id FROM trips WHERE id = ?').get(ownedTrip.id)).toBeUndefined(); + expect(await rowOf(Trips, { id: ownedTrip.id })).toBeNull(); // trip membership on others' trips is removed - expect(testDb.prepare('SELECT id FROM trip_members WHERE trip_id = ? AND user_id = ?').get(otherTrip.id, target.id)).toBeUndefined(); + expect(await rowOf(TripMembers, { trip: otherTrip.id, user: target.id })).toBeNull(); // category survives but user_id is NULL - expect((testDb.prepare('SELECT user_id FROM categories WHERE id = ?').get(userCategory.id) as any).user_id).toBeNull(); + expect((await rowOf(Categories, { id: userCategory.id }))!.user_id).toBeNull(); // tag is deleted - expect(testDb.prepare('SELECT id FROM tags WHERE id = ?').get(userTag.id)).toBeUndefined(); + expect(await rowOf(Tags, { id: userTag.id })).toBeNull(); // todo assigned_user_id is NULL - expect((testDb.prepare('SELECT assigned_user_id FROM todo_items WHERE id = ?').get(todoItem.id) as any).assigned_user_id).toBeNull(); + expect((await rowOf(TodoItems, { id: todoItem.id }))!.assigned_user_id).toBeNull(); // packing bag survives but user_id is NULL - expect((testDb.prepare('SELECT user_id FROM packing_bags WHERE id = ?').get(packBagRow.lastInsertRowid) as any).user_id).toBeNull(); + expect((await rowOf(PackingBags, { id: packBagId }))!.user_id).toBeNull(); // MCP tokens are deleted - expect(testDb.prepare('SELECT id FROM mcp_tokens WHERE user_id = ?').get(target.id)).toBeUndefined(); + expect(await rowOf(McpTokens, { user: target.id })).toBeNull(); // OAuth tokens and consents are deleted - expect(testDb.prepare('SELECT id FROM oauth_tokens WHERE user_id = ?').get(target.id)).toBeUndefined(); - expect(testDb.prepare('SELECT id FROM oauth_consents WHERE user_id = ?').get(target.id)).toBeUndefined(); + expect(await rowOf(OauthTokens, { user: target.id })).toBeNull(); + expect(await rowOf(OauthConsents, { user: target.id })).toBeNull(); // owned vacay plan is deleted - expect(testDb.prepare('SELECT id FROM vacay_plans WHERE id = ?').get(vacayPlanRow.lastInsertRowid)).toBeUndefined(); + expect(await rowOf(VacayPlans, { id: vacayPlanId })).toBeNull(); // vacay plan membership on others' plans is removed - expect(testDb.prepare('SELECT id FROM vacay_plan_members WHERE plan_id = ? AND user_id = ?').get(otherVacayPlanRow.lastInsertRowid, target.id)).toBeUndefined(); + expect(await rowOf(VacayPlanMembers, { plan: otherVacayPlanId, user: target.id })).toBeNull(); // bucket list items are deleted - expect(testDb.prepare('SELECT id FROM bucket_list WHERE user_id = ?').get(target.id)).toBeUndefined(); + expect(await rowOf(BucketList, { user: target.id })).toBeNull(); // travel history is deleted - expect(testDb.prepare('SELECT user_id FROM visited_countries WHERE user_id = ? AND country_code = ?').get(target.id, 'JP')).toBeUndefined(); - expect(testDb.prepare('SELECT id FROM visited_regions WHERE user_id = ?').get(target.id)).toBeUndefined(); + expect(await rowOf(VisitedCountries, { user: target.id, country_code: 'JP' })).toBeNull(); + expect(await rowOf(VisitedRegions, { user: target.id })).toBeNull(); // packing template is deleted - expect(testDb.prepare('SELECT id FROM packing_templates WHERE id = ?').get(packTemplateRow.lastInsertRowid)).toBeUndefined(); + expect(await rowOf(PackingTemplates, { id: packTemplateId })).toBeNull(); // invite tokens created by target are deleted - expect(testDb.prepare('SELECT id FROM invite_tokens WHERE created_by = ?').get(target.id)).toBeUndefined(); + expect(await rowOf(InviteTokens, { createdByRef: target.id })).toBeNull(); // collab content is deleted - expect(testDb.prepare('SELECT id FROM collab_notes WHERE user_id = ? AND trip_id = ?').get(target.id, otherTrip.id)).toBeUndefined(); + expect(await rowOf(CollabNotes, { user: target.id, trip: otherTrip.id })).toBeNull(); // user settings are deleted - expect(testDb.prepare("SELECT id FROM settings WHERE user_id = ?").get(target.id)).toBeUndefined(); + expect(await rowOf(Settings, { user: target.id })).toBeNull(); // password reset tokens are deleted - expect(testDb.prepare('SELECT id FROM password_reset_tokens WHERE user_id = ?').get(target.id)).toBeUndefined(); + expect(await rowOf(PasswordResetTokens, { user: target.id })).toBeNull(); // audit log entry survives but user_id is NULL - expect((testDb.prepare('SELECT user_id FROM audit_log WHERE id = ?').get(auditRow.lastInsertRowid) as any).user_id).toBeNull(); + expect((await rowOf(AuditLog, { id: auditId }))!.user_id).toBeNull(); // notification channel preferences are deleted - expect(testDb.prepare("SELECT user_id FROM notification_channel_preferences WHERE user_id = ? AND event_type = 'trip_invite'").get(target.id)).toBeUndefined(); + expect(await rowOf(NotificationChannelPreferences, { user: target.id, event_type: 'trip_invite' })).toBeNull(); }); }); diff --git a/server/tests/integration/budget.test.ts b/server/tests/integration/budget.test.ts index 6b56f204c..fd997236b 100644 --- a/server/tests/integration/budget.test.ts +++ b/server/tests/integration/budget.test.ts @@ -27,15 +27,30 @@ import { buildApp } from '../../src/bootstrap'; import { resetTestDb, resetRateLimits } from '../helpers/test-db'; import { createUser, createTrip, createBudgetItem, addTripMember, createReservation } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; +import { MikroORM } from '@mikro-orm/core'; +import { deleteRows, findRow, insertRow } from '../helpers/factories/rows'; +import { setAppSetting } from '../helpers/factories/settings'; +import { AppSettings } from '../../src/db/entities/AppSettings.entity'; +import { BudgetItems } from '../../src/db/entities/BudgetItems.entity'; +import { Reservations } from '../../src/db/entities/Reservations.entity'; let nestApp: INestApplication; let app: Application; +let orm: MikroORM; beforeAll(async () => { nestApp = await buildApp(); app = nestApp.getHttpAdapter().getInstance(); + orm = nestApp.get(MikroORM); }); +/** A hotel expense linked to the booking. */ +function seedBookedExpense(tripId: number, reservationId: number, totalPrice: number): Promise { + return insertRow(orm, BudgetItems, { + trip: tripId, name: 'Hotel Cost', category: 'Accommodation', total_price: totalPrice, reservation: reservationId, + }); +} + beforeEach(async () => { resetTestDb(testDb); await resetRateLimits(nestApp); @@ -187,18 +202,15 @@ describe('Delete budget item', () => { const trip = createTrip(testDb, user.id); const reservation = createReservation(testDb, trip.id, { title: 'Hotel Booking', type: 'hotel' }); - const result = testDb.prepare( - 'INSERT INTO budget_items (trip_id, name, category, total_price, reservation_id) VALUES (?, ?, ?, ?, ?)' - ).run(trip.id, 'Hotel Cost', 'Accommodation', 250, reservation.id); - const itemId = result.lastInsertRowid as number; + const itemId = await seedBookedExpense(trip.id, reservation.id, 250); const del = await request(app) .delete(`/api/trips/${trip.id}/budget/${itemId}`) .set('Cookie', authCookie(user.id)); expect(del.status).toBe(200); - const reservationAfter = testDb.prepare('SELECT id FROM reservations WHERE id = ?').get(reservation.id); - expect(reservationAfter).toBeDefined(); + const reservationAfter = await findRow(orm, Reservations, { id: reservation.id }); + expect(reservationAfter).not.toBeNull(); }); }); @@ -402,7 +414,7 @@ describe('Reorder budget items', () => { const item = createBudgetItem(testDb, trip.id); // Restrict budget_edit to trip_owner only - testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('perm_budget_edit', 'trip_owner')").run(); + await setAppSetting(orm, 'perm_budget_edit', 'trip_owner'); const { invalidatePermissionsCache } = await import('../../src/nest/permissions/permissions-cache'); await invalidatePermissionsCache(); @@ -413,7 +425,7 @@ describe('Reorder budget items', () => { expect(res.status).toBe(403); // Restore default - testDb.prepare("DELETE FROM app_settings WHERE key = 'perm_budget_edit'").run(); + await deleteRows(orm, AppSettings, { key: 'perm_budget_edit' }); await invalidatePermissionsCache(); }); @@ -475,10 +487,7 @@ describe('Reservation price sync on budget item update', () => { const reservation = createReservation(testDb, trip.id, { title: 'Hotel Booking', type: 'hotel' }); // Create a budget item linked to the reservation - const result = testDb.prepare( - 'INSERT INTO budget_items (trip_id, name, category, total_price, reservation_id) VALUES (?, ?, ?, ?, ?)' - ).run(trip.id, 'Hotel Cost', 'Accommodation', 200, reservation.id); - const itemId = result.lastInsertRowid as number; + const itemId = await seedBookedExpense(trip.id, reservation.id, 200); const res = await request(app) .put(`/api/trips/${trip.id}/budget/${itemId}`) @@ -488,8 +497,8 @@ describe('Reservation price sync on budget item update', () => { expect(res.body.item.total_price).toBe(350); // Verify reservation metadata was synced - const updatedReservation = testDb.prepare('SELECT metadata FROM reservations WHERE id = ?').get(reservation.id) as { metadata: string | null } | undefined; - expect(updatedReservation).toBeDefined(); + const updatedReservation = await findRow(orm, Reservations, { id: reservation.id }); + expect(updatedReservation).not.toBeNull(); const meta = JSON.parse(updatedReservation!.metadata || '{}'); expect(meta.price).toBe('350'); }); @@ -507,7 +516,7 @@ describe('Budget edit permission enforcement', () => { addTripMember(testDb, trip.id, member.id); const { invalidatePermissionsCache } = await import('../../src/nest/permissions/permissions-cache'); - testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('perm_budget_edit', 'trip_owner')").run(); + await setAppSetting(orm, 'perm_budget_edit', 'trip_owner'); await invalidatePermissionsCache(); const res = await request(app) @@ -516,7 +525,7 @@ describe('Budget edit permission enforcement', () => { .send({ name: 'Sneaky Expense', total_price: 100 }); expect(res.status).toBe(403); - testDb.prepare("DELETE FROM app_settings WHERE key = 'perm_budget_edit'").run(); + await deleteRows(orm, AppSettings, { key: 'perm_budget_edit' }); await invalidatePermissionsCache(); }); @@ -528,7 +537,7 @@ describe('Budget edit permission enforcement', () => { createBudgetItem(testDb, trip.id, { name: 'Item', category: 'Transport' }); const { invalidatePermissionsCache } = await import('../../src/nest/permissions/permissions-cache'); - testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('perm_budget_edit', 'trip_owner')").run(); + await setAppSetting(orm, 'perm_budget_edit', 'trip_owner'); await invalidatePermissionsCache(); const res = await request(app) @@ -537,7 +546,7 @@ describe('Budget edit permission enforcement', () => { .send({ orderedCategories: ['Transport'] }); expect(res.status).toBe(403); - testDb.prepare("DELETE FROM app_settings WHERE key = 'perm_budget_edit'").run(); + await deleteRows(orm, AppSettings, { key: 'perm_budget_edit' }); await invalidatePermissionsCache(); }); }); diff --git a/server/tests/integration/journey-place-order.test.ts b/server/tests/integration/journey-place-order.test.ts index 70bf642a8..1fe24ea11 100644 --- a/server/tests/integration/journey-place-order.test.ts +++ b/server/tests/integration/journey-place-order.test.ts @@ -46,22 +46,29 @@ import { resetTestDb, resetRateLimits } from '../helpers/test-db'; import { createUser, createTrip, createPlace, createJourney, linkTripToJourney } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; import { invalidatePermissionsCache } from '../../src/nest/permissions/permissions-cache'; +import { MikroORM } from '@mikro-orm/core'; +import { findRow, insertRow, upsertRow } from '../helpers/factories/rows'; +import { Addons } from '../../src/db/entities/Addons.entity'; +import { JourneyEntries } from '../../src/db/entities/JourneyEntries.entity'; +import { Places } from '../../src/db/entities/Places.entity'; let nestApp: INestApplication; let app: Application; +let orm: MikroORM; beforeAll(async () => { nestApp = await buildApp(); app = nestApp.getHttpAdapter().getInstance(); + orm = nestApp.get(MikroORM); }); beforeEach(async () => { resetTestDb(testDb); await resetRateLimits(nestApp); await invalidatePermissionsCache(); // Enable the journey addon. - testDb.prepare( - "INSERT OR REPLACE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES ('journey', 'Journey', 'Travel journal', 'global', 'Compass', 1, 35)" - ).run(); + await upsertRow(orm, Addons, { + id: 'journey', name: 'Journey', description: 'Travel journal', type: 'global', icon: 'Compass', enabled: true, sort_order: 35, + }); }); afterAll(async () => { await nestApp.close(); @@ -81,10 +88,10 @@ describe('deleting a place detaches its journey entry ahead of the FK cascade', // annotated, rather than deleted outright (that path is for content-less // skeletons) or left dangling with a stale source_place_id. const now = Date.now(); - const entryId = testDb.prepare(` - INSERT INTO journey_entries (journey_id, source_trip_id, source_place_id, author_id, type, title, story, entry_date, visibility, sort_order, created_at, updated_at) - VALUES (?, ?, ?, ?, 'entry', ?, ?, '2026-01-15', 'private', 0, ?, ?) - `).run(journey.id, trip.id, place.id, user.id, 'A café', 'Lovely coffee.', now, now).lastInsertRowid; + const entryId = await insertRow(orm, JourneyEntries, { + journey: journey.id, sourceTrip: trip.id, sourcePlace: place.id, author: user.id, type: 'entry', title: 'A café', + story: 'Lovely coffee.', entry_date: '2026-01-15', visibility: 'private', sort_order: 0, created_at: now, updated_at: now, + }); const res = await request(app) .delete(`/api/trips/${trip.id}/places/${place.id}`) @@ -93,17 +100,12 @@ describe('deleting a place detaches its journey entry ahead of the FK cascade', expect(res.body).toEqual({ success: true, tourPlaceIds: [] }); // The place is gone. - expect(testDb.prepare('SELECT 1 FROM places WHERE id = ?').get(place.id)).toBeUndefined(); + expect(await findRow(orm, Places, { id: place.id })).toBeNull(); // The entry survives, detached and annotated — the FK's ON DELETE SET // NULL alone would null source_place_id but would never touch story or // type; only onPlaceDeleted having actually run produces the note. - const entry = testDb.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as { - source_place_id: number | null; - source_trip_id: number | null; - type: string; - story: string; - }; + const entry = (await findRow(orm, JourneyEntries, { id: entryId }))!; expect(entry.source_place_id).toBeNull(); expect(entry.source_trip_id).toBeNull(); expect(entry.type).toBe('entry'); @@ -118,10 +120,10 @@ describe('deleting a place detaches its journey entry ahead of the FK cascade', linkTripToJourney(testDb, journey.id, trip.id); const now = Date.now(); - const entryId = testDb.prepare(` - INSERT INTO journey_entries (journey_id, source_trip_id, source_place_id, author_id, type, title, entry_date, visibility, sort_order, created_at, updated_at) - VALUES (?, ?, ?, ?, 'skeleton', ?, '2026-01-16', 'private', 0, ?, ?) - `).run(journey.id, trip.id, place.id, user.id, place.name, now, now).lastInsertRowid; + const entryId = await insertRow(orm, JourneyEntries, { + journey: journey.id, sourceTrip: trip.id, sourcePlace: place.id, author: user.id, type: 'skeleton', title: place.name, + entry_date: '2026-01-16', visibility: 'private', sort_order: 0, created_at: now, updated_at: now, + }); const res = await request(app) .delete(`/api/trips/${trip.id}/places/${place.id}`) @@ -130,6 +132,6 @@ describe('deleting a place detaches its journey entry ahead of the FK cascade', // onPlaceDeleted ran (and completed) as part of the request: the // content-less skeleton is gone, not merely detached. - expect(testDb.prepare('SELECT 1 FROM journey_entries WHERE id = ?').get(entryId)).toBeUndefined(); + expect(await findRow(orm, JourneyEntries, { id: entryId })).toBeNull(); }); }); diff --git a/server/tests/integration/journey.test.ts b/server/tests/integration/journey.test.ts index 39762e618..b4727ee8a 100644 --- a/server/tests/integration/journey.test.ts +++ b/server/tests/integration/journey.test.ts @@ -43,22 +43,29 @@ import { import { authCookie } from '../helpers/auth'; import { jpegWithExif } from '../helpers/exif-jpeg'; import { invalidatePermissionsCache } from '../../src/nest/permissions/permissions-cache'; +import { MikroORM } from '@mikro-orm/core'; +import { findRow, upsertRow } from '../helpers/factories/rows'; +import { setAppSetting } from '../helpers/factories/settings'; +import { Addons } from '../../src/db/entities/Addons.entity'; +import { TrekPhotos } from '../../src/db/entities/TrekPhotos.entity'; let nestApp: INestApplication; let app: Application; +let orm: MikroORM; beforeAll(async () => { nestApp = await buildApp(); app = nestApp.getHttpAdapter().getInstance(); + orm = nestApp.get(MikroORM); }); beforeEach(async () => { resetTestDb(testDb); await resetRateLimits(nestApp); await invalidatePermissionsCache(); // Enable the journey addon - testDb.prepare( - "INSERT OR REPLACE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES ('journey', 'Journey', 'Travel journal', 'global', 'Compass', 1, 35)" - ).run(); + await upsertRow(orm, Addons, { + id: 'journey', name: 'Journey', description: 'Travel journal', type: 'global', icon: 'Compass', enabled: true, sort_order: 35, + }); }); afterAll(async () => { await nestApp.close(); @@ -995,8 +1002,7 @@ describe('Provider photos — passphrase persistence', () => { expect(res.status).toBe(201); - const row = testDb.prepare('SELECT passphrase FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?') - .get('synologyphotos', 'shared-asset-1', user.id) as { passphrase: string | null } | undefined; + const row = await findRow(orm, TrekPhotos, { provider: 'synologyphotos', asset_id: 'shared-asset-1', owner: user.id }); expect(row?.passphrase).not.toBeNull(); expect(typeof row?.passphrase).toBe('string'); }); @@ -1015,8 +1021,7 @@ describe('Provider photos — passphrase persistence', () => { expect(res.body.added).toBe(2); for (const assetId of ['batch-asset-1', 'batch-asset-2']) { - const row = testDb.prepare('SELECT passphrase FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?') - .get('synologyphotos', assetId, user.id) as { passphrase: string | null } | undefined; + const row = await findRow(orm, TrekPhotos, { provider: 'synologyphotos', asset_id: assetId, owner: user.id }); expect(row?.passphrase).not.toBeNull(); } }); @@ -1078,7 +1083,7 @@ describe('Journey upload parity', () => { // The admin allowlist rejects the empty extension before the filename // fallback can run; the wildcard makes the fallback reachable so it is // actually pinned here. - testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', '*')").run(); + await setAppSetting(orm, 'allowed_file_types', '*'); const res = await request(app) .post(`/api/journeys/entries/${entry.id}/photos`) @@ -1218,8 +1223,9 @@ describe('Journey upload parity', () => { // The backfill runs detached after the response, so wait for it to land. async function captureOf(filePath: string): Promise { let row: CaptureRow | undefined; - await vi.waitFor(() => { - row = testDb.prepare('SELECT taken_at, lat, lng FROM trek_photos WHERE file_path = ?').get(filePath) as CaptureRow; + await vi.waitFor(async () => { + const photo = await findRow(orm, TrekPhotos, { file_path: filePath }); + row = photo ? { taken_at: photo.taken_at ?? null, lat: photo.lat ?? null, lng: photo.lng ?? null } : undefined; expect(row?.taken_at).toBeTruthy(); }); return row!; diff --git a/server/tests/integration/oauth-authorize.test.ts b/server/tests/integration/oauth-authorize.test.ts index eeae60aec..0639116a7 100644 --- a/server/tests/integration/oauth-authorize.test.ts +++ b/server/tests/integration/oauth-authorize.test.ts @@ -45,6 +45,8 @@ import { UnitOfWork } from '../../src/nest/database/unit-of-work'; import { createTestAddonsService } from '../helpers/test-addons'; import { AuditService } from '../../src/nest/audit/audit.service'; import { createTestOrm, type TestOrm } from '../helpers/test-orm'; +import { upsertRow } from '../helpers/factories/rows'; +import { Addons } from '../../src/db/entities/Addons.entity'; import { AuditLog } from '../../src/db/entities/AuditLog.entity'; import { Users } from '../../src/db/entities/Users.entity'; import { OauthClients } from '../../src/db/entities/OauthClients.entity'; @@ -67,10 +69,10 @@ function makePkce() { return { verifier, challenge }; } -function setMcpEnabled(enabled: boolean) { - testDb.prepare( - "INSERT OR REPLACE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES ('mcp', 'MCP', 'AI assistant integration', 'integration', 'Terminal', ?, 12)" - ).run(enabled ? 1 : 0); +async function setMcpEnabled(enabled: boolean) { + await upsertRow(t, Addons, { + id: 'mcp', name: 'MCP', description: 'AI assistant integration', type: 'integration', icon: 'Terminal', enabled, sort_order: 12, + }); } /** DCR-register a public client and return its client_id. */ @@ -93,7 +95,7 @@ beforeAll(async () => { beforeEach(async () => { resetTestDb(testDb); await resetRateLimits(nestApp); - setMcpEnabled(true); + await setMcpEnabled(true); }); afterAll(async () => { @@ -241,7 +243,7 @@ describe('GET /oauth/authorize — SDK authorizationHandler over trekOAuthProvid }); it('AUTHZ-007 — MCP addon off: authorize and register both 404 with empty bodies', async () => { - setMcpEnabled(false); + await setMcpEnabled(false); const authz = await request(app).get('/oauth/authorize').query({ client_id: 'x' }); expect(authz.status).toBe(404); expect(authz.text).toBe(''); diff --git a/server/tests/integration/oauth.test.ts b/server/tests/integration/oauth.test.ts index aec679477..9db063ebd 100644 --- a/server/tests/integration/oauth.test.ts +++ b/server/tests/integration/oauth.test.ts @@ -42,6 +42,8 @@ import { UnitOfWork } from '../../src/nest/database/unit-of-work'; import { createTestAddonsService } from '../helpers/test-addons'; import { AuditService } from '../../src/nest/audit/audit.service'; import { createTestOrm, type TestOrm } from '../helpers/test-orm'; +import { updateRows, upsertRow } from '../helpers/factories/rows'; +import { Addons } from '../../src/db/entities/Addons.entity'; import { AuditLog } from '../../src/db/entities/AuditLog.entity'; import { Users } from '../../src/db/entities/Users.entity'; import { OauthClients } from '../../src/db/entities/OauthClients.entity'; @@ -75,10 +77,10 @@ function makePkce() { // the legacy oauthService (addons.bridge) resolve the MCP addon from the real // addons row, so driving the DB row keeps mcpEnabled() AND // validateAuthorizeRequest()/token/revoke consistent from one source. -function setMcpEnabled(enabled: boolean) { - testDb.prepare( - "INSERT OR REPLACE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES ('mcp', 'MCP', 'AI assistant integration', 'integration', 'Terminal', ?, 12)" - ).run(enabled ? 1 : 0); +async function setMcpEnabled(enabled: boolean) { + await upsertRow(t, Addons, { + id: 'mcp', name: 'MCP', description: 'AI assistant integration', type: 'integration', icon: 'Terminal', enabled, sort_order: 12, + }); } beforeAll(async () => { @@ -91,7 +93,7 @@ beforeAll(async () => { beforeEach(async () => { resetTestDb(testDb); await resetRateLimits(nestApp); - setMcpEnabled(true); + await setMcpEnabled(true); }); afterAll(async () => { @@ -234,7 +236,7 @@ describe('platform/discovery parity pins', () => { }); it('PLAT-PIN-004 — every /.well-known/* path 404s with an EMPTY body when MCP is disabled', async () => { - setMcpEnabled(false); + await setMcpEnabled(false); for (const path of [ '/.well-known/openid-configuration', '/.well-known/oauth-protected-resource', @@ -284,7 +286,7 @@ describe('POST /oauth/token — authorization_code grant', () => { }); it('OAUTH-003 — MCP addon disabled returns 404', async () => { - setMcpEnabled(false); + await setMcpEnabled(false); const res = await request(app) .post('/oauth/token') .send({ grant_type: 'authorization_code', client_id: 'x', client_secret: 'y', code: 'z', redirect_uri: 'https://r.example.com/cb', code_verifier: 'v' }); @@ -646,7 +648,7 @@ describe('POST /oauth/revoke', () => { describe('GET /api/oauth/authorize/validate', () => { it('OAUTH-019 — returns 404 when MCP addon disabled (M2: prevents feature fingerprinting)', async () => { - setMcpEnabled(false); + await setMcpEnabled(false); const res = await request(app) .get('/api/oauth/authorize/validate') .query({ response_type: 'code', client_id: 'x', redirect_uri: 'https://r.example.com/cb', scope: 'trips:read', code_challenge: 'c', code_challenge_method: 'S256' }); @@ -886,7 +888,7 @@ describe('POST /api/oauth/authorize', () => { }); it('OAUTH-029 — 403 when MCP disabled', async () => { - setMcpEnabled(false); + await setMcpEnabled(false); const { user } = createUser(testDb); const res = await request(app) @@ -983,7 +985,7 @@ describe('POST /api/oauth/authorize', () => { describe('Client CRUD — /api/oauth/clients', () => { it('OAUTH-033 — GET returns 403 when addon disabled', async () => { - setMcpEnabled(false); + await setMcpEnabled(false); const { user } = createUser(testDb); const res = await request(app) @@ -1020,7 +1022,7 @@ describe('Client CRUD — /api/oauth/clients', () => { }); it('OAUTH-036 — POST returns 403 when addon disabled', async () => { - setMcpEnabled(false); + await setMcpEnabled(false); const { user } = createUser(testDb); const res = await request(app) @@ -1070,7 +1072,7 @@ describe('Client CRUD — /api/oauth/clients', () => { describe('Sessions — /api/oauth/sessions', () => { it('OAUTH-040 — GET returns 403 when addon disabled', async () => { - setMcpEnabled(false); + await setMcpEnabled(false); const { user } = createUser(testDb); const res = await request(app) @@ -1148,7 +1150,7 @@ describe('Sessions — /api/oauth/sessions', () => { }); it('OAUTH-044 — DELETE /sessions/:id returns 403 when addon disabled', async () => { - setMcpEnabled(false); + await setMcpEnabled(false); const { user } = createUser(testDb); const res = await request(app) @@ -1173,13 +1175,13 @@ describe('M1 — Cache-Control headers on /oauth/token', () => { describe('M2 — 404 when MCP disabled on discovery + revoke endpoints', () => { it('OAUTH-SEC-002 — /.well-known/oauth-authorization-server returns 404 when disabled', async () => { - setMcpEnabled(false); + await setMcpEnabled(false); const res = await request(app).get('/.well-known/oauth-authorization-server'); expect(res.status).toBe(404); }); it('OAUTH-SEC-003 — /oauth/revoke returns 404 when disabled', async () => { - setMcpEnabled(false); + await setMcpEnabled(false); const res = await request(app) .post('/oauth/revoke') .send({ token: 'x', client_id: 'y', client_secret: 'z' }); @@ -1417,10 +1419,10 @@ describe('C3 — Refresh token replay detection', () => { * below still describes theft — a token used minutes later — rather than two * clients refreshing at the same moment. */ - function agePastGrace(rawRefreshToken: string) { + async function agePastGrace(rawRefreshToken: string) { const hash = crypto.createHash('sha256').update(rawRefreshToken).digest('hex'); const old = new Date(Date.now() - 5 * 60 * 1000).toISOString().replace('T', ' ').slice(0, 19); - testDb.prepare('UPDATE oauth_tokens SET revoked_at = ? WHERE refresh_token_hash = ?').run(old, hash); + await updateRows(t, OauthTokens, { refresh_token_hash: hash }, { revoked_at: old }); } it('OAUTH-SEC-012 — replaying a rotated (old) refresh token returns invalid_grant', async () => { @@ -1460,7 +1462,7 @@ describe('C3 — Refresh token replay detection', () => { expect(t2.status).toBe(200); // Replay the original (now rotated/revoked) refresh token — must be rejected - agePastGrace(originalRefreshToken); + await agePastGrace(originalRefreshToken); const t3 = await request(app).post('/oauth/token').send({ grant_type: 'refresh_token', client_id: r.client!.client_id, @@ -1546,7 +1548,7 @@ describe('C3 — Refresh token replay detection', () => { const newRefreshToken = t2.body.refresh_token; // Replay original — triggers chain revocation - agePastGrace(originalRefreshToken); + await agePastGrace(originalRefreshToken); await request(app).post('/oauth/token').send({ grant_type: 'refresh_token', client_id: r.client!.client_id, diff --git a/server/tests/integration/passkey.test.ts b/server/tests/integration/passkey.test.ts index 42662e466..c279ebe6c 100644 --- a/server/tests/integration/passkey.test.ts +++ b/server/tests/integration/passkey.test.ts @@ -43,13 +43,19 @@ import { buildApp } from '../../src/bootstrap'; import { resetTestDb, resetRateLimits } from '../helpers/test-db'; import { createUser, createAdmin } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; +import { MikroORM } from '@mikro-orm/core'; +import { findRow, insertRow } from '../helpers/factories/rows'; +import { InviteTokens } from '../../src/db/entities/InviteTokens.entity'; +import { WebauthnCredentials } from '../../src/db/entities/WebauthnCredentials.entity'; let nestApp: INestApplication; let app: Application; +let orm: MikroORM; beforeAll(async () => { nestApp = await buildApp(); app = nestApp.getHttpAdapter().getInstance(); + orm = nestApp.get(MikroORM); }); beforeEach(async () => { @@ -98,10 +104,9 @@ describe('Passkey management — non-numeric id parity (Plan 3b Task 3 review, F // 16 instead of 404ing — verified by hand, recorded in the task report. it('PASSKEY-INT-003 — PATCH /auth/passkey/credentials/0x10 (hex-literal id) is refused by the toRowId narrowing, even though credential 16 exists', async () => { const { user } = createUser(testDb); - testDb.prepare( - `INSERT INTO webauthn_credentials (id, user_id, credential_id, public_key, name) - VALUES (16, ?, 'cred-16', X'00', 'Original16')`, - ).run(user.id); + await insertRow(orm, WebauthnCredentials, { + id: 16, user: user.id, credential_id: 'cred-16', public_key: Buffer.from([0]), name: 'Original16', + }); const res = await request(app) .patch('/api/auth/passkey/credentials/0x10') @@ -110,7 +115,7 @@ describe('Passkey management — non-numeric id parity (Plan 3b Task 3 review, F expect(res.status).toBe(404); expect(res.body).toEqual({ error: 'Passkey not found' }); - const row = testDb.prepare('SELECT name FROM webauthn_credentials WHERE id = 16').get() as { name: string }; + const row = (await findRow(orm, WebauthnCredentials, { id: 16 }))!; expect(row.name).toBe('Original16'); // untouched — not renamed to 'Ghost' }); @@ -141,9 +146,9 @@ describe('Passkey management — non-numeric id parity (Plan 3b Task 3 review, F describe('Registration invite deletion — prefixed numeric literal parity (Plan 3b Task 3 review, F2)', () => { it('INVITE-INT-001 — DELETE /admin/invites/0x10 (hex literal) returns the legacy 404 and leaves invite id 16 untouched', async () => { const { user: admin } = createAdmin(testDb); - testDb - .prepare('INSERT INTO invite_tokens (id, token, max_uses, used_count, expires_at, created_by) VALUES (16, ?, 1, 0, NULL, ?)') - .run('hex-literal-survivor', admin.id); + await insertRow(orm, InviteTokens, { + id: 16, token: 'hex-literal-survivor', max_uses: 1, used_count: 0, expires_at: null, createdByRef: admin.id, + }); const res = await request(app) .delete('/api/admin/invites/0x10') @@ -151,6 +156,6 @@ describe('Registration invite deletion — prefixed numeric literal parity (Plan expect(res.status).toBe(404); expect(res.body).toEqual({ error: 'Invite not found' }); - expect(testDb.prepare('SELECT id FROM invite_tokens WHERE id = 16').get()).toBeDefined(); + expect(await findRow(orm, InviteTokens, { id: 16 })).not.toBeNull(); }); }); diff --git a/server/tests/integration/plugins/boot-registry-order.test.ts b/server/tests/integration/plugins/boot-registry-order.test.ts index 58edbdc74..f9d6769d3 100644 --- a/server/tests/integration/plugins/boot-registry-order.test.ts +++ b/server/tests/integration/plugins/boot-registry-order.test.ts @@ -63,13 +63,23 @@ import { PluginMetaMigrations } from '../../../src/db/entities/PluginMetaMigrati import { PluginCapabilityAudit } from '../../../src/db/entities/PluginCapabilityAudit.entity'; import { Settings } from '../../../src/db/entities/Settings.entity'; import { NotificationChannelPreferences } from '../../../src/db/entities/NotificationChannelPreferences.entity'; +import { findRow, insertRow } from '../../helpers/factories/rows'; let codeRoot: string; let dataRoot: string; let mod: TestingModule; let t: TestOrm | undefined; +/** Seeds and reads the plugin rows; separate from the ORMs the cases hand their services. */ +let seedOrm: TestOrm; -beforeAll(() => { +/** The plugin's status and last error as the supervisor left them. */ +async function pluginState(id: string): Promise<{ status: string; last_error: string | null }> { + const plugin = (await findRow(seedOrm, Plugins, { id }))!; + return { status: plugin.status, last_error: plugin.last_error ?? null }; +} + +beforeAll(async () => { + seedOrm = await createTestOrm(testDb); codeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'trekplug-boot-code-')); dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'trekplug-boot-data-')); process.env.TREK_PLUGINS_DIR = codeRoot; @@ -87,14 +97,16 @@ beforeAll(() => { // The row a container recreate leaves behind: enabled, already consented to // db:own. Seeded 'inactive' (not the 'active' a real shutdown leaves) so the // poll below only terminates on a status the THIS-boot supervisor wrote. - testDb - .prepare("INSERT INTO plugins (id, name, status, enabled, permissions, granted_permissions, config, trek_range) VALUES ('migrator','migrator','inactive',1,'[\"db:own\"]','[\"db:own\"]','{}','>=3.0.0')") - .run(); + await insertRow(seedOrm, Plugins, { + id: 'migrator', name: 'migrator', status: 'inactive', enabled: 1, permissions: '["db:own"]', + granted_permissions: '["db:own"]', config: '{}', trek_range: '>=3.0.0', + }); }); afterAll(async () => { await mod?.close(); await t?.close(); + await seedOrm?.close(); delete process.env.TREK_PLUGINS_DIR; delete process.env.TREK_PLUGINS_DATA_DIR; delete process.env.TREK_PLUGINS_ENABLED; @@ -172,7 +184,7 @@ describe('plugin boot vs registry scan ordering', () => { const runtime = mod.get(PluginRuntimeService); let row = { status: 'starting', last_error: null as string | null }; for (let i = 0; i < 100; i++) { - row = testDb.prepare("SELECT status, last_error FROM plugins WHERE id='migrator'").get() as typeof row; + row = await pluginState('migrator'); if (row.status === 'active' || row.status === 'error') break; await new Promise((r) => setTimeout(r, 50)); } @@ -198,12 +210,11 @@ describe('plugin boot vs registry scan ordering', () => { const dir = path.join(codeRoot, 'addongated', 'server'); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, 'index.js'), `module.exports = { async onLoad(ctx) {} };`); - testDb.prepare("INSERT INTO addons (id, name, enabled) VALUES ('needsaddon_addon', 'needsaddon_addon', 1)").run(); - testDb - .prepare( - "INSERT INTO plugins (id, name, status, enabled, permissions, granted_permissions, config, trek_range, dependencies) VALUES ('addongated','addongated','inactive',1,'[]','[]','{}','>=3.0.0', ?)", - ) - .run(JSON.stringify({ requiredAddons: ['needsaddon_addon'] })); + await insertRow(seedOrm, Addons, { id: 'needsaddon_addon', name: 'needsaddon_addon', enabled: true }); + await insertRow(seedOrm, Plugins, { + id: 'addongated', name: 'addongated', status: 'inactive', enabled: 1, permissions: '[]', granted_permissions: '[]', + config: '{}', trek_range: '>=3.0.0', dependencies: JSON.stringify({ requiredAddons: ['needsaddon_addon'] }), + }); // Self-contained ORM/collaborators — independent of test 1's `t`, which is // assigned inside ITS `it` body rather than a shared beforeAll. Global @@ -274,7 +285,7 @@ describe('plugin boot vs registry scan ordering', () => { const runtime2 = mod2.get(PluginRuntimeService); let row = { status: 'starting', last_error: null as string | null }; for (let i = 0; i < 100; i++) { - row = testDb.prepare("SELECT status, last_error FROM plugins WHERE id='addongated'").get() as typeof row; + row = await pluginState('addongated'); if (row.status === 'active' || row.status === 'error') break; await new Promise((r) => setTimeout(r, 50)); } diff --git a/server/tests/integration/profile.test.ts b/server/tests/integration/profile.test.ts index 2c03953b4..31f3014b3 100644 --- a/server/tests/integration/profile.test.ts +++ b/server/tests/integration/profile.test.ts @@ -28,15 +28,21 @@ import { buildApp } from '../../src/bootstrap'; import { resetTestDb, resetRateLimits } from '../helpers/test-db'; import { createUser, createAdmin, createTrip } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; +import { MikroORM } from '@mikro-orm/core'; +import { readUser } from '../helpers/factories/users'; +import { insertRow } from '../helpers/factories/rows'; +import { Users } from '../../src/db/entities/Users.entity'; let nestApp: INestApplication; let app: Application; +let orm: MikroORM; const FIXTURE_JPEG = path.join(__dirname, '../fixtures/small-image.jpg'); const FIXTURE_PDF = path.join(__dirname, '../fixtures/test.pdf'); beforeAll(async () => { nestApp = await buildApp(); app = nestApp.getHttpAdapter().getInstance(); + orm = nestApp.get(MikroORM); }); beforeEach(async () => { @@ -96,7 +102,7 @@ describe('PUT /api/auth/me/settings (F3)', () => { expect(get.status).toBe(200); expect(get.body.user).toMatchObject({ username: 'after-name', email: 'after@example.test' }); - const row = testDb.prepare('SELECT username, email FROM users WHERE id = ?').get(user.id) as { username: string; email: string }; + const row = await readUser(orm, user.id); expect(row.username).toBe('after-name'); expect(row.email).toBe('after@example.test'); }); @@ -321,10 +327,7 @@ describe('Travel stats', () => { describe('Demo mode protections', () => { it('PROFILE-015 — demo user cannot upload avatar (demoUploadBlock)', async () => { // demoUploadBlock checks for email === 'demo@nomad.app' - testDb.prepare( - "INSERT INTO users (username, email, password_hash, role) VALUES ('demo', 'demo@nomad.app', 'x', 'user')" - ).run(); - const demoUser = testDb.prepare('SELECT id FROM users WHERE email = ?').get('demo@nomad.app') as { id: number }; + const demoUser = { id: await insertRow(orm, Users, { username: 'demo', email: 'demo@nomad.app', password_hash: 'x', role: 'user' }) }; process.env.DEMO_MODE = 'true'; try { diff --git a/server/tests/integration/reservations.test.ts b/server/tests/integration/reservations.test.ts index 4fac15285..4b4e63da3 100644 --- a/server/tests/integration/reservations.test.ts +++ b/server/tests/integration/reservations.test.ts @@ -27,6 +27,20 @@ import { buildApp } from '../../src/bootstrap'; import { resetTestDb, resetRateLimits } from '../helpers/test-db'; import { createUser, createTrip, createDay, createPlace, createReservation, createDayAssignment, addTripMember } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; +import { MikroORM } from '@mikro-orm/core'; +import { findRow, findRows, updateRows } from '../helpers/factories/rows'; +import { BudgetItems } from '../../src/db/entities/BudgetItems.entity'; +import { DayAccommodations } from '../../src/db/entities/DayAccommodations.entity'; +import { Days } from '../../src/db/entities/Days.entity'; +import { ReservationDayPositions } from '../../src/db/entities/ReservationDayPositions.entity'; +import { Reservations } from '../../src/db/entities/Reservations.entity'; + +let orm: MikroORM; + +/** The expense linked to the booking on the trip, or null. */ +function linkedExpense(tripId: number, reservationId: number) { + return findRow(orm, BudgetItems, { trip: tripId, reservation: reservationId }); +} let nestApp: INestApplication; let app: Application; @@ -34,6 +48,7 @@ let app: Application; beforeAll(async () => { nestApp = await buildApp(); app = nestApp.getHttpAdapter().getInstance(); + orm = nestApp.get(MikroORM); }); beforeEach(async () => { @@ -260,7 +275,7 @@ describe('Update reservation', () => { expect(updateRes.status).toBe(200); // Verify accommodation was updated with check-in/out - const accom = testDb.prepare('SELECT * FROM day_accommodations WHERE trip_id = ?').get(trip.id) as any; + const accom = (await findRow(orm, DayAccommodations, { trip: trip.id }))!; expect(accom.check_in).toBe('15:00'); expect(accom.check_out).toBe('11:00'); expect(accom.confirmation).toBe('HTL-XYZ-999'); @@ -291,7 +306,7 @@ describe('Update reservation', () => { .send({ accommodation_id: hexAccId }); expect(res.status).toBe(200); - const row = testDb.prepare('SELECT accommodation_id FROM reservations WHERE id = ?').get(resv.id) as { accommodation_id: string | null }; + const row = (await findRow(orm, Reservations, { id: resv.id }))!; expect(row.accommodation_id).toBeNull(); }); }); @@ -312,8 +327,8 @@ describe('H1 — a booking on a stay is restamped when the trip\'s dates change it('the accommodation_id RS28 stores is the legacy REAL-bound TEXT shape, and a later date change restamps the linked booking', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { start_date: '2026-12-01', end_date: '2026-12-03' }); - const day1 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? AND date = ?').get(trip.id, '2026-12-01') as { id: number }; - const day2 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? AND date = ?').get(trip.id, '2026-12-02') as { id: number }; + const day1 = (await findRow(orm, Days, { trip: trip.id, date: '2026-12-01' }))!; + const day2 = (await findRow(orm, Days, { trip: trip.id, date: '2026-12-02' }))!; const place = createPlace(testDb, trip.id, { name: 'Lighthouse Inn' }); const createRes = await request(app) @@ -331,7 +346,7 @@ describe('H1 — a booking on a stay is restamped when the trip\'s dates change // Stored-shape assert: the legacy REAL-bound TEXT shape (`'.0'`), not // the SQL-literal-inlined shape (`''`) `String(n)` used to store. - const stored = testDb.prepare('SELECT accommodation_id FROM reservations WHERE id = ?').get(resvId) as { accommodation_id: string }; + const stored = (await findRow(orm, Reservations, { id: resvId }))!; expect(stored.accommodation_id).toMatch(/^\d+\.0$/); // Move the whole trip a day later (default date_shift_mode, i.e. NOT @@ -344,8 +359,7 @@ describe('H1 — a booking on a stay is restamped when the trip\'s dates change .send({ start_date: '2026-12-02', end_date: '2026-12-04' }); expect(updateRes.status).toBe(200); - const resvAfter = testDb.prepare('SELECT day_id, reservation_time FROM reservations WHERE id = ?').get(resvId) as - { day_id: number; reservation_time: string | null }; + const resvAfter = (await findRow(orm, Reservations, { id: resvId }))!; expect(resvAfter.day_id).toBe(day1.id); // Restamped onto day1's NEW date — red without the fix, where DY23's // REAL-bound compare misses a `String(n)`-shaped accommodation_id and @@ -453,8 +467,8 @@ describe('Delete reservation', () => { .set('Cookie', authCookie(user.id)); expect(res.status).toBe(404); - const row = testDb.prepare('SELECT id FROM reservations WHERE id = ?').get(resv.id); - expect(row).toBeDefined(); + const row = await findRow(orm, Reservations, { id: resv.id }); + expect(row).not.toBeNull(); }); }); @@ -498,8 +512,8 @@ describe('Batch update positions', () => { expect(res.status).toBe(200); expect(res.body.success).toBe(true); - const row = testDb.prepare('SELECT * FROM reservation_day_positions WHERE reservation_id = ?').get(resv.id); - expect(row).toBeUndefined(); + const row = await findRow(orm, ReservationDayPositions, { reservation: resv.id }); + expect(row).toBeNull(); }); }); @@ -522,11 +536,9 @@ describe('Reservation budget entry integration', () => { }); expect(res.status).toBe(201); - const budgetItem = testDb - .prepare('SELECT * FROM budget_items WHERE trip_id = ? AND reservation_id = ?') - .get(trip.id, res.body.reservation.id) as any; - expect(budgetItem).toBeDefined(); - expect(budgetItem.total_price).toBe(250); + const budgetItem = await linkedExpense(trip.id, res.body.reservation.id); + expect(budgetItem).not.toBeNull(); + expect(budgetItem!.total_price).toBe(250); expect(budgetItem.name).toBe('Flight to Paris'); }); @@ -544,9 +556,7 @@ describe('Reservation budget entry integration', () => { }); expect(res.status).toBe(201); - const budgetItems = testDb - .prepare('SELECT * FROM budget_items WHERE trip_id = ?') - .all(trip.id) as any[]; + const budgetItems = await findRows(orm, BudgetItems, { trip: trip.id }); expect(budgetItems).toHaveLength(0); }); @@ -561,11 +571,9 @@ describe('Reservation budget entry integration', () => { .send({ create_budget_entry: { total_price: 300, category: 'Accommodation' } }); expect(res.status).toBe(200); - const budgetItem = testDb - .prepare('SELECT * FROM budget_items WHERE trip_id = ? AND reservation_id = ?') - .get(trip.id, resv.id) as any; - expect(budgetItem).toBeDefined(); - expect(budgetItem.total_price).toBe(300); + const budgetItem = await linkedExpense(trip.id, resv.id); + expect(budgetItem).not.toBeNull(); + expect(budgetItem!.total_price).toBe(300); }); it('RESV-013 — PUT with create_budget_entry updates existing linked budget item', async () => { @@ -591,9 +599,7 @@ describe('Reservation budget entry integration', () => { .send({ create_budget_entry: { total_price: 150, category: 'Transport' } }); expect(updateRes.status).toBe(200); - const items = testDb - .prepare('SELECT * FROM budget_items WHERE trip_id = ? AND reservation_id = ?') - .all(trip.id, resvId) as any[]; + const items = await findRows(orm, BudgetItems, { trip: trip.id, reservation: resvId }); expect(items).toHaveLength(1); expect(items[0].total_price).toBe(150); }); @@ -614,10 +620,8 @@ describe('Reservation budget entry integration', () => { expect(createRes.status).toBe(201); const resvId = createRes.body.reservation.id; - const before = testDb - .prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?') - .get(trip.id, resvId); - expect(before).toBeDefined(); + const before = await linkedExpense(trip.id, resvId); + expect(before).not.toBeNull(); // Update WITHOUT create_budget_entry — the booking edit must NOT touch its // linked expense (expenses are managed from the Costs section now). @@ -627,10 +631,8 @@ describe('Reservation budget entry integration', () => { .send({ title: 'Taxi Updated' }); expect(updateRes.status).toBe(200); - const after = testDb - .prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?') - .get(trip.id, resvId); - expect(after).toBeDefined(); + const after = await linkedExpense(trip.id, resvId); + expect(after).not.toBeNull(); }); it('RESV-014b — PUT with create_budget_entry total_price 0 removes the linked budget item', async () => { @@ -655,10 +657,8 @@ describe('Reservation budget entry integration', () => { .send({ title: 'Taxi', create_budget_entry: { total_price: 0 } }); expect(updateRes.status).toBe(200); - const after = testDb - .prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?') - .get(trip.id, resvId); - expect(after).toBeUndefined(); + const after = await linkedExpense(trip.id, resvId); + expect(after).toBeNull(); }); it('RESV-014c — changing the booking type updates the linked expense category', async () => { @@ -677,9 +677,7 @@ describe('Reservation budget entry integration', () => { .set('Cookie', authCookie(user.id)) .send({ title: 'Booking', type: 'hotel' }); - const item = testDb - .prepare('SELECT category FROM budget_items WHERE trip_id = ? AND reservation_id = ?') - .get(trip.id, resvId) as { category: string }; + const item = (await linkedExpense(trip.id, resvId))!; expect(item.category).toBe('accommodation'); }); @@ -694,16 +692,14 @@ describe('Reservation budget entry integration', () => { const resvId = createRes.body.reservation.id; // Simulate a manual category pick in the Costs editor. - testDb.prepare('UPDATE budget_items SET category = ? WHERE trip_id = ? AND reservation_id = ?').run('fees', trip.id, resvId); + await updateRows(orm, BudgetItems, { trip: trip.id, reservation: resvId }, { category: 'fees' }); await request(app) .put(`/api/trips/${trip.id}/reservations/${resvId}`) .set('Cookie', authCookie(user.id)) .send({ title: 'Booking', type: 'hotel' }); - const item = testDb - .prepare('SELECT category FROM budget_items WHERE trip_id = ? AND reservation_id = ?') - .get(trip.id, resvId) as { category: string }; + const item = (await linkedExpense(trip.id, resvId))!; expect(item.category).toBe('fees'); }); }); @@ -734,10 +730,8 @@ describe('Reservation accommodation delete', () => { const reservationId = createRes.body.reservation.id; // Verify accommodation was created - const accom = testDb.prepare( - 'SELECT id FROM day_accommodations WHERE trip_id = ?' - ).get(trip.id) as any; - expect(accom).toBeDefined(); + const accom = (await findRow(orm, DayAccommodations, { trip: trip.id }))!; + expect(accom).not.toBeNull(); // Delete reservation — should also remove the accommodation const delRes = await request(app) @@ -745,10 +739,8 @@ describe('Reservation accommodation delete', () => { .set('Cookie', authCookie(user.id)); expect(delRes.status).toBe(200); - const accomAfter = testDb.prepare( - 'SELECT id FROM day_accommodations WHERE id = ?' - ).get(accom.id); - expect(accomAfter).toBeUndefined(); + const accomAfter = await findRow(orm, DayAccommodations, { id: accom.id }); + expect(accomAfter).toBeNull(); }); it('RESV-009b — DELETE reservation linked to accommodation also removes its linked budget item (issue #933)', async () => { @@ -771,10 +763,8 @@ describe('Reservation accommodation delete', () => { expect(createRes.status).toBe(201); const reservationId = createRes.body.reservation.id; - const budgetBefore = testDb.prepare( - 'SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?' - ).get(trip.id, reservationId); - expect(budgetBefore).toBeDefined(); + const budgetBefore = await linkedExpense(trip.id, reservationId); + expect(budgetBefore).not.toBeNull(); // Delete via the reservation endpoint const delRes = await request(app) @@ -782,9 +772,7 @@ describe('Reservation accommodation delete', () => { .set('Cookie', authCookie(user.id)); expect(delRes.status).toBe(200); - const budgetAfter = testDb.prepare( - 'SELECT id FROM budget_items WHERE trip_id = ?' - ).get(trip.id); - expect(budgetAfter).toBeUndefined(); + const budgetAfter = await findRow(orm, BudgetItems, { trip: trip.id }); + expect(budgetAfter).toBeNull(); }); }); diff --git a/server/tests/integration/storage-restore-reload.test.ts b/server/tests/integration/storage-restore-reload.test.ts index cf299cf94..f28fab887 100644 --- a/server/tests/integration/storage-restore-reload.test.ts +++ b/server/tests/integration/storage-restore-reload.test.ts @@ -31,6 +31,9 @@ import { StorageEventsService } from '../../src/nest/storage/storage-events.serv import { BACKENDS_KEY, CATEGORIES_KEY, StorageRegistryService } from '../../src/nest/storage/storage-registry.service'; import { StorageService } from '../../src/nest/storage/storage.service'; import { createTestUnitOfWork, createTestAppSettingsRepo, sharedTestOrm } from '../helpers/test-uow'; +import { deleteRows } from '../helpers/factories/rows'; +import { setAppSetting } from '../helpers/factories/settings'; +import { AppSettings } from '../../src/db/entities/AppSettings.entity'; const testDb = createSnapshotTestDb(); @@ -45,15 +48,13 @@ function envStub(): { placePhotoDir: string | undefined } { return { placePhotoDir: undefined }; } -function setSetting(key: string, value: unknown): void { - testDb - .prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)') - .run(key, JSON.stringify(value)); +async function setSetting(key: string, value: unknown): Promise { + await setAppSetting(await sharedTestOrm(testDb), key, JSON.stringify(value)); } -afterEach(() => { +afterEach(async () => { while (tmpDirs.length) fs.rmSync(tmpDirs.pop()!, { recursive: true, force: true }); - testDb.prepare("DELETE FROM app_settings WHERE key LIKE 'storage.%'").run(); + await deleteRows(await sharedTestOrm(testDb), AppSettings, { key: { $like: 'storage.%' } }); }); describe('C6 — restore reloads the storage registry (audit #4)', () => { @@ -62,11 +63,11 @@ describe('C6 — restore reloads the storage registry (audit #4)', () => { const rootB = makeTmpDir(); // Pre-restore world: 'files' resolves to backend nas-a. - setSetting(BACKENDS_KEY, [ + await setSetting(BACKENDS_KEY, [ { name: 'nas-a', type: 'local', options: { root: rootA } }, { name: 'nas-b', type: 'local', options: { root: rootB } }, ]); - setSetting(CATEGORIES_KEY, { files: 'nas-a' }); + await setSetting(CATEGORIES_KEY, { files: 'nas-a' }); const registry = new StorageRegistryService( await createTestAppSettingsRepo(testDb), @@ -90,7 +91,7 @@ describe('C6 — restore reloads the storage registry (audit #4)', () => { // production: the restored archive's travel.db is now live, and IT names a // different backend for 'files' (an admin on the source install pointed // 'files' at a different backend before taking that backup). - setSetting(CATEGORIES_KEY, { files: 'nas-b' }); + await setSetting(CATEGORIES_KEY, { files: 'nas-b' }); // Audit #4, pre-fix: the registry is still holding the pre-restore driver // map at this point — resolve() is stale until something calls reload().