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 () => {