From be016f180e3c38ad53925f3d2fbeb321483bfe67 Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 8 Oct 2026 19:05:04 +0200 Subject: [PATCH] test(server): seed the session, lease and rollback specs through the factories The specs written alongside the session, transaction and lifecycle work still seeded and read with raw statements. They go through the ORM factories now; the few that test SQL below the ORM carry a test-sql-allow reason. The raw SQL baseline holds 177 statements in 6 files. --- server/scripts/test-sql-baseline.json | 222 +----------------- .../tests/e2e/session-revocation.e2e.test.ts | 25 +- server/tests/e2e/sessions.e2e.test.ts | 35 +-- server/tests/helpers/sessions.ts | 17 +- .../tests/unit/db/database-lazy-open.test.ts | 1 + .../db/repositories/DemoRepository.test.ts | 7 +- .../UserSessions.repository.test.ts | 68 +++--- .../unit/db/reset-admin-sessions.test.ts | 1 + .../tests/unit/mcp/tools-reservations.test.ts | 4 +- .../tests/unit/mcp/tools-transports.test.ts | 12 +- .../unit/nest/accommodations.service.test.ts | 4 +- server/tests/unit/nest/admin.service.test.ts | 12 +- .../nest/airports/airports.service.test.ts | 15 +- server/tests/unit/nest/auth.service.test.ts | 29 +-- .../tests/unit/nest/budget.service.db.test.ts | 25 +- .../unit/nest/cron-registrar.service.test.ts | 31 +-- server/tests/unit/nest/days.service.test.ts | 2 +- .../unit/nest/journey-domain.service.test.ts | 26 +- .../notification-preferences.service.test.ts | 2 +- server/tests/unit/nest/oidc.service.test.ts | 15 +- .../tests/unit/nest/packing.service.test.ts | 14 +- server/tests/unit/nest/places.service.test.ts | 2 +- .../nest/sessions/sessions.service.test.ts | 50 ++-- .../unit/nest/sqlite-database-backup.test.ts | 3 + server/tests/unit/nest/todo.service.test.ts | 2 +- .../unit/nest/trip-members.service.test.ts | 24 +- server/tests/unit/nest/trips.service.test.ts | 15 +- .../nest/unified-memories.service.test.ts | 8 +- .../plugins/output-contract.schema.test.ts | 15 +- .../unit/plugins/plugin-oauth.service.test.ts | 6 +- 30 files changed, 254 insertions(+), 438 deletions(-) diff --git a/server/scripts/test-sql-baseline.json b/server/scripts/test-sql-baseline.json index 4062459b3..0185f8335 100644 --- a/server/scripts/test-sql-baseline.json +++ b/server/scripts/test-sql-baseline.json @@ -3,239 +3,23 @@ "tests/helpers/legacy-fixture.ts": "Loads and fingerprints the legacy SQL fixtures, databases the retired positional runner built before any ORM schema existed.", "tests/helpers/legacy-upgrade-suite.ts": "Reads legacy databases before and after the legacy baseline adopts them; the bookkeeping tables it checks (schema_version, mikro_orm_migrations) are the subject.", "tests/integration/legacy-upgrade-v244.test.ts": "Compares a v4.3.3 database column by column before and after the upgrade, through pragma_table_info, on a copy no ORM is bound to.", + "tests/unit/db/booked-night-reseat.test.ts": "Tests migration step 242, which reseats booked nights over the raw connection; the rows it seeds are the ones the step transforms before any repository runs.", "tests/unit/db/dialect/sql-functions.test.ts": "Pins the SQL text and the engine result of every dialect function in src/db/dialect/sql-functions.ts; the raw statement is what is under test.", "tests/unit/db/entity-schema-parity.test.ts": "Compares PRAGMA table_info, foreign_key_list and index_list of the migrated schema against the entity metadata.", "tests/unit/db/foreign-key-indexes.test.ts": "Reads PRAGMA foreign_key_list, index_list and index_info and EXPLAIN QUERY PLAN to prove every foreign key is indexed.", "tests/unit/db/migrate-encryption-parity.test.ts": "Seeds and reads a bare database for scripts/migrate-encryption.ts, which runs on the raw handle without the ORM.", "tests/unit/db/nul-safe-sqlite-platform.test.ts": "Pins how the SQLite driver binds and returns the literals the platform has to escape (NUL, NaN, infinities).", + "tests/unit/db/place-regions-follow-place.test.ts": "Pins the place_regions trigger the migrations install, by raw UPDATEs on places and on databases built by the migrations alone.", "tests/unit/db/pre-migrate-snapshot.test.ts": "Inspects the VACUUM INTO copy written before the migrator runs, a file no ORM is bound to.", "tests/unit/db/restore-migrates-forward.test.ts": "Probes a restored database file before any ORM boots on it.", "tests/unit/db/test-orm.test.ts": "Proves the test ORM shares the raw handle it was given; the raw statements are the subject." }, "counts": { - "tests/e2e/addons.e2e.test.ts": 10, - "tests/e2e/admin.e2e.test.ts": 7, - "tests/e2e/assignments.e2e.test.ts": 54, - "tests/e2e/atlas.e2e.test.ts": 14, - "tests/e2e/auth.e2e.test.ts": 10, - "tests/e2e/booking-import.e2e.test.ts": 3, - "tests/e2e/budget.e2e.test.ts": 27, - "tests/e2e/categories.e2e.test.ts": 3, - "tests/e2e/collab.e2e.test.ts": 26, - "tests/e2e/days.e2e.test.ts": 42, - "tests/e2e/doc-sync.e2e.test.ts": 11, - "tests/e2e/feeds.e2e.test.ts": 2, - "tests/e2e/files.e2e.test.ts": 6, - "tests/e2e/mailer.e2e.test.ts": 1, - "tests/e2e/place-enrichment.e2e.test.ts": 3, - "tests/e2e/places.e2e.test.ts": 48, - "tests/e2e/plugin-pois.e2e.test.ts": 2, - "tests/e2e/plugin-search.e2e.test.ts": 1, - "tests/e2e/public-api.e2e.test.ts": 28, - "tests/e2e/push.e2e.test.ts": 7, - "tests/e2e/reservations.e2e.test.ts": 36, - "tests/e2e/school-holidays.e2e.test.ts": 4, - "tests/e2e/settings.e2e.test.ts": 11, - "tests/e2e/todo.e2e.test.ts": 8, - "tests/e2e/tours.e2e.test.ts": 34, - "tests/e2e/transit-provider.e2e.test.ts": 9, - "tests/e2e/trip-invite.e2e.test.ts": 6, - "tests/e2e/trips.e2e.test.ts": 23, "tests/helpers/db-mock.ts": 4, "tests/helpers/factories.ts": 62, "tests/helpers/test-db.ts": 5, "tests/helpers/tours-repos.ts": 1, - "tests/integration/airtrail-sync-request-context.test.ts": 2, - "tests/integration/assignments.test.ts": 5, - "tests/integration/async-closure-truthiness-guards.test.ts": 1, - "tests/integration/atlas-geocode-request-context.test.ts": 3, - "tests/integration/atlas.test.ts": 5, - "tests/integration/auth.test.ts": 75, - "tests/integration/budget.test.ts": 10, - "tests/integration/days.test.ts": 46, - "tests/integration/doc-sync-webhook-request-context.test.ts": 2, - "tests/integration/feeds-request-context.test.ts": 3, - "tests/integration/journey-place-order.test.ts": 6, - "tests/integration/journey.test.ts": 5, - "tests/integration/mcp.test.ts": 28, - "tests/integration/memories-unified.test.ts": 10, - "tests/integration/notifications.test.ts": 8, - "tests/integration/oauth-authorize.test.ts": 1, - "tests/integration/oauth.test.ts": 2, - "tests/integration/oidc.test.ts": 5, - "tests/integration/orm-request-context-seams.test.ts": 2, - "tests/integration/passkey.test.ts": 4, - "tests/integration/plugins/boot-registry-order.test.ts": 5, - "tests/integration/plugins/plugin-runtime.task2-conversion.test.ts": 23, "tests/integration/plugins/plugin-runtime.test.ts": 67, - "tests/integration/plugins/plugin-scheduler-sweep.test.ts": 9, - "tests/integration/plugins/registry.test.ts": 18, - "tests/integration/profile.test.ts": 3, - "tests/integration/reservations.test.ts": 22, - "tests/integration/security.test.ts": 5, - "tests/integration/storage-restore-reload.test.ts": 2, - "tests/integration/vacay.test.ts": 2, - "tests/unit/db/booked-night-reseat.test.ts": 11, - "tests/unit/db/connection.test.ts": 3, - "tests/unit/db/durability.test.ts": 3, - "tests/unit/db/place-regions-follow-place.test.ts": 15, - "tests/unit/db/repositories/_shared/atlas-travowns-consumers.parity.test.ts": 8, - "tests/unit/db/repositories/_shared/collection-role.parity.test.ts": 2, - "tests/unit/db/repositories/_shared/reservation-travelers-owns.parity.test.ts": 2, - "tests/unit/db/repositories/addons-config-encryption.test.ts": 1, - "tests/unit/db/repositories/Addons.repository.test.ts": 5, - "tests/unit/db/repositories/AppSettings.repository.test.ts": 6, - "tests/unit/db/repositories/BucketList.repository.test.ts": 12, - "tests/unit/db/repositories/Categories.repository.test.ts": 4, - "tests/unit/db/repositories/CollabMessages.repository.test.ts": 3, - "tests/unit/db/repositories/DawarichVisitSuggestions.repository.test.ts": 11, - "tests/unit/db/repositories/DayAssignments.repository.test.ts": 60, - "tests/unit/db/repositories/DayNotes.repository.test.ts": 10, - "tests/unit/db/repositories/DocumentProviders.repository.test.ts": 3, - "tests/unit/db/repositories/DocumentSyncItems.repository.test.ts": 14, - "tests/unit/db/repositories/GooglePlacePhotoMeta.repository.test.ts": 12, - "tests/unit/db/repositories/HiddenRegions.repository.test.ts": 4, - "tests/unit/db/repositories/IdempotencyKeys.repository.test.ts": 9, - "tests/unit/db/repositories/identity-map-writeback.test.ts": 3, - "tests/unit/db/repositories/InviteTokens.repository.test.ts": 5, - "tests/unit/db/repositories/MaintenanceRepository.test.ts": 4, - "tests/unit/db/repositories/NotificationChannelPreferences.repository.test.ts": 10, - "tests/unit/db/repositories/Notifications.repository.test.ts": 23, - "tests/unit/db/repositories/OauthClients.repository.test.ts": 7, - "tests/unit/db/repositories/OauthConsents.repository.test.ts": 9, - "tests/unit/db/repositories/OauthTokens.repository.test.ts": 31, - "tests/unit/db/repositories/PackingItems.repository.test.ts": 8, - "tests/unit/db/repositories/PhotoProviderFields.repository.test.ts": 2, - "tests/unit/db/repositories/PhotoProviders.repository.test.ts": 4, - "tests/unit/db/repositories/Places.repository.test.ts": 94, - "tests/unit/db/repositories/PlaceShadowPicks.repository.test.ts": 12, - "tests/unit/db/repositories/PluginOauthTokens.repository.test.ts": 1, - "tests/unit/db/repositories/ReservationDayPositions.repository.test.ts": 4, - "tests/unit/db/repositories/ReservationEndpoints.repository.test.ts": 5, - "tests/unit/db/repositories/Reservations.repository.test.ts": 76, - "tests/unit/db/repositories/ReservationTravelers.repository.test.ts": 7, - "tests/unit/db/repositories/RoadtripDayTracks.repository.test.ts": 1, - "tests/unit/db/repositories/Settings.repository.test.ts": 8, - "tests/unit/db/repositories/Tags.repository.test.ts": 13, - "tests/unit/db/repositories/trek-repository.test.ts": 4, - "tests/unit/db/repositories/TripInviteTokens.repository.test.ts": 7, - "tests/unit/db/repositories/Trips.repository.test.ts": 71, - "tests/unit/db/repositories/UserNoticeDismissals.repository.test.ts": 9, - "tests/unit/db/repositories/vacay-branch-coverage.test.ts": 10, - "tests/unit/db/repositories/VacayPlanMembers.repository.test.ts": 6, - "tests/unit/db/repositories/VacayPlans.repository.test.ts": 3, - "tests/unit/db/repositories/VacayShares.repository.test.ts": 11, - "tests/unit/db/repositories/VacayUserSettings.repository.test.ts": 4, - "tests/unit/db/repositories/VacayUserYears.repository.test.ts": 5, - "tests/unit/db/repositories/VacayYears.repository.test.ts": 5, - "tests/unit/db/repositories/VisitedRegions.repository.test.ts": 9, - "tests/unit/db/repositories/WebauthnCredentials.repository.test.ts": 6, - "tests/unit/db/transaction-yield.test.ts": 8, - "tests/unit/db/trip-access.test.ts": 8, - "tests/unit/demo/demo-seed.test.ts": 6, - "tests/unit/helpers/test-uow.test.ts": 1, - "tests/unit/idempotency-cleanup.test.ts": 6, - "tests/unit/mcp/tools-assignments-reservations-extra.test.ts": 2, - "tests/unit/mcp/tools-assignments.test.ts": 21, - "tests/unit/mcp/tools-atlas-expanded.test.ts": 21, - "tests/unit/mcp/tools-atlas.test.ts": 5, - "tests/unit/mcp/tools-budget-advanced.test.ts": 13, - "tests/unit/mcp/tools-budget.test.ts": 30, - "tests/unit/mcp/tools-categories.test.ts": 19, - "tests/unit/mcp/tools-collab-polls-chat.test.ts": 11, - "tests/unit/mcp/tools-days-accommodations.test.ts": 20, - "tests/unit/mcp/tools-leg-transport-mode.test.ts": 5, - "tests/unit/mcp/tools-notes.test.ts": 22, - "tests/unit/mcp/tools-notifications.test.ts": 7, - "tests/unit/mcp/tools-packing-advanced.test.ts": 17, - "tests/unit/mcp/tools-packing.test.ts": 24, - "tests/unit/mcp/tools-places.test.ts": 34, - "tests/unit/mcp/tools-plugin-pois.test.ts": 2, - "tests/unit/mcp/tools-prompts.test.ts": 6, - "tests/unit/mcp/tools-reservations.test.ts": 16, - "tests/unit/mcp/tools-roadtrip.test.ts": 8, - "tests/unit/mcp/tools-todos.test.ts": 7, - "tests/unit/mcp/tools-trip-members.test.ts": 32, - "tests/unit/mcp/tools-trips.test.ts": 49, - "tests/unit/mcp/tools-vacay.test.ts": 18, - "tests/unit/nest/accommodations.service.test.ts": 68, - "tests/unit/nest/addons.service.test.ts": 12, - "tests/unit/nest/admin.version-notification.test.ts": 5, - "tests/unit/nest/assignments.service.test.ts": 36, - "tests/unit/nest/audit.service.test.ts": 6, - "tests/unit/nest/backup-archive-version.test.ts": 1, - "tests/unit/nest/boot-restore.test.ts": 2, - "tests/unit/nest/budget.service.calc.test.ts": 11, - "tests/unit/nest/budget.service.db.test.ts": 108, - "tests/unit/nest/budget.service.test.ts": 3, - "tests/unit/nest/channel-override.test.ts": 1, - "tests/unit/nest/collections.service.test.ts": 62, - "tests/unit/nest/dawarich-suggestions.service.test.ts": 17, - "tests/unit/nest/dawarich.service.test.ts": 2, - "tests/unit/nest/day-boundaries.test.ts": 1, - "tests/unit/nest/day-notes.service.test.ts": 4, - "tests/unit/nest/day-removal.service.test.ts": 24, - "tests/unit/nest/days.service.test.ts": 66, - "tests/unit/nest/doc-sync/doc-sync-config.service.test.ts": 23, - "tests/unit/nest/doc-sync/doc-sync.controller.test.ts": 18, - "tests/unit/nest/doc-sync/doc-sync.job.test.ts": 4, - "tests/unit/nest/idempotency.interceptor.test.ts": 2, - "tests/unit/nest/immich.service.test.ts": 5, - "tests/unit/nest/journey-book.service.test.ts": 8, - "tests/unit/nest/journey-domain.service.test.ts": 173, - "tests/unit/nest/journey-share.service.test.ts": 34, - "tests/unit/nest/journey-stats.service.test.ts": 7, - "tests/unit/nest/mailer.service.test.ts": 1, - "tests/unit/nest/notification-preferences.service.test.ts": 12, - "tests/unit/nest/notification-transports.test.ts": 3, - "tests/unit/nest/notifications.service.test.ts": 45, - "tests/unit/nest/notifications/push-subscriptions.service.test.ts": 4, - "tests/unit/nest/notifications/vapid-keys.service.test.ts": 14, - "tests/unit/nest/notifications/web-push-dispatch.test.ts": 7, - "tests/unit/nest/oauth.service.test.ts": 6, - "tests/unit/nest/oidc.service.test.ts": 48, - "tests/unit/nest/packing.service.test.ts": 56, - "tests/unit/nest/photo-resolver.service.test.ts": 4, - "tests/unit/nest/place-regions-repair.test.ts": 7, - "tests/unit/nest/place-shadow.service.test.ts": 9, - "tests/unit/nest/places.directions.test.ts": 1, - "tests/unit/nest/public-api-scopes.test.ts": 6, - "tests/unit/nest/registration-invites.service.test.ts": 4, - "tests/unit/nest/reminder-jobs.service.test.ts": 15, - "tests/unit/nest/roadtrip-plan.service.test.ts": 30, - "tests/unit/nest/roadtrip.service.test.ts": 2, - "tests/unit/nest/route-usage.service.test.ts": 10, - "tests/unit/nest/school-holidays.service.test.ts": 11, - "tests/unit/nest/settings.service.test.ts": 26, - "tests/unit/nest/share.service.test.ts": 49, - "tests/unit/nest/storage/storage-admin.service.test.ts": 3, - "tests/unit/nest/storage/storage-jobs.service.test.ts": 3, - "tests/unit/nest/synology.service.test.ts": 4, - "tests/unit/nest/thumbnail.service.test.ts": 3, - "tests/unit/nest/todo.service.test.ts": 7, - "tests/unit/nest/token.service.test.ts": 12, - "tests/unit/nest/tours.gpx.atomic.test.ts": 8, - "tests/unit/nest/tours.service.test.ts": 12, - "tests/unit/nest/trek-photo-registration.service.test.ts": 9, - "tests/unit/nest/trip-invite.service.test.ts": 3, - "tests/unit/nest/trip-members.service.test.ts": 19, - "tests/unit/nest/trip-membership/trip-membership.service.test.ts": 8, - "tests/unit/nest/trips.service.test.ts": 208, - "tests/unit/nest/user-profile.service.test.ts": 16, - "tests/unit/nest/vacay.service.test.ts": 93, - "tests/unit/plugins/host-rpc.test.ts": 38, - "tests/unit/plugins/plugin-audit.test.ts": 11, - "tests/unit/plugins/plugin-host-state.test.ts": 1, - "tests/unit/plugins/plugin-instance-settings.test.ts": 17, - "tests/unit/plugins/plugin-oauth.service.test.ts": 13, - "tests/unit/plugins/plugin-repository-sql-parity.test.ts": 20, - "tests/unit/plugins/plugin-route-normalize.test.ts": 2, - "tests/unit/plugins/plugin-runtime.boot-no-orm.test.ts": 1, - "tests/unit/plugins/plugin-user-settings.test.ts": 4, - "tests/unit/plugins/plugins-service.test.ts": 29, - "tests/unit/plugins/request-context.test.ts": 1, - "tests/unit/plugins/rpc-host-inv-strip.test.ts": 1, - "tests/unit/services/airtrailImport.test.ts": 4, - "tests/unit/services/conflictUpdate.test.ts": 3 + "tests/unit/plugins/host-rpc.test.ts": 38 } } diff --git a/server/tests/e2e/session-revocation.e2e.test.ts b/server/tests/e2e/session-revocation.e2e.test.ts index fb4a0be48..2014abcc2 100644 --- a/server/tests/e2e/session-revocation.e2e.test.ts +++ b/server/tests/e2e/session-revocation.e2e.test.ts @@ -14,6 +14,7 @@ import request from 'supertest'; import type { Application } from 'express'; import type { INestApplication } from '@nestjs/common'; import jwt from 'jsonwebtoken'; +import { MikroORM } from '@mikro-orm/core'; vi.mock('../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); @@ -26,6 +27,8 @@ import { buildApp } from '../../src/bootstrap'; import { resetRateLimits } from '../helpers/test-db'; import { createAdmin, createUser } from '../helpers/factories'; import { sessionRows } from '../helpers/sessions'; +import { findRow, updateRows } from '../helpers/factories/rows'; +import { Users } from '../../src/db/entities/Users.entity'; let nestApp: INestApplication; let app: Application; @@ -35,8 +38,8 @@ beforeAll(async () => { app = nestApp.getHttpAdapter().getInstance(); }); -beforeEach(() => { - resetRateLimits(nestApp); +beforeEach(async () => { + await resetRateLimits(nestApp); }); afterAll(async () => { @@ -53,7 +56,7 @@ async function signIn(email: string, password: string, device: string): Promise< const sessionIdOf = (cookie: string) => (jwt.decode(cookie.slice('trek_session='.length)) as { jti: string }).jti; const me = (cookie: string) => request(app).get('/api/auth/me').set('Cookie', cookie); -const liveRows = (userId: number) => sessionRows(testDb as never, userId).filter((row) => row.revoked_at === null); +const liveRows = async (userId: number) => (await sessionRows(testDb as never, userId)).filter((row) => row.revoked_at === null); /** An admin and a member, each signed in; the member on two devices. */ async function signedInPair(name: string) { @@ -80,14 +83,14 @@ describe('Admin actions that end another account\'s sessions', () => { expect((await me(laptop)).status).toBe(401); expect((await me(phone)).status).toBe(401); - expect(sessionRows(testDb as never, member.id).map((row) => row.id).sort()).toEqual([sessionIdOf(laptop), sessionIdOf(phone)].sort()); - expect(liveRows(member.id)).toEqual([]); + expect((await sessionRows(testDb as never, member.id)).map((row) => row.id).sort()).toEqual([sessionIdOf(laptop), sessionIdOf(phone)].sort()); + expect(await liveRows(member.id)).toEqual([]); expect((await me(adminCookie)).status).toBe(200); }, 30000); it('SESS-E2E-ADMIN-002: clearing a member\'s two-factor ends every session of that member', async () => { const { adminCookie, member, laptop, phone } = await signedInPair('sess-admin-mfa'); - testDb.prepare("UPDATE users SET mfa_enabled = 1, mfa_secret = 'x' WHERE id = ?").run(member.id); + await updateRows(nestApp.get(MikroORM), Users, { id: member.id }, { mfa_enabled: 1, mfa_secret: 'x' }); const res = await request(app).delete(`/api/admin/users/${member.id}/mfa`).set('Cookie', adminCookie); expect(res.status).toBe(200); @@ -95,20 +98,20 @@ describe('Admin actions that end another account\'s sessions', () => { expect((await me(laptop)).status).toBe(401); expect((await me(phone)).status).toBe(401); - expect(liveRows(member.id)).toEqual([]); - expect(testDb.prepare('SELECT mfa_enabled FROM users WHERE id = ?').get(member.id)).toEqual({ mfa_enabled: 0 }); + expect(await liveRows(member.id)).toEqual([]); + expect((await findRow(nestApp.get(MikroORM), Users, { id: member.id }))?.mfa_enabled).toBe(0); expect((await me(adminCookie)).status).toBe(200); }, 30000); it('SESS-E2E-ADMIN-003: deleting a member takes the member\'s session rows with the account', async () => { const { adminCookie, member, laptop, phone } = await signedInPair('sess-admin-delete'); - expect(sessionRows(testDb as never, member.id)).toHaveLength(2); + expect(await sessionRows(testDb as never, member.id)).toHaveLength(2); const res = await request(app).delete(`/api/admin/users/${member.id}`).set('Cookie', adminCookie); expect(res.status).toBe(200); expect(res.body).toEqual({ success: true }); - expect(sessionRows(testDb as never, member.id)).toEqual([]); + expect(await sessionRows(testDb as never, member.id)).toEqual([]); expect((await me(laptop)).status).toBe(401); expect((await me(phone)).status).toBe(401); expect((await me(adminCookie)).status).toBe(200); @@ -125,6 +128,6 @@ describe('Admin actions that end another account\'s sessions', () => { expect((await me(laptop)).status).toBe(200); expect((await me(phone)).status).toBe(200); - expect(liveRows(member.id)).toHaveLength(2); + expect(await liveRows(member.id)).toHaveLength(2); }, 30000); }); diff --git a/server/tests/e2e/sessions.e2e.test.ts b/server/tests/e2e/sessions.e2e.test.ts index ad798f6c7..ee4261d27 100644 --- a/server/tests/e2e/sessions.e2e.test.ts +++ b/server/tests/e2e/sessions.e2e.test.ts @@ -47,6 +47,9 @@ import { SessionPurgeJob } from '../../src/nest/sessions/session-purge.job'; import { withRequestContext } from '../../src/nest/database/request-context'; import { encryptMfaSecret } from '../../src/nest/common/crypto/mfaCrypto'; import { sessionRows } from '../helpers/sessions'; +import { insertRow, updateRows } from '../helpers/factories/rows'; +import { Users } from '../../src/db/entities/Users.entity'; +import { UserSessions } from '../../src/db/entities/UserSessions.entity'; import { SessionRenewalInterceptor } from '../../src/nest/auth/session-renewal.interceptor'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; @@ -226,7 +229,7 @@ describe('Sessions e2e (sign-in sessions that can be ended)', () => { expect(change.body).toEqual({ success: true }); expect(((change.headers['set-cookie'] ?? []) as unknown as string[]).some((c) => c.startsWith('trek_session='))).toBe(false); - const rows = sessionRows(db as never, user.id); + const rows = await sessionRows(db as never, user.id); expect(rows).toEqual([expect.objectContaining({ id: sessionIdOf(cookie), revoked_at: expect.any(String) })]); }, 15000); @@ -292,7 +295,7 @@ describe('Sessions e2e (sign-in sessions that can be ended)', () => { }, 15000); /** The rows of the user's sessions that still let a token in. */ - const liveRows = (userId: number) => sessionRows(db as never, userId).filter((row) => row.revoked_at === null); + const liveRows = async (userId: number) => (await sessionRows(db as never, userId)).filter((row) => row.revoked_at === null); it('a password reset by email ends every session of the account', async () => { const { user, password } = freshUser('sess-reset'); @@ -312,8 +315,8 @@ describe('Sessions e2e (sign-in sessions that can be ended)', () => { expect((await me(laptop)).status).toBe(401); expect((await me(phone)).status).toBe(401); - expect(sessionRows(db as never, user.id).map((row) => row.id).sort()).toEqual([sessionIdOf(laptop), sessionIdOf(phone)].sort()); - expect(liveRows(user.id)).toEqual([]); + expect((await sessionRows(db as never, user.id)).map((row) => row.id).sort()).toEqual([sessionIdOf(laptop), sessionIdOf(phone)].sort()); + expect(await liveRows(user.id)).toEqual([]); // The new password signs in again, as a new session. const after = await signIn(user.email, 'Reset1234!x', 'Laptop'); expect((await me(after)).status).toBe(200); @@ -325,7 +328,7 @@ describe('Sessions e2e (sign-in sessions that can be ended)', () => { const there = await signIn(user.email, password, 'There'); // Two-factor turned on after both sign-ins, as the factory does it. const secret = 'JBSWY3DPEHPK3PXP'; - db.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ? WHERE id = ?').run(encryptMfaSecret(secret), user.id); + await updateRows(moduleRef.get(MikroORM), Users, { id: user.id }, { mfa_enabled: 1, mfa_secret: encryptMfaSecret(secret) }); const off = await request(server) .post('/api/auth/mfa/disable') @@ -336,19 +339,19 @@ describe('Sessions e2e (sign-in sessions that can be ended)', () => { expect((await me(there)).status).toBe(401); expect((await me(here)).status).toBe(200); - expect(liveRows(user.id).map((row) => row.id)).toEqual([sessionIdOf(here)]); + expect((await liveRows(user.id)).map((row) => row.id)).toEqual([sessionIdOf(here)]); }, 15000); it('deleting the account takes its sessions with it', async () => { const { user, password } = freshUser('sess-delete'); const here = await signIn(user.email, password, 'Here'); const there = await signIn(user.email, password, 'There'); - expect(sessionRows(db as never, user.id)).toHaveLength(2); + expect(await sessionRows(db as never, user.id)).toHaveLength(2); const res = await request(server).delete('/api/auth/me').set('Cookie', here); expect(res.status).toBe(200); - expect(sessionRows(db as never, user.id)).toEqual([]); + expect(await sessionRows(db as never, user.id)).toEqual([]); expect((await me(here)).status).toBe(401); expect((await me(there)).status).toBe(401); }, 15000); @@ -358,15 +361,19 @@ describe('Sessions e2e (sign-in sessions that can be ended)', () => { const live = await signIn(user.email, password, 'Live'); const ended = await signIn(user.email, password, 'Ended'); expect((await request(server).delete(`/api/auth/sessions/${sessionIdOf(ended)}`).set('Cookie', live)).status).toBe(200); - db.prepare( - "INSERT INTO user_sessions (id, user_id, created_at, last_seen_at, expires_at) VALUES ('0b7c6f3e-2a51-4c8e-9d43-5f1e2b7a9c10', ?, '2020-01-01 00:00:00', '2020-01-01 00:00:00', '2020-01-02 00:00:00')", - ).run(user.id); - expect(sessionRows(db as never, user.id)).toHaveLength(3); + await insertRow(moduleRef.get(MikroORM), UserSessions, { + id: '0b7c6f3e-2a51-4c8e-9d43-5f1e2b7a9c10', + user: user.id, + created_at: '2020-01-01 00:00:00', + last_seen_at: '2020-01-01 00:00:00', + expires_at: '2020-01-02 00:00:00', + }); + expect(await sessionRows(db as never, user.id)).toHaveLength(3); const job = moduleRef.get(SessionPurgeJob); await withRequestContext(moduleRef.get(MikroORM), () => job.tick()); - expect(sessionRows(db as never, user.id).map((row) => row.id).sort()).toEqual([sessionIdOf(live), sessionIdOf(ended)].sort()); + expect((await sessionRows(db as never, user.id)).map((row) => row.id).sort()).toEqual([sessionIdOf(live), sessionIdOf(ended)].sort()); expect((await me(live)).status).toBe(200); expect((await me(ended)).status).toBe(401); }, 15000); @@ -388,7 +395,7 @@ describe('Sessions e2e (sign-in sessions that can be ended)', () => { const again = await request(server).get('/api/auth/me').set('Cookie', old).set('User-Agent', 'Old'); expect(again.status).toBe(200); expect(((again.headers['set-cookie'] ?? []) as unknown as string[]).some((c) => c.startsWith('trek_session='))).toBe(false); - expect(sessionRows(db as never, user.id)).toEqual([expect.objectContaining({ id: sessionIdOf(renewed), revoked_at: expect.any(String) })]); + expect(await sessionRows(db as never, user.id)).toEqual([expect.objectContaining({ id: sessionIdOf(renewed), revoked_at: expect.any(String) })]); const list = await request(server).get('/api/auth/sessions').set('Cookie', old); expect(list.body.sessions).toEqual([]); }, 15000); diff --git a/server/tests/helpers/sessions.ts b/server/tests/helpers/sessions.ts index a5cd9aff1..82b9d0498 100644 --- a/server/tests/helpers/sessions.ts +++ b/server/tests/helpers/sessions.ts @@ -1,5 +1,6 @@ import type Database from 'better-sqlite3'; import { sharedTestOrm } from './test-uow'; +import { findRows } from './factories/rows'; import { UserSessions } from '../../src/db/entities/UserSessions.entity'; import type { UserSessionsRepository } from '../../src/db/repositories/UserSessions.repository'; import { SessionsService } from '../../src/nest/sessions/sessions.service'; @@ -19,12 +20,16 @@ export async function createTestSessionsService(db: Database.Database): Promise< return new SessionsService(await createTestUserSessionsRepo(db)); } -/** The session rows of a user, oldest first, straight from the table. */ -export function sessionRows( +/** The session rows of a user, oldest first, read through the ORM on the suite's own handle. */ +export async function sessionRows( db: Database.Database, userId: number, -): { id: string; revoked_at: string | null; expires_at: string; user_agent: string | null }[] { - return db - .prepare('SELECT id, revoked_at, expires_at, user_agent FROM user_sessions WHERE user_id = ? ORDER BY created_at, id') - .all(userId) as { id: string; revoked_at: string | null; expires_at: string; user_agent: string | null }[]; +): Promise<{ id: string; revoked_at: string | null; expires_at: string; user_agent: string | null }[]> { + const rows = await findRows(await sharedTestOrm(db), UserSessions, { user: userId }, { created_at: 'asc', id: 'asc' }); + return rows.map(({ id, revoked_at, expires_at, user_agent }) => ({ + id, + revoked_at: revoked_at ?? null, + expires_at, + user_agent: user_agent ?? null, + })); } diff --git a/server/tests/unit/db/database-lazy-open.test.ts b/server/tests/unit/db/database-lazy-open.test.ts index 7c9065350..24cdc88dc 100644 --- a/server/tests/unit/db/database-lazy-open.test.ts +++ b/server/tests/unit/db/database-lazy-open.test.ts @@ -50,6 +50,7 @@ describe('db/database.ts connection lifecycle', () => { log.mockRestore(); } expect(() => getRawConnection()).toThrow('Database connection is not available (restore in progress?)'); + // test-sql-allow: tests that the raw db proxy itself refuses a statement while the connection is closed; no ORM is involved. expect(() => db.prepare('SELECT 1')).toThrow('Database connection is not available (restore in progress?)'); expect(opened.count).toBe(1); }); diff --git a/server/tests/unit/db/repositories/DemoRepository.test.ts b/server/tests/unit/db/repositories/DemoRepository.test.ts index 6b7a4b455..4e4c22f1c 100644 --- a/server/tests/unit/db/repositories/DemoRepository.test.ts +++ b/server/tests/unit/db/repositories/DemoRepository.test.ts @@ -16,6 +16,7 @@ import { PackingItems } from '../../../../src/db/entities/PackingItems.entity'; import { Places } from '../../../../src/db/entities/Places.entity'; import { Reservations } from '../../../../src/db/entities/Reservations.entity'; import { TripMembers } from '../../../../src/db/entities/TripMembers.entity'; +import { Trips } from '../../../../src/db/entities/Trips.entity'; import { Users } from '../../../../src/db/entities/Users.entity'; /** The given columns of a row, the shape a narrow SELECT returned. */ @@ -114,7 +115,7 @@ describe('DemoRepository', () => { const { user } = createAdmin(testDb); const tripId = await demo.insertTrip(user.id, 'Ids', 'd', '2026-05-01', '2026-05-02', 'EUR'); expect(typeof tripId).toBe('number'); - const latest = testDb.prepare('SELECT MAX(id) AS id FROM trips').get() as { id: number }; + const [latest] = await findRows(t, Trips, {}, { id: 'desc' }); expect(tripId).toBe(latest.id); const dayId = await demo.insertDay(tripId, 1, '2026-05-01'); @@ -122,8 +123,8 @@ describe('DemoRepository', () => { expect(secondDayId).toBeGreaterThan(dayId); await demo.insertPackingItem(tripId, 'Charger', 0, 'Tech', 1); - const packing = testDb.prepare('SELECT updated_at FROM packing_items WHERE trip_id = ?').get(tripId) as { updated_at: string | null }; - expect(packing.updated_at).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/); + const packing = await findRow(t, PackingItems, { trip: tripId }); + expect(packing?.updated_at).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/); }); it('DEMOREPO-CRED-001 (DMR1/DMR5 round trip): getAdminCredentials reads exactly what restoreAdminCredentials wrote', async () => { diff --git a/server/tests/unit/db/repositories/UserSessions.repository.test.ts b/server/tests/unit/db/repositories/UserSessions.repository.test.ts index cd45bc30d..f81efa3c8 100644 --- a/server/tests/unit/db/repositories/UserSessions.repository.test.ts +++ b/server/tests/unit/db/repositories/UserSessions.repository.test.ts @@ -3,7 +3,9 @@ import { createSnapshotTestDb } from '../../../helpers/db-mock'; import { resetTestDb } from '../../../helpers/test-db'; import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; import { createUser } from '../../../helpers/factories'; +import { deleteRows, findRow, updateRows } from '../../../helpers/factories/rows'; import { UserSessions } from '../../../../src/db/entities/UserSessions.entity'; +import { Users } from '../../../../src/db/entities/Users.entity'; import type { UserSessionsRepository } from '../../../../src/db/repositories/UserSessions.repository'; const testDb = createSnapshotTestDb(); @@ -22,7 +24,11 @@ beforeEach(() => { resetTestDb(testDb); t.clear(); }); afterAll(async () => { await t.close(); testDb.close(); }); function row(id: string) { - return testDb.prepare('SELECT * FROM user_sessions WHERE id = ?').get(id) as Record | undefined; + return findRow(t, UserSessions, { id }); +} + +function revoke(ids: string[], at: string) { + return updateRows(t, UserSessions, { id: { $in: ids } }, { revoked_at: at }); } async function add(id: string, userId: number, overrides: { created_at?: string; expires_at?: string; user_agent?: string | null } = {}) { @@ -39,7 +45,7 @@ describe('UserSessionsRepository', () => { it('SESSREPO-001: insert writes the row, last_seen_at starting at created_at', async () => { const { user } = createUser(testDb); await add('s1', user.id, { user_agent: 'Firefox' }); - expect(row('s1')).toStrictEqual({ + expect(await row('s1')).toStrictEqual({ id: 's1', user_id: user.id, created_at: EARLIER, @@ -56,7 +62,7 @@ describe('UserSessionsRepository', () => { await add('live', user.id); await add('expired', user.id, { expires_at: NOW }); await add('revoked', user.id); - testDb.prepare("UPDATE user_sessions SET revoked_at = ? WHERE id = 'revoked'").run(EARLIER); + await revoke(['revoked'], EARLIER); expect(await sessions.findActive('live', user.id, NOW)).toEqual({ id: 'live', last_seen_at: EARLIER }); expect(await sessions.findActive('live', other.id, NOW)).toBeNull(); @@ -69,19 +75,19 @@ describe('UserSessionsRepository', () => { const { user } = createUser(testDb); await add('s1', user.id); await sessions.touchLastSeen('s1', NOW); - expect(row('s1')).toEqual(expect.objectContaining({ last_seen_at: NOW, created_at: EARLIER, expires_at: LATER })); + expect(await row('s1')).toEqual(expect.objectContaining({ last_seen_at: NOW, created_at: EARLIER, expires_at: LATER })); }); it('SESSREPO-004: extendActive moves the expiry of an active session and refuses an ended one', async () => { const { user } = createUser(testDb); await add('live', user.id); await add('revoked', user.id); - testDb.prepare("UPDATE user_sessions SET revoked_at = ? WHERE id = 'revoked'").run(EARLIER); + await revoke(['revoked'], EARLIER); expect(await sessions.extendActive('live', user.id, NOW, '2026-12-01 00:00:00')).toBe(true); - expect(row('live')).toEqual(expect.objectContaining({ expires_at: '2026-12-01 00:00:00', last_seen_at: NOW })); + expect(await row('live')).toEqual(expect.objectContaining({ expires_at: '2026-12-01 00:00:00', last_seen_at: NOW })); expect(await sessions.extendActive('revoked', user.id, NOW, '2026-12-01 00:00:00')).toBe(false); - expect(row('revoked')).toEqual(expect.objectContaining({ expires_at: LATER })); + expect(await row('revoked')).toEqual(expect.objectContaining({ expires_at: LATER })); }); it('SESSREPO-005: listActiveForUser lists the active sessions, most recently seen first', async () => { @@ -92,7 +98,7 @@ describe('UserSessionsRepository', () => { await add('ended', user.id); await add('theirs', other.id); await sessions.touchLastSeen('newer', NOW); - testDb.prepare("UPDATE user_sessions SET revoked_at = ? WHERE id = 'ended'").run(EARLIER); + await revoke(['ended'], EARLIER); expect(await sessions.listActiveForUser(user.id, NOW)).toEqual([ { id: 'newer', created_at: EARLIER, last_seen_at: NOW, expires_at: LATER, user_agent: 'B' }, @@ -106,9 +112,9 @@ describe('UserSessionsRepository', () => { await add('s1', user.id); expect(await sessions.revokeForUser('s1', other.id, NOW)).toBe(false); - expect(row('s1')).toEqual(expect.objectContaining({ revoked_at: null })); + expect(await row('s1')).toEqual(expect.objectContaining({ revoked_at: null })); expect(await sessions.revokeForUser('s1', user.id, NOW)).toBe(true); - expect(row('s1')).toEqual(expect.objectContaining({ revoked_at: NOW })); + expect(await row('s1')).toEqual(expect.objectContaining({ revoked_at: NOW })); expect(await sessions.revokeForUser('s1', user.id, NOW)).toBe(false); }); @@ -121,10 +127,10 @@ describe('UserSessionsRepository', () => { await add('theirs', other.id); expect(await sessions.revokeAllForUser(user.id, NOW, 'b')).toBe(2); - expect(row('b')).toEqual(expect.objectContaining({ revoked_at: null })); + expect(await row('b')).toEqual(expect.objectContaining({ revoked_at: null })); expect(await sessions.revokeAllForUser(user.id, NOW)).toBe(1); - expect(row('a')).toEqual(expect.objectContaining({ revoked_at: NOW })); - expect(row('theirs')).toEqual(expect.objectContaining({ revoked_at: null })); + expect(await row('a')).toEqual(expect.objectContaining({ revoked_at: NOW })); + expect(await row('theirs')).toEqual(expect.objectContaining({ revoked_at: null })); }); it('SESSREPO-008: deleteInactive removes the expired rows, revoked or not, and keeps the rest', async () => { @@ -133,21 +139,21 @@ describe('UserSessionsRepository', () => { await add('expired', user.id, { expires_at: NOW }); await add('revoked', user.id); await add('revoked-expired', user.id, { expires_at: EARLIER }); - testDb.prepare("UPDATE user_sessions SET revoked_at = ? WHERE id IN ('revoked', 'revoked-expired')").run(EARLIER); + await revoke(['revoked', 'revoked-expired'], EARLIER); expect(await sessions.deleteInactive(NOW)).toBe(2); - expect(row('live')).toBeDefined(); - expect(row('expired')).toBeUndefined(); - expect(row('revoked-expired')).toBeUndefined(); + expect(await row('live')).not.toBeNull(); + expect(await row('expired')).toBeNull(); + expect(await row('revoked-expired')).toBeNull(); // Revoked but not yet expired: kept, it still refuses the token it was derived from. - expect(row('revoked')).toEqual(expect.objectContaining({ revoked_at: EARLIER })); + expect(await row('revoked')).toEqual(expect.objectContaining({ revoked_at: EARLIER })); }); it('SESSREPO-009: the rows go with their user', async () => { const { user } = createUser(testDb); await add('s1', user.id); - testDb.prepare('DELETE FROM users WHERE id = ?').run(user.id); - expect(row('s1')).toBeUndefined(); + await deleteRows(t, Users, { id: user.id }); + expect(await row('s1')).toBeNull(); }); it('SESSREPO-010: listActiveToCarry reads every active session with the email of its owner', async () => { @@ -157,7 +163,7 @@ describe('UserSessionsRepository', () => { await add('b1', other.id); await add('expired', user.id, { expires_at: NOW }); await add('revoked', other.id); - testDb.prepare("UPDATE user_sessions SET revoked_at = ? WHERE id = 'revoked'").run(EARLIER); + await revoke(['revoked'], EARLIER); expect(await sessions.listActiveToCarry(NOW)).toEqual([ { id: 'a1', user_id: user.id, email: 'carry-a@example.test', created_at: EARLIER, last_seen_at: EARLIER, expires_at: LATER, user_agent: 'A' }, @@ -175,13 +181,13 @@ describe('UserSessionsRepository', () => { const carried = await sessions.listActiveToCarry(NOW); // What a swap does: the rows are gone (or ended), and one id now names somebody else. - testDb.prepare("DELETE FROM user_sessions WHERE id IN ('kept', 'elsewhere')").run(); - testDb.prepare("UPDATE user_sessions SET revoked_at = ? WHERE id = 'present'").run(EARLIER); - testDb.prepare("UPDATE users SET email = 'someone-else@example.test' WHERE id = ?").run(renamed.id); + await deleteRows(t, UserSessions, { id: { $in: ['kept', 'elsewhere'] } }); + await revoke(['present'], EARLIER); + await updateRows(t, Users, { id: renamed.id }, { email: 'someone-else@example.test' }); t.clear(); expect(await sessions.restoreCarried(carried)).toBe(2); - expect(row('kept')).toStrictEqual({ + expect(await row('kept')).toStrictEqual({ id: 'kept', user_id: user.id, created_at: EARLIER, @@ -191,31 +197,31 @@ describe('UserSessionsRepository', () => { user_agent: 'Kept', }); // The swapped-in file's own row wins, ended or not. - expect(row('present')).toEqual(expect.objectContaining({ revoked_at: EARLIER })); - expect(row('elsewhere')).toBeUndefined(); + expect(await row('present')).toEqual(expect.objectContaining({ revoked_at: EARLIER })); + expect(await row('elsewhere')).toBeNull(); }); it('SESSREPO-012: restoreCarried drops a session whose user is gone, and does nothing for none', async () => { const { user } = createUser(testDb, { email: 'gone@example.test' }); await add('orphan', user.id); const carried = await sessions.listActiveToCarry(NOW); - testDb.prepare('DELETE FROM users WHERE id = ?').run(user.id); + await deleteRows(t, Users, { id: user.id }); t.clear(); expect(await sessions.restoreCarried(carried)).toBe(0); - expect(row('orphan')).toBeUndefined(); + expect(await row('orphan')).toBeNull(); expect(await sessions.restoreCarried([])).toBe(0); }); it('SESSREPO-013: insertSessionIfAbsent writes a new row and leaves an existing one alone', async () => { const { user } = createUser(testDb); await sessions.insertSessionIfAbsent({ id: 'derived', user_id: user.id, created_at: EARLIER, expires_at: LATER, user_agent: 'First' }); - testDb.prepare("UPDATE user_sessions SET revoked_at = ? WHERE id = 'derived'").run(NOW); + await revoke(['derived'], NOW); t.clear(); await sessions.insertSessionIfAbsent({ id: 'derived', user_id: user.id, created_at: NOW, expires_at: '2026-12-01 00:00:00', user_agent: 'Second' }); - expect(row('derived')).toStrictEqual({ + expect(await row('derived')).toStrictEqual({ id: 'derived', user_id: user.id, created_at: EARLIER, diff --git a/server/tests/unit/db/reset-admin-sessions.test.ts b/server/tests/unit/db/reset-admin-sessions.test.ts index 068a40dd8..2b3638ac0 100644 --- a/server/tests/unit/db/reset-admin-sessions.test.ts +++ b/server/tests/unit/db/reset-admin-sessions.test.ts @@ -80,6 +80,7 @@ function resetAdmin(email: string): void { function read(sql: string): T[] { const db = new Database(dbPath); try { + // test-sql-allow: reads the throwaway file reset-admin.js ran on, a hand-built two-table database no ORM is bound to. return db.prepare(sql).all() as T[]; } finally { db.close(); diff --git a/server/tests/unit/mcp/tools-reservations.test.ts b/server/tests/unit/mcp/tools-reservations.test.ts index a45f81132..134491de5 100644 --- a/server/tests/unit/mcp/tools-reservations.test.ts +++ b/server/tests/unit/mcp/tools-reservations.test.ts @@ -241,13 +241,13 @@ describe('Tool: update_reservation', () => { const reservation = createReservation(testDb, trip.id, { title: 'Dinner', type: 'restaurant' }); const auto = createBudgetItem(testDb, trip.id, { name: 'Dinner', category: 'food' }); const picked = createBudgetItem(testDb, trip.id, { name: 'Taxi', category: 'transport' }); - testDb.prepare('UPDATE budget_items SET reservation_id = ? WHERE id IN (?, ?)').run(reservation.id, auto.id, picked.id); + await updateRows(orm, BudgetItems, { id: { $in: [auto.id, picked.id] } }, { reservation: reservation.id }); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'update_reservation', arguments: { tripId: trip.id, reservationId: reservation.id, type: 'activity' } }); expect((parseToolResult(result) as { reservation: { type: string } }).reservation.type).toBe('activity'); }); // restaurant -> activity moves the auto-derived category; the hand-picked one stays. - expect(testDb.prepare('SELECT id, category FROM budget_items WHERE trip_id = ? ORDER BY id').all(trip.id)) + expect((await findRows(orm, BudgetItems, { trip: trip.id }, { id: 'asc' })).map(r => ({ id: r.id, category: r.category }))) .toEqual([{ id: auto.id, category: 'activities' }, { id: picked.id, category: 'transport' }]); const updated = broadcastMock.mock.calls.filter(c => c[1] === 'budget:updated').map(c => (c[2] as { item: { id: number } }).item.id); expect(updated).toEqual([auto.id]); diff --git a/server/tests/unit/mcp/tools-transports.test.ts b/server/tests/unit/mcp/tools-transports.test.ts index 47ef8f495..fd6a4acd0 100644 --- a/server/tests/unit/mcp/tools-transports.test.ts +++ b/server/tests/unit/mcp/tools-transports.test.ts @@ -223,10 +223,8 @@ describe('Tool: update_transport', () => { name: 'create_transport', arguments: { tripId: trip.id, type: 'flight', title: 'F', endpoints: flightEndpoints }, })) as { reservation: { id: number } }; - const auto = Number(testDb.prepare("INSERT INTO budget_items (trip_id, name, category, total_price, reservation_id) VALUES (?, 'Fare', 'flights', 100, ?)") - .run(trip.id, created.reservation.id).lastInsertRowid); - const picked = Number(testDb.prepare("INSERT INTO budget_items (trip_id, name, category, total_price, reservation_id) VALUES (?, 'Lounge', 'food', 20, ?)") - .run(trip.id, created.reservation.id).lastInsertRowid); + const auto = (await makeBudgetItem(orm, trip.id, { name: 'Fare', category: 'flights', total_price: 100, reservation: created.reservation.id })).id; + const picked = (await makeBudgetItem(orm, trip.id, { name: 'Lounge', category: 'food', total_price: 20, reservation: created.reservation.id })).id; broadcastMock.mockClear(); const result = await h.client.callTool({ @@ -236,9 +234,9 @@ describe('Tool: update_transport', () => { expect((parseToolResult(result) as { reservation: { type: string } }).reservation.type).toBe('train'); // flight -> train moves the auto-derived category; the hand-picked one stays. - const category = (id: number) => (testDb.prepare('SELECT category FROM budget_items WHERE id = ?').get(id) as { category: string }).category; - expect(category(auto)).toBe('transport'); - expect(category(picked)).toBe('food'); + const category = async (id: number) => (await findRow(orm, BudgetItems, { id }))!.category; + expect(await category(auto)).toBe('transport'); + expect(await category(picked)).toBe('food'); const updated = broadcastMock.mock.calls.filter(c => c[1] === 'budget:updated').map(c => (c[2] as { item: { id: number } }).item.id); expect(updated).toEqual([auto]); // The expense goes out after the commit, before the booking. diff --git a/server/tests/unit/nest/accommodations.service.test.ts b/server/tests/unit/nest/accommodations.service.test.ts index 70e24d621..29dbfbf44 100644 --- a/server/tests/unit/nest/accommodations.service.test.ts +++ b/server/tests/unit/nest/accommodations.service.test.ts @@ -216,14 +216,14 @@ describe('updateAccommodation', () => { const day = createDay(testDb, trip.id); const place = createPlace(testDb, trip.id, { name: 'Hotel' }); await svc.createAccommodation(trip.id, { place_id: place.id, start_day_id: day.id, end_day_id: day.id, check_in: '14:00' }); - const { id } = testDb.prepare('SELECT id FROM day_accommodations WHERE trip_id = ?').get(trip.id) as { id: number }; + const { id } = (await findRow(await orm(), DayAccommodations, { trip: trip.id }))!; const existing = (await svc.getAccommodation(id, trip.id))!; const reservations = await createTestReservationsRepo(testDb); const spy = vi.spyOn(reservations, 'setMetadataAndConfirmation').mockRejectedValueOnce(new Error('boom')); await expect(svc.updateAccommodation(id, existing, { check_in: '18:00' })).rejects.toThrow('boom'); - expect(testDb.prepare('SELECT check_in FROM day_accommodations WHERE id = ?').get(id)).toEqual({ check_in: '14:00' }); + expect((await findRow(await orm(), DayAccommodations, { id }))!.check_in).toBe('14:00'); spy.mockRestore(); }); diff --git a/server/tests/unit/nest/admin.service.test.ts b/server/tests/unit/nest/admin.service.test.ts index 4141c02b1..a3f7c5287 100644 --- a/server/tests/unit/nest/admin.service.test.ts +++ b/server/tests/unit/nest/admin.service.test.ts @@ -116,6 +116,7 @@ import { readAppSetting } from '../../helpers/factories/settings'; import { readUser } from '../../helpers/factories/users'; import { McpTokens } from '../../../src/db/entities/McpTokens.entity'; import { Users } from '../../../src/db/entities/Users.entity'; +import { UserSessions } from '../../../src/db/entities/UserSessions.entity'; import { createTestBudgetItemsRepo } from '../../helpers/files-repos'; import { createTestJourneysRepo, createTestJourneyEntriesRepo, createTestJourneyContributorsRepo } from '../../helpers/journey-repos'; import { createTestJourneyShareTokensRepo } from '../../helpers/journey-share-repos'; @@ -734,8 +735,7 @@ const addPushDevice = async (userId: number, endpoint: string): Promise => await insertRow(orm, PushSubscriptions, { user: userId, endpoint, p256dh: 'p', auth: 'a', vapid_public_key: 'k' }); }; -const liveSessions = (id: number): number => - (testDb.prepare('SELECT COUNT(*) AS n FROM user_sessions WHERE user_id = ? AND revoked_at IS NULL').get(id) as { n: number }).n; +const liveSessions = (id: number): Promise => countRows(orm, UserSessions, { user: id, revoked_at: null }); const pushDeviceCount = (id: number): Promise => countRows(orm, PushSubscriptions, { user: id }); @@ -804,8 +804,8 @@ describe('admin password reset revokes what an intruder already holds', () => { await updateUser(String(user.id), { password: 'ANewStrongPass123!' }); - expect(liveSessions(user.id)).toBe(0); - expect(liveSessions(other.id)).toBe(1); + expect(await liveSessions(user.id)).toBe(0); + expect(await liveSessions(other.id)).toBe(1); expect(await auth.verifyJwtToken(intruder)).toBeNull(); expect(await auth.verifyJwtToken(bystander)).not.toBeNull(); }); @@ -832,8 +832,8 @@ describe('resetUserMfa', () => { await auth.generateToken({ id: admin.user.id }); expect((await svc.resetUserMfa(String(user.id), admin.user.id)) as { success?: boolean }).toMatchObject({ success: true }); - expect(liveSessions(user.id)).toBe(0); - expect(liveSessions(admin.user.id)).toBe(1); + expect(await liveSessions(user.id)).toBe(0); + expect(await liveSessions(admin.user.id)).toBe(1); }); it('ADMIN-SVC-083 — clears the three columns disableMfa clears, so both paths leave one state', async () => { diff --git a/server/tests/unit/nest/airports/airports.service.test.ts b/server/tests/unit/nest/airports/airports.service.test.ts index 3dc47092f..2a0fd97de 100644 --- a/server/tests/unit/nest/airports/airports.service.test.ts +++ b/server/tests/unit/nest/airports/airports.service.test.ts @@ -22,6 +22,9 @@ import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; import { createTestReservationsRepo, createTestReservationEndpointsRepo, createTestUnitOfWork } from '../../../helpers/test-uow'; import type { UnitOfWork } from '../../../../src/nest/database/unit-of-work'; import { createUser, createTrip } from '../../../helpers/factories'; +import { makeReservation } from '../../../helpers/factories/reservations'; +import { countRows } from '../../../helpers/factories/rows'; +import { ReservationEndpoints } from '../../../../src/db/entities/ReservationEndpoints.entity'; import type { ReservationsRepository } from '../../../../src/db/repositories/Reservations.repository'; import type { ReservationEndpointsRepository } from '../../../../src/db/repositories/ReservationEndpoints.repository'; @@ -112,8 +115,8 @@ describe('AirportsService boot backfill', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const meta = JSON.stringify({ departure_airport: 'FRA', arrival_airport: 'JFK' }); - const half = Number(testDb.prepare("INSERT INTO reservations (trip_id, title, type, metadata) VALUES (?, 'Out', 'flight', ?)").run(trip.id, meta).lastInsertRowid); - const whole = Number(testDb.prepare("INSERT INTO reservations (trip_id, title, type, metadata) VALUES (?, 'Back', 'flight', ?)").run(trip.id, meta).lastInsertRowid); + const half = (await makeReservation(t, trip.id, { title: 'Out', type: 'flight', metadata: meta })).id; + const whole = (await makeReservation(t, trip.id, { title: 'Back', type: 'flight', metadata: meta })).id; const insert = endpointsRepo.insertEndpoint.bind(endpointsRepo); // The first flight's 'to' endpoint fails, which ends this run. const spy = vi.spyOn(endpointsRepo, 'insertEndpoint') @@ -122,14 +125,14 @@ describe('AirportsService boot backfill', () => { const svc = new AirportsService(reservationsRepo, endpointsRepo, {} as CronRegistrarService, uow); await expect(svc.backfillFlightEndpoints()).rejects.toThrow('disk full'); - const count = (id: number) => (testDb.prepare('SELECT COUNT(*) AS c FROM reservation_endpoints WHERE reservation_id = ?').get(id) as { c: number }).c; - expect(count(half)).toBe(0); + const count = (id: number) => countRows(t, ReservationEndpoints, { reservation: id }); + expect(await count(half)).toBe(0); spy.mockRestore(); const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); await svc.backfillFlightEndpoints(); logSpy.mockRestore(); - expect(count(half)).toBe(2); - expect(count(whole)).toBe(2); + expect(await count(half)).toBe(2); + expect(await count(whole)).toBe(2); }); }); diff --git a/server/tests/unit/nest/auth.service.test.ts b/server/tests/unit/nest/auth.service.test.ts index ad144813f..090d4f327 100644 --- a/server/tests/unit/nest/auth.service.test.ts +++ b/server/tests/unit/nest/auth.service.test.ts @@ -97,6 +97,7 @@ import { McpTokens } from '../../../src/db/entities/McpTokens.entity'; import { PasswordResetTokens } from '../../../src/db/entities/PasswordResetTokens.entity'; import { PushSubscriptions } from '../../../src/db/entities/PushSubscriptions.entity'; import { Users } from '../../../src/db/entities/Users.entity'; +import { UserSessions } from '../../../src/db/entities/UserSessions.entity'; import { createTestShareTokensRepo, createTestPluginsRepo, createTestPluginUserErasureQueueRepo } from '../../helpers/share-repos'; import { createTestBudgetItemsRepo } from '../../helpers/files-repos'; import { createTestJourneysRepo, createTestJourneyEntriesRepo, createTestJourneyContributorsRepo } from '../../helpers/journey-repos'; @@ -536,8 +537,8 @@ describe('changePassword — session invalidation', () => { const result = await svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' }, undefined, {}, false); expect(result).toEqual({ success: true }); - expect(pvOf(user.id)).toBe(1); - const rows = sessionRows(testDb, user.id); + expect(await pvOf(user.id)).toBe(1); + const rows = await sessionRows(testDb, user.id); expect(rows).toHaveLength(1); expect(rows.every((row) => row.revoked_at !== null)).toBe(true); }); @@ -800,7 +801,7 @@ describe('demoLogin', () => { const result = await svc.demoLogin({ userAgent: 'Demo visitor' }); expect(typeof result.token).toBe('string'); expect(result.user).not.toHaveProperty('password_hash'); - expect(sessionRows(testDb, user.id).map((r) => r.user_agent)).toEqual(['Demo visitor']); + expect((await sessionRows(testDb, user.id)).map((r) => r.user_agent)).toEqual(['Demo visitor']); vi.unstubAllEnvs(); }); }); @@ -1287,14 +1288,14 @@ describe('registerUser loses the race for an email', () => { }); describe('session tracking', () => { - const active = (userId: number) => sessionRows(testDb, userId).filter((row) => row.revoked_at === null); + const active = async (userId: number) => (await sessionRows(testDb, userId)).filter((row) => row.revoked_at === null); it('AUTH-SESS-001: a minted token names a new session row whose expiry is the token\'s own', async () => { const { user } = createUser(testDb); const token = await svc.generateToken({ id: user.id }, true, { userAgent: 'x'.repeat(300) }); const claims = decodeJwt(token) as { jti: string; exp: number }; - const rows = sessionRows(testDb, user.id); + const rows = await sessionRows(testDb, user.id); expect(rows).toHaveLength(1); expect(rows[0].id).toBe(claims.jti); expect(rows[0].expires_at).toBe(new Date(claims.exp * 1000).toISOString().slice(0, 19).replace('T', ' ')); @@ -1306,7 +1307,7 @@ describe('session tracking', () => { const { user, password } = createUser(testDb); const result = await svc.loginUser({ email: user.email, password }, { userAgent: 'Firefox/130' }); expect(typeof result.token).toBe('string'); - expect(sessionRows(testDb, user.id).map((row) => row.user_agent)).toEqual(['Firefox/130']); + expect((await sessionRows(testDb, user.id)).map((row) => row.user_agent)).toEqual(['Firefox/130']); }); it('AUTH-SESS-003: a revoked session token no longer verifies', async () => { @@ -1314,7 +1315,7 @@ describe('session tracking', () => { const token = await svc.generateToken({ id: user.id }); expect(await svc.verifyJwtToken(token)).not.toBeNull(); - testDb.prepare("UPDATE user_sessions SET revoked_at = '2026-01-01 00:00:00' WHERE user_id = ?").run(user.id); + await updateRows(orm, UserSessions, { user: user.id }, { revoked_at: '2026-01-01 00:00:00' }); expect(await svc.verifyJwtToken(token)).toBeNull(); }); @@ -1328,7 +1329,7 @@ describe('session tracking', () => { expect(await svc.verifyJwtToken(laptop)).toBeNull(); expect(await svc.verifyJwtToken(phone)).toBeNull(); expect(await svc.verifyJwtToken(result.token!)).not.toBeNull(); - expect(active(user.id).map((row) => row.user_agent)).toEqual(['Safari']); + expect((await active(user.id)).map((row) => row.user_agent)).toEqual(['Safari']); }); it('AUTH-SESS-005: a password reset ends every session', async () => { @@ -1337,14 +1338,14 @@ describe('session tracking', () => { const issued = await svc.requestPasswordReset(user.email, null); expect(await svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!' })).toEqual({ success: true, userId: user.id }); - expect(active(user.id)).toEqual([]); + expect(await active(user.id)).toEqual([]); expect(await svc.verifyJwtToken(token)).toBeNull(); }); it('AUTH-SESS-006: disabling MFA ends every other session and keeps the one it was done from', async () => { const { user, password } = createUser(testDb); const secret = authenticator.generateSecret(); - testDb.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ? WHERE id = ?').run('enc:' + secret, user.id); + await updateRows(orm, Users, { id: user.id }, { mfa_enabled: 1, mfa_secret: 'enc:' + secret }); const here = await svc.generateToken({ id: user.id }); const elsewhere = await svc.generateToken({ id: user.id }); const hereId = (decodeJwt(here) as { jti: string }).jti; @@ -1357,7 +1358,7 @@ describe('session tracking', () => { it('AUTH-SESS-007: a refused MFA disable leaves the sessions alone', async () => { const { user } = createUser(testDb); const secret = authenticator.generateSecret(); - testDb.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ? WHERE id = ?').run('enc:' + secret, user.id); + await updateRows(orm, Users, { id: user.id }, { mfa_enabled: 1, mfa_secret: 'enc:' + secret }); const token = await svc.generateToken({ id: user.id }); expect((await svc.disableMfa(user.id, user.email, { password: 'wrong', code: authenticator.generate(secret) })).status).toBe(401); @@ -1367,17 +1368,17 @@ describe('session tracking', () => { it('AUTH-SESS-008: an MFA login records the device too', async () => { const { user, password } = createUser(testDb); const secret = authenticator.generateSecret(); - testDb.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ? WHERE id = ?').run('enc:' + secret, user.id); + await updateRows(orm, Users, { id: user.id }, { mfa_enabled: 1, mfa_secret: 'enc:' + secret }); const interstitial = await svc.loginUser({ email: user.email, password }); const result = await svc.verifyMfaLogin({ mfa_token: interstitial.mfa_token, code: authenticator.generate(secret) }, { userAgent: 'Edge' }); expect(await svc.verifyJwtToken(result.token!)).not.toBeNull(); - expect(sessionRows(testDb, user.id).map((row) => row.user_agent)).toEqual(['Edge']); + expect((await sessionRows(testDb, user.id)).map((row) => row.user_agent)).toEqual(['Edge']); }); it('AUTH-SESS-009: register records the first session with its device', async () => { const result = await svc.registerUser({ username: 'sess-reg', email: 'sess-reg@example.test', password: 'Secure123!' }, { userAgent: 'Chrome' }); const userId = (decodeJwt(result.token!) as { id: number }).id; - expect(sessionRows(testDb, userId).map((row) => row.user_agent)).toEqual(['Chrome']); + expect((await sessionRows(testDb, userId)).map((row) => row.user_agent)).toEqual(['Chrome']); }); }); diff --git a/server/tests/unit/nest/budget.service.db.test.ts b/server/tests/unit/nest/budget.service.db.test.ts index 46fdc931a..501285e93 100644 --- a/server/tests/unit/nest/budget.service.db.test.ts +++ b/server/tests/unit/nest/budget.service.db.test.ts @@ -102,6 +102,12 @@ async function budgetItemRow(id: number) { return row; } +/** The stored expense's currency and frozen rate, the two columns a rebase writes. */ +async function budgetItemFx(id: number): Promise<{ currency: string | null | undefined; exchange_rate: number }> { + const { currency, exchange_rate } = await budgetItemRow(id); + return { currency, exchange_rate }; +} + async function addPlace(tripId: number, name: string): Promise { return insertRow(await orm(), Places, { trip: tripId, name }); } @@ -624,33 +630,32 @@ describe('composite service paths (ex budget.bridge delegation)', () => { await budget.rebaseTripCurrency(trip.id, 'RUB'); - const row = await budgetItemRow(item.id); - expect(row).toEqual({ currency: 'EUR', exchange_rate: RATES.RUB.EUR }); + expect(await budgetItemFx(item.id)).toEqual({ currency: 'EUR', exchange_rate: RATES.RUB.EUR }); }); it('BUDGET-SVC-DB-086: prepareCurrencyRebase writes nothing and applyCurrencyRebase reads the outgoing currency inside its write', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - testDb.prepare("UPDATE trips SET currency = 'EUR' WHERE id = ?").run(trip.id); + await setTrip(trip.id, { currency: 'EUR' }); const item = await budget.createBudgetItem(trip.id, { name: 'Implicit', total_price: 100, members: [{ user_id: user.id }] }); - const row = () => testDb.prepare('SELECT currency, exchange_rate FROM budget_items WHERE id = ?').get(item.id); - const before = row(); + const row = () => budgetItemFx(item.id); + const before = await row(); expect(await budget.prepareCurrencyRebase(trip.id, 'eur')).toBeNull(); expect(await budget.prepareCurrencyRebase(trip.id, '')).toBeNull(); expect(await budget.prepareCurrencyRebase(999999, 'RUB')).toBeNull(); const plan = await budget.prepareCurrencyRebase(trip.id, 'rub'); expect(plan).toEqual({ next: 'RUB', rates: RATES.RUB }); - expect(row()).toEqual(before); + expect(await row()).toEqual(before); // The trip took the new currency in the meantime: nothing is left to rebase. - testDb.prepare("UPDATE trips SET currency = 'RUB' WHERE id = ?").run(trip.id); + await setTrip(trip.id, { currency: 'RUB' }); await budget.applyCurrencyRebase(trip.id, plan!); - expect(row()).toEqual(before); + expect(await row()).toEqual(before); - testDb.prepare("UPDATE trips SET currency = 'EUR' WHERE id = ?").run(trip.id); + await setTrip(trip.id, { currency: 'EUR' }); await budget.applyCurrencyRebase(trip.id, plan!); - expect(row()).toEqual({ currency: 'EUR', exchange_rate: RATES.RUB.EUR }); + expect(await row()).toEqual({ currency: 'EUR', exchange_rate: RATES.RUB.EUR }); }); it('BUDGET-SVC-DB-018: linkBudgetItemToReservation stamps the reservation id', async () => { diff --git a/server/tests/unit/nest/cron-registrar.service.test.ts b/server/tests/unit/nest/cron-registrar.service.test.ts index cc4e583fd..bf5e86333 100644 --- a/server/tests/unit/nest/cron-registrar.service.test.ts +++ b/server/tests/unit/nest/cron-registrar.service.test.ts @@ -64,6 +64,8 @@ import { createSnapshotTestDb } from '../../helpers/db-mock'; import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; import { Users } from '../../../src/db/entities/Users.entity'; import { SchedulerLeasesRepository } from '../../../src/db/repositories/SchedulerLeases.repository'; +import { SchedulerLeases } from '../../../src/db/entities/SchedulerLeases.entity'; +import { findRow, insertRow, updateRows } from '../../helpers/factories/rows'; import { currentCorrelation, type Correlation } from '../../../src/nest/common/request-correlation'; function makeRegistrar(isTest: boolean) { @@ -299,16 +301,11 @@ describe('CronRegistrarService', () => { await t.close(); }); - const holder = (name: string) => - testDb.prepare('SELECT owner, expires_at FROM scheduler_leases WHERE name = ?').get(name) as - | { owner: string; expires_at: number } - | undefined; + const holder = (name: string) => findRow(t, SchedulerLeases, { name }); it('CRONREG-017: a tick another process holds does not run here', async () => { const name = 'lease-held-elsewhere'; - testDb - .prepare('INSERT INTO scheduler_leases (name, owner, expires_at) VALUES (?, ?, ?)') - .run(name, 'other-host:1:peer', Date.now() + 60_000); + await insertRow(t, SchedulerLeases, { name, owner: 'other-host:1:peer', expires_at: Date.now() + 60_000 }); const registrar = new CronRegistrarService(new SchedulerRegistry(), { isTest: () => false } as RuntimeEnvService, t.orm); let ran = false; registrar.register(name, '* * * * *', () => { @@ -316,14 +313,12 @@ describe('CronRegistrarService', () => { }); await h.jobs[0].onTick(); expect(ran).toBe(false); - expect(holder(name)!.owner).toBe('other-host:1:peer'); + expect((await holder(name))!.owner).toBe('other-host:1:peer'); }); it('CRONREG-021: a tick lost to another process is traced as skipped, and a tick run here as ok', async () => { const name = 'lease-trace-line'; - testDb - .prepare('INSERT INTO scheduler_leases (name, owner, expires_at) VALUES (?, ?, ?)') - .run(name, 'other-host:1:peer', Date.now() + 60_000); + await insertRow(t, SchedulerLeases, { name, owner: 'other-host:1:peer', expires_at: Date.now() + 60_000 }); const registrar = new CronRegistrarService(new SchedulerRegistry(), { isTest: () => false } as RuntimeEnvService, t.orm); registrar.register(name, '* * * * *', () => undefined); logDebugMock.mockClear(); @@ -333,7 +328,7 @@ describe('CronRegistrarService', () => { expect(lines()).toHaveLength(1); expect(lines()[0]).toMatch(new RegExp(`^cron ${name} skipped \\d+ms: lease held by another process$`)); - testDb.prepare('UPDATE scheduler_leases SET expires_at = 1 WHERE name = ?').run(name); + await updateRows(t, SchedulerLeases, { name }, { expires_at: 1 }); logDebugMock.mockClear(); await h.jobs[0].onTick(); await vi.waitFor(() => expect(lines()).toHaveLength(1)); @@ -359,7 +354,7 @@ describe('CronRegistrarService', () => { await h.jobs[0].onTick(); expect(inTransaction).toEqual([true]); expect(ran).toBe(true); - expect(holder(name)!.owner).toBe(LEASE_OWNER); + expect((await holder(name))!.owner).toBe(LEASE_OWNER); } finally { spy.mockRestore(); } @@ -367,9 +362,7 @@ describe('CronRegistrarService', () => { it('CRONREG-018: a lapsed lease is taken over, and held for the settle window after the tick', async () => { const name = 'lease-lapsed'; - testDb - .prepare('INSERT INTO scheduler_leases (name, owner, expires_at) VALUES (?, ?, ?)') - .run(name, 'crashed-host:9:gone', Date.now() - 1); + await insertRow(t, SchedulerLeases, { name, owner: 'crashed-host:9:gone', expires_at: Date.now() - 1 }); const registrar = new CronRegistrarService(new SchedulerRegistry(), { isTest: () => false } as RuntimeEnvService, t.orm); let ran = false; registrar.register(name, '* * * * *', () => { @@ -378,7 +371,7 @@ describe('CronRegistrarService', () => { const before = Date.now(); await h.jobs[0].onTick(); expect(ran).toBe(true); - const row = holder(name)!; + const row = (await holder(name))!; expect(row.owner).toBe(LEASE_OWNER); expect(row.expires_at).toBeGreaterThanOrEqual(before + LEASE_SETTLE_MS); expect(row.expires_at).toBeLessThan(before + LEASE_TTL_MS); @@ -413,9 +406,9 @@ describe('CronRegistrarService', () => { }); const tick = h.jobs[0].onTick(); await running; - testDb.prepare('UPDATE scheduler_leases SET expires_at = 1 WHERE name = ?').run(name); + await updateRows(t, SchedulerLeases, { name }, { expires_at: 1 }); vi.advanceTimersByTime(LEASE_HEARTBEAT_MS); - await vi.waitFor(() => expect(holder(name)!.expires_at).toBeGreaterThan(Date.now())); + await vi.waitFor(async () => expect((await holder(name))!.expires_at).toBeGreaterThan(Date.now())); finish(); await tick; }); diff --git a/server/tests/unit/nest/days.service.test.ts b/server/tests/unit/nest/days.service.test.ts index 3809a16eb..b2dbf4093 100644 --- a/server/tests/unit/nest/days.service.test.ts +++ b/server/tests/unit/nest/days.service.test.ts @@ -277,7 +277,7 @@ describe('create (service)', () => { const trip = createTrip(testDb, user.id); // Without the transaction both read the same MAX and both became day 1. await Promise.all([svc.create(trip.id), svc.create(trip.id), svc.create(trip.id)]); - const numbers = (testDb.prepare('SELECT day_number FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { day_number: number }[]) + const numbers = (await findRows(await orm(), Days, { trip: trip.id }, { day_number: 'asc' })) .map((d) => d.day_number); expect(numbers).toEqual([1, 2, 3]); }); diff --git a/server/tests/unit/nest/journey-domain.service.test.ts b/server/tests/unit/nest/journey-domain.service.test.ts index 851a6c566..c555b20c7 100644 --- a/server/tests/unit/nest/journey-domain.service.test.ts +++ b/server/tests/unit/nest/journey-domain.service.test.ts @@ -2584,9 +2584,7 @@ describe('journey gallery', () => { ]))); expect(batches.map((b) => b.length)).toEqual([2, 2, 2]); - const orders = testDb - .prepare('SELECT sort_order FROM journey_photos WHERE journey_id = ? ORDER BY sort_order') - .all(journey.id) as { sort_order: number }[]; + const orders = await pickAll(JourneyPhotos, { journey: journey.id }, ['sort_order'], { sort_order: 'asc' }); expect(orders.map((o) => o.sort_order)).toEqual([0, 1, 2, 3, 4, 5]); }); @@ -2596,13 +2594,9 @@ describe('journey gallery', () => { const photos = await Promise.all(['a', 'b', 'c'].map((n) => svc.addPhoto(entry.id, user.id, `journey/e-${n}.jpg`))); expect(photos.every(Boolean)).toBe(true); - const gallery = testDb - .prepare('SELECT sort_order FROM journey_photos WHERE journey_id = ? ORDER BY sort_order') - .all(journey.id) as { sort_order: number }[]; + const gallery = await pickAll(JourneyPhotos, { journey: journey.id }, ['sort_order'], { sort_order: 'asc' }); expect(gallery.map((o) => o.sort_order)).toEqual([0, 1, 2]); - const linked = testDb - .prepare('SELECT sort_order FROM journey_entry_photos WHERE entry_id = ? ORDER BY sort_order') - .all(entry.id) as { sort_order: number }[]; + const linked = await pickAll(JourneyEntryPhotos, { entry: entry.id }, ['sort_order'], { sort_order: 'asc' }); expect(linked.map((o) => o.sort_order)).toEqual([0, 1, 2]); }); @@ -3656,20 +3650,20 @@ describe('Plan 3g Task 2 — transaction rollback proofs (JG-TX1..4)', () => { await expect(svc.createJourney(user.id, { title: 'Orphan' })).rejects.toThrow('boom'); - expect(testDb.prepare("SELECT id FROM journeys WHERE title = 'Orphan'").all()).toEqual([]); + expect(await pickAll(Journeys, { title: 'Orphan' }, ['id'])).toEqual([]); }); it('JG-TX6 (createJourney): a failure after the trip is linked rolls back the journey and its owner, and nothing is broadcast', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Trip', start_date: '2026-03-15', end_date: '2026-03-16' }); vi.spyOn(journeysRepoDirect, 'updateCoverImage').mockRejectedValueOnce(new Error('boom')); - testDb.prepare("UPDATE trips SET cover_image = '/uploads/covers/x.jpg' WHERE id = ?").run(trip.id); + await updateRows(await orm(), Trips, { id: trip.id }, { cover_image: '/uploads/covers/x.jpg' }); const broadcast = vi.spyOn(svc, 'broadcastJourneyEvent'); await expect(svc.createJourney(user.id, { title: 'Half', trip_ids: [trip.id] })).rejects.toThrow('boom'); - expect(testDb.prepare("SELECT id FROM journeys WHERE title = 'Half'").all()).toEqual([]); - expect(testDb.prepare('SELECT journey_id FROM journey_contributors WHERE user_id = ?').all(user.id)).toEqual([]); + expect(await pickAll(Journeys, { title: 'Half' }, ['id'])).toEqual([]); + expect(await pickAll(JourneyContributors, { user: user.id }, ['journey_id'])).toEqual([]); expect(broadcast).not.toHaveBeenCalled(); }); @@ -3682,9 +3676,9 @@ describe('Plan 3g Task 2 — transaction rollback proofs (JG-TX1..4)', () => { await expect(svc.addPhoto(entry!.id, user.id, 'journey/tx7.jpg')).rejects.toThrow('boom'); - expect(testDb.prepare('SELECT * FROM journey_photos WHERE journey_id = ?').all(journey.id)).toEqual([]); - expect(testDb.prepare("SELECT * FROM trek_photos WHERE file_path = 'journey/tx7.jpg'").all()).toEqual([]); - expect(testDb.prepare('SELECT type FROM journey_entries WHERE id = ?').get(entry!.id)).toEqual({ type: 'entry' }); + expect(await findRows(await orm(), JourneyPhotos, { journey: journey.id })).toEqual([]); + expect(await findRows(await orm(), TrekPhotos, { file_path: 'journey/tx7.jpg' })).toEqual([]); + expect(await pickOne(JourneyEntries, { id: entry!.id }, ['type'])).toEqual({ type: 'entry' }); }); }); diff --git a/server/tests/unit/nest/notification-preferences.service.test.ts b/server/tests/unit/nest/notification-preferences.service.test.ts index 8aa199658..581fb923d 100644 --- a/server/tests/unit/nest/notification-preferences.service.test.ts +++ b/server/tests/unit/nest/notification-preferences.service.test.ts @@ -361,7 +361,7 @@ describe('setAdminPreferences', () => { const prefsRepo = await createTestNotificationChannelPreferencesRepo(testDb); const spy = vi.spyOn(prefsRepo, 'upsertPreference').mockRejectedValueOnce(new Error('disk full')); await expect(setAdminPreferences(user.id, { version_available: { email: false, inapp: false } })).rejects.toThrow('disk full'); - expect(testDb.prepare("SELECT value FROM app_settings WHERE key = ?").get('admin_notif_pref_version_available_email')).toBeUndefined(); + expect(await storedAppSetting('admin_notif_pref_version_available_email')).toBeNull(); spy.mockRestore(); }); }); diff --git a/server/tests/unit/nest/oidc.service.test.ts b/server/tests/unit/nest/oidc.service.test.ts index 26859c29e..1c7f4b05b 100644 --- a/server/tests/unit/nest/oidc.service.test.ts +++ b/server/tests/unit/nest/oidc.service.test.ts @@ -110,6 +110,7 @@ import { makeInviteToken } from '../../helpers/factories/tokens'; import { InviteTokens } from '../../../src/db/entities/InviteTokens.entity'; import { TripMembers } from '../../../src/db/entities/TripMembers.entity'; import { Users } from '../../../src/db/entities/Users.entity'; +import { UserSessions } from '../../../src/db/entities/UserSessions.entity'; const orm = () => sharedTestOrm(testDb); @@ -329,8 +330,9 @@ describe('generateToken', () => { const { user } = createUser(testDb, { email: 'sso-session@example.com' }); const token = await svc.generateToken({ id: user.id }, undefined, { userAgent: 'SSO Browser' }); const { jti } = jwtLib.decode(token) as { jti: string }; - const row = testDb.prepare('SELECT user_id, user_agent, revoked_at FROM user_sessions WHERE id = ?').get(jti); - expect(row).toEqual({ user_id: user.id, user_agent: 'SSO Browser', revoked_at: null }); + const row = await findRow(await orm(), UserSessions, { id: jti }); + expect(row && { user_id: row.user_id, user_agent: row.user_agent, revoked_at: row.revoked_at }) + .toEqual({ user_id: user.id, user_agent: 'SSO Browser', revoked_at: null }); expect((await auth.verifyJwtToken(token))?.id).toBe(user.id); }); }); @@ -802,8 +804,9 @@ describe('findOrCreateUser', () => { it('OIDC-SVC-062: the identity switch and the avatar are one write: a failing avatar keeps the old link', async () => { const { user } = createUser(testDb, { email: 'switch4@example.com' }); - testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ?, avatar = ? WHERE id = ?') - .run('sub-old-4', 'https://old-idp.example.com', 'https://old-idp.example.com/u/me.png', user.id); + await setUserColumns(user.id, { + oidc_sub: 'sub-old-4', oidc_issuer: 'https://old-idp.example.com', avatar: 'https://old-idp.example.com/u/me.png', + }); const users = await createTestUsersRepo(testDb); const spy = vi.spyOn(users, 'setAvatarRaw').mockRejectedValueOnce(new Error('disk full')); @@ -812,8 +815,8 @@ describe('findOrCreateUser', () => { { ...MOCK_CONFIG, issuer: 'https://new-idp.example.com' }, )).rejects.toThrow('disk full'); - expect(testDb.prepare('SELECT oidc_sub, oidc_issuer FROM users WHERE id = ?').get(user.id)) - .toEqual({ oidc_sub: 'sub-old-4', oidc_issuer: 'https://old-idp.example.com' }); + const { oidc_sub, oidc_issuer } = await readUser(await orm(), user.id); + expect({ oidc_sub, oidc_issuer }).toEqual({ oidc_sub: 'sub-old-4', oidc_issuer: 'https://old-idp.example.com' }); spy.mockRestore(); }); diff --git a/server/tests/unit/nest/packing.service.test.ts b/server/tests/unit/nest/packing.service.test.ts index 655bf04ec..a1b9074e6 100644 --- a/server/tests/unit/nest/packing.service.test.ts +++ b/server/tests/unit/nest/packing.service.test.ts @@ -247,15 +247,15 @@ async function seedTemplate(userId: number, itemNames: string[]): Promise { it('PACK-SVC-002b: template categories and items added at the same time take one position each', async () => { const { user } = createUser(testDb); - const templateId = seedTemplate(user.id, ['Tent']); + const templateId = await seedTemplate(user.id, ['Tent']); // seedTemplate's own category holds position 0 and its item position 0. const categories = await Promise.all(['Kitchen', 'Sleep', 'Clothes'].map((name) => svc.createTemplateCategory(String(templateId), name))); const catIds = categories.map((c) => (c as { category: { id: number } }).category.id); - expect((testDb.prepare('SELECT sort_order FROM packing_template_categories WHERE template_id = ? ORDER BY sort_order').all(templateId) as { sort_order: number }[]) + expect((await findRows(await orm(), PackingTemplateCategories, { template: templateId }, { sort_order: 'asc' })) .map((r) => r.sort_order)).toEqual([0, 1, 2, 3]); await Promise.all(['Stove', 'Pot', 'Lighter'].map((name) => svc.createTemplateItem(String(templateId), String(catIds[0]), name))); - expect((testDb.prepare('SELECT sort_order FROM packing_template_items WHERE category_id = ? ORDER BY sort_order').all(catIds[0]) as { sort_order: number }[]) + expect((await findRows(await orm(), PackingTemplateItems, { category: catIds[0] }, { sort_order: 'asc' })) .map((r) => r.sort_order)).toEqual([0, 1, 2]); }); }); @@ -371,10 +371,10 @@ describe('createBag / deleteBag', () => { // Without the transaction every one of them read the same MAX and landed on 0. await Promise.all(['Carry-On', 'Checked', 'Daypack'].map((name) => svc.createBag(trip.id, { name }))); await Promise.all(['Tent', 'Stove', 'Mat'].map((name) => svc.createItem(trip.id, { name }, user.id))); - const orders = (table: string) => (testDb.prepare(`SELECT sort_order FROM ${table} WHERE trip_id = ? ORDER BY sort_order`).all(trip.id) as { sort_order: number }[]) - .map((r) => r.sort_order); - expect(orders('packing_bags')).toEqual([0, 1, 2]); - expect(orders('packing_items')).toEqual([0, 1, 2]); + const bags = await findRows(await orm(), PackingBags, { trip: trip.id }, { sort_order: 'asc' }); + const items = await findRows(await orm(), PackingItems, { trip: trip.id }, { sort_order: 'asc' }); + expect(bags.map((r) => r.sort_order)).toEqual([0, 1, 2]); + expect(items.map((r) => r.sort_order)).toEqual([0, 1, 2]); }); it('PACK-SVC-006: deleteBag removes the bag and returns true', async () => { diff --git a/server/tests/unit/nest/places.service.test.ts b/server/tests/unit/nest/places.service.test.ts index 4c4536a82..31923861e 100644 --- a/server/tests/unit/nest/places.service.test.ts +++ b/server/tests/unit/nest/places.service.test.ts @@ -294,7 +294,7 @@ describe('create', () => { await expect(svc.create(String(trip.id), { name: 'Half Place', tags: [tag.id] })).rejects.toThrow('disk full'); - expect(testDb.prepare("SELECT id FROM places WHERE name = 'Half Place'").all()).toEqual([]); + expect(await findRows(orm, Places, { name: 'Half Place' })).toEqual([]); spy.mockRestore(); }); diff --git a/server/tests/unit/nest/sessions/sessions.service.test.ts b/server/tests/unit/nest/sessions/sessions.service.test.ts index 8b7926769..c167e647b 100644 --- a/server/tests/unit/nest/sessions/sessions.service.test.ts +++ b/server/tests/unit/nest/sessions/sessions.service.test.ts @@ -5,6 +5,7 @@ import { createSnapshotTestDb } from '../../../helpers/db-mock'; import { resetTestDb } from '../../../helpers/test-db'; import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; import { createUser } from '../../../helpers/factories'; +import { countRows, findRow, findRows } from '../../../helpers/factories/rows'; import { UserSessions } from '../../../../src/db/entities/UserSessions.entity'; import { SessionsService, USER_AGENT_MAX_LENGTH, legacySessionId, sessionClientFrom } from '../../../../src/nest/sessions/sessions.service'; import { userSessionIdSchema } from '@trek/shared'; @@ -32,9 +33,7 @@ function claimsOf(token: string): Claims { } function rowOf(id: string) { - return testDb.prepare('SELECT * FROM user_sessions WHERE id = ?').get(id) as - | { user_id: number; created_at: string; last_seen_at: string; expires_at: string; revoked_at: string | null; user_agent: string | null } - | undefined; + return findRow(t, UserSessions, { id }); } function textOf(seconds: number): string { @@ -62,7 +61,7 @@ describe('SessionsService.issue', () => { expect('remember' in claims).toBe(false); expect(claims.jti).toMatch(/^[0-9a-f-]{36}$/); expect(claims.exp - claims.iat).toBe(SESSION_DURATION_SECONDS); - expect(rowOf(claims.jti)).toEqual({ + expect(await rowOf(claims.jti)).toEqual({ id: claims.jti, user_id: user.id, created_at: textOf(claims.iat), @@ -90,8 +89,8 @@ describe('SessionsService.issue', () => { const b = claimsOf(await svc.issue({ id: user.id, pv: 0 })); expect(a.jti).not.toBe(b.jti); - expect(rowOf(a.jti)?.user_agent).toHaveLength(USER_AGENT_MAX_LENGTH); - expect(rowOf(b.jti)?.user_agent).toBeNull(); + expect((await rowOf(a.jti))?.user_agent).toHaveLength(USER_AGENT_MAX_LENGTH); + expect((await rowOf(b.jti))?.user_agent).toBeNull(); }); }); @@ -111,13 +110,13 @@ describe('SessionsService.renew', () => { expect(claims.jti).toBe(first.jti); expect(claims.remember).toBe(false); expect(claims.exp).toBeGreaterThan(first.exp); - expect(rowOf(first.jti)).toEqual(expect.objectContaining({ + expect(await rowOf(first.jti)).toEqual(expect.objectContaining({ expires_at: textOf(claims.exp), last_seen_at: textOf(Date.parse('2026-10-08T00:00:00Z') / 1000 + SESSION_DURATION_SECONDS * 0.75), created_at: '2026-10-08 00:00:00', user_agent: null, })); - expect(testDb.prepare('SELECT COUNT(*) AS n FROM user_sessions').get()).toEqual({ n: 1 }); + expect(await countRows(t, UserSessions)).toBe(1); }); it('SESS-005: a session that ended in the meantime is not renewed', async () => { @@ -126,7 +125,7 @@ describe('SessionsService.renew', () => { await svc.revoke(user.id, first.jti); expect(await svc.renew({ id: user.id, pv: 0, jti: first.jti })).toBeNull(); - expect(rowOf(first.jti)?.expires_at).toBe(textOf(first.exp)); + expect((await rowOf(first.jti))?.expires_at).toBe(textOf(first.exp)); }); it('SESS-006: a token from before sessions were tracked comes back as a tracked session', async () => { @@ -136,7 +135,7 @@ describe('SessionsService.renew', () => { expect(claims.pv).toBe(0); expect(claims.remember).toBe(true); - expect(rowOf(claims.jti)).toEqual(expect.objectContaining({ user_id: user.id, user_agent: 'Safari', revoked_at: null })); + expect(await rowOf(claims.jti)).toEqual(expect.objectContaining({ user_id: user.id, user_agent: 'Safari', revoked_at: null })); }); }); @@ -156,7 +155,8 @@ describe('SessionsService.renew of a token from before tracking', () => { const ids = new Set(renewed.map((token) => claimsOf(token!).jti)); expect(ids).toEqual(new Set([legacySessionId(legacy)])); - expect(testDb.prepare('SELECT id, user_agent, revoked_at FROM user_sessions WHERE user_id = ?').all(user.id)).toEqual([ + const rows = await findRows(t, UserSessions, { user: user.id }); + expect(rows.map(({ id, user_agent, revoked_at }) => ({ id, user_agent, revoked_at }))).toEqual([ { id: legacySessionId(legacy), user_agent: 'Page load', revoked_at: null }, ]); expect(await svc.list(user.id)).toHaveLength(1); @@ -169,8 +169,8 @@ describe('SessionsService.renew of a token from before tracking', () => { expect(await svc.revoke(user.id, first.jti)).toBe(true); expect(await svc.renew({ id: user.id, pv: 0, token: legacy })).toBeNull(); - expect(rowOf(first.jti)?.revoked_at).not.toBeNull(); - expect(testDb.prepare('SELECT COUNT(*) AS n FROM user_sessions WHERE user_id = ?').get(user.id)).toEqual({ n: 1 }); + expect((await rowOf(first.jti))?.revoked_at).not.toBeNull(); + expect(await countRows(t, UserSessions, { user: user.id })).toBe(1); }); it('SESS-017: a session derived from it that was ended stays ended through the nightly purge', async () => { @@ -182,7 +182,7 @@ describe('SessionsService.renew of a token from before tracking', () => { // The purge that night, while the old token is still valid. expect(await svc.purgeInactive(new Date())).toBe(0); expect(await svc.renew({ id: user.id, pv: 0, token: legacy })).toBeNull(); - expect(rowOf(first.jti)?.revoked_at).not.toBeNull(); + expect((await rowOf(first.jti))?.revoked_at).not.toBeNull(); expect(await svc.list(user.id)).toEqual([]); // Once the old token has expired the row may go, since the token is refused by then. @@ -198,7 +198,7 @@ describe('SessionsService.renew of a token from before tracking', () => { const renewed = claimsOf((await svc.renew({ id: user.id, pv: 0, token: legacy }))!); expect(renewed.exp - renewed.iat).toBe(SESSION_DURATION_SECONDS); - expect(rowOf(renewed.jti)?.expires_at).toBe(textOf((jwt.decode(legacy) as { exp: number }).exp)); + expect((await rowOf(renewed.jti))?.expires_at).toBe(textOf((jwt.decode(legacy) as { exp: number }).exp)); }); it('SESS-014: two different tokens of the same user stay two sessions', async () => { @@ -246,7 +246,7 @@ describe('SessionsService list and revoke', () => { expect(await svc.revoke(other.id, mine.jti)).toBe(false); expect(await svc.revoke(user.id, mine.jti)).toBe(true); - expect(rowOf(mine.jti)?.revoked_at).not.toBeNull(); + expect((await rowOf(mine.jti))?.revoked_at).not.toBeNull(); }); it('SESS-009: revokeAll ends every session, or every one but the current', async () => { @@ -256,9 +256,9 @@ describe('SessionsService list and revoke', () => { const c = claimsOf(await svc.issue({ id: user.id, pv: 0 })); expect(await svc.revokeAll(user.id, b.jti)).toBe(2); - expect(rowOf(b.jti)?.revoked_at).toBeNull(); - expect(rowOf(a.jti)?.revoked_at).not.toBeNull(); - expect(rowOf(c.jti)?.revoked_at).not.toBeNull(); + expect((await rowOf(b.jti))?.revoked_at).toBeNull(); + expect((await rowOf(a.jti))?.revoked_at).not.toBeNull(); + expect((await rowOf(c.jti))?.revoked_at).not.toBeNull(); expect(await svc.revokeAll(user.id)).toBe(1); }); @@ -269,7 +269,7 @@ describe('SessionsService list and revoke', () => { expect(await svc.endSession(null)).toBe(false); expect(await svc.endSession({ id: user.id })).toBe(false); expect(await svc.endSession({ id: user.id, jti: mine.jti })).toBe(true); - expect(rowOf(mine.jti)?.revoked_at).not.toBeNull(); + expect((await rowOf(mine.jti))?.revoked_at).not.toBeNull(); }); it('SESS-011: purgeInactive removes the expired rows and keeps a revoked one until it expires', async () => { @@ -282,13 +282,13 @@ describe('SessionsService list and revoke', () => { // Just past the default lifetime: the short session has expired, the remembered ones have not. expect(SESSION_DURATION_REMEMBER_SECONDS).toBeGreaterThan(SESSION_DURATION_SECONDS + 60); expect(await svc.purgeInactive(new Date(Date.now() + (SESSION_DURATION_SECONDS + 60) * 1000))).toBe(1); - expect(rowOf(live.jti)).toBeDefined(); - expect(rowOf(ended.jti)?.revoked_at).not.toBeNull(); - expect(rowOf(short.jti)).toBeUndefined(); + expect(await rowOf(live.jti)).not.toBeNull(); + expect((await rowOf(ended.jti))?.revoked_at).not.toBeNull(); + expect(await rowOf(short.jti)).toBeNull(); // Past the long lifetime as well: the revoked row goes with the live one. expect(await svc.purgeInactive(new Date(Date.now() + (SESSION_DURATION_REMEMBER_SECONDS + 60) * 1000))).toBe(2); - expect(rowOf(ended.jti)).toBeUndefined(); + expect(await rowOf(ended.jti)).toBeNull(); }); }); @@ -303,7 +303,7 @@ describe('SessionsService as the session check', () => { expect(await svc.findActive(live.jti, user.id, now)).toEqual({ id: live.jti, last_seen_at: textOf(live.iat) }); expect(await svc.findActive(ended.jti, user.id, now)).toBeNull(); await svc.touchLastSeen(live.jti, '2030-01-01 00:00:00'); - expect(rowOf(live.jti)?.last_seen_at).toBe('2030-01-01 00:00:00'); + expect((await rowOf(live.jti))?.last_seen_at).toBe('2030-01-01 00:00:00'); const sign = (jti: string) => jwt.sign({ id: user.id, pv: 0 }, JWT_SECRET, { algorithm: 'HS256', expiresIn: 600, jwtid: jti }); expect((await verifyJwtAndLoadUser(sign(live.jti), t.repo(Users), svc))?.id).toBe(user.id); diff --git a/server/tests/unit/nest/sqlite-database-backup.test.ts b/server/tests/unit/nest/sqlite-database-backup.test.ts index b8d969b28..e5dcd7df1 100644 --- a/server/tests/unit/nest/sqlite-database-backup.test.ts +++ b/server/tests/unit/nest/sqlite-database-backup.test.ts @@ -38,6 +38,7 @@ function portWith(maintenance: { walCheckpoint: unknown; vacuumInto: unknown }): function writeDb(file: string, label: string): void { const db = new Database(file); db.exec('CREATE TABLE marker (label TEXT NOT NULL)'); + // test-sql-allow: the marker table is a bare fixture file no entity maps, and no ORM is bound to this handle. db.prepare('INSERT INTO marker (label) VALUES (?)').run(label); db.close(); } @@ -45,6 +46,7 @@ function writeDb(file: string, label: string): void { function readLabel(file: string): string { const db = new Database(file, { readonly: true }); try { + // test-sql-allow: reads the copied file on a read-only handle no ORM is bound to, from a table no entity maps. return (db.prepare('SELECT label FROM marker').get() as { label: string }).label; } finally { db.close(); @@ -102,6 +104,7 @@ describe('SqliteDatabaseBackup', () => { it('SQLBK-004: writes a consistent copy of the live database', async () => { // A write that sits in the WAL only: VACUUM INTO still carries it. + // test-sql-allow: the marker table is a bare fixture no entity maps; the write has to land in the WAL of this file. live.prepare('UPDATE marker SET label = ?').run('after'); const target = path.join(dir, 'copy.db'); const port = portWith(new MaintenanceRepository(orm.em)); diff --git a/server/tests/unit/nest/todo.service.test.ts b/server/tests/unit/nest/todo.service.test.ts index 35421007c..945e21bf0 100644 --- a/server/tests/unit/nest/todo.service.test.ts +++ b/server/tests/unit/nest/todo.service.test.ts @@ -124,7 +124,7 @@ describe('listItems and createItem', () => { const trip = createTrip(testDb, user.id); // Without the transaction both read the same MAX and both landed on 0. await Promise.all(['A', 'B', 'C'].map((name) => svc.createItem(trip.id, { name }))); - const orders = (testDb.prepare('SELECT sort_order FROM todo_items WHERE trip_id = ? ORDER BY sort_order').all(trip.id) as { sort_order: number }[]) + const orders = (await findRows(await sharedTestOrm(testDb), TodoItems, { trip: trip.id }, { sort_order: 'asc' })) .map((r) => r.sort_order); expect(orders).toEqual([0, 1, 2]); }); diff --git a/server/tests/unit/nest/trip-members.service.test.ts b/server/tests/unit/nest/trip-members.service.test.ts index b1e0fde38..b9b7fd722 100644 --- a/server/tests/unit/nest/trip-members.service.test.ts +++ b/server/tests/unit/nest/trip-members.service.test.ts @@ -50,8 +50,12 @@ import type { User } from '../../../src/types'; import { notificationsStub } from '../../helpers/notifications'; import { createTestUnitOfWork, createTestAppSettingsRepo, createTestUsersRepo, createTestTripsRepo, createTestTripMembersRepo, sharedTestOrm } from '../../helpers/test-uow'; import { budgetRepoArgs } from '../../helpers/budget-repos'; -import { countRows, findRow, insertRow, updateRows } from '../../helpers/factories/rows'; +import { countRows, deleteRows, findRow, findRows, insertRow, updateRows } from '../../helpers/factories/rows'; import { readUser } from '../../helpers/factories/users'; +import { makePlugin, setPluginUserConfig } from '../../helpers/factories/plugins'; +import { PluginUserConfig } from '../../../src/db/entities/PluginUserConfig.entity'; +import { PluginUserErasureQueue } from '../../../src/db/entities/PluginUserErasureQueue.entity'; +import { Plugins } from '../../../src/db/entities/Plugins.entity'; import { TripMembers } from '../../../src/db/entities/TripMembers.entity'; import { Trips } from '../../../src/db/entities/Trips.entity'; import { Users } from '../../../src/db/entities/Users.entity'; @@ -438,10 +442,8 @@ describe('Task 6 review items — rollback and concurrency', () => { const { user: owner } = createUser(testDb); const trip = createTrip(testDb, owner.id); const { member: guest } = await roster.createGuest(trip.id, 'Rollback Ray', owner.id); - testDb.prepare('INSERT INTO plugins (id, name, version, permissions) VALUES (?, ?, ?, ?)') - .run('members-svc-019', 'members-svc-019', '1.0.0', JSON.stringify(['hook:user-data'])); - testDb.prepare('INSERT INTO plugin_user_config (plugin_id, user_id, config) VALUES (?, ?, ?)') - .run('members-svc-019', guest.id, '{"token":"keep-me"}'); + await makePlugin(await orm(), 'members-svc-019', { permissions: JSON.stringify(['hook:user-data']) }); + await setPluginUserConfig(await orm(), 'members-svc-019', guest.id, { token: 'keep-me' }); const spy = vi.spyOn(usersRepo, 'deleteGuest').mockRejectedValueOnce(new Error('boom')); try { @@ -450,15 +452,15 @@ describe('Task 6 review items — rollback and concurrency', () => { // The erasure ran first, then the users delete rejected. Erasing outside the // transaction would have left the host rows deleted and an erasure queued for // a guest that still exists. - expect(testDb.prepare('SELECT id FROM users WHERE id = ?').get(guest.id)).toBeDefined(); - expect(testDb.prepare('SELECT user_id FROM plugin_user_config WHERE user_id = ?').get(guest.id)).toBeDefined(); - expect(testDb.prepare('SELECT plugin_id FROM plugin_user_erasure_queue WHERE user_id = ?').all(guest.id)).toEqual([]); + expect(await findRow(await orm(), Users, { id: guest.id })).not.toBeNull(); + expect(await findRow(await orm(), PluginUserConfig, { user_id: guest.id })).not.toBeNull(); + expect(await findRows(await orm(), PluginUserErasureQueue, { user_id: guest.id })).toEqual([]); } finally { spy.mockRestore(); // resetTestDb leaves the plugin tables alone, so this test cleans up its own rows. - for (const t of ['plugin_user_erasure_queue', 'plugin_user_config', 'plugins']) { - testDb.prepare(`DELETE FROM ${t} WHERE ${t === 'plugins' ? 'id' : 'plugin_id'} = ?`).run('members-svc-019'); - } + await deleteRows(await orm(), PluginUserErasureQueue, { plugin_id: 'members-svc-019' }); + await deleteRows(await orm(), PluginUserConfig, { plugin_id: 'members-svc-019' }); + await deleteRows(await orm(), Plugins, { id: 'members-svc-019' }); } }); diff --git a/server/tests/unit/nest/trips.service.test.ts b/server/tests/unit/nest/trips.service.test.ts index f68bca5f7..1d071480a 100644 --- a/server/tests/unit/nest/trips.service.test.ts +++ b/server/tests/unit/nest/trips.service.test.ts @@ -913,7 +913,7 @@ describe('resyncReservationDays (#1288)', () => { await expect(svc.updateTrip(trip.id, user.id, { start_date: '2025-06-02', end_date: '2025-06-06' }, 'user')).rejects.toThrow('boom'); - expect(testDb.prepare('SELECT start_date, end_date FROM trips WHERE id = ?').get(trip.id)) + expect(await storedFields(Trips, { id: trip.id }, ['start_date', 'end_date'])) .toEqual({ start_date: '2025-06-01', end_date: '2025-06-05' }); spy.mockRestore(); }); @@ -1443,11 +1443,12 @@ describe('TripsService wrapper helpers', () => { it('re-anchors the budget before the trip row leaves its old currency (#1543)', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - testDb.prepare("UPDATE trips SET currency = 'EUR' WHERE id = ?").run(trip.id); + await updateRows(await orm(), Trips, { id: trip.id }, { currency: 'EUR' }); const plan = { next: 'RUB', rates: null }; const seen: string[] = []; const prepareSpy = vi.spyOn(budgetSvc, 'prepareCurrencyRebase').mockResolvedValue(plan); const applySpy = vi.spyOn(budgetSvc, 'applyCurrencyRebase').mockImplementation(async () => { + // test-sql-allow: runs inside the update's open transaction, where an ORM read in a context of its own would wait on the held connection. seen.push((testDb.prepare('SELECT currency FROM trips WHERE id = ?').get(trip.id) as { currency: string }).currency); }); try { @@ -1456,7 +1457,7 @@ describe('TripsService wrapper helpers', () => { // The rebase reads the outgoing currency off the trip row, so it runs before the row moves. expect(applySpy).toHaveBeenCalledWith(trip.id, plan); expect(seen).toEqual(['EUR']); - expect(testDb.prepare('SELECT currency FROM trips WHERE id = ?').get(trip.id)).toEqual({ currency: 'RUB' }); + expect(await storedFields(Trips, { id: trip.id }, ['currency'])).toEqual({ currency: 'RUB' }); } finally { prepareSpy.mockRestore(); applySpy.mockRestore(); @@ -1466,16 +1467,16 @@ describe('TripsService wrapper helpers', () => { it('TRIP-SVC-092: a failing day rebuild leaves the budget on the old currency with the trip (#1543)', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { start_date: '2026-07-01', end_date: '2026-07-03' }); - testDb.prepare("UPDATE trips SET currency = 'EUR' WHERE id = ?").run(trip.id); + await updateRows(await orm(), Trips, { id: trip.id }, { currency: 'EUR' }); const item = createBudgetItem(testDb, trip.id, { total_price: 100 }); - testDb.prepare("UPDATE budget_items SET currency = 'USD', exchange_rate = 1.1 WHERE id = ?").run(item.id); + await updateRows(await orm(), BudgetItems, { id: item.id }, { currency: 'USD', exchange_rate: 1.1 }); // Rates come from the network in production; the plan is handed in so the real write runs. const prepareSpy = vi.spyOn(budgetSvc, 'prepareCurrencyRebase').mockResolvedValue({ next: 'JPY', rates: { USD: 0.0067, EUR: 0.0062 } }); const daysSpy = vi.spyOn(svc, 'generateDays').mockRejectedValue(new Error('boom')); try { await expect(svc.update(trip.id, user.id, { currency: 'JPY', end_date: '2026-07-05' }, 'user')).rejects.toThrow('boom'); - expect(testDb.prepare('SELECT currency, end_date FROM trips WHERE id = ?').get(trip.id)).toEqual({ currency: 'EUR', end_date: '2026-07-03' }); - expect(testDb.prepare('SELECT currency, exchange_rate FROM budget_items WHERE id = ?').get(item.id)).toEqual({ currency: 'USD', exchange_rate: 1.1 }); + expect(await storedFields(Trips, { id: trip.id }, ['currency', 'end_date'])).toEqual({ currency: 'EUR', end_date: '2026-07-03' }); + expect(await storedFields(BudgetItems, { id: item.id }, ['currency', 'exchange_rate'])).toEqual({ currency: 'USD', exchange_rate: 1.1 }); } finally { prepareSpy.mockRestore(); daysSpy.mockRestore(); diff --git a/server/tests/unit/nest/unified-memories.service.test.ts b/server/tests/unit/nest/unified-memories.service.test.ts index ecab2bad7..9c821080e 100644 --- a/server/tests/unit/nest/unified-memories.service.test.ts +++ b/server/tests/unit/nest/unified-memories.service.test.ts @@ -248,15 +248,15 @@ describe('addTripPhotos is atomic per photo', () => { it('MEM-UNIFIED-007b: the photo row and its trip link are one write: a failing link leaves no photo row', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - testDb.prepare( - 'INSERT OR IGNORE INTO photo_providers (id, name, description, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?)' - ).run('tx-prov', 'Tx', 'Tx provider', 'Image', 1, 98); + await insertRowIgnoringConflict(orm, PhotoProviders, { + id: 'tx-prov', name: 'Tx', description: 'Tx provider', icon: 'Image', enabled: 1, sort_order: 98, + }); const spy = vi.spyOn(tripPhotosRepo, 'insertIgnore').mockRejectedValueOnce(new Error('disk full')); const result = await addTripPhotos(String(trip.id), user.id, false, [{ provider: 'tx-prov', asset_ids: ['asset-tx'] }], 'sid'); expect(result.success).toBe(false); - expect(testDb.prepare("SELECT id FROM trek_photos WHERE asset_id = 'asset-tx'").all()).toEqual([]); + expect(await findRows(orm, TrekPhotos, { asset_id: 'asset-tx' })).toEqual([]); spy.mockRestore(); }); }); diff --git a/server/tests/unit/plugins/output-contract.schema.test.ts b/server/tests/unit/plugins/output-contract.schema.test.ts index 8d68c52e6..b656cea11 100644 --- a/server/tests/unit/plugins/output-contract.schema.test.ts +++ b/server/tests/unit/plugins/output-contract.schema.test.ts @@ -17,6 +17,11 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { createSnapshotTestDb } from '../../helpers/db-mock'; import { createPluginRpcHostParts } from '../../helpers/plugin-host'; +import { sharedTestOrm } from '../../helpers/test-uow'; +import { updateRows } from '../../helpers/factories/rows'; +import { readTripDays } from '../../helpers/factories/trips'; +import { tagPlace } from '../../helpers/factories/places'; +import { Trips } from '../../../src/db/entities/Trips.entity'; import { addTripMember, createBudgetItem, @@ -63,6 +68,7 @@ const tableOf = (entity: PluginEntityName): string => { }; const columnsOf = (table: string): string[] => + // test-sql-allow: the check is against the columns the migrated table has, which only PRAGMA table_info reports. (testDb.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>).map((c) => c.name); describe('the output contract against the migrated schema', () => { @@ -138,13 +144,12 @@ describe('the output contract drops only the withheld fields', () => { tripId = trip.id; addTripMember(testDb, trip.id, member.id); // A real credential in the row, so "withheld" is proven rather than vacuous. - testDb.prepare("UPDATE trips SET feed_token = 'feed-secret' WHERE id = ?").run(trip.id); - dayIds = (testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as Array<{ id: number }>).map( - (d) => d.id, - ); + const orm = await sharedTestOrm(testDb); + await updateRows(orm, Trips, { id: trip.id }, { feed_token: 'feed-secret' }); + dayIds = (await readTripDays(orm, trip.id)).map((d) => d.id); placeId = createPlace(testDb, trip.id).id; const tag = createTag(testDb, owner.id); - testDb.prepare('INSERT INTO place_tags (place_id, tag_id) VALUES (?, ?)').run(placeId, tag.id); + await tagPlace(orm, placeId, [tag.id]); createDayAssignment(testDb, dayIds[0], placeId); createDayNote(testDb, dayIds[0], trip.id); createBudgetItem(testDb, trip.id); diff --git a/server/tests/unit/plugins/plugin-oauth.service.test.ts b/server/tests/unit/plugins/plugin-oauth.service.test.ts index 1d8961550..7d415dd4d 100644 --- a/server/tests/unit/plugins/plugin-oauth.service.test.ts +++ b/server/tests/unit/plugins/plugin-oauth.service.test.ts @@ -40,7 +40,7 @@ import { Plugins } from '../../../src/db/entities/Plugins.entity'; import { PluginOauthTokens } from '../../../src/db/entities/PluginOauthTokens.entity'; import { PluginOauthState } from '../../../src/db/entities/PluginOauthState.entity'; import { PluginSettingsFields } from '../../../src/db/entities/PluginSettingsFields.entity'; -import { countRows, findRow, insertRow, updateRows } from '../../helpers/factories/rows'; +import { countRows, findRow, findRows, insertRow, updateRows } from '../../helpers/factories/rows'; const CFG = { oauth_authorize_url: 'https://provider.example/authorize', @@ -130,8 +130,8 @@ describe('PluginOAuthService', () => { const orm = await sharedTestOrm(getDb.current as Database.Database); vi.spyOn(orm.repo(PluginOauthState), 'insertState').mockRejectedValueOnce(new Error('disk full')); await expect(svc.startConnect('p', 42, NOW)).rejects.toThrow('disk full'); - const rows = getDb.current as unknown as InstanceType; - expect(rows.prepare('SELECT state FROM plugin_oauth_state WHERE user_id = 42').all()).toEqual([{ state: first }]); + const rows = await findRows(orm, PluginOauthState, { user_id: 42 }); + expect(rows.map(({ state }) => ({ state }))).toEqual([{ state: first }]); }); it('rejects a non-https / loopback / metadata / internal authorize endpoint', async () => {