diff --git a/server/tests/unit/nest/addons.service.test.ts b/server/tests/unit/nest/addons.service.test.ts index 3a3c0ede1..26536c1c3 100644 --- a/server/tests/unit/nest/addons.service.test.ts +++ b/server/tests/unit/nest/addons.service.test.ts @@ -2,7 +2,7 @@ * Unit tests for AddonsService — rebuilt on real rows (Plan 3a Task 4). * * The legacy version of this file fully stubbed DatabaseService at the - * `db.prepare(sql).{get,all,run}` level: a single shared fake `stmt` fed + * `db.prepare` level (its `get`, `all` and `run`): a single shared fake `stmt` fed * canned rows *in call order* (three `.all()` reads for addons/providers/ * fields, a `.get()` for the journey gate, and so on). That coupling cannot * survive AddonsService now calling four different repositories instead of @@ -26,6 +26,7 @@ import { createSnapshotTestDb } from '../../helpers/db-mock'; import { resetTestDb } from '../../helpers/test-db'; import { createUser } from '../../helpers/factories'; import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; +import { deleteRows, findRow, insertRow, updateRows, upsertRow } from '../../helpers/factories/rows'; import { AddonsService } from '../../../src/nest/addons/addons.service'; import { UnitOfWork } from '../../../src/nest/database/unit-of-work'; import { PlaceShadowService } from '../../../src/nest/place-shadow/place-shadow.service'; @@ -77,10 +78,11 @@ function providerFields(addon: ListAddon): PhotoProviderField[] { return (addon as ListAddon & { fields: PhotoProviderField[] }).fields; } -function insertAddon(row: { id: string; name: string; type?: string; icon?: string | null; enabled: 0 | 1; sort_order?: number }): void { - testDb - .prepare('INSERT INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, NULL, ?, ?, ?, ?)') - .run(row.id, row.name, row.type ?? 'global', row.icon ?? null, row.enabled, row.sort_order ?? 0); +async function insertAddon(row: { id: string; name: string; type?: string; icon?: string | null; enabled: 0 | 1; sort_order?: number }): Promise { + await insertRow(t, Addons, { + id: row.id, name: row.name, description: null, type: row.type ?? 'global', icon: row.icon ?? null, + enabled: row.enabled === 1, sort_order: row.sort_order ?? 0, + }); } /** @@ -100,13 +102,13 @@ function stubJourneyEnabled(): ReturnType { return vi.spyOn(addonsRepo, 'isEnabled').mockImplementation(async (id: string) => id === 'journey'); } -function insertProvider(row: { id: string; name: string; icon?: string | null; enabled: 0 | 1; sort_order?: number }): void { - testDb - .prepare('INSERT INTO photo_providers (id, name, description, icon, enabled, sort_order) VALUES (?, ?, NULL, ?, ?, ?)') - .run(row.id, row.name, row.icon ?? null, row.enabled, row.sort_order ?? 0); +async function insertProvider(row: { id: string; name: string; icon?: string | null; enabled: 0 | 1; sort_order?: number }): Promise { + await insertRow(t, PhotoProviders, { + id: row.id, name: row.name, description: null, icon: row.icon ?? null, enabled: row.enabled, sort_order: row.sort_order ?? 0, + }); } -function insertField(row: { +async function insertField(row: { provider_id: string; field_key: string; label: string; @@ -118,36 +120,29 @@ function insertField(row: { settings_key?: string | null; payload_key?: string | null; sort_order?: number; -}): void { - testDb - .prepare( - `INSERT INTO photo_provider_fields - (provider_id, field_key, label, input_type, placeholder, hint, required, secret, settings_key, payload_key, sort_order) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ) - .run( - row.provider_id, - row.field_key, - row.label, - row.input_type ?? 'text', - row.placeholder ?? null, - row.hint ?? null, - row.required ?? 0, - row.secret ?? 0, - row.settings_key ?? null, - row.payload_key ?? null, - row.sort_order ?? 0, - ); +}): Promise { + await insertRow(t, PhotoProviderFields, { + provider: row.provider_id, + field_key: row.field_key, + label: row.label, + input_type: row.input_type ?? 'text', + placeholder: row.placeholder ?? null, + hint: row.hint ?? null, + required: row.required ?? 0, + secret: row.secret ?? 0, + settings_key: row.settings_key ?? null, + payload_key: row.payload_key ?? null, + sort_order: row.sort_order ?? 0, + }); } -function setAppSetting(key: string, value: string | null): void { - testDb.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run(key, value); +async function setAppSetting(key: string, value: string | null): Promise { + await upsertRow(t, AppSettings, { key, value }); } -function rawAppSetting(key: string): { key: string; value: string | null } | undefined { - return testDb.prepare('SELECT key, value FROM app_settings WHERE key = ?').get(key) as - | { key: string; value: string | null } - | undefined; +async function storedAppSetting(key: string): Promise<{ key: string; value: string | null } | undefined> { + const row = await findRow(t, AppSettings, { key }); + return row ? { key: row.key!, value: row.value ?? null } : undefined; } beforeAll(async () => { @@ -187,8 +182,8 @@ afterAll(async () => { describe('AddonsService.list', () => { it('returns the collab features and the bag-tracking flag from app_settings', async () => { - setAppSetting('collab_chat_enabled', 'false'); - setAppSetting('bag_tracking_enabled', 'true'); + await setAppSetting('collab_chat_enabled', 'false'); + await setAppSetting('bag_tracking_enabled', 'true'); const res = await svc.list(); expect(res.collabFeatures).toEqual({ chat: false, notes: true, links: true, polls: true, whatsnext: true }); @@ -202,8 +197,8 @@ describe('AddonsService.list', () => { // enabled row that does (the entity's `enabled` is p.boolean(), so the // stored int comes back as a real JS boolean, not just truthy). it('only returns enabled addons, with the stored int coerced to a JS boolean', async () => { - insertAddon({ id: 'atlas', name: 'Atlas', type: 'page', icon: 'globe', enabled: 1 }); - insertAddon({ id: 'vacay', name: 'Vacay', type: 'page', icon: 'sun', enabled: 0 }); + await insertAddon({ id: 'atlas', name: 'Atlas', type: 'page', icon: 'globe', enabled: 1 }); + await insertAddon({ id: 'vacay', name: 'Vacay', type: 'page', icon: 'sun', enabled: 0 }); const res = await svc.list(); expect(res.addons).toEqual([{ id: 'atlas', name: 'Atlas', type: 'page', icon: 'globe', enabled: true }]); @@ -211,7 +206,7 @@ describe('AddonsService.list', () => { it('maps a photo provider with no fields to an empty fields array (the || [] fallback)', async () => { stubJourneyEnabled(); - insertProvider({ id: 'immich', name: 'Immich', icon: 'image', enabled: 1 }); + await insertProvider({ id: 'immich', name: 'Immich', icon: 'image', enabled: 1 }); getPhotoProviderConfig.mockReturnValue({ baseUrl: 'http://x' }); const res = await svc.list(); @@ -236,8 +231,8 @@ describe('AddonsService.list', () => { // AddonsService does the `!!` itself, unlike the Addons entity above). it('only returns enabled photo providers, with the stored integer coerced to a JS boolean', async () => { stubJourneyEnabled(); - insertProvider({ id: 'synology', name: 'Synology', icon: 'image', enabled: 1, sort_order: 1 }); - insertProvider({ id: 'off-provider', name: 'Off', icon: 'image', enabled: 0, sort_order: 2 }); + await insertProvider({ id: 'synology', name: 'Synology', icon: 'image', enabled: 1, sort_order: 1 }); + await insertProvider({ id: 'off-provider', name: 'Off', icon: 'image', enabled: 0, sort_order: 2 }); const res = await svc.list(); expect(res.addons.map((a) => (a as { id: string }).id)).toEqual(['synology']); @@ -246,8 +241,8 @@ describe('AddonsService.list', () => { it('groups multiple fields under their provider and keeps insertion order', async () => { stubJourneyEnabled(); - insertProvider({ id: 'immich', name: 'Immich', icon: 'image', enabled: 1 }); - insertField({ + await insertProvider({ id: 'immich', name: 'Immich', icon: 'image', enabled: 1 }); + await insertField({ provider_id: 'immich', field_key: 'url', label: 'URL', @@ -261,7 +256,7 @@ describe('AddonsService.list', () => { sort_order: 0, }); // Second field for the SAME provider exercises the `get(...) || []` truthy branch. - insertField({ + await insertField({ provider_id: 'immich', field_key: 'token', label: 'Token', @@ -302,8 +297,8 @@ describe('AddonsService.list', () => { it('falls back placeholder→"", hint→null, settings/payload keys→null when columns are missing/empty', async () => { stubJourneyEnabled(); - insertProvider({ id: 'p', name: 'P', icon: 'i', enabled: 1 }); - insertField({ provider_id: 'p', field_key: 'k', label: 'L' }); // placeholder/hint/settings_key/payload_key all null + await insertProvider({ id: 'p', name: 'P', icon: 'i', enabled: 1 }); + await insertField({ provider_id: 'p', field_key: 'k', label: 'L' }); // placeholder/hint/settings_key/payload_key all null const res = await svc.list(); const field = providerFields(res.addons[0])[0]; @@ -319,9 +314,9 @@ describe('AddonsService.list', () => { stubJourneyEnabled(); // A field exists, but for a DIFFERENT provider than the one returned — exercises // the `fieldsByProvider.get(p.id) || []` fallback while the map is non-empty. - insertProvider({ id: 'has-none', name: 'X', icon: 'i', enabled: 1 }); - insertProvider({ id: 'other', name: 'Other', icon: 'i', enabled: 0 }); // FK target for the field below - insertField({ provider_id: 'other', field_key: 'k', label: 'L' }); + await insertProvider({ id: 'has-none', name: 'X', icon: 'i', enabled: 1 }); + await insertProvider({ id: 'other', name: 'Other', icon: 'i', enabled: 0 }); // FK target for the field below + await insertField({ provider_id: 'other', field_key: 'k', label: 'L' }); const res = await svc.list(); expect(providerFields(res.addons[0])).toEqual([]); @@ -329,8 +324,8 @@ describe('AddonsService.list', () => { it('concatenates regular addons before the photo providers', async () => { stubJourneyEnabled(); - insertAddon({ id: 'atlas', name: 'Atlas', type: 'page', icon: 'globe', enabled: 1 }); - insertProvider({ id: 'immich', name: 'Immich', icon: 'image', enabled: 1 }); + await insertAddon({ id: 'atlas', name: 'Atlas', type: 'page', icon: 'globe', enabled: 1 }); + await insertProvider({ id: 'immich', name: 'Immich', icon: 'image', enabled: 1 }); const res = await svc.list(); expect(res.addons.map((a) => (a as { id: string }).id)).toEqual(['atlas', 'immich']); @@ -340,8 +335,8 @@ describe('AddonsService.list', () => { it('drops the photo providers while the journey addon is off, whatever their rows say', async () => { // No 'journey' row at all — isAddonEnabled('journey') reads false, same as // the legacy `!!undefined`. - insertAddon({ id: 'atlas', name: 'Atlas', type: 'page', icon: 'globe', enabled: 1 }); - insertProvider({ id: 'immich', name: 'Immich', icon: 'image', enabled: 1 }); + await insertAddon({ id: 'atlas', name: 'Atlas', type: 'page', icon: 'globe', enabled: 1 }); + await insertProvider({ id: 'immich', name: 'Immich', icon: 'image', enabled: 1 }); const listSpy = vi.spyOn(photoProvidersRepo, 'listEnabled'); const res = await svc.list(); @@ -356,37 +351,37 @@ describe('AddonsService.list', () => { // purpose: bag tracking is opt-in (=== 'true'), collab flags opt-out (!== 'false'). describe('AddonsService addon/feature flags', () => { it('isAddonEnabled coerces the enabled column (1/0/missing row)', async () => { - insertAddon({ id: 'budget', name: 'Budget', enabled: 1 }); + await insertAddon({ id: 'budget', name: 'Budget', enabled: 1 }); expect(await svc.isAddonEnabled('budget')).toBe(true); - testDb.prepare('UPDATE addons SET enabled = 0 WHERE id = ?').run('budget'); + await updateRows(t, Addons, { id: 'budget' }, { enabled: false }); expect(await svc.isAddonEnabled('budget')).toBe(false); expect(await svc.isAddonEnabled('nope')).toBe(false); }); it('getBagTracking is opt-in: only the literal string true enables it', async () => { - setAppSetting('bag_tracking_enabled', 'true'); + await setAppSetting('bag_tracking_enabled', 'true'); expect(await svc.getBagTracking()).toEqual({ enabled: true }); - setAppSetting('bag_tracking_enabled', 'false'); + await setAppSetting('bag_tracking_enabled', 'false'); expect(await svc.getBagTracking()).toEqual({ enabled: false }); - testDb.prepare("DELETE FROM app_settings WHERE key = 'bag_tracking_enabled'").run(); + await deleteRows(t, AppSettings, { key: 'bag_tracking_enabled' }); expect(await svc.getBagTracking()).toEqual({ enabled: false }); // absent row → OFF }); it('updateBagTracking persists true/false strings and echoes the flag (ADMIN-SVC-030)', async () => { expect(await svc.updateBagTracking(true)).toEqual({ enabled: true }); - expect(rawAppSetting('bag_tracking_enabled')).toEqual({ key: 'bag_tracking_enabled', value: 'true' }); + expect(await storedAppSetting('bag_tracking_enabled')).toEqual({ key: 'bag_tracking_enabled', value: 'true' }); expect(await svc.updateBagTracking(false)).toEqual({ enabled: false }); - expect(rawAppSetting('bag_tracking_enabled')).toEqual({ key: 'bag_tracking_enabled', value: 'false' }); + expect(await storedAppSetting('bag_tracking_enabled')).toEqual({ key: 'bag_tracking_enabled', value: 'false' }); }); it('getCollabFeatures is opt-out: absent rows default ON, only false disables', async () => { - setAppSetting('collab_chat_enabled', 'false'); - setAppSetting('collab_polls_enabled', 'true'); + await setAppSetting('collab_chat_enabled', 'false'); + await setAppSetting('collab_polls_enabled', 'true'); expect(await svc.getCollabFeatures()).toEqual({ chat: false, notes: true, links: true, polls: true, whatsnext: true }); }); @@ -400,7 +395,7 @@ describe('AddonsService addon/feature flags', () => { expect(first.features.chat).toBe(false); expect(setValueSpy).toHaveBeenCalledTimes(1); expect(setValueSpy).toHaveBeenCalledWith('collab_chat_enabled', 'false'); - expect(rawAppSetting('collab_chat_enabled')).toEqual({ key: 'collab_chat_enabled', value: 'false' }); + expect(await storedAppSetting('collab_chat_enabled')).toEqual({ key: 'collab_chat_enabled', value: 'false' }); // identical save → before and after read the same → no change, MCP sessions must survive setValueSpy.mockClear(); @@ -447,17 +442,17 @@ describe('AddonsService places flags', () => { it('ADDONS-SVC-080 an unset flag reads as OFF, and anything but the literal true does too', async () => { for (const [getter, , key] of cases) { - testDb.prepare('DELETE FROM app_settings WHERE key = ?').run(key); + await deleteRows(t, AppSettings, { key }); expect(await svc[getter]()).toEqual({ enabled: false }); - setAppSetting(key, 'garbage'); + await setAppSetting(key, 'garbage'); expect(await svc[getter]()).toEqual({ enabled: false }); } }); it('ADDONS-SVC-081 a stored "true" reads as ON', async () => { for (const [getter, , key] of cases) { - setAppSetting(key, 'true'); + await setAppSetting(key, 'true'); expect(await svc[getter]()).toEqual({ enabled: true }); } }); @@ -465,10 +460,10 @@ describe('AddonsService places flags', () => { it('ADDONS-SVC-082 the setters persist the literal string and echo the boolean back', async () => { for (const [, setter, key] of cases) { expect(await svc[setter](true)).toEqual({ enabled: true }); - expect(rawAppSetting(key)).toEqual({ key, value: 'true' }); + expect(await storedAppSetting(key)).toEqual({ key, value: 'true' }); expect(await svc[setter](false)).toEqual({ enabled: false }); - expect(rawAppSetting(key)).toEqual({ key, value: 'false' }); + expect(await storedAppSetting(key)).toEqual({ key, value: 'false' }); } }); }); @@ -489,21 +484,21 @@ describe('AddonsService places enrichment flag', () => { }); it('ADDONS-SVC-084 only the literal "false" switches it off', async () => { - setAppSetting('places_enrich_enabled', 'false'); + await setAppSetting('places_enrich_enabled', 'false'); expect(await svc.getPlacesEnrich()).toEqual({ enabled: false }); for (const value of ['true', 'garbage', '']) { - setAppSetting('places_enrich_enabled', value); + await setAppSetting('places_enrich_enabled', value); expect(await svc.getPlacesEnrich()).toEqual({ enabled: true }); } }); it('ADDONS-SVC-085 the setter persists the literal string and echoes the boolean back', async () => { expect(await svc.updatePlacesEnrich(false)).toEqual({ enabled: false }); - expect(rawAppSetting('places_enrich_enabled')).toEqual({ key: 'places_enrich_enabled', value: 'false' }); + expect(await storedAppSetting('places_enrich_enabled')).toEqual({ key: 'places_enrich_enabled', value: 'false' }); expect(await svc.updatePlacesEnrich(true)).toEqual({ enabled: true }); - expect(rawAppSetting('places_enrich_enabled')).toEqual({ key: 'places_enrich_enabled', value: 'true' }); + expect(await storedAppSetting('places_enrich_enabled')).toEqual({ key: 'places_enrich_enabled', value: 'true' }); }); }); @@ -524,21 +519,21 @@ describe('AddonsService transit provider', () => { }); it('ADDONS-SVC-087 only a known provider name is honoured', async () => { - setAppSetting('transit_provider', 'google'); + await setAppSetting('transit_provider', 'google'); expect((await svc.getTransitProvider()).provider).toBe('google'); for (const value of ['someday-maps', '', 'GOOGLE']) { - setAppSetting('transit_provider', value); + await setAppSetting('transit_provider', value); expect((await svc.getTransitProvider()).provider).toBe('transitous'); } }); it('ADDONS-SVC-088 the setter persists the name and echoes it back', async () => { expect((await svc.updateTransitProvider('google')).provider).toBe('google'); - expect(rawAppSetting('transit_provider')).toEqual({ key: 'transit_provider', value: 'google' }); + expect(await storedAppSetting('transit_provider')).toEqual({ key: 'transit_provider', value: 'google' }); expect((await svc.updateTransitProvider('transitous')).provider).toBe('transitous'); - expect(rawAppSetting('transit_provider')).toEqual({ key: 'transit_provider', value: 'transitous' }); + expect(await storedAppSetting('transit_provider')).toEqual({ key: 'transit_provider', value: 'transitous' }); }); /** @@ -547,14 +542,14 @@ describe('AddonsService transit provider', () => { * means "works for this admin, Transitous for everybody else". */ it('ADDONS-SVC-089 reports where the Google key resolved from', async () => { - setAppSetting('transit_provider', 'google'); - setAppSetting('maps_api_key', 'instance-key'); + await setAppSetting('transit_provider', 'google'); + await setAppSetting('maps_api_key', 'instance-key'); expect((await svc.getTransitProvider(7)).googleKeySource).toBe('instance'); // No instance row, but the caller's own users column has one. - testDb.prepare("DELETE FROM app_settings WHERE key = 'maps_api_key'").run(); + await deleteRows(t, AppSettings, { key: 'maps_api_key' }); const { user } = createUser(testDb); - testDb.prepare('UPDATE users SET maps_api_key = ? WHERE id = ?').run('personal-key', user.id); + await updateRows(t, Users, { id: user.id }, { maps_api_key: 'personal-key' }); expect((await svc.getTransitProvider(user.id)).googleKeySource).toBe('user-row'); // Nothing anywhere. @@ -570,21 +565,21 @@ describe('AddonsService transit provider', () => { */ describe('AddonsService places Google-only flag', () => { it('ADDONS-SVC-095 an unset flag reads as OFF, only the literal "true" switches it on', async () => { - expect(rawAppSetting('places_google_only')).toBeUndefined(); + expect(await storedAppSetting('places_google_only')).toBeUndefined(); expect(await svc.getPlacesGoogleOnly()).toEqual({ enabled: false }); for (const value of ['false', 'TRUE', '1', '']) { - setAppSetting('places_google_only', value); + await setAppSetting('places_google_only', value); expect(await svc.getPlacesGoogleOnly()).toEqual({ enabled: false }); } - setAppSetting('places_google_only', 'true'); + await setAppSetting('places_google_only', 'true'); expect(await svc.getPlacesGoogleOnly()).toEqual({ enabled: true }); }); it('ADDONS-SVC-096 the setter persists the literal string under its own key', async () => { expect(await svc.updatePlacesGoogleOnly(true)).toEqual({ enabled: true }); - expect(rawAppSetting('places_google_only')).toEqual({ key: 'places_google_only', value: 'true' }); + expect(await storedAppSetting('places_google_only')).toEqual({ key: 'places_google_only', value: 'true' }); expect(await svc.updatePlacesGoogleOnly(false)).toEqual({ enabled: false }); - expect(rawAppSetting('places_google_only')).toEqual({ key: 'places_google_only', value: 'false' }); + expect(await storedAppSetting('places_google_only')).toEqual({ key: 'places_google_only', value: 'false' }); }); }); @@ -601,28 +596,28 @@ describe('AddonsService place shadow flag', () => { expect(await svc.getPlaceShadow()).toEqual({ enabled: false }); for (const value of ['false', 'TRUE', '1', '']) { - setAppSetting('place_shadow_enabled', value); + await setAppSetting('place_shadow_enabled', value); expect(await svc.getPlaceShadow()).toEqual({ enabled: false }); } }); it('ADDONS-SVC-091 a stored "true" reads as ON', async () => { - setAppSetting('place_shadow_enabled', 'true'); + await setAppSetting('place_shadow_enabled', 'true'); expect(await svc.getPlaceShadow()).toEqual({ enabled: true }); }); it('ADDONS-SVC-092 the setter round-trips through the getter under its own key', async () => { expect(await svc.updatePlaceShadow(true)).toEqual({ enabled: true }); - expect(rawAppSetting('place_shadow_enabled')).toEqual({ key: 'place_shadow_enabled', value: 'true' }); + expect(await storedAppSetting('place_shadow_enabled')).toEqual({ key: 'place_shadow_enabled', value: 'true' }); expect(await svc.getPlaceShadow()).toEqual({ enabled: true }); // a sibling switch must not ride along expect(await svc.getPlacesDetails()).toEqual({ enabled: false }); expect(await svc.updatePlaceShadow(false)).toEqual({ enabled: false }); - expect(rawAppSetting('place_shadow_enabled')).toEqual({ key: 'place_shadow_enabled', value: 'false' }); + expect(await storedAppSetting('place_shadow_enabled')).toEqual({ key: 'place_shadow_enabled', value: 'false' }); expect(await svc.getPlaceShadow()).toEqual({ enabled: false }); // no sibling key was ever written - expect(rawAppSetting('places_details_enabled')).toBeUndefined(); + expect(await storedAppSetting('places_details_enabled')).toBeUndefined(); }); it('ADDONS-SVC-093 answers the same as PlaceShadowService.enabled() for every stored value', async () => { @@ -635,8 +630,8 @@ describe('AddonsService place shadow flag', () => { ]; for (const [value, expected] of cases) { - if (value === undefined) testDb.prepare("DELETE FROM app_settings WHERE key = 'place_shadow_enabled'").run(); - else setAppSetting('place_shadow_enabled', value); + if (value === undefined) await deleteRows(t, AppSettings, { key: 'place_shadow_enabled' }); + else await setAppSetting('place_shadow_enabled', value); expect(await svc.getPlaceShadow()).toEqual({ enabled: expected }); expect(await shadow.enabled()).toBe(expected); diff --git a/server/tests/unit/nest/admin.version-notification.test.ts b/server/tests/unit/nest/admin.version-notification.test.ts index 0bd80ac06..2668d2a25 100644 --- a/server/tests/unit/nest/admin.version-notification.test.ts +++ b/server/tests/unit/nest/admin.version-notification.test.ts @@ -74,6 +74,9 @@ import { createTestPlacesRepo, sharedTestOrm, } from '../../helpers/test-uow'; +import { countRows, findRow, findRows } from '../../helpers/factories/rows'; +import { readAppSetting, setAppSetting } from '../../helpers/factories/settings'; +import { Notifications } from '../../../src/db/entities/Notifications.entity'; import { AuditLog } from '../../../src/db/entities/AuditLog.entity'; import { Addons } from '../../../src/db/entities/Addons.entity'; import { PhotoProviders } from '../../../src/db/entities/PhotoProviders.entity'; @@ -165,16 +168,12 @@ function mockGitHubFetchFailure(): void { vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error'))); } -function getLastNotifiedVersion(): string | undefined { - return ( - testDb.prepare('SELECT value FROM app_settings WHERE key = ?').get('last_notified_version') as - | { value: string } - | undefined - )?.value; +async function getLastNotifiedVersion(): Promise { + return (await readAppSetting(await sharedTestOrm(testDb), 'last_notified_version')) ?? undefined; } -function getNotificationCount(): number { - return (testDb.prepare('SELECT COUNT(*) as c FROM notifications').get() as { c: number }).c; +async function getNotificationCount(): Promise { + return countRows(await sharedTestOrm(testDb), Notifications); } beforeEach(() => { @@ -201,8 +200,8 @@ describe('checkAndNotifyVersion', () => { await checkAndNotifyVersion(); - expect(getNotificationCount()).toBe(0); - expect(getLastNotifiedVersion()).toBeUndefined(); + expect(await getNotificationCount()).toBe(0); + expect(await getLastNotifiedVersion()).toBeUndefined(); }); it('VNOTIF-002 — creates a navigate notification for all admins when update available', async () => { @@ -212,11 +211,7 @@ describe('checkAndNotifyVersion', () => { await checkAndNotifyVersion(); - const notifications = testDb.prepare('SELECT * FROM notifications ORDER BY id').all() as Array<{ - recipient_id: number; - type: string; - scope: string; - }>; + const notifications = await findRows(await sharedTestOrm(testDb), Notifications, {}, { id: 'asc' }); expect(notifications.length).toBe(2); const recipientIds = notifications.map((n) => n.recipient_id); expect(recipientIds).toContain(admin1.id); @@ -231,7 +226,7 @@ describe('checkAndNotifyVersion', () => { await checkAndNotifyVersion(); - expect(getLastNotifiedVersion()).toBe('99.1.0'); + expect(await getLastNotifiedVersion()).toBe('99.1.0'); }); it('VNOTIF-004 — does NOT create duplicate notification if last_notified_version matches', async () => { @@ -240,26 +235,24 @@ describe('checkAndNotifyVersion', () => { // First call notifies await checkAndNotifyVersion(); - const countAfterFirst = getNotificationCount(); + const countAfterFirst = await getNotificationCount(); expect(countAfterFirst).toBe(1); // Second call with same version — should not create another await checkAndNotifyVersion(); - expect(getNotificationCount()).toBe(countAfterFirst); + expect(await getNotificationCount()).toBe(countAfterFirst); }); it('VNOTIF-005 — creates new notification when last_notified_version is an older version', async () => { createAdmin(testDb); // Simulate having been notified about an older version - testDb - .prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)') - .run('last_notified_version', '98.0.0'); + await setAppSetting(await sharedTestOrm(testDb), 'last_notified_version', '98.0.0'); mockGitHubLatest('v99.3.0'); await checkAndNotifyVersion(); - expect(getNotificationCount()).toBe(1); - expect(getLastNotifiedVersion()).toBe('99.3.0'); + expect(await getNotificationCount()).toBe(1); + expect(await getLastNotifiedVersion()).toBe('99.3.0'); }); it('VNOTIF-006 — notification has correct type, scope, and navigate_target', async () => { @@ -268,14 +261,7 @@ describe('checkAndNotifyVersion', () => { await checkAndNotifyVersion(); - const notif = testDb.prepare('SELECT * FROM notifications LIMIT 1').get() as { - type: string; - scope: string; - navigate_target: string; - title_key: string; - text_key: string; - navigate_text_key: string; - }; + const notif = (await findRow(await sharedTestOrm(testDb), Notifications, {}))!; expect(notif.type).toBe('navigate'); expect(notif.scope).toBe('admin'); expect(notif.navigate_target).toBe('/admin'); @@ -290,7 +276,7 @@ describe('checkAndNotifyVersion', () => { // Should not throw await expect(checkAndNotifyVersion()).resolves.toBeUndefined(); - expect(getNotificationCount()).toBe(0); - expect(getLastNotifiedVersion()).toBeUndefined(); + expect(await getNotificationCount()).toBe(0); + expect(await getLastNotifiedVersion()).toBeUndefined(); }); }); diff --git a/server/tests/unit/nest/boot-restore.test.ts b/server/tests/unit/nest/boot-restore.test.ts index be03621ed..983c472ca 100644 --- a/server/tests/unit/nest/boot-restore.test.ts +++ b/server/tests/unit/nest/boot-restore.test.ts @@ -40,6 +40,7 @@ afterEach(() => { function trekDb(file: string, tables = ['users', 'trips', 'trip_members', 'places', 'days']) { const db = new Database(file); for (const t of tables) db.exec(`CREATE TABLE ${t} (id INTEGER PRIMARY KEY, name TEXT)`); + // test-sql-allow: a hand-made backup database, before any boot; no ORM is bound to it. if (tables.includes('users')) db.prepare('INSERT INTO users (name) VALUES (?)').run('from-backup'); db.close(); } @@ -80,6 +81,7 @@ describe('restoreOnFirstBoot', () => { expect(out.restored).toBe(true); const db = new Database(dbFile, { readonly: true }); + // test-sql-allow: the restored file is read before any boot; no ORM is bound to it. expect(db.prepare('SELECT name FROM users').get()).toEqual({ name: 'from-backup' }); db.close(); expect(fs.readFileSync(path.join(dataDir, '.encryption_key'), 'utf8')).toBe('a'.repeat(64)); diff --git a/server/tests/unit/nest/budget.service.calc.test.ts b/server/tests/unit/nest/budget.service.calc.test.ts index 73c63ade5..052537acd 100644 --- a/server/tests/unit/nest/budget.service.calc.test.ts +++ b/server/tests/unit/nest/budget.service.calc.test.ts @@ -38,6 +38,14 @@ import type { BudgetItem, BudgetItemMember, BudgetItemPayer } from '../../../src import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; import { createTestUnitOfWork } from '../../helpers/test-uow'; import { budgetRepoArgs } from '../../helpers/budget-repos'; +import { sharedTestOrm } from '../../helpers/test-uow'; +import { deleteRows, findRow, insertRow } from '../../helpers/factories/rows'; +import { BudgetItemMembers } from '../../../src/db/entities/BudgetItemMembers.entity'; +import { BudgetItemPayers } from '../../../src/db/entities/BudgetItemPayers.entity'; +import { BudgetItems } from '../../../src/db/entities/BudgetItems.entity'; +import { BudgetSettlements } from '../../../src/db/entities/BudgetSettlements.entity'; +import { Trips } from '../../../src/db/entities/Trips.entity'; +import { Users } from '../../../src/db/entities/Users.entity'; const mockRates = { getRates: vi.fn() }; @@ -98,22 +106,19 @@ const centSum = (values: number[]) => values.reduce((a, v) => a + Math.round(v * /** Every trip in this suite is id 1, owned by a user no test ever names or asserts on. */ const GHOST_OWNER_ID = 999999; -function seedBaseline() { - testDb.prepare( - 'INSERT INTO users (id, username, email, password_hash, role) VALUES (?, ?, ?, ?, ?)', - ).run(GHOST_OWNER_ID, 'ghost_owner', 'ghost_owner@test.example.com', 'x', 'user'); - testDb.prepare( - "INSERT INTO trips (id, user_id, title, currency) VALUES (1, ?, 'Trip', 'EUR')", - ).run(GHOST_OWNER_ID); +async function seedBaseline() { + const orm = await sharedTestOrm(testDb); + await insertRow(orm, Users, { id: GHOST_OWNER_ID, username: 'ghost_owner', email: 'ghost_owner@test.example.com', password_hash: 'x', role: 'user' }); + await insertRow(orm, Trips, { id: 1, user: GHOST_OWNER_ID, title: 'Trip', currency: 'EUR' }); } -function seedUser(id: number, username: string) { - testDb.prepare( - 'INSERT INTO users (id, username, email, password_hash, role) VALUES (?, ?, ?, ?, ?)', - ).run(id, username, `${username}.${id}@test.example.com`, 'x', 'user'); +async function seedUser(id: number, username: string) { + await insertRow(await sharedTestOrm(testDb), Users, { + id, username, email: `${username}.${id}@test.example.com`, password_hash: 'x', role: 'user', + }); } -function setupDb( +async function setupDb( items: BudgetItem[], members: (BudgetItemMember & { budget_item_id: number })[], payers: (BudgetItemPayer & { budget_item_id: number })[] = [], @@ -123,11 +128,12 @@ function setupDb( // after booking its own offered flows), so a second call must not collide with // the first's rows. Children before parents; users last since settlements and // items/members/payers all reference them. - testDb.exec('DELETE FROM budget_settlements'); - testDb.exec('DELETE FROM budget_item_payers'); - testDb.exec('DELETE FROM budget_item_members'); - testDb.exec('DELETE FROM budget_items'); - testDb.prepare('DELETE FROM users WHERE id != ?').run(GHOST_OWNER_ID); + const orm = await sharedTestOrm(testDb); + await deleteRows(orm, BudgetSettlements); + await deleteRows(orm, BudgetItemPayers); + await deleteRows(orm, BudgetItemMembers); + await deleteRows(orm, BudgetItems); + await deleteRows(orm, Users, { id: { $ne: GHOST_OWNER_ID } }); // Every user id these rows reference, with the username the fixture gave it — // members/payers carry one explicitly; a settlement-only party (no expense @@ -139,55 +145,52 @@ function setupDb( if (!usernames.has(s.from_user_id)) usernames.set(s.from_user_id, s.from_username); if (!usernames.has(s.to_user_id)) usernames.set(s.to_user_id, s.to_username); } - for (const [id, username] of usernames) seedUser(id, username); + for (const [id, username] of usernames) await seedUser(id, username); for (const item of items) { - testDb.prepare( - 'INSERT INTO budget_items (id, trip_id, name, total_price, currency, exchange_rate) VALUES (?, ?, ?, ?, ?, ?)', - ).run(item.id, item.trip_id, item.name, item.total_price, item.currency ?? null, item.exchange_rate ?? 1); + await insertRow(orm, BudgetItems, { + id: item.id, trip: item.trip_id, name: item.name, total_price: item.total_price, + currency: item.currency ?? null, exchange_rate: item.exchange_rate ?? 1, + }); } for (const m of members) { - testDb.prepare( - 'INSERT INTO budget_item_members (budget_item_id, user_id, paid, amount) VALUES (?, ?, ?, ?)', - ).run(m.budget_item_id, m.user_id, m.paid ?? 0, m.amount ?? null); + await insertRow(orm, BudgetItemMembers, { budgetItem: m.budget_item_id, user: m.user_id, paid: m.paid ?? 0, amount: m.amount ?? null }); } for (const p of payers) { - testDb.prepare( - 'INSERT INTO budget_item_payers (budget_item_id, user_id, amount) VALUES (?, ?, ?)', - ).run(p.budget_item_id, p.user_id, p.amount); + await insertRow(orm, BudgetItemPayers, { budgetItem: p.budget_item_id, user: p.user_id, amount: p.amount }); } for (const s of settlements) { - testDb.prepare( - 'INSERT INTO budget_settlements (id, trip_id, from_user_id, to_user_id, amount, currency, exchange_rate) VALUES (?, 1, ?, ?, ?, ?, ?)', - ).run(s.id, s.from_user_id, s.to_user_id, s.amount, s.currency, s.exchange_rate); + await insertRow(orm, BudgetSettlements, { + id: s.id, trip: 1, fromUser: s.from_user_id, toUser: s.to_user_id, amount: s.amount, currency: s.currency, exchange_rate: s.exchange_rate, + }); } } -beforeEach(() => { +beforeEach(async () => { vi.clearAllMocks(); resetTestDb(testDb); - seedBaseline(); - setupDb([], [], []); + await seedBaseline(); + await setupDb([], [], []); }); // ── calculateSettlement ────────────────────────────────────────────────────── describe('calculateSettlement', () => { it('returns empty balances and flows when trip has no items', async () => { - setupDb([], [], []); + await setupDb([], [], []); const result = await budget.calculateSettlement(1); expect(result.balances).toEqual([]); expect(result.flows).toEqual([]); }); it('returns no flows when there are items but no members', async () => { - setupDb([makeItem(1, 100)], [], [makePayer(1, 1, 100, 'alice')]); + await setupDb([makeItem(1, 100)], [], [makePayer(1, 1, 100, 'alice')]); const result = await budget.calculateSettlement(1); expect(result.flows).toEqual([]); }); it('returns no flows when no one has paid', async () => { - setupDb( + await setupDb( [makeItem(1, 100)], [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')], [], @@ -201,7 +204,7 @@ describe('calculateSettlement', () => { it('2 members, 1 payer: payer is owed half, non-payer owes half', async () => { // Item: $100. Alice paid all, [Alice, Bob] split. Each owes $50. Alice net: +$50. Bob: -$50. - setupDb( + await setupDb( [makeItem(1, 100)], [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')], [makePayer(1, 1, 100, 'alice')], @@ -219,7 +222,7 @@ describe('calculateSettlement', () => { it('3 members, 1 payer: correct 3-way split', async () => { // Item: $90. Alice paid. Each of 3 owes $30. Alice net: +$60. Bob: -$30. Carol: -$30. - setupDb( + await setupDb( [makeItem(1, 90)], [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')], [makePayer(1, 1, 90, 'alice')], @@ -236,7 +239,7 @@ describe('calculateSettlement', () => { it('all paid equally: all balances are zero, no flows', async () => { // Item: $60. 3 members, each paid $20 and owes $20. Net: 0 for everyone. - setupDb( + await setupDb( [makeItem(1, 60)], [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')], [makePayer(1, 1, 20, 'alice'), makePayer(1, 2, 20, 'bob'), makePayer(1, 3, 20, 'carol')], @@ -252,7 +255,7 @@ describe('calculateSettlement', () => { it('flow direction: from is debtor (owes), to is creditor (is owed)', async () => { // Alice paid $100 for 2 people. Bob owes Alice $50. - setupDb( + await setupDb( [makeItem(1, 100)], [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')], [makePayer(1, 1, 100, 'alice')], @@ -265,7 +268,7 @@ describe('calculateSettlement', () => { it('amounts are rounded to 2 decimal places', async () => { // Item: $10. 3 members, 1 payer. Share = 3.333... Each rounded to 3.33. - setupDb( + await setupDb( [makeItem(1, 10)], [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')], [makePayer(1, 1, 10, 'alice')], @@ -287,7 +290,7 @@ describe('calculateSettlement', () => { // Item 1: $100, Alice paid, [Alice, Bob] (Alice net: +50, Bob: -50) // Item 2: $60, Bob paid, [Alice, Bob] (Bob net: +30, Alice: -30) // Final: Alice: +50 - 30 = +20, Bob: -50 + 30 = -20 - setupDb( + await setupDb( [makeItem(1, 100), makeItem(2, 60)], [ makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), @@ -306,7 +309,7 @@ describe('calculateSettlement', () => { it('counts a settlement with no matching expense as an amount still to square up', async () => { // bob paid alice 30 but every expense behind it was deleted: alice now owes bob. - setupDb([], [], [], [makeSettlementRow(1, 2, 1, 30)]); + await setupDb([], [], [], [makeSettlementRow(1, 2, 1, 30)]); const result = await budget.calculateSettlement(1); const alice = result.balances.find(b => b.user_id === 1)!; const bob = result.balances.find(b => b.user_id === 2)!; @@ -321,7 +324,7 @@ describe('calculateSettlement', () => { // $110 booked at a frozen rate of 1.1 (USD per 1 EUR) = 100 EUR. Live rates have since // drifted to 1.2, but the converted amount must stay on the frozen rate so an already // settled position isn't re-opened with a residual. - setupDb( + await setupDb( [{ ...makeItem(1, 110), currency: 'USD', exchange_rate: 1.1 } as BudgetItem], [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')], [makePayer(1, 1, 110, 'alice')], @@ -333,7 +336,7 @@ describe('calculateSettlement', () => { }); it('#1335 a legacy row (exchange_rate = 1) still converts with live rates', async () => { - setupDb( + await setupDb( [{ ...makeItem(1, 120), currency: 'USD', exchange_rate: 1 } as BudgetItem], [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')], [makePayer(1, 1, 120, 'alice')], @@ -349,7 +352,7 @@ describe('calculateSettlement', () => { // at the settle-time rate of 1.25 USD/EUR. He records that 62.50 USD transfer, which // freezes currency=USD, exchange_rate=1.25. Live rates have since drifted (now the // recompute passes EUR=0.5 per 1 USD), but the frozen transfer still nets to -50+50=0. - setupDb( + await setupDb( [makeItem(1, 100)], // trip-currency expense (currency NULL) [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')], [makePayer(1, 1, 100, 'alice')], @@ -364,7 +367,7 @@ describe('calculateSettlement', () => { // Same shape, but the transfer predates the fix (currency NULL). It is re-converted // to trip currency with live rates, so a drift re-opens the position — unchanged // legacy behaviour that normalises once the row is re-edited. - setupDb( + await setupDb( [makeItem(1, 100)], [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')], [makePayer(1, 1, 100, 'alice')], @@ -383,7 +386,7 @@ describe('calculateSettlement', () => { it('2 payers, 3 members: each payer is credited what they actually paid', async () => { // $90 bill split 3 ways ($30 each). Alice and Bob each fronted $45. // Alice: +45 - 30 = +15. Bob: +45 - 30 = +15. Carol: -30. - setupDb( + await setupDb( [makeItem(1, 90)], [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')], [makePayer(1, 1, 45, 'alice'), makePayer(1, 2, 45, 'bob')], @@ -399,7 +402,7 @@ describe('calculateSettlement', () => { it('2 payers with unequal amounts: credits follow the actual amounts paid', async () => { // $100 bill split 2 ways ($50 each). Alice fronted $70, Bob fronted $30. // Alice: +70 - 50 = +20. Bob: +30 - 50 = -20. Bob owes Alice $20. - setupDb( + await setupDb( [makeItem(1, 100)], [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')], [makePayer(1, 1, 70, 'alice'), makePayer(1, 2, 30, 'bob')], @@ -417,7 +420,7 @@ describe('calculateSettlement', () => { it('multi-payer balances sum to zero (no money invented or destroyed)', async () => { // The invariant that makes settle-up trustworthy: credits == debits. - setupDb( + await setupDb( [makeItem(1, 90), makeItem(2, 55)], [ makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol'), @@ -436,7 +439,7 @@ describe('calculateSettlement', () => { it('3 payers on one bill: an odd total still splits to the cent', async () => { // $100.01 split 3 ways. splitEqualShares distributes the remainder cent, // so debits must still exactly cancel the $100.01 of credits. - setupDb( + await setupDb( [makeItem(1, 100.01)], [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')], [makePayer(1, 1, 33.34, 'alice'), makePayer(1, 2, 33.34, 'bob'), makePayer(1, 3, 33.33, 'carol')], @@ -453,7 +456,7 @@ describe('calculateSettlement — negative amounts (#2176)', () => { it('a refund credits the members and debits its recipient', async () => { // A 30 € refund lands with Alice; all three had shared the original cost. // Alice received 30 but only 10 of it was hers, so she owes 20. - setupDb( + await setupDb( [makeItem(1, -30)], [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')], [makePayer(1, 1, -30, 'alice')], @@ -470,7 +473,7 @@ describe('calculateSettlement — negative amounts (#2176)', () => { it('a refund reduces the debt its expense created', async () => { // 90 € dinner fronted by Alice, then a 30 € partial reimbursement she keeps — // both split three ways. Bob's debt drops from 30 to 20. - setupDb( + await setupDb( [makeItem(1, 90), makeItem(2, -30)], [ makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol'), @@ -490,7 +493,7 @@ describe('calculateSettlement — negative amounts (#2176)', () => { }); it('an odd negative total still nets to exactly zero', async () => { - setupDb( + await setupDb( [makeItem(1, -100.01)], [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')], [makePayer(1, 1, -100.01, 'alice')], @@ -502,7 +505,7 @@ describe('calculateSettlement — negative amounts (#2176)', () => { it('a negative custom split settles by the custom amounts', async () => { // 100 € refund to Alice; by agreement Bob is owed 70 of it, Carol 30. - setupDb( + await setupDb( [makeItem(1, -100)], [ { ...makeMember(1, 2, 'bob'), amount: -70 }, @@ -523,7 +526,7 @@ describe('calculateSettlement — negative amounts (#2176)', () => { // Nobody is recorded as having received the refund, so there is no credit to // hand back: the row is outstanding, not a debt the trip owes its members. // It used to credit all three 30 € out of thin air. - setupDb( + await setupDb( [makeItem(1, -90)], [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')], [], @@ -572,7 +575,7 @@ describe('calculateSettlement — finalBudgets', () => { // Alice fronts 100 for the two of them. Nothing has been paid back yet, so the // trip costs each of them 50: Alice is out 100 with 50 still coming, Bob is out // nothing with 50 still to pay. - setupDb( + await setupDb( [makeItem(1, 100)], [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')], [makePayer(1, 1, 100, 'alice')], @@ -589,7 +592,7 @@ describe('calculateSettlement — finalBudgets', () => { it('a recorded transfer moves out of pending and into reimbursed, leaving the final alone', async () => { // Bob pays his 50 back. What the trip costs either of them cannot change — only // which of the two lines under it the 50 now sits on. - setupDb( + await setupDb( [makeItem(1, 100)], [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')], [makePayer(1, 1, 100, 'alice')], @@ -607,7 +610,7 @@ describe('calculateSettlement — finalBudgets', () => { it('sums the finals to what the trip actually spent', async () => { // 90 + 60 across three people, fronted by two of them. However the debts are // arranged, the trip cost the group exactly what it spent. - setupDb( + await setupDb( [makeItem(1, 90), makeItem(2, 60)], [ makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol'), @@ -626,7 +629,7 @@ describe('calculateSettlement — finalBudgets', () => { it('follows a custom split rather than an equal one', async () => { // Alice fronts 100 but only owes 20 of it — the split says so. - setupDb( + await setupDb( [makeItem(1, 100)], [ { ...makeMember(1, 1, 'alice'), amount: 20 }, @@ -643,7 +646,7 @@ describe('calculateSettlement — finalBudgets', () => { it('leaves an expense nobody paid out of the final budget (#2225)', async () => { // The unpaid row stays out of the ledger, so it cannot charge anybody either. - setupDb( + await setupDb( [makeItem(1, 90)], [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')], [], @@ -656,7 +659,7 @@ describe('calculateSettlement — finalBudgets', () => { it('gives a refund back to whoever was charged for it (#2176)', async () => { // A 30 refund Alice received, split between the two of them: each is 15 better // off, so the trip costs them -15 on this row alone. - setupDb( + await setupDb( [makeItem(1, -30)], [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')], [makePayer(1, 1, -30, 'alice')], @@ -672,7 +675,7 @@ describe('calculateSettlement — finalBudgets', () => { // The three lines are converted as their own sets, like the balances are, and // the final is subtracted afterwards — so what the breakdown prints adds up in // whatever currency the viewer picked, at whatever the live rate happens to be. - setupDb( + await setupDb( [makeItem(1, 100)], [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')], [makePayer(1, 1, 100, 'alice')], @@ -693,7 +696,7 @@ describe('calculateSettlement — finalBudgets', () => { // Bob handed Alice 40 with no expense on the trip to justify it. Alice has the // 40 but owes it straight back, so the trip has cost neither of them anything — // the transfer shows up as reimbursed on one line and outstanding on the next. - setupDb([], [], [], [makeSettlementRow(1, 2, 1, 40)]); + await setupDb([], [], [], [makeSettlementRow(1, 2, 1, 40)]); const result = await budget.calculateSettlement(1); expect(result.finalBudgets.find(f => f.user_id === 1)!).toMatchObject({ expenses: 0, reimbursed: 40, pending: -40, final: 0 }); @@ -705,7 +708,7 @@ describe('calculateSettlement — finalBudgets', () => { // The first case read row by row, with 20 of Bob's 50 already sent: Alice // fronted the one expense, received the 20, and the open flow is the 30 coming // to her; on Bob's side the same transfer and flow are going out. - setupDb( + await setupDb( [makeItem(1, 100)], [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')], [makePayer(1, 1, 100, 'alice')], @@ -728,7 +731,7 @@ describe('calculateSettlement — finalBudgets', () => { it('keeps an expense with no split members out of the rows, as it is out of the ledger', async () => { // Item 1 has a payer but nobody to split it with: a planning-only entry that // charges nobody, so it cannot be listed as something Alice fronted either. - setupDb( + await setupDb( [makeItem(1, 100), makeItem(2, 40)], [makeMember(2, 1, 'alice'), makeMember(2, 2, 'bob')], [makePayer(1, 1, 100, 'alice'), makePayer(2, 1, 40, 'alice')], @@ -745,7 +748,7 @@ describe('calculateSettlement — finalBudgets', () => { // viewed in pounds: every row goes through the conversion its figure went // through, and the cents lost to rounding land on rows instead of between them. const sum = (rows: { cents: number }[]) => rows.reduce((a, r) => a + r.cents, 0); - setupDb( + await setupDb( [ { ...makeItem(1, 100), currency: 'USD', exchange_rate: 1.08 }, { ...makeItem(2, 33.33), currency: 'USD', exchange_rate: 1.1 }, @@ -809,7 +812,7 @@ describe('calculateSettlement — cent-exact settle-up (#1382)', () => { // 10.00 and Carol pays exactly, so one cent is still open. The reporter's // symptom was that the balances showed that cent while "Settle up" offered // nothing to clear it: below 0.01 the old filter dropped the person entirely. - setupDb( + await setupDb( [makeItem(1, 30)], [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')], [makePayer(1, 1, 30, 'alice')], @@ -833,13 +836,13 @@ describe('calculateSettlement — cent-exact settle-up (#1382)', () => { const items = [makeItem(1, 10)]; const members = [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')]; const payers = [makePayer(1, 1, 10, 'alice')]; - setupDb(items, members, payers); + await setupDb(items, members, payers); const first = await budget.calculateSettlement(1); expect(first.flows.length).toBeGreaterThan(0); const booked = first.flows.map((f, i) => makeSettlementRow(i + 1, f.from.user_id, f.to.user_id, f.amount)); - setupDb(items, members, payers, booked); + await setupDb(items, members, payers, booked); const after = await budget.calculateSettlement(1); expect(after.balances.map(b => b.balance)).toEqual([0, 0, 0]); @@ -850,7 +853,7 @@ describe('calculateSettlement — cent-exact settle-up (#1382)', () => { // 100 USD frozen at 1.1 is 90.909090… EUR: no split of it lands on whole cents. // The equal split divides the credited cents rather than the foreign total, so // the debits cancel the credits exactly instead of leaving dust behind. - setupDb( + await setupDb( [{ ...makeItem(1, 100), currency: 'USD', exchange_rate: 1.1 } as BudgetItem], [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')], [makePayer(1, 1, 100, 'alice')], @@ -867,7 +870,7 @@ describe('calculateSettlement — cent-exact settle-up (#1382)', () => { // is USD. Rounding each balance into that currency on its own used to leave the // set adding up to a cent that no flow could ever clear, and the drift moved // with the live rate — money appearing without a single expense being touched. - setupDb( + await setupDb( [makeItem(1, 100)], [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')], [makePayer(1, 1, 100, 'alice')], @@ -889,7 +892,7 @@ describe('calculateSettlement — cent-exact settle-up (#1382)', () => { // Nobody is down as a payer, so nobody is out of pocket and there is nothing // to pay back. It used to debit all three 30 € against no credit at all, // leaving Σ(balances) at -90 with no flow able to clear it. - setupDb( + await setupDb( [makeItem(1, 90)], [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')], [], @@ -910,7 +913,7 @@ describe('calculateSettlement: unpaid expenses (#2225)', () => { // be debited with no credit behind it, and the simplifier (which knows // nothing about which expense made which debt) handed the extra 60 € to // Alice, who was never owed it. - setupDb( + await setupDb( [makeItem(1, 300), makeItem(2, 60)], [ makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol'), makeMember(1, 4, 'dave'), @@ -933,7 +936,7 @@ describe('calculateSettlement: unpaid expenses (#2225)', () => { it('an unpaid expense with a custom split is skipped too', async () => { // The custom branch had the same hole and no coverage: 100 € split 70/30 by // agreement, with nobody recorded as having paid it. - setupDb( + await setupDb( [makeItem(1, 90), makeItem(2, 100)], [ makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol'), @@ -956,7 +959,7 @@ describe('calculateSettlement: unpaid expenses (#2225)', () => { // way, and both panels synthesise a missing member's 0.00 row from the trip // roster (CostsPanel.tsx:853, MCostsTab.tsx:273), so dropping the row costs // the UI nothing. - setupDb( + await setupDb( [makeItem(1, 0)], [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')], [], @@ -973,7 +976,7 @@ describe('calculateSettlement: unpaid expenses (#2225)', () => { // row survives as custom per-member amounts with no credit behind them. A // guard that also required a non-zero total would let exactly this shape // through and rebuild the #2225 phantom debt from a first-party endpoint. - setupDb( + await setupDb( [makeItem(1, 0)], [ { ...makeMember(1, 2, 'bob'), amount: 40 }, @@ -998,7 +1001,7 @@ describe('calculateSettlement: unconverted rows', () => { const none = { item_ids: [], settlement_ids: [], currencies: [] }; it('VND/AUD regression: an unfrozen 8,920,000 VND bill on an AUD trip with no rates is left out whole, Σ balances 0, no VND-scale balance', async () => { - setupDb( + await setupDb( [vndBill(1), makeItem(2, 40)], [...four(), makeMember(2, 2, 'bob'), makeMember(2, 3, 'carol')], [makePayer(1, 1, 8920000, 'alice'), makePayer(2, 2, 40, 'bob')], @@ -1014,7 +1017,7 @@ describe('calculateSettlement: unconverted rows', () => { }); it('VND/AUD regression: frozen at 18241.3 it books 489.00 AUD, split 4 ways +366.75 / -122.25 x3', async () => { - setupDb([vndBill(18241.3)], four(), [makePayer(1, 1, 8920000, 'alice')]); + await setupDb([vndBill(18241.3)], four(), [makePayer(1, 1, 8920000, 'alice')]); const result = await budget.calculateSettlement(1, { base: 'AUD', tripCurrency: 'AUD', rates: null }); expect(result.unconverted).toEqual(none); expect(result.balances.map(b => b.balance)).toEqual([366.75, -122.25, -122.25, -122.25]); @@ -1022,7 +1025,7 @@ describe('calculateSettlement: unconverted rows', () => { }); it('a foreign row the rates do not quote is left out and listed while others convert live', async () => { - setupDb( + await setupDb( [vndBill(1), { ...makeItem(2, 65), currency: 'USD', exchange_rate: 1 } as BudgetItem], [...four(), makeMember(2, 1, 'alice'), makeMember(2, 2, 'bob')], [makePayer(1, 1, 8920000, 'alice'), makePayer(2, 1, 65, 'alice')], @@ -1034,7 +1037,7 @@ describe('calculateSettlement: unconverted rows', () => { }); it('a frozen row converts without any rates (unchanged)', async () => { - setupDb( + await setupDb( [{ ...makeItem(1, 110), currency: 'USD', exchange_rate: 1.1 } as BudgetItem], [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')], [makePayer(1, 1, 110, 'alice')], @@ -1045,7 +1048,7 @@ describe('calculateSettlement: unconverted rows', () => { }); it('trip-currency and NULL-currency rows with rate 1 count as they are', async () => { - setupDb( + await setupDb( [{ ...makeItem(1, 100), currency: 'aud', exchange_rate: 1 } as BudgetItem, { ...makeItem(2, 60), currency: null, exchange_rate: 1 } as BudgetItem], [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(2, 1, 'alice'), makeMember(2, 2, 'bob')], [makePayer(1, 1, 100, 'alice'), makePayer(2, 1, 60, 'alice')], @@ -1056,7 +1059,7 @@ describe('calculateSettlement: unconverted rows', () => { }); it('planning-only and unpaid unconvertible rows are listed but never touch balances', async () => { - setupDb( + await setupDb( [ { ...makeItem(1, 500000), currency: 'VND', exchange_rate: 1 } as BudgetItem, // planning-only { ...makeItem(2, 700000), currency: 'VND', exchange_rate: 1 } as BudgetItem, // nobody paid yet @@ -1071,7 +1074,7 @@ describe('calculateSettlement: unconverted rows', () => { }); it('an unfrozen foreign transfer without a quote is left out and listed in settlement_ids', async () => { - setupDb( + await setupDb( [makeItem(1, 100)], [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')], [makePayer(1, 1, 100, 'alice')], @@ -1087,7 +1090,7 @@ describe('calculateSettlement: unconverted rows', () => { }); it('a NULL-currency transfer still reads in the display currency', async () => { - setupDb( + await setupDb( [makeItem(1, 100)], [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')], [makePayer(1, 1, 100, 'alice')], @@ -1100,7 +1103,7 @@ describe('calculateSettlement: unconverted rows', () => { }); it('base EUR on an AUD trip without quote or baseRate answers in AUD with currency \'AUD\'', async () => { - setupDb([makeItem(1, 100)], [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')], [makePayer(1, 1, 100, 'alice')]); + await setupDb([makeItem(1, 100)], [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')], [makePayer(1, 1, 100, 'alice')]); for (const rates of [null, { AUD: 1, USD: 0.65 }]) { const result = await budget.calculateSettlement(1, { base: 'EUR', tripCurrency: 'AUD', rates }); // Trip cents, labelled as what they are rather than printed as euros. @@ -1113,28 +1116,28 @@ describe('calculateSettlement: unconverted rows', () => { const bill = () => [makeItem(1, 123.45)]; const pair = () => [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')]; const paid = () => [makePayer(1, 1, 123.45, 'alice')]; - setupDb(bill(), pair(), paid()); + await setupDb(bill(), pair(), paid()); const before = await budget.calculateSettlement(1, { base: 'USD', tripCurrency: 'EUR', rates: null, baseRate: 1.1429 }); expect(before.currency).toBe('USD'); // Bob's 61.73 EUR at the browser's 1.1429 dollars per euro. expect(before.flows.map(f => f.amount)).toEqual([70.55]); // Bob pays what settle-up offers, frozen at the same browser quote (fallback_fx). - setupDb(bill(), pair(), paid(), [makeSettlementRow(9, 2, 1, 70.55, 'USD', 1.1429)]); + await setupDb(bill(), pair(), paid(), [makeSettlementRow(9, 2, 1, 70.55, 'USD', 1.1429)]); const after = await budget.calculateSettlement(1, { base: 'USD', tripCurrency: 'EUR', rates: null, baseRate: 1.1429 }); expect(after.balances.map(b => b.balance)).toEqual([0, 0]); expect(after.flows).toEqual([]); }); it('a server quote wins over baseRate', async () => { - setupDb([makeItem(1, 123.45)], [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')], [makePayer(1, 1, 123.45, 'alice')]); + await setupDb([makeItem(1, 123.45)], [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')], [makePayer(1, 1, 123.45, 'alice')]); const result = await budget.calculateSettlement(1, { base: 'USD', tripCurrency: 'EUR', rates: { EUR: 1, USD: 1.1429 }, baseRate: 2 }); expect(result.currency).toBe('USD'); expect(result.flows.map(f => f.amount)).toEqual([70.55]); }); it('baseRate never converts a row', async () => { - setupDb( + await setupDb( [{ ...makeItem(1, 100), currency: 'EUR', exchange_rate: 1 } as BudgetItem], [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')], [makePayer(1, 1, 100, 'alice')], @@ -1182,9 +1185,7 @@ describe('freezeForeignRate', () => { }); it('does not re-freeze on update when the currency is unchanged', async () => { - testDb.prepare( - 'INSERT INTO budget_items (id, trip_id, name, total_price, currency) VALUES (9, 1, ?, ?, ?)', - ).run('Existing', 1, 'USD'); + await insertRow(await sharedTestOrm(testDb), BudgetItems, { id: 9, trip: 1, name: 'Existing', total_price: 1, currency: 'USD' }); const data: { currency?: string | null; exchange_rate?: number } = { currency: 'USD' }; await budget.freezeForeignRate(1, data, 9); expect(mockRates.getRates).not.toHaveBeenCalled(); @@ -1215,20 +1216,22 @@ describe('applySettlementUpdate', () => { }); it('updates the row (rounded to cents) and returns the refreshed settlement', async () => { - seedUser(1, 'alice'); - seedUser(2, 'bob'); - testDb.prepare( - 'INSERT INTO budget_settlements (id, trip_id, from_user_id, to_user_id, amount, currency, exchange_rate, settled_at) VALUES (7, 1, 1, 2, 5, NULL, 1, NULL)', - ).run(); + await seedUser(1, 'alice'); + await seedUser(2, 'bob'); + await insertRow(await sharedTestOrm(testDb), BudgetSettlements, { + id: 7, trip: 1, fromUser: 1, toUser: 2, amount: 5, currency: null, exchange_rate: 1, settled_at: null, + }); const res = await budget.applySettlementUpdate(7, 1, { from_user_id: 2, to_user_id: 1, amount: 10.126 }); // Quirk fix: currency/exchange_rate/settled_at/note are presence-gated CASE guards — // omitted from this update, so the row keeps what it already had (NULL/1/NULL/NULL, // the note since #2340) while from/to/amount (rounded) take the new values. Checked // against the persisted row rather than a mocked `run` call, now that there is a real one. - const row = testDb.prepare( - 'SELECT from_user_id, to_user_id, amount, currency, exchange_rate, settled_at, note FROM budget_settlements WHERE id = ?', - ).get(7); + const stored = (await findRow(await sharedTestOrm(testDb), BudgetSettlements, { id: 7 }))!; + const row = { + from_user_id: stored.from_user_id, to_user_id: stored.to_user_id, amount: stored.amount, currency: stored.currency, + exchange_rate: stored.exchange_rate, settled_at: stored.settled_at, note: stored.note, + }; expect(row).toEqual({ from_user_id: 2, to_user_id: 1, amount: 10.13, currency: null, exchange_rate: 1, settled_at: null, note: null }); expect(res).toMatchObject({ id: 7, from_user_id: 2, to_user_id: 1, amount: 10.13 }); }); diff --git a/server/tests/unit/nest/channel-override.test.ts b/server/tests/unit/nest/channel-override.test.ts index 8004e14e7..177a8f295 100644 --- a/server/tests/unit/nest/channel-override.test.ts +++ b/server/tests/unit/nest/channel-override.test.ts @@ -67,7 +67,8 @@ import { WebhookService } from '../../../src/nest/notifications/transports/webho import { createPluginRuntime } from '../../helpers/plugin-host'; import { MailerService } from '../../../src/nest/notifications/mailer/mailer.service'; import { NotificationPreferencesService } from '../../../src/nest/notifications/notification-preferences.service'; -import { createTestUnitOfWork, createTestAppSettingsRepo, createTestSettingsRepo, createTestUsersRepo } from '../../helpers/test-uow'; +import { createTestUnitOfWork, createTestAppSettingsRepo, createTestSettingsRepo, createTestUsersRepo, sharedTestOrm } from '../../helpers/test-uow'; +import { makePlugin } from '../../helpers/factories/plugins'; import { createTestNotificationChannelPreferencesRepo } from '../../helpers/notifications-repos'; import { @@ -284,7 +285,7 @@ describe('the plugin channel source reaches the outside-container instance', () delivered.push({ userId: user.id, title: msg.title }); return true; }; - testDb.prepare("INSERT INTO plugins (id, name, version, status, enabled) VALUES ('gotify', 'Gotify', '1.0.0', 'active', 1)").run(); + await makePlugin(await sharedTestOrm(testDb), 'gotify', { name: 'Gotify', version: '1.0.0', status: 'active', enabled: 1 }); // The exact line plugin-runtime.service.ts runs in onModuleInit. setPluginChannelSource(() => runtime.notificationChannels()); diff --git a/server/tests/unit/nest/day-notes.service.test.ts b/server/tests/unit/nest/day-notes.service.test.ts index 35a07f4af..57ace8500 100644 --- a/server/tests/unit/nest/day-notes.service.test.ts +++ b/server/tests/unit/nest/day-notes.service.test.ts @@ -19,14 +19,6 @@ vi.mock('../../../src/db/database', async () => { closeDb: () => {}, reinitialize: () => {}, getPlaceWithTags: () => null, - canAccessTrip: (tripId: number | string, userId: number) => - db.prepare(` - SELECT t.id, t.user_id FROM trips t - LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? - WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL) - `).get(userId, tripId, userId), - isOwner: (tripId: number | string, userId: number) => - !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId), }; }); vi.mock('../../../src/config', () => ({ @@ -44,6 +36,9 @@ import { DayNotesService } from '../../../src/nest/day-notes/day-notes.service'; import type { DayNote } from '../../../src/types'; import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; import { createTestUnitOfWork, createTestAppSettingsRepo, createTestDayNotesRepo, createTestDaysRepo, createTestTripsRepo } from '../../helpers/test-uow'; +import { sharedTestOrm } from '../../helpers/test-uow'; +import { findRow } from '../../helpers/factories/rows'; +import { DayNotes } from '../../../src/db/entities/DayNotes.entity'; let svc: DayNotesService; beforeAll(async () => { @@ -121,6 +116,7 @@ describe('create', () => { const note = await svc.create(day.id, trip.id, 'Lunch', '12:00', '🍜', 2) as DayNote; expect(note).toMatchObject({ day_id: day.id, trip_id: trip.id, text: 'Lunch', time: '12:00', icon: '🍜', sort_order: 2 }); expect(note.id).toBeGreaterThan(0); + // test-sql-allow: the raw full row is the oracle the service's return value is compared against. const row = testDb.prepare('SELECT * FROM day_notes WHERE id = ?').get(note.id); expect(row).toEqual(note); }); @@ -269,7 +265,7 @@ describe('remove', () => { const { trip, day } = seedTripAndDay(); const note = await svc.create(day.id, trip.id, 'Lunch') as DayNote; await svc.remove(note.id); - expect(testDb.prepare('SELECT * FROM day_notes WHERE id = ?').get(note.id)).toBeUndefined(); + expect(await findRow(await sharedTestOrm(testDb), DayNotes, { id: note.id })).toBeNull(); }); }); diff --git a/server/tests/unit/nest/idempotency.interceptor.test.ts b/server/tests/unit/nest/idempotency.interceptor.test.ts index f1631b6b8..e0ee8772d 100644 --- a/server/tests/unit/nest/idempotency.interceptor.test.ts +++ b/server/tests/unit/nest/idempotency.interceptor.test.ts @@ -9,6 +9,7 @@ import { createUser } from '../../helpers/factories'; import { IdempotencyKeys } from '../../../src/db/entities/IdempotencyKeys.entity'; import type { IdempotencyKeysRepository } from '../../../src/db/repositories/IdempotencyKeys.repository'; import { IdempotencyInterceptor } from '../../../src/nest/common/idempotency.interceptor'; +import { findRow as findStoredRow, insertRow } from '../../helpers/factories/rows'; type ReqShape = { method: string; @@ -56,8 +57,10 @@ beforeEach(() => { }); afterAll(async () => { await t.close(); testDb.close(); }); -const findRow = (key: string, user: number, method: string, path: string) => - testDb.prepare('SELECT status_code, response_body FROM idempotency_keys WHERE key = ? AND user_id = ? AND method = ? AND path = ?').get(key, user, method, path); +const findRow = async (key: string, user: number, method: string, path: string) => { + const row = await findStoredRow(t, IdempotencyKeys, { key, user, method, path }); + return row ? { status_code: row.status_code, response_body: row.response_body } : undefined; +}; // A few `await Promise.resolve()` hops give the repository's async write // chain (and its `.finally(release)`) the same room it gets under a real @@ -116,8 +119,7 @@ describe('IdempotencyInterceptor (parity with the legacy applyIdempotency middle }); it('replays a cached response and skips the handler', async () => { - testDb.prepare('INSERT INTO idempotency_keys (key, user_id, method, path, status_code, response_body) VALUES (?, ?, ?, ?, ?, ?)') - .run('k', userId, 'POST', '/api/categories', 201, '{"id":5}'); + await insertRow(t, IdempotencyKeys, { key: 'k', user: userId, method: 'POST', path: '/api/categories', status_code: 201, response_body: '{"id":5}' }); const res = makeRes(); const h = handler('should-not-run'); const out = await lastValueFrom( @@ -145,7 +147,7 @@ describe('IdempotencyInterceptor (parity with the legacy applyIdempotency middle res.json({ created: true }); await settle(); - expect(findRow('k', userId, 'POST', '/api/categories')).toEqual({ status_code: 201, response_body: '{"created":true}' }); + expect(await findRow('k', userId, 'POST', '/api/categories')).toEqual({ status_code: 201, response_body: '{"created":true}' }); }); it('does not cache a non-2xx response', async () => { @@ -161,7 +163,7 @@ describe('IdempotencyInterceptor (parity with the legacy applyIdempotency middle res.json({ error: 'bad' }); await settle(); - expect(findRow('k', userId, 'POST', '/api/categories')).toBeUndefined(); + expect(await findRow('k', userId, 'POST', '/api/categories')).toBeUndefined(); }); it('does not cache a body that exceeds the 256 KiB cap', async () => { @@ -178,7 +180,7 @@ describe('IdempotencyInterceptor (parity with the legacy applyIdempotency middle res.json(big); await settle(); - expect(findRow('k', userId, 'POST', '/api/categories')).toBeUndefined(); + expect(await findRow('k', userId, 'POST', '/api/categories')).toBeUndefined(); }); it('swallows a storage failure so the response still succeeds', async () => { diff --git a/server/tests/unit/nest/journey-stats.service.test.ts b/server/tests/unit/nest/journey-stats.service.test.ts index 356756201..5bc35c2b0 100644 --- a/server/tests/unit/nest/journey-stats.service.test.ts +++ b/server/tests/unit/nest/journey-stats.service.test.ts @@ -62,6 +62,11 @@ import { TripPhotos } from '../../../src/db/entities/TripPhotos.entity'; import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service'; import { db as dbConn } from '../../../src/db/database'; import { createTestUnitOfWork, sharedTestOrm, createTestTripsRepo, createTestPlacesRepo } from '../../helpers/test-uow'; +import { insertRow, updateRows } from '../../helpers/factories/rows'; +import { JourneyContributors } from '../../../src/db/entities/JourneyContributors.entity'; +import { JourneyEntries } from '../../../src/db/entities/JourneyEntries.entity'; +import { JourneyPhotos } from '../../../src/db/entities/JourneyPhotos.entity'; +import { PlaceRegions } from '../../../src/db/entities/PlaceRegions.entity'; import { createTestJourneysRepo, createTestJourneyContributorsRepo, createTestJourneyTripsRepo, createTestJourneyEntriesRepo, createTestJourneyPhotosRepo, createTestJourneyEntryPhotosRepo, @@ -70,7 +75,7 @@ import { let svc: JourneyDomainService; /** The factory has no coordinate fields, and a route is made of coordinates. */ -function placeEntry( +async function placeEntry( journeyId: number, authorId: number, lat: number, @@ -78,9 +83,7 @@ function placeEntry( overrides: { title?: string; entry_date?: string; stats_excluded?: number } = {}, ) { const entry = createJourneyEntry(testDb, journeyId, authorId, overrides); - testDb - .prepare('UPDATE journey_entries SET location_lat = ?, location_lng = ? WHERE id = ?') - .run(lat, lng, entry.id); + await updateRows(await sharedTestOrm(testDb), JourneyEntries, { id: entry.id }, { location_lat: lat, location_lng: lng }); return entry; } @@ -147,8 +150,8 @@ describe('journeyStats route', () => { it('builds the route from the entries when they carry coordinates', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); - placeEntry(journey.id, user.id, 64.14, -21.94, { title: 'Reykjavík', entry_date: '2026-06-02' }); - placeEntry(journey.id, user.id, 65.68, -18.12, { title: 'Akureyri', entry_date: '2026-06-06' }); + await placeEntry(journey.id, user.id, 64.14, -21.94, { title: 'Reykjavík', entry_date: '2026-06-02' }); + await placeEntry(journey.id, user.id, 65.68, -18.12, { title: 'Akureyri', entry_date: '2026-06-06' }); const stats = (await svc.journeyStats(journey.id, user.id))!; @@ -161,8 +164,8 @@ describe('journeyStats route', () => { it('orders the route by entry date, not by insertion', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); - placeEntry(journey.id, user.id, 65.68, -18.12, { title: 'second', entry_date: '2026-06-06' }); - placeEntry(journey.id, user.id, 64.14, -21.94, { title: 'first', entry_date: '2026-06-02' }); + await placeEntry(journey.id, user.id, 65.68, -18.12, { title: 'second', entry_date: '2026-06-06' }); + await placeEntry(journey.id, user.id, 64.14, -21.94, { title: 'first', entry_date: '2026-06-02' }); const stats = (await svc.journeyStats(journey.id, user.id))!; @@ -172,7 +175,7 @@ describe('journeyStats route', () => { it('skips entries without coordinates but still counts them as steps', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); - placeEntry(journey.id, user.id, 64.14, -21.94, { entry_date: '2026-06-02' }); + await placeEntry(journey.id, user.id, 64.14, -21.94, { entry_date: '2026-06-02' }); createJourneyEntry(testDb, journey.id, user.id, { title: 'no place', entry_date: '2026-06-03' }); const stats = (await svc.journeyStats(journey.id, user.id))!; @@ -210,7 +213,7 @@ describe('journeyStats route', () => { const trip = createTrip(testDb, user.id); linkTripToJourney(testDb, journey.id, trip.id); createPlace(testDb, trip.id, { name: 'a place', lat: 48.85, lng: 2.35 }); - placeEntry(journey.id, user.id, 64.14, -21.94, { title: 'an entry', entry_date: '2026-06-02' }); + await placeEntry(journey.id, user.id, 64.14, -21.94, { title: 'an entry', entry_date: '2026-06-02' }); const stats = (await svc.journeyStats(journey.id, user.id))!; @@ -275,8 +278,8 @@ describe('journeyStats countries', () => { it('resolves each stop to a country and names it', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); - placeEntry(journey.id, user.id, 64.14, -21.94, { entry_date: '2026-06-02' }); - placeEntry(journey.id, user.id, 52.52, 13.4, { entry_date: '2026-06-20' }); + await placeEntry(journey.id, user.id, 64.14, -21.94, { entry_date: '2026-06-02' }); + await placeEntry(journey.id, user.id, 52.52, 13.4, { entry_date: '2026-06-20' }); const stats = (await svc.journeyStats(journey.id, user.id))!; @@ -296,9 +299,7 @@ describe('journeyStats countries', () => { const trip = createTrip(testDb, user.id); linkTripToJourney(testDb, journey.id, trip.id); const place = createPlace(testDb, trip.id, { name: 'somewhere', lat: 64.14, lng: -21.94 }); - testDb - .prepare('INSERT INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, ?, ?, ?)') - .run(place.id, 'fr', 'FR-75', 'Paris'); + await insertRow(await sharedTestOrm(testDb), PlaceRegions, { place: place.id, country_code: 'fr', region_code: 'FR-75', region_name: 'Paris' }); const stats = (await svc.journeyStats(journey.id, user.id))!; @@ -311,7 +312,7 @@ describe('journeyStats countries', () => { it('leaves a stop with no country out of the list rather than inventing one', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); - placeEntry(journey.id, user.id, -33.86, 151.2, { entry_date: '2026-06-02' }); + await placeEntry(journey.id, user.id, -33.86, 151.2, { entry_date: '2026-06-02' }); const stats = (await svc.journeyStats(journey.id, user.id))!; @@ -325,12 +326,8 @@ describe('journeyStats totals', () => { it('counts the gallery photographs', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); - const photo = testDb - .prepare("INSERT INTO trek_photos (provider, file_path, owner_id) VALUES ('local', 'x.jpg', ?)") - .run(user.id); - testDb - .prepare('INSERT INTO journey_photos (journey_id, photo_id, sort_order, created_at) VALUES (?, ?, 0, ?)') - .run(journey.id, photo.lastInsertRowid, Date.now()); + const photoId = await insertRow(await sharedTestOrm(testDb), TrekPhotos, { provider: 'local', file_path: 'x.jpg', owner: user.id }); + await insertRow(await sharedTestOrm(testDb), JourneyPhotos, { journey: journey.id, photo: photoId, sort_order: 0, created_at: Date.now() }); expect((await svc.journeyStats(journey.id, user.id))!.photos).toBe(1); }); @@ -368,9 +365,7 @@ describe('journeyStats totals', () => { const { user: owner } = createUser(testDb); const { user: helper } = createUser(testDb); const journey = createJourney(testDb, owner.id); - testDb - .prepare('INSERT INTO journey_contributors (journey_id, user_id, role, added_at) VALUES (?, ?, ?, ?)') - .run(journey.id, helper.id, 'editor', Date.now()); + await insertRow(await sharedTestOrm(testDb), JourneyContributors, { journey: journey.id, user: helper.id, role: 'editor', added_at: Date.now() }); expect(await svc.journeyStats(journey.id, helper.id)).not.toBeNull(); }); @@ -386,9 +381,9 @@ describe('journeyStats excluded stops', () => { it('leaves a switched-off entry out of the route, the distance and the countries', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); - placeEntry(journey.id, user.id, 52.52, 13.4, { title: 'Berlin, the airport', entry_date: '2026-06-01', stats_excluded: 1 }); - placeEntry(journey.id, user.id, 64.14, -21.94, { title: 'Reykjavík', entry_date: '2026-06-02' }); - placeEntry(journey.id, user.id, 65.68, -18.12, { title: 'Akureyri', entry_date: '2026-06-06' }); + await placeEntry(journey.id, user.id, 52.52, 13.4, { title: 'Berlin, the airport', entry_date: '2026-06-01', stats_excluded: 1 }); + await placeEntry(journey.id, user.id, 64.14, -21.94, { title: 'Reykjavík', entry_date: '2026-06-02' }); + await placeEntry(journey.id, user.id, 65.68, -18.12, { title: 'Akureyri', entry_date: '2026-06-06' }); const stats = (await svc.journeyStats(journey.id, user.id))!; @@ -403,8 +398,8 @@ describe('journeyStats excluded stops', () => { it('names the switched-off stop under excluded so it can be switched back on', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); - const airport = placeEntry(journey.id, user.id, 63.98, -22.62, { title: 'Keflavík', entry_date: '2026-06-01', stats_excluded: 1 }); - placeEntry(journey.id, user.id, 64.14, -21.94, { title: 'Reykjavík', entry_date: '2026-06-02' }); + const airport = await placeEntry(journey.id, user.id, 63.98, -22.62, { title: 'Keflavík', entry_date: '2026-06-01', stats_excluded: 1 }); + await placeEntry(journey.id, user.id, 64.14, -21.94, { title: 'Reykjavík', entry_date: '2026-06-02' }); const stats = (await svc.journeyStats(journey.id, user.id))!; @@ -414,8 +409,8 @@ describe('journeyStats excluded stops', () => { it('does not count a switched-off entry as a step', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); - placeEntry(journey.id, user.id, 63.98, -22.62, { entry_date: '2026-06-01', stats_excluded: 1 }); - placeEntry(journey.id, user.id, 64.14, -21.94, { entry_date: '2026-06-02' }); + await placeEntry(journey.id, user.id, 63.98, -22.62, { entry_date: '2026-06-01', stats_excluded: 1 }); + await placeEntry(journey.id, user.id, 64.14, -21.94, { entry_date: '2026-06-02' }); expect((await svc.journeyStats(journey.id, user.id))!.steps).toBe(1); }); @@ -425,7 +420,7 @@ describe('journeyStats excluded stops', () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); createJourneyEntry(testDb, journey.id, user.id, { title: 'a thought', entry_date: '2026-06-03', stats_excluded: 1 }); - placeEntry(journey.id, user.id, 64.14, -21.94, { entry_date: '2026-06-02' }); + await placeEntry(journey.id, user.id, 64.14, -21.94, { entry_date: '2026-06-02' }); const stats = (await svc.journeyStats(journey.id, user.id))!; @@ -436,8 +431,8 @@ describe('journeyStats excluded stops', () => { it('carries the entry on every stop read from the entries', async () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); - const a = placeEntry(journey.id, user.id, 64.14, -21.94, { entry_date: '2026-06-02' }); - const b = placeEntry(journey.id, user.id, 65.68, -18.12, { entry_date: '2026-06-06' }); + const a = await placeEntry(journey.id, user.id, 64.14, -21.94, { entry_date: '2026-06-02' }); + const b = await placeEntry(journey.id, user.id, 65.68, -18.12, { entry_date: '2026-06-06' }); expect((await svc.journeyStats(journey.id, user.id))!.points.map(p => p.entryId)).toEqual([a.id, b.id]); }); @@ -452,9 +447,7 @@ describe('journeyStats excluded stops', () => { createDayAssignment(testDb, createDay(testDb, trip.id, { date: '2026-06-01' }).id, airport.id); createDayAssignment(testDb, createDay(testDb, trip.id, { date: '2026-06-02' }).id, town.id); await svc.syncTripPlaces(journey.id, trip.id, user.id); - testDb - .prepare('UPDATE journey_entries SET stats_excluded = 1 WHERE journey_id = ? AND source_place_id = ?') - .run(journey.id, airport.id); + await updateRows(await sharedTestOrm(testDb), JourneyEntries, { journey: journey.id, sourcePlace: airport.id }, { stats_excluded: 1 }); const stats = (await svc.journeyStats(journey.id, user.id))!; @@ -483,13 +476,9 @@ describe('journeyStats excluded stops', () => { // Switched off, and its point cleared by hand afterwards, which is what // sends the journey down the fallback while the skeleton still names the // place it came from. - testDb - .prepare(` - UPDATE journey_entries - SET stats_excluded = 1, location_lat = NULL, location_lng = NULL - WHERE journey_id = ? AND source_place_id = ? - `) - .run(journey.id, airport.id); + await updateRows(await sharedTestOrm(testDb), JourneyEntries, { journey: journey.id, sourcePlace: airport.id }, { + stats_excluded: 1, location_lat: null, location_lng: null, + }); const stats = (await svc.journeyStats(journey.id, user.id))!; @@ -509,7 +498,7 @@ describe('journeyStats excluded stops', () => { const trip = createTrip(testDb, user.id); linkTripToJourney(testDb, journey.id, trip.id); createPlace(testDb, trip.id, { name: 'a trip place', lat: 48.85, lng: 2.35 }); - placeEntry(journey.id, user.id, 52.52, 13.4, { title: 'Berlin, the airport', entry_date: '2026-06-01', stats_excluded: 1 }); + await placeEntry(journey.id, user.id, 52.52, 13.4, { title: 'Berlin, the airport', entry_date: '2026-06-01', stats_excluded: 1 }); const stats = (await svc.journeyStats(journey.id, user.id))!; diff --git a/server/tests/unit/nest/mailer.service.test.ts b/server/tests/unit/nest/mailer.service.test.ts index cc0135018..a8c1c7d8b 100644 --- a/server/tests/unit/nest/mailer.service.test.ts +++ b/server/tests/unit/nest/mailer.service.test.ts @@ -50,16 +50,17 @@ import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; import { Users } from '../../../src/db/entities/Users.entity'; import { Settings } from '../../../src/db/entities/Settings.entity'; import { AppSettings } from '../../../src/db/entities/AppSettings.entity'; +import { setAppSetting as storeAppSetting } from '../../helpers/factories/settings'; -function setAppSetting(key: string, value: string): void { - testDb.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run(key, value); +function setAppSetting(key: string, value: string): Promise { + return storeAppSetting(testOrm, key, value); } /** The minimum that makes getSmtpConfig() return a config instead of null. */ -function configureSmtp(): void { - setAppSetting('smtp_host', 'mail.internal.example'); - setAppSetting('smtp_port', '587'); - setAppSetting('smtp_from', 'trek@example.com'); +async function configureSmtp(): Promise { + await setAppSetting('smtp_host', 'mail.internal.example'); + await setAppSetting('smtp_port', '587'); + await setAppSetting('smtp_from', 'trek@example.com'); } let testOrm: TestOrm; @@ -89,7 +90,7 @@ afterAll(() => { describe('MailerService TLS options', () => { it('MAILER-001: leaves certificate verification on by default', async () => { - configureSmtp(); + await configureSmtp(); expect(await newMailer().sendEmail('someone@example.com', 'Subject', 'Body')).toBe(true); @@ -98,8 +99,8 @@ describe('MailerService TLS options', () => { }); it('MAILER-002: the smtp_skip_tls_verify setting turns verification off', async () => { - configureSmtp(); - setAppSetting('smtp_skip_tls_verify', 'true'); + await configureSmtp(); + await setAppSetting('smtp_skip_tls_verify', 'true'); await newMailer().sendEmail('someone@example.com', 'Subject', 'Body'); @@ -107,8 +108,8 @@ describe('MailerService TLS options', () => { }); it('MAILER-003: anything other than "true" leaves verification on', async () => { - configureSmtp(); - setAppSetting('smtp_skip_tls_verify', 'false'); + await configureSmtp(); + await setAppSetting('smtp_skip_tls_verify', 'false'); await newMailer().sendEmail('someone@example.com', 'Subject', 'Body'); @@ -117,8 +118,8 @@ describe('MailerService TLS options', () => { }); it('MAILER-004: skipping verification is announced, and names the host it applies to', async () => { - configureSmtp(); - setAppSetting('smtp_skip_tls_verify', 'true'); + await configureSmtp(); + await setAppSetting('smtp_skip_tls_verify', 'true'); await newMailer().sendEmail('someone@example.com', 'Subject', 'Body'); @@ -129,8 +130,8 @@ describe('MailerService TLS options', () => { }); it('MAILER-005: the warning is logged once per process, not once per mail', async () => { - configureSmtp(); - setAppSetting('smtp_skip_tls_verify', 'true'); + await configureSmtp(); + await setAppSetting('smtp_skip_tls_verify', 'true'); const mailer = newMailer(); await mailer.sendEmail('first@example.com', 'One', 'Body'); @@ -149,7 +150,7 @@ describe('MailerService TLS options', () => { }); it('MAILER-007: connecting and greeting are bounded hard, the transfer is not', async () => { - configureSmtp(); + await configureSmtp(); await newMailer().sendEmail('someone@example.com', 'Subject', 'Body'); @@ -166,7 +167,7 @@ describe('MailerService TLS options', () => { }); it('MAILER-007b: the admin test send bounds the transfer too, because somebody is waiting', async () => { - configureSmtp(); + await configureSmtp(); await newMailer().testSmtp('someone@example.com'); @@ -187,8 +188,8 @@ describe('MailerService test send', () => { } it('MAILER-008: an incomplete configuration names the fields that are missing', async () => { - setAppSetting('smtp_host', 'mail.internal.example'); - setAppSetting('smtp_port', '587'); + await setAppSetting('smtp_host', 'mail.internal.example'); + await setAppSetting('smtp_port', '587'); const result = await newMailer().testSmtp('admin@example.com'); @@ -198,9 +199,9 @@ describe('MailerService test send', () => { }); it('MAILER-009: an unusable port is refused before anything reaches a socket', async () => { - setAppSetting('smtp_host', 'mail.internal.example'); - setAppSetting('smtp_port', 'smtp.example.com'); - setAppSetting('smtp_from', 'trek@example.com'); + await setAppSetting('smtp_host', 'mail.internal.example'); + await setAppSetting('smtp_port', 'smtp.example.com'); + await setAppSetting('smtp_from', 'trek@example.com'); const result = await newMailer().testSmtp('admin@example.com'); @@ -211,7 +212,7 @@ describe('MailerService test send', () => { }); it('MAILER-010: a rejected login comes back as a reason, not as a bare failure', async () => { - configureSmtp(); + await configureSmtp(); sendMail.mockRejectedValueOnce(smtpError('Invalid login: 535 5.7.8 Username and Password not accepted', 'EAUTH')); const result = await newMailer().testSmtp('admin@example.com'); @@ -222,7 +223,7 @@ describe('MailerService test send', () => { }); it('MAILER-011: a refused connection is classified and reaches the log with its code', async () => { - configureSmtp(); + await configureSmtp(); sendMail.mockRejectedValueOnce(smtpError('connect ECONNREFUSED 10.0.0.5:587', 'ESOCKET')); const result = await newMailer().testSmtp('admin@example.com'); @@ -236,9 +237,9 @@ describe('MailerService test send', () => { }); it('MAILER-012: a stalled relay is named as a timeout and points at the 465/587 split', async () => { - setAppSetting('smtp_host', 'mail.internal.example'); - setAppSetting('smtp_port', '465'); - setAppSetting('smtp_from', 'trek@example.com'); + await setAppSetting('smtp_host', 'mail.internal.example'); + await setAppSetting('smtp_port', '465'); + await setAppSetting('smtp_from', 'trek@example.com'); sendMail.mockRejectedValueOnce(smtpError('Greeting never received', 'ETIMEDOUT')); const result = await newMailer().testSmtp('admin@example.com'); @@ -248,9 +249,9 @@ describe('MailerService test send', () => { }); it('MAILER-013: the password appears in neither the response nor the log', async () => { - configureSmtp(); - setAppSetting('smtp_user', 'trek@example.com'); - setAppSetting('smtp_pass', 'correct-horse-battery'); + await configureSmtp(); + await setAppSetting('smtp_user', 'trek@example.com'); + await setAppSetting('smtp_pass', 'correct-horse-battery'); sendMail.mockRejectedValueOnce(smtpError('Invalid login for correct-horse-battery', 'EAUTH')); const result = await newMailer().testSmtp('admin@example.com'); @@ -260,7 +261,7 @@ describe('MailerService test send', () => { }); it('MAILER-014: a successful test leaves a line in the log naming the relay', async () => { - configureSmtp(); + await configureSmtp(); expect(await newMailer().testSmtp('admin@example.com')).toEqual({ success: true }); @@ -302,7 +303,7 @@ describe('MailerService header logo (#2507)', () => { } it('MAILER-015: a notification mail carries the logo its HTML points to', async () => { - configureSmtp(); + await configureSmtp(); expect(await newMailer().sendEmail('someone@example.com', 'Subject', 'Body')).toBe(true); @@ -310,7 +311,7 @@ describe('MailerService header logo (#2507)', () => { }); it('MAILER-016: the password-reset mail carries it too', async () => { - configureSmtp(); + await configureSmtp(); expect((await newMailer().sendPasswordResetEmail('someone@example.com', 'https://trek.example/reset', null)).delivered).toBe('email'); @@ -318,7 +319,7 @@ describe('MailerService header logo (#2507)', () => { }); it('MAILER-017: the plain-text test mail has no HTML, so it carries no logo either', async () => { - configureSmtp(); + await configureSmtp(); await newMailer().testSmtp('admin@example.com'); diff --git a/server/tests/unit/nest/notification-transports.test.ts b/server/tests/unit/nest/notification-transports.test.ts index 1fa2a8159..14167f5c6 100644 --- a/server/tests/unit/nest/notification-transports.test.ts +++ b/server/tests/unit/nest/notification-transports.test.ts @@ -53,6 +53,9 @@ import { buildBuiltinChannels } from '../../../src/nest/notifications/channels/b import { checkSsrf } from '../../../src/utils/ssrfGuard'; import { logError } from '../../../src/nest/audit/audit-log.logger'; import { createTestSettingsRepo, createTestAppSettingsRepo } from '../../helpers/test-uow'; +import { sharedTestOrm } from '../../helpers/test-uow'; +import { makeAdmin, makeUser } from '../../helpers/factories/users'; +import { setAppSetting, setUserSetting } from '../../helpers/factories/settings'; // The transports are providers now, taking SettingsRepository/ // AppSettingsRepository instead of DatabaseService — a real, throwaway @@ -476,19 +479,15 @@ describe('GHSA-7pqc-fj3c-9346: ntfy token is only attached when the target is th describe('GHSA-7pqc-fj3c-9346 (live path): buildBuiltinChannels sendToUser resolves configs through the repositories', () => { it('operator-server user gets the admin token; foreign-server user does not', async () => { const liveDb = createSnapshotTestDb(); - liveDb - .prepare("INSERT INTO users (id, username, email, password_hash, role) VALUES (1,'op','op@x','x','admin'),(2,'u2','u2@x','x','user')") - .run(); - liveDb - .prepare( - "INSERT INTO app_settings (key, value) VALUES ('admin_ntfy_server','https://ntfy.operator.example'),('admin_ntfy_topic','ops'),('admin_ntfy_token','operator-secret')", - ) - .run(); - liveDb - .prepare( - "INSERT INTO settings (user_id, key, value) VALUES (1,'ntfy_topic','t1'),(2,'ntfy_topic','t2'),(2,'ntfy_server','https://ntfy.attacker.example')", - ) - .run(); + const liveOrm = await sharedTestOrm(liveDb); + await makeAdmin(liveOrm, { id: 1, username: 'op', email: 'op@x' }); + await makeUser(liveOrm, { id: 2, username: 'u2', email: 'u2@x' }); + await setAppSetting(liveOrm, 'admin_ntfy_server', 'https://ntfy.operator.example'); + await setAppSetting(liveOrm, 'admin_ntfy_topic', 'ops'); + await setAppSetting(liveOrm, 'admin_ntfy_token', 'operator-secret'); + await setUserSetting(liveOrm, 1, 'ntfy_topic', 't1'); + await setUserSetting(liveOrm, 2, 'ntfy_topic', 't2'); + await setUserSetting(liveOrm, 2, 'ntfy_server', 'https://ntfy.attacker.example'); const liveNtfy = new NtfyService(await createTestSettingsRepo(liveDb), await createTestAppSettingsRepo(liveDb)); const ntfyChannel = buildBuiltinChannels({ mailer: {} as never, webhook: {} as never, ntfy: liveNtfy, push: {} as never }).find((c) => c.id === 'ntfy')!; diff --git a/server/tests/unit/nest/notifications/push-subscriptions.service.test.ts b/server/tests/unit/nest/notifications/push-subscriptions.service.test.ts index 4146614c7..ab557fef9 100644 --- a/server/tests/unit/nest/notifications/push-subscriptions.service.test.ts +++ b/server/tests/unit/nest/notifications/push-subscriptions.service.test.ts @@ -30,6 +30,10 @@ import { db as testDb } from '../../../../src/db/database'; import { resetTestDb } from '../../../helpers/test-db'; import { createUser } from '../../../helpers/factories'; import { makePushSubscriptionsService } from '../../../helpers/notifications'; +import { sharedTestOrm } from '../../../helpers/test-uow'; +import { countRows, deleteRows, updateRows } from '../../../helpers/factories/rows'; +import { PushSubscriptions } from '../../../../src/db/entities/PushSubscriptions.entity'; +import { Users } from '../../../../src/db/entities/Users.entity'; import { MAX_PUSH_DEVICES_PER_USER, PushSubscriptionsService, @@ -215,7 +219,7 @@ describe('PushSubscriptionsService', () => { } // Same second for every row; the id breaks the tie, so d0 is the oldest. // d0 subscribing again renews it, which leaves d1 as the oldest. - testDb.prepare("UPDATE push_subscriptions SET created_at = '2026-01-01 00:00:00'").run(); + await updateRows(await sharedTestOrm(testDb), PushSubscriptions, {}, { created_at: '2026-01-01 00:00:00' }); await subs.upsert(user.id, checked('https://fcm.googleapis.com/fcm/send/d0'), 'k'); expect(await subs.upsert(user.id, checked('https://fcm.googleapis.com/fcm/send/new'), 'k')).toBe( MAX_PUSH_DEVICES_PER_USER, @@ -258,12 +262,13 @@ describe('PushSubscriptionsService', () => { const { user } = createUser(testDb); await subs.upsert(user.id, checked(FCM), 'k', 'x'.repeat(1000)); expect((await subs.listForUser(user.id))[0].user_agent).toHaveLength(256); - testDb.prepare('DELETE FROM users WHERE id = ?').run(user.id); - expect(testDb.prepare('SELECT COUNT(*) AS n FROM push_subscriptions').get()).toEqual({ n: 0 }); + await deleteRows(await sharedTestOrm(testDb), Users, { id: user.id }); + expect(await countRows(await sharedTestOrm(testDb), PushSubscriptions)).toBe(0); }); it('PUSHSUB-008: migration 245 created the table with its user index', async () => { const index = testDb + // test-sql-allow: the index list comes from sqlite_master, which no entity or repository maps. .prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'push_subscriptions'") .all() as { name: string }[]; expect(index.map((i) => i.name)).toContain('idx_push_subscriptions_user'); diff --git a/server/tests/unit/nest/oauth.service.test.ts b/server/tests/unit/nest/oauth.service.test.ts index 624c96f1d..17732baae 100644 --- a/server/tests/unit/nest/oauth.service.test.ts +++ b/server/tests/unit/nest/oauth.service.test.ts @@ -20,10 +20,6 @@ vi.mock('../../../src/db/database', async () => { closeDb: () => {}, reinitialize: () => {}, getPlaceWithTags: () => null, - canAccessTrip: (tripId: any, userId: number) => - db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId), - isOwner: (tripId: any, userId: number) => - !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId), }; return mock; }); @@ -68,6 +64,8 @@ import { sweepPendingCodes, } from '../../../src/nest/oauth/oauth.pending-codes'; import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; +import { insertRow, updateRows } from '../../helpers/factories/rows'; +import { dbNow } from '../../../src/db/types'; import { AuditLog } from '../../../src/db/entities/AuditLog.entity'; import type { AuditLogRepository } from '../../../src/db/repositories/AuditLog.repository'; import { Users } from '../../../src/db/entities/Users.entity'; @@ -920,10 +918,10 @@ describe('getUserByAccessToken — includes clientId (C2)', () => { * replay cases below still describe theft — a token used minutes later — rather * than two clients racing. */ -function agePastGrace(rawRefreshToken: string) { +async function agePastGrace(rawRefreshToken: string) { const hash = crypto.createHash('sha256').update(rawRefreshToken).digest('hex'); const old = new Date(Date.now() - 5 * 60 * 1000).toISOString().replace('T', ' ').slice(0, 19); - testDb.prepare('UPDATE oauth_tokens SET revoked_at = ? WHERE refresh_token_hash = ?').run(old, hash); + await updateRows(t, OauthTokens, { refresh_token_hash: hash }, { revoked_at: old }); } describe('refreshTokens — replay detection (C3)', () => { @@ -941,7 +939,7 @@ describe('refreshTokens — replay detection (C3)', () => { // Replay the FIRST (now revoked) refresh token, long enough after the // rotation that it cannot be a concurrent refresh. - agePastGrace(firstRefresh); + await agePastGrace(firstRefresh); const callsBefore = vi.mocked(revokeUserSessionsForClient).mock.calls.length; const replayResult = await refreshTokens(firstRefresh, clientId, rawSecret); expect(replayResult.error).toBe('invalid_grant'); @@ -963,7 +961,7 @@ describe('refreshTokens — replay detection (C3)', () => { const { access_token: access2, refresh_token: second } = r1.tokens!; // Replay first (revoked) refresh token → chain revoke - agePastGrace(first); + await agePastGrace(first); await refreshTokens(first, clientId, rawSecret); // The rotated access token should also be dead now @@ -990,7 +988,7 @@ describe('refreshTokens — replay detection (C3)', () => { const { refresh_token: third } = r2.tokens!; // Replay the first revoked token → revokes chain containing first+second+third - agePastGrace(first); + await agePastGrace(first); await refreshTokens(first, clientId, rawSecret); // third should now be revoked too (it's in the same chain) @@ -1046,7 +1044,7 @@ describe('refreshTokens — concurrent rotation grace (#1007)', () => { const { refresh_token: shared } = await issueTokens(clientId, user.id, ['trips:read']); const rotated = (await refreshTokens(shared, clientId, rawSecret)).tokens!; - agePastGrace(shared); + await agePastGrace(shared); const replay = await refreshTokens(shared, clientId, rawSecret); expect(replay.error).toBe('invalid_grant'); @@ -1071,7 +1069,7 @@ describe('refreshTokens — concurrent rotation grace (#1007)', () => { const second = (await refreshTokens(first, clientId, rawSecret)).tokens!; // Real theft: the first token turns up again once the window has passed. - agePastGrace(first); + await agePastGrace(first); expect((await refreshTokens(first, clientId, rawSecret)).error).toBe('invalid_grant'); // The successor was revoked with the chain, so presenting it now must not @@ -1235,7 +1233,7 @@ describe('branches the legacy suite could not reach', () => { const created = await makeClient(user.id); const clientId = (created.client as { client_id: string }).client_id; const tokens = await issueTokens(clientId, user.id, ['trips:read']); - testDb.prepare("UPDATE oauth_tokens SET access_token_expires_at = '2000-01-01T00:00:00.000Z'").run(); + await updateRows(t, OauthTokens, {}, { access_token_expires_at: '2000-01-01T00:00:00.000Z' }); expect(await getUserByAccessToken(tokens.access_token)).toBeNull(); }); @@ -1363,12 +1361,11 @@ describe('OauthModule', () => { describe('admin OAuth sessions', () => { it('ADMIN-SVC-074 — listAllOAuthSessions survives a row with malformed scopes JSON', async () => { const { user } = createUser(testDb); - testDb.prepare("INSERT INTO oauth_clients (client_id, client_secret_hash, name) VALUES ('c1', 'hash', 'Client')").run(); - testDb.prepare(` - INSERT INTO oauth_tokens (client_id, user_id, access_token_hash, refresh_token_hash, scopes, - access_token_expires_at, refresh_token_expires_at) - VALUES ('c1', ?, 'ahash', 'rhash', 'not-json{', datetime('now', '+1 hour'), datetime('now', '+1 day')) - `).run(user.id); + await insertRow(t, OauthClients, { client_id: 'c1', client_secret_hash: 'hash', name: 'Client' }); + await insertRow(t, OauthTokens, { + client: 'c1', user: user.id, access_token_hash: 'ahash', refresh_token_hash: 'rhash', scopes: 'not-json{', + access_token_expires_at: dbNow(new Date(Date.now() + 3600_000)), refresh_token_expires_at: dbNow(new Date(Date.now() + 86_400_000)), + }); const sessions = (await svc.listAllOAuthSessions()) as any[]; expect(sessions).toHaveLength(1); diff --git a/server/tests/unit/nest/place-regions-repair.test.ts b/server/tests/unit/nest/place-regions-repair.test.ts index fb514cf49..d45f7510d 100644 --- a/server/tests/unit/nest/place-regions-repair.test.ts +++ b/server/tests/unit/nest/place-regions-repair.test.ts @@ -14,6 +14,10 @@ import { PLACE_REGIONS_REPAIR_DONE_KEY, PlaceRegionsRepairJob } from '../../../s import type { CronRegistrarService } from '../../../src/nest/scheduling/cron-registrar.service'; import { createTrip, createUser } from '../../helpers/factories'; import { resetTestDb } from '../../helpers/test-db'; +import { findRow, insertRow } from '../../helpers/factories/rows'; +import { AppSettings } from '../../../src/db/entities/AppSettings.entity'; +import { PlaceRegions } from '../../../src/db/entities/PlaceRegions.entity'; +import { Places } from '../../../src/db/entities/Places.entity'; import { createTestAppSettingsRepo, createTestPlacesRepo, createTestReservationEndpointsRepo, createTestTripsRepo, createTestUnitOfWork, sharedTestOrm, } from '../../helpers/test-uow'; @@ -60,22 +64,20 @@ describe('the one time repair of place_regions rows cached before #2527', () => let tripId: number; const fetchMock = vi.fn(); - const addPlace = (name: string, at: Location, cached?: [string, string, string]) => { - const id = db - .prepare('INSERT INTO places (trip_id, name, lat, lng, address) VALUES (?, ?, ?, ?, ?)') - .run(tripId, name, at.lat, at.lng, at.address).lastInsertRowid as number; + const orm = () => sharedTestOrm(db); + const addPlace = async (name: string, at: Location, cached?: [string, string, string]) => { + const id = await insertRow(await orm(), Places, { trip: tripId, name, lat: at.lat, lng: at.lng, address: at.address }); if (cached) { - db.prepare( - 'INSERT INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, ?, ?, ?)', - ).run(id, ...cached); + const [country_code, region_code, region_name] = cached; + await insertRow(await orm(), PlaceRegions, { place: id, country_code, region_code, region_name }); } return id; }; - const cachedRow = (id: number) => - db.prepare('SELECT country_code, region_code FROM place_regions WHERE place_id = ?').get(id) as - | { country_code: string; region_code: string } - | undefined; - const markedDone = () => db.prepare('SELECT 1 FROM app_settings WHERE key = ?').get(PLACE_REGIONS_REPAIR_DONE_KEY); + const cachedRow = async (id: number) => { + const row = await findRow(await orm(), PlaceRegions, { place: id }); + return row ? { country_code: row.country_code, region_code: row.region_code } : undefined; + }; + const markedDone = async () => (await findRow(await orm(), AppSettings, { key: PLACE_REGIONS_REPAIR_DONE_KEY })) ?? undefined; // runOnBoot is where the real registrar opens the request context; here it just runs the pass. const registrar = (enabled: boolean) => ({ isEnabled: () => enabled, runOnBoot: async (_name: string, fn: () => Promise) => fn() }) as unknown as CronRegistrarService; @@ -101,57 +103,57 @@ describe('the one time repair of place_regions rows cached before #2527', () => it('shows an upgraded install the country its corrected places are in now, without editing them again', async () => { // The shape from the report: moved to Germany on 4.3.2, still cached in France. - const hotel = addPlace('Hotel Adlon', BERLIN, ['FR', 'FR-IDF', 'Ile-de-France']); - const rhine = addPlace('Rhine Hotel', KEHL, ['FR', 'FR-GES', 'Grand Est']); + const hotel = await addPlace('Hotel Adlon', BERLIN, ['FR', 'FR-IDF', 'Ile-de-France']); + const rhine = await addPlace('Rhine Hotel', KEHL, ['FR', 'FR-GES', 'Grand Est']); expect((await atlas.stats(userId)).countries.map((c) => c.code)).toEqual(['FR']); await job.runOnce(); - expect(cachedRow(hotel)).toEqual({ country_code: 'DE', region_code: 'DE-BE' }); - expect(cachedRow(rhine)).toEqual({ country_code: 'DE', region_code: 'DE-BW' }); + expect(await cachedRow(hotel)).toEqual({ country_code: 'DE', region_code: 'DE-BE' }); + expect(await cachedRow(rhine)).toEqual({ country_code: 'DE', region_code: 'DE-BW' }); expect((await atlas.stats(userId)).countries.map((c) => c.code)).toEqual(['DE']); expect(Object.keys((await atlas.visitedRegions(userId)).regions)).toEqual(['DE']); expect((await atlas.getTravelStats(userId)).countries).toEqual(['DE']); expect((await atlas.lastTrip(userId))?.countries).toEqual(['DE']); - expect(markedDone()).toBeTruthy(); + expect(await markedDone()).toBeTruthy(); expect(fetchMock).not.toHaveBeenCalled(); }); it('re-derives a region that moved inside the same country', async () => { - const museum = addPlace('Musee des Confluences', LYON, ['FR', 'FR-IDF', 'Ile-de-France']); + const museum = await addPlace('Musee des Confluences', LYON, ['FR', 'FR-IDF', 'Ile-de-France']); expect(await atlas.repairStaleRegionCache()).toEqual({ replaced: 1, dropped: 0 }); - expect(cachedRow(museum)).toEqual({ country_code: 'FR', region_code: 'FR-ARA' }); + expect(await cachedRow(museum)).toEqual({ country_code: 'FR', region_code: 'FR-ARA' }); }); it('leaves good rows alone, the address fallback and rows only Nominatim could judge included', async () => { - const paris = addPlace('Louvre', PARIS, ['FR', 'FR-IDF', 'Ile-de-France']); - const airbnb = addPlace('Airbnb', BOLLENDORF_PONT, ['LU', 'LU-EC', 'Canton Echternach']); + const paris = await addPlace('Louvre', PARIS, ['FR', 'FR-IDF', 'Ile-de-France']); + const airbnb = await addPlace('Airbnb', BOLLENDORF_PONT, ['LU', 'LU-EC', 'Canton Echternach']); // The bundle calls this point Italian and has no region for it; the address backs the row. - const harbour = addPlace('Port Lympia', NICE_HARBOUR, ['FR', 'FR-PAC', "Provence-Alpes-Cote d'Azur"]); - const ship = addPlace('Cruise ship', { lat: 0.5, lng: -30, address: null }, ['BR', 'BR-PE', 'Pernambuco']); + const harbour = await addPlace('Port Lympia', NICE_HARBOUR, ['FR', 'FR-PAC', "Provence-Alpes-Cote d'Azur"]); + const ship = await addPlace('Cruise ship', { lat: 0.5, lng: -30, address: null }, ['BR', 'BR-PE', 'Pernambuco']); expect(await atlas.repairStaleRegionCache()).toEqual({ replaced: 0, dropped: 0 }); - expect(cachedRow(paris)).toEqual({ country_code: 'FR', region_code: 'FR-IDF' }); - expect(cachedRow(airbnb)).toEqual({ country_code: 'LU', region_code: 'LU-EC' }); - expect(cachedRow(harbour)).toEqual({ country_code: 'FR', region_code: 'FR-PAC' }); - expect(cachedRow(ship)).toEqual({ country_code: 'BR', region_code: 'BR-PE' }); + expect(await cachedRow(paris)).toEqual({ country_code: 'FR', region_code: 'FR-IDF' }); + expect(await cachedRow(airbnb)).toEqual({ country_code: 'LU', region_code: 'LU-EC' }); + expect(await cachedRow(harbour)).toEqual({ country_code: 'FR', region_code: 'FR-PAC' }); + expect(await cachedRow(ship)).toEqual({ country_code: 'BR', region_code: 'BR-PE' }); }); it('drops a row the bundle cannot re-derive when coordinates and address both name another country', async () => { - const beach = addPlace('Beach hut', SYLT_BEACH, ['FR', 'FR-BRE', 'Bretagne']); - const cleared = addPlace('Somewhere', { lat: null, lng: null, address: null }, ['FR', 'FR-IDF', 'Ile-de-France']); + const beach = await addPlace('Beach hut', SYLT_BEACH, ['FR', 'FR-BRE', 'Bretagne']); + const cleared = await addPlace('Somewhere', { lat: null, lng: null, address: null }, ['FR', 'FR-IDF', 'Ile-de-France']); expect(await atlas.repairStaleRegionCache()).toEqual({ replaced: 0, dropped: 2 }); - expect(cachedRow(beach)).toBeUndefined(); - expect(cachedRow(cleared)).toBeUndefined(); + expect(await cachedRow(beach)).toBeUndefined(); + expect(await cachedRow(cleared)).toBeUndefined(); // The dashboard, which reads only the cache, no longer counts France. expect((await atlas.getTravelStats(userId)).countries).toEqual([]); }); it('does not write over a place that is edited while the pass runs', async () => { - const hotel = addPlace('Hotel Adlon', BERLIN, ['FR', 'FR-IDF', 'Ile-de-France']); - const beach = addPlace('Beach hut', SYLT_BEACH, ['FR', 'FR-BRE', 'Bretagne']); + const hotel = await addPlace('Hotel Adlon', BERLIN, ['FR', 'FR-IDF', 'Ile-de-France']); + const beach = await addPlace('Beach hut', SYLT_BEACH, ['FR', 'FR-BRE', 'Bretagne']); // Both places are edited once the pass has read their rows, before it writes. const read = PlaceRegionsRepository.prototype.listWithPlaceLocation; @@ -159,13 +161,16 @@ describe('the one time repair of place_regions rows cached before #2527', () => this: PlaceRegionsRepository, ) { const rows = await read.call(this); + // test-sql-allow: an edit landing mid-pass has to reach the connection directly, past the pass's own ORM work. db.prepare('UPDATE places SET lat = ?, lng = ?, address = ? WHERE id = ?').run( MUNICH.lat, MUNICH.lng, MUNICH.address, hotel, ); + // test-sql-allow: an edit landing mid-pass has to reach the connection directly, past the pass's own ORM work. db.prepare("UPDATE places SET address = 'Westerland, Germany' WHERE id = ?").run(beach); + // test-sql-allow: a fresh lookup landing mid-pass has to reach the connection directly, past the pass's own ORM work. db.prepare( "INSERT INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, 'DE', 'DE-SH', 'Schleswig-Holstein')", ).run(beach); @@ -181,39 +186,38 @@ describe('the one time repair of place_regions rows cached before #2527', () => expect(drop).toHaveBeenCalledTimes(1); await expect(drop.mock.results[0].value).resolves.toBe(false); // The trigger dropped the moved place's row and nothing wrote Berlin back. - expect(cachedRow(hotel)).toBeUndefined(); + expect(await cachedRow(hotel)).toBeUndefined(); // The row a fresh lookup wrote for the edited place stays. - expect(cachedRow(beach)).toEqual({ country_code: 'DE', region_code: 'DE-SH' }); + expect(await cachedRow(beach)).toEqual({ country_code: 'DE', region_code: 'DE-SH' }); }); it('runs once, and a second pass over the same rows changes nothing', async () => { // Enough rows for the pass to give way to other work along the way. - const moved = Array.from({ length: 250 }, (_, i) => - addPlace(`Stop ${i}`, BERLIN, ['FR', 'FR-IDF', 'Ile-de-France']), - ); + const moved: number[] = []; + for (let i = 0; i < 250; i++) moved.push(await addPlace(`Stop ${i}`, BERLIN, ['FR', 'FR-IDF', 'Ile-de-France'])); await job.runOnce(); - expect(moved.every((id) => cachedRow(id)?.region_code === 'DE-BE')).toBe(true); + expect((await Promise.all(moved.map(cachedRow))).every((row) => row?.region_code === 'DE-BE')).toBe(true); expect(await atlas.repairStaleRegionCache()).toEqual({ replaced: 0, dropped: 0 }); // Marked as done, the next start does not read the cache again. - const later = addPlace('Later', BERLIN, ['FR', 'FR-IDF', 'Ile-de-France']); + const later = await addPlace('Later', BERLIN, ['FR', 'FR-IDF', 'Ile-de-France']); await job.runOnce(); - expect(cachedRow(later)).toEqual({ country_code: 'FR', region_code: 'FR-IDF' }); + expect(await cachedRow(later)).toEqual({ country_code: 'FR', region_code: 'FR-IDF' }); }); it('is not marked as done when the pass fails, so the next start tries again', async () => { - const hotel = addPlace('Hotel Adlon', BERLIN, ['FR', 'FR-IDF', 'Ile-de-France']); + const hotel = await addPlace('Hotel Adlon', BERLIN, ['FR', 'FR-IDF', 'Ile-de-France']); vi.spyOn(atlas, 'repairStaleRegionCache').mockRejectedValueOnce(new Error('disk I/O error')); vi.spyOn(console, 'error').mockImplementation(() => {}); await job.runOnce(); - expect(markedDone()).toBeUndefined(); - expect(cachedRow(hotel)?.country_code).toBe('FR'); + expect(await markedDone()).toBeUndefined(); + expect((await cachedRow(hotel))?.country_code).toBe('FR'); await job.runOnce(); - expect(markedDone()).toBeTruthy(); - expect(cachedRow(hotel)?.country_code).toBe('DE'); + expect(await markedDone()).toBeTruthy(); + expect((await cachedRow(hotel))?.country_code).toBe('DE'); }); it('starts from the boot hook, but not where the scheduler is off', async () => { diff --git a/server/tests/unit/nest/place-shadow.service.test.ts b/server/tests/unit/nest/place-shadow.service.test.ts index b667e551f..9287ecf21 100644 --- a/server/tests/unit/nest/place-shadow.service.test.ts +++ b/server/tests/unit/nest/place-shadow.service.test.ts @@ -12,6 +12,9 @@ import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest'; import { createSnapshotTestDb } from '../../helpers/db-mock'; import { resetTestDb } from '../../helpers/test-db'; import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; +import { countRows, findRow, findRows, updateRows } from '../../helpers/factories/rows'; +import { setAppSetting } from '../../helpers/factories/settings'; +import { dbNow } from '../../../src/db/types'; import { PlaceShadowService, RETENTION_DAYS } from '../../../src/nest/place-shadow/place-shadow.service'; import { PlaceShadowPicks } from '../../../src/db/entities/PlaceShadowPicks.entity'; import type { PlaceShadowPicksRepository } from '../../../src/db/repositories/PlaceShadowPicks.repository'; @@ -38,8 +41,12 @@ const PICK: PlaceShadowPickRequest = { }; function enable(on = true) { - testDb.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)') - .run('place_shadow_enabled', on ? 'true' : 'false'); + return setAppSetting(t, 'place_shadow_enabled', on ? 'true' : 'false'); +} + +/** A timestamp in the stored datetime('now') format, `days` days back. */ +function daysAgo(days: number): string { + return dbNow(new Date(Date.now() - days * 86_400_000)); } beforeAll(async () => { @@ -56,19 +63,18 @@ describe('PlaceShadowService', () => { it('is off when the setting row is absent', async () => { expect(await svc.enabled()).toBe(false); expect(await svc.record(PICK)).toBe(false); - expect(testDb.prepare('SELECT COUNT(*) AS n FROM place_shadow_picks').get()).toEqual({ n: 0 }); + expect(await countRows(t, PlaceShadowPicks)).toBe(0); }); it('is off for any value that is not exactly "true"', async () => { for (const value of ['false', '1', 'yes', 'TRUE', '']) { - testDb.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)') - .run('place_shadow_enabled', value); + await setAppSetting(t, 'place_shadow_enabled', value); expect(await svc.enabled(), `value ${JSON.stringify(value)}`).toBe(false); } }); it('writes once switched on', async () => { - enable(); + await enable(); expect(await svc.record(PICK)).toBe(true); expect((await svc.summary()).total).toBe(1); }); @@ -79,7 +85,7 @@ describe('PlaceShadowService', () => { it('rounds every coordinate to three decimals', async () => { await svc.record({ ...PICK, pickedLat: 54.0891234, pickedLng: 12.1372987, biasLat: 54.08871, biasLng: 12.14049 }); - const row = testDb.prepare('SELECT * FROM place_shadow_picks').get() as Record; + const row = (await findRow(t, PlaceShadowPicks, {}))!; expect(row.picked_lat).toBe(54.089); expect(row.picked_lng).toBe(12.137); expect(row.bias_lat).toBe(54.089); @@ -88,7 +94,7 @@ describe('PlaceShadowService', () => { it('stores an absent bias and place id as NULL rather than inventing zeroes', async () => { await svc.record({ ...PICK, biasLat: undefined, biasLng: undefined, pickedPlaceId: undefined }); - const row = testDb.prepare('SELECT * FROM place_shadow_picks').get() as Record; + const row = (await findRow(t, PlaceShadowPicks, {}))!; expect(row.bias_lat).toBeNull(); expect(row.bias_lng).toBeNull(); expect(row.picked_place_id).toBeNull(); @@ -105,7 +111,7 @@ describe('PlaceShadowService', () => { describe('summary', () => { beforeEach(async () => { - enable(); + await enable(); const ranks = [0, 0, 0, 1, 4, 5, 9, 10, 42]; // Sequential, not Promise.all: the rows are inserted in rank order and the // export/paging assertions below read that order back. @@ -146,14 +152,14 @@ describe('PlaceShadowService', () => { it('reports the switch state it was asked about', async () => { expect((await svc.summary()).enabled).toBe(true); - enable(false); + await enable(false); expect((await svc.summary()).enabled).toBe(false); }); }); describe('export', () => { beforeEach(async () => { - enable(); + await enable(); for (let i = 0; i < 7; i++) await svc.record({ ...PICK, query: `q${i}` }); }); @@ -199,23 +205,22 @@ describe('PlaceShadowService', () => { // `resetTestDb` never resets `sqlite_sequence` (ids keep growing across // tests in this file, by design — see its own docstring), so the // "first" row's id is read back rather than hardcoded as `1`. - const [first] = testDb.prepare('SELECT id FROM place_shadow_picks ORDER BY id ASC').all() as { id: number }[]; - testDb.prepare("UPDATE place_shadow_picks SET created_at = datetime('now', '-200 days') WHERE id = ?").run(first.id); + const [first] = await findRows(t, PlaceShadowPicks, {}, { id: 'asc' }); + await updateRows(t, PlaceShadowPicks, { id: first.id }, { created_at: daysAgo(200) }); expect(await svc.purgeExpired()).toBe(1); expect((await svc.summary()).total).toBe(1); }); it('keeps a row that is one day short of the window', async () => { await svc.record(PICK); - testDb.prepare("UPDATE place_shadow_picks SET created_at = datetime('now', ?)") - .run(`-${RETENTION_DAYS - 1} days`); + await updateRows(t, PlaceShadowPicks, {}, { created_at: daysAgo(RETENTION_DAYS - 1) }); expect(await svc.purgeExpired()).toBe(0); }); it('runs even while the log is switched off, so old rows still age out', async () => { await svc.record(PICK); - testDb.prepare("UPDATE place_shadow_picks SET created_at = datetime('now', '-200 days')").run(); - enable(false); + await updateRows(t, PlaceShadowPicks, {}, { created_at: daysAgo(200) }); + await enable(false); expect(await svc.purgeExpired()).toBe(1); }); diff --git a/server/tests/unit/nest/registration-invites.service.test.ts b/server/tests/unit/nest/registration-invites.service.test.ts index 8a7e77c5c..21672720a 100644 --- a/server/tests/unit/nest/registration-invites.service.test.ts +++ b/server/tests/unit/nest/registration-invites.service.test.ts @@ -20,6 +20,9 @@ import { resetTestDb } from '../../helpers/test-db'; import { createUser, createAdmin, createTrip, createInviteToken } from '../../helpers/factories'; import { RegistrationInvitesService } from '../../../src/nest/auth/registration-invites.service'; import { createTestInviteTokensRepo, createTestTripsRepo } from '../../helpers/test-uow'; +import { sharedTestOrm } from '../../helpers/test-uow'; +import { countRows, findRow, insertRow } from '../../helpers/factories/rows'; +import { InviteTokens } from '../../../src/db/entities/InviteTokens.entity'; let svc: RegistrationInvitesService; @@ -57,8 +60,8 @@ describe('Invites', () => { const invite = createInviteToken(testDb, { created_by: admin.id }) as any; const result = await svc.deleteInvite(String(invite.id)) as any; expect(result.error).toBeUndefined(); - const check = testDb.prepare('SELECT id FROM invite_tokens WHERE id = ?').get(invite.id); - expect(check).toBeUndefined(); + const check = await findRow(await sharedTestOrm(testDb), InviteTokens, { id: invite.id }); + expect(check).toBeNull(); }); it('ADMIN-SVC-028 — deleteInvite returns 404 for non-existent invite', async () => { @@ -68,12 +71,12 @@ describe('Invites', () => { it('ADMIN-SVC-029 — deleteInvite(\'0x10\') is the legacy 404, not a hex-literal delete of invite id 16 (Plan 3b Task 3 review, F2)', async () => { const { user: admin } = createAdmin(testDb); - testDb.prepare('INSERT INTO invite_tokens (id, token, max_uses, used_count, expires_at, created_by) VALUES (16, ?, 1, 0, NULL, ?)').run('hex-survivor', admin.id); + await insertRow(await sharedTestOrm(testDb), InviteTokens, { id: 16, token: 'hex-survivor', max_uses: 1, used_count: 0, expires_at: null, createdByRef: admin.id }); const result = await svc.deleteInvite('0x10'); expect(result).toEqual({ error: 'Invite not found', status: 404 }); - expect(testDb.prepare('SELECT id FROM invite_tokens WHERE id = 16').get()).toBeDefined(); + expect(await findRow(await sharedTestOrm(testDb), InviteTokens, { id: 16 })).not.toBeNull(); }); }); @@ -82,7 +85,7 @@ describe('Invites — trip binding', () => { const { user: admin } = createAdmin(testDb); expect(await svc.createInvite(admin.id, { trip_id: 99999 }) as any).toMatchObject({ status: 404, error: 'Trip not found' }); expect(await svc.createInvite(admin.id, { trip_id: 'not-a-number' }) as any).toMatchObject({ status: 404 }); - expect(testDb.prepare('SELECT COUNT(*) as c FROM invite_tokens').get()).toEqual({ c: 0 }); + expect(await countRows(await sharedTestOrm(testDb), InviteTokens)).toBe(0); // An absent/blank binding is still a plain registration invite. expect((await svc.createInvite(admin.id, {}) as any).tripId).toBeNull(); }); diff --git a/server/tests/unit/nest/storage/storage-jobs.service.test.ts b/server/tests/unit/nest/storage/storage-jobs.service.test.ts index 0378df6d0..acf253c90 100644 --- a/server/tests/unit/nest/storage/storage-jobs.service.test.ts +++ b/server/tests/unit/nest/storage/storage-jobs.service.test.ts @@ -27,6 +27,9 @@ import { StorageJobsService, } from '../../../../src/nest/storage/storage-jobs.service'; import { createTestUnitOfWork, createTestAppSettingsRepo, sharedTestOrm } from '../../../helpers/test-uow'; +import { deleteRows } from '../../../helpers/factories/rows'; +import { readAppSetting, setAppSetting } from '../../../helpers/factories/settings'; +import { AppSettings } from '../../../../src/db/entities/AppSettings.entity'; const tmpDirs: string[] = []; function makeTmpDir(): string { @@ -34,11 +37,11 @@ function makeTmpDir(): string { tmpDirs.push(dir); return dir; } -function setSetting(key: string, value: string): void { - testDb.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run(key, value); +async function setSetting(key: string, value: string): Promise { + await setAppSetting(await sharedTestOrm(testDb), key, value); } -beforeEach(() => { - testDb.prepare("DELETE FROM app_settings WHERE key LIKE 'storage.%'").run(); +beforeEach(async () => { + await deleteRows(await sharedTestOrm(testDb), AppSettings, { key: { $like: 'storage.%' } }); }); afterEach(() => { vi.restoreAllMocks(); @@ -50,7 +53,7 @@ async function makeWorld() { const uploadsRoot = makeTmpDir(); const backupsRoot = makeTmpDir(); const nasRoot = makeTmpDir(); - setSetting( + await setSetting( 'storage.backends', JSON.stringify([ { name: 'uploads-local', type: 'local', options: { root: uploadsRoot } }, @@ -59,7 +62,7 @@ async function makeWorld() { { name: 'm', type: 'mirror', options: { primary: 'backups-local', replicas: ['nas'] } }, ]), ); - setSetting('storage.categories', JSON.stringify({ backups: 'm' })); + await setSetting('storage.categories', JSON.stringify({ backups: 'm' })); const env = { placePhotoDir: undefined }; const registry = new StorageRegistryService( await createTestAppSettingsRepo(testDb), @@ -87,7 +90,7 @@ async function makeMigrationWorld() { const uploadsRoot = makeTmpDir(); const backupsRoot = makeTmpDir(); const destRoot = makeTmpDir(); - setSetting( + await setSetting( 'storage.backends', JSON.stringify([ { name: 'uploads-local', type: 'local', options: { root: uploadsRoot } }, @@ -95,7 +98,7 @@ async function makeMigrationWorld() { { name: 'dest-local', type: 'local', options: { root: destRoot } }, ]), ); - setSetting('storage.categories', JSON.stringify({ files: 'uploads-local' })); + await setSetting('storage.categories', JSON.stringify({ files: 'uploads-local' })); const env = { placePhotoDir: undefined }; const registry = new StorageRegistryService( await createTestAppSettingsRepo(testDb), @@ -121,7 +124,7 @@ async function makePhotosGoogleMigrationWorld() { const backupsRoot = makeTmpDir(); const placePhotoRoot = makeTmpDir(); const destRoot = makeTmpDir(); - setSetting( + await setSetting( 'storage.backends', JSON.stringify([ { name: 'uploads-local', type: 'local', options: { root: uploadsRoot } }, @@ -149,7 +152,7 @@ async function makeMigrationBackfillWorld() { const backupsRoot = makeTmpDir(); const nasRoot = makeTmpDir(); const destRoot = makeTmpDir(); - setSetting( + await setSetting( 'storage.backends', JSON.stringify([ { name: 'uploads-local', type: 'local', options: { root: uploadsRoot } }, @@ -159,7 +162,7 @@ async function makeMigrationBackfillWorld() { { name: 'm', type: 'mirror', options: { primary: 'backups-local', replicas: ['nas'] } }, ]), ); - setSetting('storage.categories', JSON.stringify({ backups: 'm', files: 'uploads-local' })); + await setSetting('storage.categories', JSON.stringify({ backups: 'm', files: 'uploads-local' })); const env = { placePhotoDir: undefined }; const registry = new StorageRegistryService( await createTestAppSettingsRepo(testDb), @@ -174,12 +177,10 @@ async function makeMigrationBackfillWorld() { return { registry, storage, jobs, uploadsRoot, backupsRoot, nasRoot, destRoot }; } -/** Reads the raw 'storage.categories' app_settings row (undefined key ⇒ {}). */ -function registryCategoriesRow(): Record { - const row = testDb.prepare('SELECT value FROM app_settings WHERE key = ?').get(CATEGORIES_KEY) as - | { value: string } - | undefined; - return row ? (JSON.parse(row.value) as Record) : {}; +/** Reads the stored 'storage.categories' app_settings row (undefined key ⇒ {}). */ +async function registryCategoriesRow(): Promise> { + const value = await readAppSetting(await sharedTestOrm(testDb), CATEGORIES_KEY); + return value !== null ? (JSON.parse(value) as Record) : {}; } /** Polls migrationStatuses() for the named category to leave 'running', bounded ~5s. */ @@ -307,7 +308,7 @@ describe('StorageJobsService migrations', () => { // copy phase's own (re-run) list(), so assert combined coverage, not // which phase caught it. expect(final.copied + final.skipped).toBeGreaterThanOrEqual(3); - expect(registryCategoriesRow().files).toBe('dest-local'); // flip persisted + expect((await registryCategoriesRow()).files).toBe('dest-local'); // flip persisted expect(fs.existsSync(path.join(destRoot, 'files', 'a.txt'))).toBe(true); expect(fs.existsSync(path.join(destRoot, 'files', 'b.txt'))).toBe(true); expect(fs.existsSync(path.join(destRoot, 'files', 'c.txt'))).toBe(true); @@ -325,7 +326,7 @@ describe('StorageJobsService migrations', () => { const final = await waitTerminal(jobs, 'files'); expect(final.status).toBe('failed'); expect(final.failed).toBeGreaterThan(0); - expect(registryCategoriesRow().files).toBe('uploads-local'); // NOT flipped + expect((await registryCategoriesRow()).files).toBe('uploads-local'); // NOT flipped }); it('MIG-003 cancel before the flip leaves everything untouched', async () => { @@ -340,7 +341,7 @@ describe('StorageJobsService migrations', () => { const final = await waitTerminal(jobs, 'files'); expect(final.status).toBe('cancelled'); - expect(registryCategoriesRow().files).toBe('uploads-local'); + expect((await registryCategoriesRow()).files).toBe('uploads-local'); }); it('MIG-007 a cancel landing after the copy loop but before the flip ends cancelled, categories row untouched', async () => { @@ -361,7 +362,7 @@ describe('StorageJobsService migrations', () => { const final = await waitTerminal(jobs, 'files'); expect(final.status).toBe('cancelled'); - expect(registryCategoriesRow().files).toBe('uploads-local'); // never flipped + expect((await registryCategoriesRow()).files).toBe('uploads-local'); // never flipped }); it('MIG-004 validations: 400s, 404, and 409 against a running backfill (and vice versa)', async () => { @@ -396,7 +397,7 @@ describe('StorageJobsService migrations', () => { expect(final.status).toBe('done'); expect(final.skipped).toBeGreaterThanOrEqual(1); expect(final.copied).toBe(0); - expect(registryCategoriesRow().files).toBe('dest-local'); + expect((await registryCategoriesRow()).files).toBe('dest-local'); }); it('MIG-006 a sweep-phase copy failure is reported without undoing the already-flipped category', async () => { @@ -426,7 +427,7 @@ describe('StorageJobsService migrations', () => { const final = await waitTerminal(jobs, 'files'); expect(final.status).toBe('done'); // sweep failures don't undo a completed flip expect(final.failed).toBeGreaterThanOrEqual(1); - expect(registryCategoriesRow().files).toBe('dest-local'); + expect((await registryCategoriesRow()).files).toBe('dest-local'); }); it('MIG-008 photos-google mode-A (bare "" prefix) migrating to a prefixed backend rewrites destination keys (audit #8)', async () => { @@ -448,7 +449,7 @@ describe('StorageJobsService migrations', () => { expect(fs.existsSync(path.join(destRoot, 'photos', 'google', 'abc.jpg'))).toBe(true); expect(fs.existsSync(path.join(destRoot, 'photos', 'google', 'sub', 'def.jpg'))).toBe(true); expect(fs.existsSync(path.join(destRoot, 'abc.jpg'))).toBe(false); // not left at the bare source key - expect(registryCategoriesRow()['photos-google']).toBe('dest-local'); + expect((await registryCategoriesRow())['photos-google']).toBe('dest-local'); }); it('MIG-009 the skip-check on a rewritten-prefix migration stats the DESTINATION key, not the source key', async () => { @@ -466,7 +467,7 @@ describe('StorageJobsService migrations', () => { expect(final.status).toBe('done'); expect(final.skipped).toBeGreaterThanOrEqual(1); expect(final.copied).toBe(0); - expect(registryCategoriesRow()['photos-google']).toBe('dest-local'); + expect((await registryCategoriesRow())['photos-google']).toBe('dest-local'); }); it('MIG-010 the delta sweep rewrites a raced object\'s destination key too', async () => { @@ -488,7 +489,7 @@ describe('StorageJobsService migrations', () => { expect(final.status).toBe('done'); expect(fs.existsSync(path.join(destRoot, 'photos', 'google', 'a.jpg'))).toBe(true); expect(fs.existsSync(path.join(destRoot, 'photos', 'google', 'raced.jpg'))).toBe(true); - expect(registryCategoriesRow()['photos-google']).toBe('dest-local'); + expect((await registryCategoriesRow())['photos-google']).toBe('dest-local'); }); it('MIG-011 equal-prefix migrations (files -> dest-local) stay byte-identical: destination key equals the source key', async () => { @@ -503,7 +504,7 @@ describe('StorageJobsService migrations', () => { // Both source and destination use the same 'files/' prefix — the key is // unchanged, exactly today's pre-fix behavior. expect(fs.existsSync(path.join(destRoot, 'files', 'a.txt'))).toBe(true); - expect(registryCategoriesRow().files).toBe('dest-local'); + expect((await registryCategoriesRow()).files).toBe('dest-local'); }); }); @@ -516,8 +517,8 @@ describe('StorageJobsService.cancelJobsForMissingBackends', () => { // Simulate a config save that drops mirror 'm' entirely — 'backups' reverts // to the built-in default ('backups-local'), a self-consistent config. - setSetting('storage.backends', JSON.stringify([])); - setSetting('storage.categories', JSON.stringify({})); + await setSetting('storage.backends', JSON.stringify([])); + await setSetting('storage.categories', JSON.stringify({})); await registry.reload(); jobs.cancelJobsForMissingBackends(); @@ -533,7 +534,7 @@ describe('StorageJobsService.cancelJobsForMissingBackends', () => { const uploadsRoot = makeTmpDir(); const nasRoot = makeTmpDir(); const destRoot = makeTmpDir(); - setSetting( + await setSetting( 'storage.backends', JSON.stringify([ { name: 'uploads-local', type: 'local', options: { root: uploadsRoot } }, @@ -541,7 +542,7 @@ describe('StorageJobsService.cancelJobsForMissingBackends', () => { { name: 'dest-local', type: 'local', options: { root: destRoot } }, ]), ); - setSetting('storage.categories', JSON.stringify({ files: 'nas' })); + await setSetting('storage.categories', JSON.stringify({ files: 'nas' })); const env = { placePhotoDir: undefined }; const registry = new StorageRegistryService( await createTestAppSettingsRepo(testDb), @@ -562,14 +563,14 @@ describe('StorageJobsService.cancelJobsForMissingBackends', () => { // migration's in-flight FROM backend ('nas') is dropped from the config // entirely — self-consistent (validateConfig only checks the new // category map), but the running migration's `from` no longer resolves. - setSetting( + await setSetting( 'storage.backends', JSON.stringify([ { name: 'uploads-local', type: 'local', options: { root: uploadsRoot } }, { name: 'dest-local', type: 'local', options: { root: destRoot } }, ]), ); - setSetting('storage.categories', JSON.stringify({ files: 'uploads-local' })); + await setSetting('storage.categories', JSON.stringify({ files: 'uploads-local' })); await registry.reload(); jobs.cancelJobsForMissingBackends(); @@ -588,7 +589,7 @@ describe('StorageJobsService.cancelJobsForMissingBackends', () => { // still exist (the migration's already-resolved source driver instance // is unaffected by the roots below — the in-flight guarantee), so the // existing missing-backend check wouldn't catch this. - setSetting( + await setSetting( 'storage.backends', JSON.stringify([ { name: 'uploads-local', type: 'local', options: { root: makeTmpDir() } }, @@ -597,13 +598,13 @@ describe('StorageJobsService.cancelJobsForMissingBackends', () => { { name: 'third-local', type: 'local', options: { root: makeTmpDir() } }, ]), ); - setSetting('storage.categories', JSON.stringify({ files: 'third-local' })); + await setSetting('storage.categories', JSON.stringify({ files: 'third-local' })); await registry.reload(); jobs.cancelJobsForMissingBackends(); const final = await waitTerminal(jobs, 'files'); expect(final.status).toBe('cancelled'); - expect(registryCategoriesRow().files).toBe('third-local'); // the save's reroute stands — no flip + expect((await registryCategoriesRow()).files).toBe('third-local'); // the save's reroute stands — no flip }); it('JOBS-023 a save that does not touch the migrating category leaves it running', async () => { @@ -613,7 +614,7 @@ describe('StorageJobsService.cancelJobsForMissingBackends', () => { await waitFor(() => jobs.migrationStatuses().some((m) => m.category === 'files' && m.done >= 1), 5000, 1); // A save that touches an unrelated category only — 'files' route is untouched. - setSetting( + await setSetting( 'storage.backends', JSON.stringify([ { name: 'uploads-local', type: 'local', options: { root: makeTmpDir() } }, @@ -622,7 +623,7 @@ describe('StorageJobsService.cancelJobsForMissingBackends', () => { ]), ); // Re-declare the original mapping unchanged (files still on uploads-local). - setSetting('storage.categories', JSON.stringify({ files: 'uploads-local' })); + await setSetting('storage.categories', JSON.stringify({ files: 'uploads-local' })); jobs.cancelJobsForMissingBackends(); expect(jobs.migrationStatuses().find((m) => m.category === 'files')!.status).toBe('running'); diff --git a/server/tests/unit/nest/token.service.test.ts b/server/tests/unit/nest/token.service.test.ts index 3a96c137e..81ed3daf8 100644 --- a/server/tests/unit/nest/token.service.test.ts +++ b/server/tests/unit/nest/token.service.test.ts @@ -44,6 +44,14 @@ import { revokeUserSessions } from '../../../src/mcp/sessionManager'; import { expectRegisteredProvider } from '../../helpers/module-providers'; import { EphemeralTokenService } from '../../../src/nest/auth/ephemeral-token.service'; import { createTestMcpTokensRepo, createTestUsersRepo } from '../../helpers/test-uow'; +import { sharedTestOrm } from '../../helpers/test-uow'; +import { countRows, findRow, insertRow, updateRows } from '../../helpers/factories/rows'; +import { McpTokens } from '../../../src/db/entities/McpTokens.entity'; +import { Users } from '../../../src/db/entities/Users.entity'; +/** The token row, or null once it is gone. */ +async function tokenRow(id: number | string) { + return findRow(await sharedTestOrm(testDb), McpTokens, { id: Number(id) }); +} // Schema first, then the ORM-backed repositories `TokenService` now takes // (Plan 3b Task 2) — `sharedTestOrm` (behind `createTestMcpTokensRepo`/ @@ -91,9 +99,7 @@ describe('MCP token service', () => { it('AUTH-DB-044: createMcpToken returns 400 when user has 10 tokens already', async () => { const { user } = createUser(testDb); for (let i = 0; i < 10; i++) { - testDb.prepare( - 'INSERT INTO mcp_tokens (user_id, name, token_hash, token_prefix) VALUES (?, ?, ?, ?)' - ).run(user.id, `Token ${i}`, `hash${i}`, `trek_prefix${i}`); + await insertRow(await sharedTestOrm(testDb), McpTokens, { user: user.id, name: `Token ${i}`, token_hash: `hash${i}`, token_prefix: `trek_prefix${i}` }); } const result = await svc.createMcpToken(user.id, 'One More'); expect(result.status).toBe(400); @@ -113,8 +119,8 @@ describe('MCP token service', () => { const result = await svc.deleteMcpToken(user.id, tokenId); expect(result).toEqual({ success: true }); - const row = testDb.prepare('SELECT id FROM mcp_tokens WHERE id = ?').get(tokenId); - expect(row).toBeUndefined(); + const row = await tokenRow(tokenId); + expect(row).toBeNull(); }); it('AUTH-DB-092: deleteMcpToken succeeds even when the session sweep throws (best-effort)', async () => { @@ -124,7 +130,7 @@ describe('MCP token service', () => { vi.mocked(revokeUserSessions).mockImplementationOnce(() => { throw new Error('sweep down'); }); expect(await svc.deleteMcpToken(user.id, tokenId)).toEqual({ success: true }); - expect(testDb.prepare('SELECT id FROM mcp_tokens WHERE id = ?').get(tokenId)).toBeUndefined(); + expect(await tokenRow(tokenId)).toBeNull(); }); it('TOKEN-001: listMcpTokens is scoped to the caller and never exposes the hash', async () => { @@ -146,7 +152,7 @@ describe('MCP token service', () => { const tokenId = String((created.token as { id: number }).id); expect(await svc.deleteMcpToken(user.id, tokenId)).toEqual({ error: 'Token not found', status: 404 }); - expect(testDb.prepare('SELECT id FROM mcp_tokens WHERE id = ?').get(tokenId)).toBeDefined(); + expect(await tokenRow(tokenId)).not.toBeNull(); }); it('TOKEN-017: deleteMcpToken 404s (not 500) on a non-numeric id — Plan 3b Task 2 review, F1', async () => { @@ -207,8 +213,8 @@ describe('API key service', () => { expect(await svc.deleteMcpToken(user.id, apiId)).toEqual({ error: 'Token not found', status: 404 }); // Neither row was touched: a wrong-kind delete must not be a way to revoke // someone's assistant access from the API-key screen. - expect(testDb.prepare('SELECT id FROM mcp_tokens WHERE id = ?').get(mcpId)).toBeDefined(); - expect(testDb.prepare('SELECT id FROM mcp_tokens WHERE id = ?').get(apiId)).toBeDefined(); + expect(await tokenRow(mcpId)).not.toBeNull(); + expect(await tokenRow(apiId)).not.toBeNull(); }); it('TOKEN-014: the ten-token ceiling counts each kind on its own', async () => { @@ -225,9 +231,7 @@ describe('API key service', () => { const created = await svc.createApiToken(user.id, 'dawarich'); const raw = (created.token as { raw_token: string }).raw_token; - const row = testDb - .prepare('SELECT token_hash, token_prefix, kind FROM mcp_tokens WHERE user_id = ?') - .get(user.id) as { token_hash: string; token_prefix: string; kind: string }; + const row = (await findRow(await sharedTestOrm(testDb), McpTokens, { user: user.id }))!; expect(row.kind).toBe('api'); expect(row.token_hash).not.toBe(raw); expect(raw.startsWith(row.token_prefix)).toBe(true); @@ -289,7 +293,7 @@ describe('MCP token service (admin view)', () => { const tokenId = String((created.token as { id: number }).id); expect(await svc.adminDeleteMcpToken(tokenId)).toEqual({}); - expect(testDb.prepare('SELECT id FROM mcp_tokens WHERE id = ?').get(tokenId)).toBeUndefined(); + expect(await tokenRow(tokenId)).toBeNull(); expect(revokeUserSessions).toHaveBeenCalledWith(user.id); }); @@ -330,7 +334,7 @@ describe('ephemeral tokens', () => { it('TOKEN-006: createWsToken binds the caller password_version, so a pre-reset token is rejected on connect', async () => { const { user } = createUser(testDb); - testDb.prepare('UPDATE users SET password_version = 7 WHERE id = ?').run(user.id); + await updateRows(await sharedTestOrm(testDb), Users, { id: user.id }, { password_version: 7 }); vi.mocked(createEphemeralToken).mockReturnValueOnce('ws-tok'); await svc.createWsToken(user.id); @@ -370,13 +374,13 @@ describe('verifyMcpToken', () => { const created = await svc.createMcpToken(user.id, 'stamped'); const raw = (created.token as { raw_token: string }).raw_token; const id = (created.token as { id: number }).id; - expect((testDb.prepare('SELECT last_used_at FROM mcp_tokens WHERE id = ?').get(id) as { last_used_at: string | null }).last_used_at).toBeNull(); + expect((await tokenRow(id))!.last_used_at).toBeNull(); await svc.verifyMcpToken(raw); - expect((testDb.prepare('SELECT last_used_at FROM mcp_tokens WHERE id = ?').get(id) as { last_used_at: string | null }).last_used_at).not.toBeNull(); + expect((await tokenRow(id))!.last_used_at).not.toBeNull(); await svc.verifyMcpToken('trek_wrong'); - expect(testDb.prepare('SELECT COUNT(*) c FROM mcp_tokens').get()).toEqual({ c: 1 }); + expect(await countRows(await sharedTestOrm(testDb), McpTokens)).toBe(1); }); it('TOKEN-010: verifyMcpToken returns identity columns only, never the password hash', async () => { diff --git a/server/tests/unit/nest/tours.gpx.atomic.test.ts b/server/tests/unit/nest/tours.gpx.atomic.test.ts index 509ba7d1d..4cff40d23 100644 --- a/server/tests/unit/nest/tours.gpx.atomic.test.ts +++ b/server/tests/unit/nest/tours.gpx.atomic.test.ts @@ -47,6 +47,11 @@ import { createTestAssignmentParticipantsRepo, createTestPlacesRepo, createTestTripMembersRepo, createTestDayAssignmentsRepo, createTestCategoriesRepo, createTestTripsRepo, createTestCollectionPlacesRepo, sharedTestOrm, } from '../../helpers/test-uow'; +import type { EntityClass } from '@mikro-orm/core'; +import { countRows, findRows } from '../../helpers/factories/rows'; +import { Places } from '../../../src/db/entities/Places.entity'; +import { TourWaypoints } from '../../../src/db/entities/TourWaypoints.entity'; +import { Tours } from '../../../src/db/entities/Tours.entity'; import { createTestBudgetItemsRepo } from '../../helpers/files-repos'; import { createTestJourneysRepo, createTestJourneyContributorsRepo, createTestJourneyTripsRepo, createTestJourneyEntriesRepo, @@ -105,7 +110,18 @@ let tours: ToursService; let tripId: string; let broadcast: MockInstance; -const count = (table: string) => (testDb.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get() as { n: number }).n; +const countedTables = { places: Places, tours: Tours, tour_waypoints: TourWaypoints } as const; +const count = async (table: keyof typeof countedTables) => + countRows(await sharedTestOrm(testDb), countedTables[table] as EntityClass); +/** + * The broadcast probe reads mid-request, while the import may still hold the + * connection inside its transaction: it has to see the handle's own state + * synchronously, past the ORM's connection queue, which a raw read on the + * shared handle does. + */ +const countNow = (table: keyof typeof countedTables) => + // test-sql-allow: a synchronous read on the shared handle mid-transaction is what this probe measures. + (testDb.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get() as { n: number }).n; beforeAll(async () => { uow = await createTestUnitOfWork(testDb); @@ -131,15 +147,15 @@ afterEach(() => { afterAll(() => { testDb.close(); }); describe('Tours GPX atomic persistence and postcommit publication', () => { - function expectEmpty() { - for (const table of ['places', 'tours', 'tour_waypoints']) expect(count(table)).toBe(0); + async function expectEmpty() { + for (const table of ['places', 'tours', 'tour_waypoints'] as const) expect(await count(table)).toBe(0); expect(broadcast).not.toHaveBeenCalled(); } it('commits routes, tracks, colors and facets once before publishing, without Planner waypoints', async () => { const transactional = vi.spyOn(uow, 'transactional'); const seen: { inTransaction: boolean; tours: number }[] = []; - broadcast.mockImplementation(() => { seen.push({ inTransaction: testDb.inTransaction, tours: count('tours') }); }); + broadcast.mockImplementation(() => { seen.push({ inTransaction: testDb.inTransaction, tours: countNow('tours') }); }); const result = (await tours.importGpxAsTour(tripId, mixedGpx, 'walk.gpx', 'socket'))!; @@ -151,15 +167,15 @@ describe('Tours GPX atomic persistence and postcommit publication', () => { expect(result.tours[0]).toMatchObject({ name: 'walk', tour_type: 'hike', max_hiking_difficulty: 2, planned: false, has_waypoints: false, caution: true, match_confidence: 0.3 }); expect(result.tours[1]).toMatchObject({ name: 'Ridge', elevation_gain: 50, elevation_loss: 30, caution: false, match_confidence: 1 }); - const rows = testDb.prepare('SELECT * FROM places ORDER BY id').all() as { id: number; trip_id: number; description: string; route_geometry: string; route_color: string }[]; + const rows = (await findRows(await sharedTestOrm(testDb), Places, {}, { id: 'asc' })) as Array<{ id: number; trip_id: number; description: string; route_geometry: string; route_color: string }>; expect(rows.map(row => row.trip_id)).toEqual([Number(tripId), Number(tripId)]); expect(rows.map(row => row.description)).toEqual(['Route description', 'Track description']); expect(JSON.parse(rows[0].route_geometry)).toEqual([[48, 11], [48.01, 11.01]]); expect(JSON.parse(rows[1].route_geometry)).toEqual([[49, 12, 100], [49.01, 12.01, 150], [49.02, 12.02, 120]]); expect(new Set(rows.map(row => row.route_color)).size).toBe(2); - expect(testDb.prepare('SELECT place_id, tour_type FROM tours ORDER BY place_id').all()) + expect((await findRows(await sharedTestOrm(testDb), Tours, {}, { place: 'asc' })).map(tour => ({ place_id: tour.place_id, tour_type: tour.tour_type }))) .toEqual(rows.map(row => ({ place_id: row.id, tour_type: 'hike' }))); - expect(count('tour_waypoints')).toBe(0); + expect(await count('tour_waypoints')).toBe(0); expect(seen).toEqual([ { inTransaction: false, tours: 2 }, { inTransaction: false, tours: 2 }, { inTransaction: false, tours: 2 }, @@ -179,28 +195,28 @@ describe('Tours GPX atomic persistence and postcommit publication', () => { WHEN (SELECT COUNT(*) FROM tours) = ${ordinal - 1} BEGIN SELECT RAISE(ABORT, 'facet failure'); END;`); await expect(tours.importGpxAsTour(tripId, mixedGpx)).rejects.toThrow('facet failure'); - expectEmpty(); + await expectEmpty(); }); it('rolls back earlier carriers when the later Place insert fails', async () => { testDb.exec(`CREATE TRIGGER fail_place BEFORE INSERT ON places WHEN NEW.name = 'Ridge' BEGIN SELECT RAISE(ABORT, 'place failure'); END;`); await expect(tours.importGpxAsTour(tripId, mixedGpx)).rejects.toThrow('place failure'); - expectEmpty(); + await expectEmpty(); }); it('rolls back carriers and earlier colors when later coloring fails', async () => { testDb.exec(`CREATE TRIGGER fail_color BEFORE UPDATE OF route_color ON places WHEN NEW.name = 'Ridge' BEGIN SELECT RAISE(ABORT, 'color failure'); END;`); await expect(tours.importGpxAsTour(tripId, mixedGpx)).rejects.toThrow('color failure'); - expectEmpty(); + await expectEmpty(); }); it.each(['', '', '', ''])('does not write or publish unusable input %s', async (xml) => { const transactional = vi.spyOn(uow, 'transactional'); expect(await tours.importGpxAsTour(tripId, Buffer.from(xml))).toBeNull(); expect(transactional).not.toHaveBeenCalled(); - expectEmpty(); + await expectEmpty(); }); it('propagates parser failures before entering the transaction', async () => { @@ -208,14 +224,14 @@ describe('Tours GPX atomic persistence and postcommit publication', () => { vi.spyOn(gpxParser, 'parse').mockImplementationOnce(() => { throw new Error('parser failure'); }); await expect(tours.importGpxAsTour(tripId, mixedGpx)).rejects.toThrow('parser failure'); expect(transactional).not.toHaveBeenCalled(); - expectEmpty(); + await expectEmpty(); }); it('keeps a committed response and attempts remaining events if publication throws', async () => { const warn = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => {}); broadcast.mockImplementationOnce(() => { throw new Error('transport unavailable'); }); expect((await tours.importGpxAsTour(tripId, mixedGpx))?.tours).toHaveLength(2); - expect(count('tours')).toBe(2); + expect(await count('tours')).toBe(2); expect(broadcast).toHaveBeenCalledTimes(3); expect(warn).toHaveBeenCalledOnce(); }); @@ -227,7 +243,7 @@ describe('Tours GPX atomic persistence and postcommit publication', () => { }); expect((await tours.importGpxAsTour(tripId, mixedGpx))?.tours).toHaveLength(2); - expect(count('places')).toBe(2); + expect(await count('places')).toBe(2); expect(warn).toHaveBeenCalledTimes(2); }); @@ -240,21 +256,25 @@ describe('Tours GPX atomic persistence and postcommit publication', () => { const result = await tours.importGpxAsTour(tripId, mixedGpx); expect(result?.tours).toHaveLength(2); - expect(count('places')).toBe(2); - expect(count('tours')).toBe(2); + expect(await count('places')).toBe(2); + expect(await count('tours')).toBe(2); expect(broadcast.mock.calls.map(call => call[1])).toEqual(['tours:changed', 'place:created', 'place:created']); expect(warn).toHaveBeenCalledOnce(); }); it('returns a successful skipped result for duplicate-only imports without publishing events', async () => { expect((await tours.importGpxAsTour(tripId, mixedGpx))?.tours).toHaveLength(2); - const rowsBefore = testDb.prepare('SELECT id, trip_id, name FROM places ORDER BY id').all(); - const facetsBefore = testDb.prepare('SELECT place_id FROM tours ORDER BY place_id').all(); + const placeRows = async () => + (await findRows(await sharedTestOrm(testDb), Places, {}, { id: 'asc' })).map(row => ({ id: row.id, trip_id: row.trip_id, name: row.name })); + const tourFacets = async () => + (await findRows(await sharedTestOrm(testDb), Tours, {}, { place: 'asc' })).map(tour => ({ place_id: tour.place_id })); + const rowsBefore = await placeRows(); + const facetsBefore = await tourFacets(); broadcast.mockClear(); expect(await tours.importGpxAsTour(tripId, mixedGpx)).toEqual({ tours: [], caution: false, skipped: 2 }); - expect(testDb.prepare('SELECT id, trip_id, name FROM places ORDER BY id').all()).toEqual(rowsBefore); - expect(testDb.prepare('SELECT place_id FROM tours ORDER BY place_id').all()).toEqual(facetsBefore); + expect(await placeRows()).toEqual(rowsBefore); + expect(await tourFacets()).toEqual(facetsBefore); expect(broadcast).not.toHaveBeenCalled(); }); @@ -270,8 +290,8 @@ describe('Tours GPX atomic persistence and postcommit publication', () => { expect(result?.tours).toHaveLength(1); expect(result?.tours[0].name).toBe('walk'); expect(result?.skipped).toBe(1); - expect(count('places')).toBe(2); - expect(count('tours')).toBe(2); + expect(await count('places')).toBe(2); + expect(await count('tours')).toBe(2); const walkId = result!.tours[0].place_id; expect(broadcast.mock.calls.map(call => [call[1], call[2]])).toEqual([ ['tours:changed', { placeIds: [walkId] }], @@ -289,7 +309,7 @@ describe('Tours GPX atomic persistence and postcommit publication', () => { expect(result.tours).toHaveLength(1); expect(result.tours[0].name).toBe('single'); expect(result.caution).toBe(true); - expect(testDb.prepare('SELECT trip_id FROM places').all()).toEqual([{ trip_id: Number(otherTripId) }]); + expect((await findRows(await sharedTestOrm(testDb), Places, {}, { id: 'asc' })).map(row => ({ trip_id: row.trip_id }))).toEqual([{ trip_id: Number(otherTripId) }]); expect((await tours.listTours(otherTripId)).map(t => t.place_id)).toEqual([result.tours[0].place_id]); expect(await tours.listTours(tripId)).toEqual([]); expect(broadcast.mock.calls.map(call => call[1])).toEqual(['tours:changed', 'place:created']); diff --git a/server/tests/unit/nest/trip-invite.service.test.ts b/server/tests/unit/nest/trip-invite.service.test.ts index c576e7ef2..303c0b422 100644 --- a/server/tests/unit/nest/trip-invite.service.test.ts +++ b/server/tests/unit/nest/trip-invite.service.test.ts @@ -33,6 +33,9 @@ import { PermissionsService } from '../../../src/nest/permissions/permissions.se import { TripMembershipService } from '../../../src/nest/trip-membership/trip-membership.service'; import { TripInviteService } from '../../../src/nest/trip-invite/trip-invite.service'; import { createTestUnitOfWork, createTestAppSettingsRepo, createTestTripsRepo, createTestTripMembersRepo, createTestTripInviteTokensRepo } from '../../helpers/test-uow'; +import { sharedTestOrm } from '../../helpers/test-uow'; +import { countRows, updateRows } from '../../helpers/factories/rows'; +import { TripInviteTokens } from '../../../src/db/entities/TripInviteTokens.entity'; let svc: TripInviteService; beforeAll(async () => { @@ -75,8 +78,8 @@ describe('TripInviteService', () => { const first = await svc.createOrRotate(trip.id, owner.id); const second = await svc.createOrRotate(trip.id, owner.id); expect(second.token).not.toBe(first.token); - const count = testDb.prepare('SELECT COUNT(*) as n FROM trip_invite_tokens WHERE trip_id = ?').get(trip.id) as { n: number }; - expect(count.n).toBe(1); + const count = await countRows(await sharedTestOrm(testDb), TripInviteTokens, { trip: trip.id }); + expect(count).toBe(1); // The old token no longer resolves. expect(await svc.resolve(first.token)).toBeNull(); }); @@ -93,16 +96,14 @@ describe('TripInviteService', () => { // Use the exact ISO-8601 format the service writes, one hour in the past — // this catches the lexicographic-SQL-comparison bug where a same-UTC-day // expiry would otherwise still resolve. - testDb.prepare('UPDATE trip_invite_tokens SET expires_at = ? WHERE trip_id = ?') - .run(new Date(Date.now() - 3600_000).toISOString(), trip.id); + await updateRows(await sharedTestOrm(testDb), TripInviteTokens, { trip: trip.id }, { expires_at: new Date(Date.now() - 3600_000).toISOString() }); expect(await svc.resolve(info.token)).toBeNull(); }); it('TRIP-INVITE-005b: a not-yet-expired token still resolves', async () => { const { owner, trip } = setup(); const info = await svc.createOrRotate(trip.id, owner.id); - testDb.prepare('UPDATE trip_invite_tokens SET expires_at = ? WHERE trip_id = ?') - .run(new Date(Date.now() + 3600_000).toISOString(), trip.id); + await updateRows(await sharedTestOrm(testDb), TripInviteTokens, { trip: trip.id }, { expires_at: new Date(Date.now() + 3600_000).toISOString() }); expect(await svc.resolve(info.token)).toEqual({ trip_id: trip.id, title: trip.title }); });