From 49ba73fcae233d528db0139a0d067d5802a7ba98 Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 8 Oct 2026 15:59:17 +0200 Subject: [PATCH] test(server): seed the atlas, settings, budget and notification specs through the factories Eleven more suites (atlas and settings e2e, the bucket list, shadow pick and Dawarich suggestion repositories, the budget and collab MCP tools, notification preferences, VAPID keys, school holidays and tours) write and read their rows through the ORM factories. Time-relative fixtures that leaned on SQLite's date('now', ...) build the same text in JS with dbNow(), and helpers that returned undefined for a missing row keep doing so where an assertion depends on it. The legacy statements the parity cases compare against stay raw, marked as oracles. --- server/tests/e2e/atlas.e2e.test.ts | 90 ++++--- server/tests/e2e/settings.e2e.test.ts | 57 +++-- .../BucketList.repository.test.ts | 55 +++-- ...awarichVisitSuggestions.repository.test.ts | 232 +++++++++--------- .../PlaceShadowPicks.repository.test.ts | 38 ++- .../unit/mcp/tools-budget-advanced.test.ts | 54 ++-- .../unit/mcp/tools-collab-polls-chat.test.ts | 53 ++-- .../notification-preferences.service.test.ts | 65 ++--- .../notifications/vapid-keys.service.test.ts | 123 +++++----- .../unit/nest/school-holidays.service.test.ts | 24 +- server/tests/unit/nest/tours.service.test.ts | 67 +++-- 11 files changed, 472 insertions(+), 386 deletions(-) diff --git a/server/tests/e2e/atlas.e2e.test.ts b/server/tests/e2e/atlas.e2e.test.ts index ec7d7ebc8..e23888bcc 100644 --- a/server/tests/e2e/atlas.e2e.test.ts +++ b/server/tests/e2e/atlas.e2e.test.ts @@ -35,12 +35,23 @@ import { AtlasModule } from '../../src/nest/atlas/atlas.module'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; import { TestUnitOfWorkModule } from '../helpers/test-uow'; -import { createTestMikroOrmModule } from '../helpers/test-orm'; +import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm'; +import { countRows, deleteRows, findRow, upsertRow } from '../helpers/factories/rows'; +import { markCountryVisited } from '../helpers/factories/atlas'; +import { makePlace } from '../helpers/factories/places'; +import { makeReservation, makeReservationEndpoint } from '../helpers/factories/reservations'; +import { makeMcpToken } from '../helpers/factories/tokens'; +import { BucketList } from '../../src/db/entities/BucketList.entity'; +import { HiddenCountries } from '../../src/db/entities/HiddenCountries.entity'; +import { PlaceRegions } from '../../src/db/entities/PlaceRegions.entity'; +import { VisitedCountries } from '../../src/db/entities/VisitedCountries.entity'; +import { VisitedRegions } from '../../src/db/entities/VisitedRegions.entity'; describe('Atlas e2e (real auth guard + real service + temp SQLite)', () => { let server: Server; let app: Awaited>; let userId: number; + let orm: TestOrm; async function build() { const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), AtlasModule] }).compile(); @@ -56,12 +67,14 @@ describe('Atlas e2e (real auth guard + real service + temp SQLite)', () => { beforeAll(async () => { userId = createUser(db as never, { username: 'atlas-e2e', email: 'atlas-e2e@test.example' }).user.id; + orm = await createTestOrm(db); app = await build(); server = app.getHttpServer(); }); afterAll(async () => { await app.close(); + await orm.close(); }); it('401 without a session cookie', async () => { @@ -80,7 +93,7 @@ describe('Atlas e2e (real auth guard + real service + temp SQLite)', () => { }); it('200 stats for an authenticated user — the trip-less branch keeps its bespoke shape', async () => { - db.prepare('INSERT OR IGNORE INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(userId, 'JP'); + await markCountryVisited(orm, userId, 'JP'); const res = await request(server).get('/api/addons/atlas/stats').set('Cookie', sessionCookie(userId)); expect(res.status).toBe(200); // Zero-trip early return: has a `trips` key and only the four base stats — @@ -90,17 +103,17 @@ describe('Atlas e2e (real auth guard + real service + temp SQLite)', () => { trips: [], stats: { totalTrips: 0, totalPlaces: 0, totalCountries: 1, totalDays: 0 }, }); - db.prepare('DELETE FROM visited_countries WHERE user_id = ?').run(userId); + await deleteRows(orm, VisitedCountries, { user: userId }); }); it('200 (not 201) on POST country mark, with upper-cased code written to the db', async () => { const res = await request(server).post('/api/addons/atlas/country/de/mark').set('Cookie', sessionCookie(userId)); expect(res.status).toBe(200); expect(res.body).toEqual({ success: true }); - const row = db.prepare('SELECT country_code FROM visited_countries WHERE user_id = ?').get(userId); - expect(row).toEqual({ country_code: 'DE' }); - db.prepare('DELETE FROM visited_countries WHERE user_id = ?').run(userId); - db.prepare('DELETE FROM hidden_countries WHERE user_id = ?').run(userId); + const row = await findRow(orm, VisitedCountries, { user: userId }); + expect(row?.country_code).toBe('DE'); + await deleteRows(orm, VisitedCountries, { user: userId }); + await deleteRows(orm, HiddenCountries, { user: userId }); }); it('400 on region mark without country_code (ZodValidationPipe envelope)', async () => { @@ -109,7 +122,7 @@ describe('Atlas e2e (real auth guard + real service + temp SQLite)', () => { // The legacy hand-rolled 'name and country_code are required' body became // the pipe's `field: message` envelope with the atlas DTO ratchet. expect(res.body.error).toMatch(/^country_code: /); - expect(db.prepare('SELECT COUNT(*) AS n FROM visited_regions WHERE user_id = ?').get(userId)).toEqual({ n: 0 }); + expect(await countRows(orm, VisitedRegions, { user: userId })).toBe(0); }); it("400 'Name is required' on whitespace-only bucket name (legacy trim guard survives the DTO)", async () => { @@ -136,8 +149,8 @@ describe('Atlas e2e (real auth guard + real service + temp SQLite)', () => { const res = await request(server).post('/api/addons/atlas/bucket-list').set('Cookie', sessionCookie(userId)).send({ name: 'Kyoto' }); expect(res.status).toBe(201); expect(res.body.item.name).toBe('Kyoto'); - const row = db.prepare('SELECT name, user_id FROM bucket_list WHERE id = ?').get(res.body.item.id); - expect(row).toEqual({ name: 'Kyoto', user_id: userId }); + const row = await findRow(orm, BucketList, { id: res.body.item.id }); + expect({ name: row?.name, user_id: row?.user_id }).toEqual({ name: 'Kyoto', user_id: userId }); }); it('keeps a wished-for region, and refuses one outside the item country (#1901)', async () => { @@ -166,9 +179,9 @@ describe('Atlas e2e (real auth guard + real service + temp SQLite)', () => { const past = createTrip(db as never, plannerId, { title: 'Rome, last month', start_date: iso(-40), end_date: iso(-30) }); const future = createTrip(db as never, plannerId, { title: 'Tokyo, next month', start_date: iso(30), end_date: iso(40) }); // Address-only (no lat/lng), keeping the file's no-background-geocode property. - const insertPlace = db.prepare('INSERT INTO places (trip_id, name, address) VALUES (?, ?, ?)'); - insertPlace.run(past.id, 'Colosseum', 'Piazza del Colosseo, Rome, Italy'); - insertPlace.run(future.id, 'Senso-ji', 'Asakusa, Tokyo, Japan'); + const unplaced = { lat: null, lng: null, category: null }; + await makePlace(orm, past.id, { ...unplaced, name: 'Colosseum', address: 'Piazza del Colosseo, Rome, Italy' }); + await makePlace(orm, future.id, { ...unplaced, name: 'Senso-ji', address: 'Asakusa, Tokyo, Japan' }); const res = await request(server).get('/api/addons/atlas/stats').set('Cookie', sessionCookie(plannerId)); expect(res.status).toBe(200); @@ -200,16 +213,25 @@ describe('Atlas e2e (real auth guard + real service + temp SQLite)', () => { end_date: iso(-10), }); const day = iso(-15); - const insertFlight = db.prepare("INSERT INTO reservations (trip_id, title, type) VALUES (?, ?, 'flight')"); - const insertEndpoint = db.prepare( - 'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, lat, lng, code, local_date, local_time) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', - ); - const inbound = insertFlight.run(trip.id, 'BRU-HEL').lastInsertRowid; - insertEndpoint.run(inbound, 'from', 0, 'Brussels', 50.9014, 4.4844, 'BRU', day, '07:00'); - insertEndpoint.run(inbound, 'to', 1, 'Helsinki', 60.3172, 24.9633, 'HEL', day, '09:30'); - const onward = insertFlight.run(trip.id, 'HEL-JFK').lastInsertRowid; - insertEndpoint.run(onward, 'from', 0, 'Helsinki', 60.3172, 24.9633, 'HEL', day, '11:00'); - insertEndpoint.run(onward, 'to', 1, 'New York', 40.6413, -73.7781, 'JFK', day, '15:00'); + const insertFlight = async (title: string) => (await makeReservation(orm, trip.id, { title, type: 'flight' })).id; + const insertEndpoint = ( + reservationId: number, + role: string, + sequence: number, + name: string, + lat: number, + lng: number, + code: string, + localDate: string, + localTime: string, + ) => + makeReservationEndpoint(orm, reservationId, { role, sequence, name, lat, lng, code, local_date: localDate, local_time: localTime }); + const inbound = await insertFlight('BRU-HEL'); + await insertEndpoint(inbound, 'from', 0, 'Brussels', 50.9014, 4.4844, 'BRU', day, '07:00'); + await insertEndpoint(inbound, 'to', 1, 'Helsinki', 60.3172, 24.9633, 'HEL', day, '09:30'); + const onward = await insertFlight('HEL-JFK'); + await insertEndpoint(onward, 'from', 0, 'Helsinki', 60.3172, 24.9633, 'HEL', day, '11:00'); + await insertEndpoint(onward, 'to', 1, 'New York', 40.6413, -73.7781, 'JFK', day, '15:00'); const res = await request(server).get('/api/addons/atlas/stats').set('Cookie', sessionCookie(flyerId)); expect(res.status).toBe(200); @@ -263,12 +285,11 @@ describe('Atlas e2e (real auth guard + real service + temp SQLite)', () => { // is the suite that already boots the real AtlasModule against a full schema — // the public-api e2e builds a hand-rolled minimal one and could not. describe('public API stats', () => { - function mintApiKey(uid: number, kind = 'api'): string { + async function mintApiKey(uid: number, kind = 'api'): Promise { // eslint-disable-next-line @typescript-eslint/no-require-imports - const { createHash, randomBytes } = require('crypto'); + const { randomBytes } = require('crypto'); const raw = 'trek_' + randomBytes(24).toString('hex'); - db.prepare('INSERT INTO mcp_tokens (user_id, name, token_hash, token_prefix, kind) VALUES (?, ?, ?, ?, ?)') - .run(uid, `key-${kind}-${uid}`, createHash('sha256').update(raw).digest('hex'), raw.slice(0, 12), kind); + await makeMcpToken(orm, uid, { rawToken: raw, name: `key-${kind}-${uid}`, kind }); return raw; } @@ -281,7 +302,7 @@ describe('Atlas e2e (real auth guard + real service + temp SQLite)', () => { }); it('401 for an MCP token — the wrong kind is indistinguishable from no token', async () => { - const mcpKey = mintApiKey(userId, 'mcp'); + const mcpKey = await mintApiKey(userId, 'mcp'); const res = await request(server).get('/api/v1/stats').set('X-API-Key', mcpKey); expect(res.status).toBe(401); expect(res.body).toEqual({ error: 'Invalid API token', code: 'API_TOKEN_INVALID' }); @@ -289,18 +310,15 @@ describe('Atlas e2e (real auth guard + real service + temp SQLite)', () => { it('200 with counts and the last started trip, over both header spellings', async () => { const { user } = createUser(db as never, { username: 'stats-e2e', email: 'stats-e2e@test.example' }); - const key = mintApiKey(user.id); + const key = await mintApiKey(user.id); const iso = (offset: number) => new Date(Date.now() + offset * 86400000).toISOString().slice(0, 10); createTrip(db as never, user.id, { title: 'Older', start_date: iso(-90), end_date: iso(-80) }); const recent = createTrip(db as never, user.id, { title: 'Recent', start_date: iso(-40), end_date: iso(-30) }); createTrip(db as never, user.id, { title: 'Booked', start_date: iso(30), end_date: iso(40) }); - const cat = db.prepare('SELECT id FROM categories LIMIT 1').get() as { id: number } | undefined; - const placeId = db - .prepare('INSERT INTO places (trip_id, name, address, category_id) VALUES (?, ?, ?, ?)') - .run(recent.id, 'Trevi', 'Piazza di Trevi, 00187, Roma, Italy', cat?.id ?? null).lastInsertRowid as number; - db.prepare('INSERT OR REPLACE INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, ?, ?, ?)') - .run(placeId, 'IT', 'IT-62', 'Lazio'); + // In the first category, with no coordinates of its own. + const placeId = (await makePlace(orm, recent.id, { name: 'Trevi', address: 'Piazza di Trevi, 00187, Roma, Italy', lat: null, lng: null })).id; + await upsertRow(orm, PlaceRegions, { place: placeId, country_code: 'IT', region_code: 'IT-62', region_name: 'Lazio' }); const res = await request(server).get('/api/v1/stats').set('Authorization', `Bearer ${key}`); expect(res.status).toBe(200); @@ -322,7 +340,7 @@ describe('Atlas e2e (real auth guard + real service + temp SQLite)', () => { it('counts only the caller own trips', async () => { const { user: stranger } = createUser(db as never, { username: 'stats-other', email: 'stats-other@test.example' }); - const key = mintApiKey(stranger.id); + const key = await mintApiKey(stranger.id); const res = await request(server).get('/api/v1/stats').set('X-API-Key', key); expect(res.status).toBe(200); expect(res.body).toMatchObject({ total_trips: 0, total_countries: 0, last_trip: null, next_trip: null }); diff --git a/server/tests/e2e/settings.e2e.test.ts b/server/tests/e2e/settings.e2e.test.ts index f374fc795..50e4c78bf 100644 --- a/server/tests/e2e/settings.e2e.test.ts +++ b/server/tests/e2e/settings.e2e.test.ts @@ -31,11 +31,19 @@ import { SettingsModule } from '../../src/nest/settings/settings.module'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; import { TestUnitOfWorkModule } from '../helpers/test-uow'; -import { createTestMikroOrmModule } from '../helpers/test-orm'; +import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm'; +import { countRows, deleteRows, findRow, findRows, insertRow } from '../helpers/factories/rows'; +import { readUserSetting } from '../helpers/factories/settings'; +import { AppSettings } from '../../src/db/entities/AppSettings.entity'; +import { Settings } from '../../src/db/entities/Settings.entity'; describe('Settings e2e (real auth guard + temp SQLite)', () => { let server: Server; let app: Awaited>; + let orm: TestOrm; + + /** The stored value of one of `userId`'s settings, or null. */ + const userSetting = (userId: number, key: string) => readUserSetting(orm, userId, key); async function build() { const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), SettingsModule] }).compile(); @@ -50,17 +58,19 @@ describe('Settings e2e (real auth guard + temp SQLite)', () => { beforeAll(async () => { seedUser(db as never, { id: 1 }); seedUser(db as never, { id: 2, role: 'admin', email: 'e2e-admin@example.test' }); + orm = await createTestOrm(db); app = await build(); server = app.getHttpServer(); }); - beforeEach(() => { - db.exec('DELETE FROM settings'); - db.exec('DELETE FROM app_settings'); + beforeEach(async () => { + await deleteRows(orm, Settings); + await deleteRows(orm, AppSettings); }); afterAll(async () => { await app.close(); + await orm.close(); }); it('401 without a session cookie', async () => { @@ -68,7 +78,7 @@ describe('Settings e2e (real auth guard + temp SQLite)', () => { }); it('200 list with a session', async () => { - db.prepare("INSERT INTO settings (user_id, key, value) VALUES (1, 'theme', 'dark')").run(); + await insertRow(orm, Settings, { user: 1, key: 'theme', value: 'dark' }); const res = await request(server).get('/api/settings').set('Cookie', sessionCookie(1)); expect(res.status).toBe(200); expect(res.body).toEqual({ settings: { theme: 'dark' } }); @@ -78,7 +88,7 @@ describe('Settings e2e (real auth guard + temp SQLite)', () => { const res = await request(server).put('/api/settings').set('Cookie', sessionCookie(1)).send({ value: 'x' }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/^key: /); - expect(db.prepare('SELECT COUNT(*) AS n FROM settings').get()).toEqual({ n: 0 }); + expect(await countRows(orm, Settings)).toBe(0); }); it('POST /bulk 400 without a settings object (pipe envelope via SettingsBulkDto)', async () => { @@ -91,22 +101,21 @@ describe('Settings e2e (real auth guard + temp SQLite)', () => { const res = await request(server).put('/api/settings').set('Cookie', sessionCookie(1)).send({ key: 'language', value: 'fr' }); expect(res.status).toBe(200); expect(res.body).toEqual({ success: true, key: 'language', value: 'fr' }); - const row = db.prepare("SELECT value FROM settings WHERE user_id = 1 AND key = 'language'").get() as { value: string }; - expect(row.value).toBe('fr'); + expect(await userSetting(1, 'language')).toBe('fr'); }); it('PUT no-ops on the masked sentinel', async () => { const res = await request(server).put('/api/settings').set('Cookie', sessionCookie(1)).send({ key: 'secret', value: '••••••••' }); expect(res.status).toBe(200); expect(res.body).toEqual({ success: true, key: 'secret', unchanged: true }); - expect(db.prepare("SELECT COUNT(*) AS n FROM settings WHERE user_id = 1 AND key = 'secret'").get()).toEqual({ n: 0 }); + expect(await countRows(orm, Settings, { user: 1, key: 'secret' })).toBe(0); }); it('POST /bulk 200', async () => { const res = await request(server).post('/api/settings/bulk').set('Cookie', sessionCookie(1)).send({ settings: { a: 1, b: 2 } }); expect(res.status).toBe(200); expect(res.body).toEqual({ success: true, updated: 2 }); - const rows = db.prepare('SELECT key, value FROM settings WHERE user_id = 1 ORDER BY key').all(); + const rows = (await findRows(orm, Settings, { user: 1 }, { key: 'asc' })).map((r) => ({ key: r.key, value: r.value })); expect(rows).toEqual([ { key: 'a', value: '1' }, { key: 'b', value: '2' }, @@ -114,32 +123,32 @@ describe('Settings e2e (real auth guard + temp SQLite)', () => { }); it('POST /bulk skips masked-sentinel values (a stored secret survives)', async () => { - db.prepare("INSERT INTO settings (user_id, key, value) VALUES (1, 'ntfy_token', 'tok-real')").run(); + await insertRow(orm, Settings, { user: 1, key: 'ntfy_token', value: 'tok-real' }); const res = await request(server).post('/api/settings/bulk').set('Cookie', sessionCookie(1)) .send({ settings: { ntfy_topic: 'trek', ntfy_token: '••••••••' } }); expect(res.status).toBe(200); expect(res.body).toEqual({ success: true, updated: 1 }); - expect(db.prepare("SELECT value FROM settings WHERE user_id = 1 AND key = 'ntfy_token'").get()).toEqual({ value: 'tok-real' }); + expect(await userSetting(1, 'ntfy_token')).toBe('tok-real'); }); // #1772: a free-form LLM endpoint is admin-only. The resolver ignores such a // row for a non-admin either way; these routes refuse it so the user is told. describe('LLM endpoint settings are admin-only (#1772)', () => { - const countRows = () => (db.prepare('SELECT COUNT(*) AS n FROM settings').get() as { n: number }).n; + const settingsCount = () => countRows(orm, Settings); it('PUT 403 for a non-admin naming a base URL', async () => { const res = await request(server).put('/api/settings').set('Cookie', sessionCookie(1)) .send({ key: 'llm_base_url', value: 'http://192.168.1.5:11434' }); expect(res.status).toBe(403); expect(res.body).toEqual({ error: 'Admin access required' }); - expect(countRows()).toBe(0); + expect(await settingsCount()).toBe(0); }); it('PUT 403 for a non-admin picking the local provider', async () => { const res = await request(server).put('/api/settings').set('Cookie', sessionCookie(1)) .send({ key: 'llm_provider', value: 'local' }); expect(res.status).toBe(403); - expect(countRows()).toBe(0); + expect(await settingsCount()).toBe(0); }); it('PUT lets a non-admin clear the base URL and pick a hosted provider', async () => { @@ -147,8 +156,7 @@ describe('Settings e2e (real auth guard + temp SQLite)', () => { .send({ key: 'llm_base_url', value: ' ' })).status).toBe(200); expect((await request(server).put('/api/settings').set('Cookie', sessionCookie(1)) .send({ key: 'llm_provider', value: 'anthropic' })).status).toBe(200); - expect(db.prepare("SELECT value FROM settings WHERE user_id = 1 AND key = 'llm_provider'").get()) - .toEqual({ value: 'anthropic' }); + expect(await userSetting(1, 'llm_provider')).toBe('anthropic'); }); it('PUT 403 for a non-admin naming a routing engine (#1797)', async () => { @@ -161,7 +169,7 @@ describe('Settings e2e (real auth guard + temp SQLite)', () => { .send({ key: 'routing_base_url', value: 'https://osrm.example.org' }); expect(res.status).toBe(403); expect(res.body).toEqual({ error: 'Admin access required' }); - expect(countRows()).toBe(0); + expect(await settingsCount()).toBe(0); }); it('PUT lets a non-admin clear a routing engine somebody set before the rule', async () => { @@ -173,7 +181,7 @@ describe('Settings e2e (real auth guard + temp SQLite)', () => { const res = await request(server).post('/api/settings/bulk').set('Cookie', sessionCookie(1)) .send({ settings: { routing_base_url: 'https://osrm.example.org' } }); expect(res.status).toBe(403); - expect(countRows()).toBe(0); + expect(await settingsCount()).toBe(0); }); it('PUT 403 for a non-admin naming the second routing engine', async () => { @@ -185,7 +193,7 @@ describe('Settings e2e (real auth guard + temp SQLite)', () => { .send({ key: 'valhalla_base_url', value: 'https://valhalla.example.org' }); expect(res.status).toBe(403); expect(res.body).toEqual({ error: 'Admin access required' }); - expect(countRows()).toBe(0); + expect(await settingsCount()).toBe(0); }); it('PUT lets a non-admin clear the second routing engine', async () => { @@ -204,7 +212,7 @@ describe('Settings e2e (real auth guard + temp SQLite)', () => { .send({ settings: { llm_provider: 'local', llm_base_url: 'http://192.168.1.5:11434', llm_model: 'nuextract' } }); expect(res.status).toBe(403); expect(res.body).toEqual({ error: 'Admin access required' }); - expect(countRows()).toBe(0); + expect(await settingsCount()).toBe(0); }); it('POST /bulk 200 for a non-admin bringing their own hosted key', async () => { @@ -222,15 +230,14 @@ describe('Settings e2e (real auth guard + temp SQLite)', () => { .send({ settings: { llm_provider: 'local', llm_base_url: 'http://192.168.1.5:11434' } }); expect(res.status).toBe(403); expect(res.body).toEqual({ error: 'Admin access required' }); - expect(countRows()).toBe(0); + expect(await settingsCount()).toBe(0); }); it('POST /bulk 200 for an admin bringing their own hosted key', async () => { const res = await request(server).post('/api/settings/bulk').set('Cookie', sessionCookie(2)) .send({ settings: { llm_provider: 'openai', llm_base_url: '', llm_model: 'gpt-4o-mini' } }); expect(res.status).toBe(200); - expect(db.prepare("SELECT value FROM settings WHERE user_id = 2 AND key = 'llm_provider'").get()) - .toEqual({ value: 'openai' }); + expect(await userSetting(2, 'llm_provider')).toBe('openai'); }); }); @@ -248,7 +255,7 @@ describe('Settings e2e (real auth guard + temp SQLite)', () => { const res = await request(server).put('/api/admin/default-user-settings').set('Cookie', sessionCookie(2)) .send({ week_start: 'friday' }); expect(res.status).toBe(400); - expect(db.prepare("SELECT value FROM app_settings WHERE key = 'default_user_setting_week_start'").get()).toBeUndefined(); + expect(await findRow(orm, AppSettings, { key: 'default_user_setting_week_start' })).toBeNull(); }); }); }); diff --git a/server/tests/unit/db/repositories/BucketList.repository.test.ts b/server/tests/unit/db/repositories/BucketList.repository.test.ts index 7c9a15dab..9c0cd95e4 100644 --- a/server/tests/unit/db/repositories/BucketList.repository.test.ts +++ b/server/tests/unit/db/repositories/BucketList.repository.test.ts @@ -4,6 +4,7 @@ import { resetTestDb } from '../../../helpers/test-db'; import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; import { createUser } from '../../../helpers/factories'; import { BucketList } from '../../../../src/db/entities/BucketList.entity'; +import { insertRow, insertRows } from '../../../helpers/factories/rows'; import type { BucketListRepository } from '../../../../src/db/repositories/BucketList.repository'; const testDb = createSnapshotTestDb(); @@ -21,13 +22,13 @@ describe('BucketListRepository.listForPublicApi (Plan 4 Task 1, public-api.servi it('BUCKETREPO-001: name/lat/lng/country_code/notes/target_date only, ordered by created_at DESC then id DESC, scoped to the caller', async () => { const { user } = createUser(testDb); const { user: other } = createUser(testDb, { username: 'other' }); - const insert = testDb.prepare( - 'INSERT INTO bucket_list (user_id, name, lat, lng, country_code, notes, target_date, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', - ); - insert.run(user.id, 'Older', 1, 2, 'JP', 'note', null, '2026-01-01T00:00:00.000Z'); - insert.run(user.id, 'Newer', null, null, null, null, '2027-03-01', '2026-02-01T00:00:00.000Z'); - insert.run(other.id, 'Not mine', 9, 9, null, null, null, '2026-03-01T00:00:00.000Z'); + await insertRows(t, BucketList, [ + { user: user.id, name: 'Older', lat: 1, lng: 2, country_code: 'JP', notes: 'note', target_date: null, created_at: '2026-01-01T00:00:00.000Z' }, + { user: user.id, name: 'Newer', lat: null, lng: null, country_code: null, notes: null, target_date: '2027-03-01', created_at: '2026-02-01T00:00:00.000Z' }, + { user: other.id, name: 'Not mine', lat: 9, lng: 9, country_code: null, notes: null, target_date: null, created_at: '2026-03-01T00:00:00.000Z' }, + ]); + // test-sql-allow: the legacy statement is the oracle the repository read is held to. const legacy = testDb.prepare( 'SELECT name, lat, lng, country_code, notes, target_date FROM bucket_list WHERE user_id = ? ORDER BY created_at DESC, id DESC', ).all(user.id); @@ -39,11 +40,8 @@ describe('BucketListRepository.listForPublicApi (Plan 4 Task 1, public-api.servi it('BUCKETREPO-002: same-instant rows tiebreak on id DESC (the second ORDER BY key)', async () => { const { user } = createUser(testDb); - const insert = testDb.prepare( - 'INSERT INTO bucket_list (user_id, name, created_at) VALUES (?, ?, ?)', - ); - insert.run(user.id, 'First inserted', '2026-01-01T00:00:00.000Z'); - insert.run(user.id, 'Second inserted', '2026-01-01T00:00:00.000Z'); + await insertRow(t, BucketList, { user: user.id, name: 'First inserted', created_at: '2026-01-01T00:00:00.000Z' }); + await insertRow(t, BucketList, { user: user.id, name: 'Second inserted', created_at: '2026-01-01T00:00:00.000Z' }); const rows = await bucketList.listForPublicApi(user.id); expect(rows.map((r) => r.name)).toEqual(['Second inserted', 'First inserted']); @@ -66,19 +64,27 @@ describe('BucketListRepository.listForUser (AT30) — SELECT * ordered by create it('BUCKETREPO-004: matches the legacy row exactly, every nullable column both NULL and SET', async () => { const { user } = createUser(testDb); const { user: other } = createUser(testDb, { username: 'other' }); - const sparse = testDb.prepare( - 'INSERT INTO bucket_list (user_id, name, created_at) VALUES (?, ?, ?)', - ).run(user.id, 'Sparse', '2026-01-01T00:00:00.000Z'); - const full = testDb.prepare( - 'INSERT INTO bucket_list (user_id, name, lat, lng, country_code, notes, target_date, visited_at, visited_source, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', - ).run(user.id, 'Full', 10.5, 20.5, 'JP', 'note', '2027-01-01', '2026-06-01', 'manual', '2026-02-01T00:00:00.000Z'); - testDb.prepare('INSERT INTO bucket_list (user_id, name) VALUES (?, ?)').run(other.id, 'Not mine'); + const sparse = await insertRow(t, BucketList, { user: user.id, name: 'Sparse', created_at: '2026-01-01T00:00:00.000Z' }); + const full = await insertRow(t, BucketList, { + user: user.id, + name: 'Full', + lat: 10.5, + lng: 20.5, + country_code: 'JP', + notes: 'note', + target_date: '2027-01-01', + visited_at: '2026-06-01', + visited_source: 'manual', + created_at: '2026-02-01T00:00:00.000Z', + }); + await insertRow(t, BucketList, { user: other.id, name: 'Not mine' }); + // test-sql-allow: the legacy statement is the oracle the repository read is held to. const legacy = testDb.prepare('SELECT * FROM bucket_list WHERE user_id = ? ORDER BY created_at DESC').all(user.id); const rows = await bucketList.listForUser(user.id); expect(rows).toEqual(legacy); - expect(rows.map((r) => r.id)).toEqual([Number(full.lastInsertRowid), Number(sparse.lastInsertRowid)]); + expect(rows.map((r) => r.id)).toEqual([full, sparse]); }); it('BUCKETREPO-005: empty array for a user with nothing on their list', async () => { @@ -90,9 +96,9 @@ describe('BucketListRepository.listForUser (AT30) — SELECT * ordered by create describe('BucketListRepository.findById (AT33) — SELECT * WHERE id = ?, NOT user-scoped', () => { it('BUCKETREPO-006: matches the legacy row, regardless of owner', async () => { const { user } = createUser(testDb); - const inserted = testDb.prepare('INSERT INTO bucket_list (user_id, name) VALUES (?, ?)').run(user.id, 'Item'); - const id = Number(inserted.lastInsertRowid); + const id = await insertRow(t, BucketList, { user: user.id, name: 'Item' }); + // test-sql-allow: the legacy statement is the oracle the repository read is held to. const legacy = testDb.prepare('SELECT * FROM bucket_list WHERE id = ?').get(id); expect(await bucketList.findById(id)).toEqual(legacy); }); @@ -105,9 +111,9 @@ describe('BucketListRepository.findById (AT33) — SELECT * WHERE id = ?, NOT us describe('BucketListRepository.findForUser (AT34/AT36/AT37) — SELECT * WHERE id = ? AND user_id = ?', () => { it('BUCKETREPO-008: matches the legacy row for the owning user', async () => { const { user } = createUser(testDb); - const inserted = testDb.prepare('INSERT INTO bucket_list (user_id, name) VALUES (?, ?)').run(user.id, 'Item'); - const id = Number(inserted.lastInsertRowid); + const id = await insertRow(t, BucketList, { user: user.id, name: 'Item' }); + // test-sql-allow: the legacy statement is the oracle the repository read is held to. const legacy = testDb.prepare('SELECT * FROM bucket_list WHERE id = ? AND user_id = ?').get(id, user.id); expect(await bucketList.findForUser(id, user.id)).toEqual(legacy); }); @@ -115,8 +121,7 @@ describe('BucketListRepository.findForUser (AT34/AT36/AT37) — SELECT * WHERE i it('BUCKETREPO-009: undefined for a foreign owner — the guard never leaks another user\'s item', async () => { const { user } = createUser(testDb); const { user: other } = createUser(testDb, { username: 'other' }); - const inserted = testDb.prepare('INSERT INTO bucket_list (user_id, name) VALUES (?, ?)').run(user.id, 'Item'); - const id = Number(inserted.lastInsertRowid); + const id = await insertRow(t, BucketList, { user: user.id, name: 'Item' }); expect(await bucketList.findForUser(id, other.id)).toBeUndefined(); }); diff --git a/server/tests/unit/db/repositories/DawarichVisitSuggestions.repository.test.ts b/server/tests/unit/db/repositories/DawarichVisitSuggestions.repository.test.ts index 60d1b592b..6139360b0 100644 --- a/server/tests/unit/db/repositories/DawarichVisitSuggestions.repository.test.ts +++ b/server/tests/unit/db/repositories/DawarichVisitSuggestions.repository.test.ts @@ -4,6 +4,11 @@ import { resetTestDb } from '../../../helpers/test-db'; import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; import { createUser, createTrip } from '../../../helpers/factories'; import { DawarichVisitSuggestions } from '../../../../src/db/entities/DawarichVisitSuggestions.entity'; +import { Journeys } from '../../../../src/db/entities/Journeys.entity'; +import { JourneyEntries } from '../../../../src/db/entities/JourneyEntries.entity'; +import { deleteRows, findRow, findRows, insertRow, updateRows } from '../../../helpers/factories/rows'; +import { makePlace } from '../../../helpers/factories/places'; +import { makeBucketListItem } from '../../../helpers/factories/atlas'; import type { DawarichVisitSuggestionsRepository } from '../../../../src/db/repositories/DawarichVisitSuggestions.repository'; const testDb = createSnapshotTestDb(); @@ -26,7 +31,7 @@ afterAll(async () => { let _seq = 0; -function seed( +async function seed( userId: number, over: Partial<{ source_visit_id: string; @@ -52,68 +57,65 @@ function seed( accepted_hash: string | null; source_missing_at: string | null; }> = {}, -): number { +): Promise { _seq++; - const result = testDb - .prepare( - `INSERT INTO dawarich_visit_suggestions - (user_id, source_visit_id, trip_id, name, lat, lng, started_at, ended_at, duration_minutes, - local_date, source_status, confidence, confidence_band, country_code, state, target, - accepted_place_id, accepted_journal_entry_id, accepted_bucket_list_item_id, - matched_bucket_list_item_id, source_hash, accepted_hash, source_missing_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ) - .run( - userId, - over.source_visit_id ?? `visit-${_seq}`, - over.trip_id ?? null, - over.name ?? 'Cafe Central', - over.lat === undefined ? 48.2092 : over.lat, - over.lng === undefined ? 16.3658 : over.lng, - over.started_at ?? '2026-09-01T09:30:00Z', - over.ended_at ?? '2026-09-01T11:45:00Z', - over.duration_minutes ?? 135, - over.local_date ?? '2026-09-01', - over.source_status ?? 'suggested', - over.confidence ?? 0.7, - over.confidence_band ?? 'high', - over.country_code ?? 'AT', - over.state ?? 'new', - over.target ?? null, - over.accepted_place_id ?? null, - over.accepted_journal_entry_id ?? null, - over.accepted_bucket_list_item_id ?? null, - over.matched_bucket_list_item_id ?? null, - over.source_hash ?? `hash-${_seq}`, - over.accepted_hash ?? null, - over.source_missing_at ?? null, - ); - return Number(result.lastInsertRowid); + return insertRow(t, DawarichVisitSuggestions, { + user: userId, + source_visit_id: over.source_visit_id ?? `visit-${_seq}`, + trip: over.trip_id ?? null, + name: over.name ?? 'Cafe Central', + lat: over.lat === undefined ? 48.2092 : over.lat, + lng: over.lng === undefined ? 16.3658 : over.lng, + started_at: over.started_at ?? '2026-09-01T09:30:00Z', + ended_at: over.ended_at ?? '2026-09-01T11:45:00Z', + duration_minutes: over.duration_minutes ?? 135, + local_date: over.local_date ?? '2026-09-01', + source_status: over.source_status ?? 'suggested', + confidence: over.confidence ?? 0.7, + confidence_band: over.confidence_band ?? 'high', + country_code: over.country_code ?? 'AT', + state: over.state ?? 'new', + target: over.target ?? null, + acceptedPlace: over.accepted_place_id ?? null, + accepted_journal_entry_id: over.accepted_journal_entry_id ?? null, + acceptedBucketListItem: over.accepted_bucket_list_item_id ?? null, + matchedBucketListItem: over.matched_bucket_list_item_id ?? null, + source_hash: over.source_hash ?? `hash-${_seq}`, + accepted_hash: over.accepted_hash ?? null, + source_missing_at: over.source_missing_at ?? null, + }); } -function rawRow(id: number): Record { - return testDb.prepare('SELECT * FROM dawarich_visit_suggestions WHERE id = ?').get(id) as Record; +function storedRow(id: number) { + return findRow(t, DawarichVisitSuggestions, { id }); } -function seedPlace(tripId: number): number { - const category = testDb.prepare('SELECT id FROM categories LIMIT 1').get() as { id: number } | undefined; - const result = testDb.prepare('INSERT INTO places (trip_id, name, category_id) VALUES (?, ?, ?)').run(tripId, 'A place', category?.id ?? null); - return Number(result.lastInsertRowid); +/** The stored row; fails the case when it is gone. */ +async function rawRow(id: number) { + const row = await storedRow(id); + if (!row) throw new Error(`no suggestion ${id}`); + return row; } -function seedJourneyEntry(userId: number): number { - const journeyId = Number( - testDb.prepare("INSERT INTO journeys (user_id, title, status, created_at, updated_at) VALUES (?, 'Trip diary', 'active', 0, 0)").run(userId).lastInsertRowid, - ); - return Number( - testDb - .prepare("INSERT INTO journey_entries (journey_id, author_id, type, title, entry_date, created_at, updated_at) VALUES (?, ?, 'entry', 'Cafe', '2026-09-01', 0, 0)") - .run(journeyId, userId).lastInsertRowid, - ); +async function seedPlace(tripId: number): Promise { + return (await makePlace(t, tripId, { name: 'A place', lat: null, lng: null })).id; } -function seedBucketItem(userId: number): number { - return Number(testDb.prepare('INSERT INTO bucket_list (user_id, name) VALUES (?, ?)').run(userId, 'Wish').lastInsertRowid); +async function seedJourneyEntry(userId: number): Promise { + const journeyId = await insertRow(t, Journeys, { user: userId, title: 'Trip diary', status: 'active', created_at: 0, updated_at: 0 }); + return insertRow(t, JourneyEntries, { + journey: journeyId, + author: userId, + type: 'entry', + title: 'Cafe', + entry_date: '2026-09-01', + created_at: 0, + updated_at: 0, + }); +} + +async function seedBucketItem(userId: number): Promise { + return (await makeBucketListItem(t, userId, { name: 'Wish' })).id; } describe('DawarichVisitSuggestionsRepository', () => { @@ -121,11 +123,12 @@ describe('DawarichVisitSuggestionsRepository', () => { it('DVSREPO-001: a fully-seeded row (with a trip and a matched wish) joins trip_title/matched_bucket_name and every scalar column, matching a legacy raw join', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Vienna' }); - const wish = seedBucketItem(user.id); - const id = seed(user.id, { trip_id: trip.id, matched_bucket_list_item_id: wish, name: 'Cafe Central' }); + const wish = await seedBucketItem(user.id); + const id = await seed(user.id, { trip_id: trip.id, matched_bucket_list_item_id: wish, name: 'Cafe Central' }); const rows = await suggestions.list(user.id, {}); const legacy = testDb + // test-sql-allow: the legacy statement is the oracle the repository read is held to. .prepare( `SELECT s.*, t.title AS trip_title, b.name AS matched_bucket_name FROM dawarich_visit_suggestions s @@ -145,21 +148,21 @@ describe('DawarichVisitSuggestionsRepository', () => { it('DVSREPO-002: scoped by user_id — a stranger\'s row never appears', async () => { const { user } = createUser(testDb); const { user: stranger } = createUser(testDb); - seed(stranger.id); + await seed(stranger.id); expect(await suggestions.list(user.id, {})).toEqual([]); }); it('DVSREPO-003: filters by tripId and state, ordered started_at DESC, id DESC', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const older = seed(user.id, { trip_id: trip.id, started_at: '2026-09-01T00:00:00Z' }); - const newer = seed(user.id, { trip_id: trip.id, started_at: '2026-09-02T00:00:00Z' }); - seed(user.id, { started_at: '2026-09-03T00:00:00Z' }); // no trip + const older = await seed(user.id, { trip_id: trip.id, started_at: '2026-09-01T00:00:00Z' }); + const newer = await seed(user.id, { trip_id: trip.id, started_at: '2026-09-02T00:00:00Z' }); + await seed(user.id, { started_at: '2026-09-03T00:00:00Z' }); // no trip const byTrip = await suggestions.list(user.id, { tripId: trip.id }); expect(byTrip.map((r) => r.id)).toEqual([newer, older]); - seed(user.id, { trip_id: trip.id, state: 'dismissed' }); + await seed(user.id, { trip_id: trip.id, state: 'dismissed' }); const byState = await suggestions.list(user.id, { state: 'dismissed' }); expect(byState).toHaveLength(1); expect(byState[0].state).toBe('dismissed'); @@ -168,7 +171,7 @@ describe('DawarichVisitSuggestionsRepository', () => { it('DVSREPO-004: getOne is the single-row variant, still user-scoped', async () => { const { user } = createUser(testDb); const { user: stranger } = createUser(testDb); - const id = seed(user.id); + const id = await seed(user.id); expect(await suggestions.getOne(stranger.id, id)).toBeNull(); expect((await suggestions.getOne(user.id, id))?.id).toBe(id); }); @@ -177,54 +180,54 @@ describe('DawarichVisitSuggestionsRepository', () => { describe('reopenOrphaned (DWS2)', () => { it('DVSREPO-010: place target with a NULL accepted_place_id is reopened', async () => { const { user } = createUser(testDb); - const id = seed(user.id, { state: 'accepted', target: 'place', accepted_place_id: null }); + const id = await seed(user.id, { state: 'accepted', target: 'place', accepted_place_id: null }); await suggestions.reopenOrphaned(user.id); - const row = rawRow(id); + const row = await rawRow(id); expect(row.state).toBe('new'); expect(row.target).toBeNull(); }); it('DVSREPO-011: bucket_list target with a NULL accepted_bucket_list_item_id is reopened', async () => { const { user } = createUser(testDb); - const id = seed(user.id, { state: 'accepted', target: 'bucket_list', accepted_bucket_list_item_id: null }); + const id = await seed(user.id, { state: 'accepted', target: 'bucket_list', accepted_bucket_list_item_id: null }); await suggestions.reopenOrphaned(user.id); - expect(rawRow(id).state).toBe('new'); + expect((await rawRow(id)).state).toBe('new'); }); it('DVSREPO-012: journal target whose entry no longer exists (cross-domain NOT EXISTS against journey_entries) is reopened', async () => { const { user } = createUser(testDb); - const entryId = seedJourneyEntry(user.id); - const id = seed(user.id, { state: 'accepted', target: 'journal', accepted_journal_entry_id: entryId }); + const entryId = await seedJourneyEntry(user.id); + const id = await seed(user.id, { state: 'accepted', target: 'journal', accepted_journal_entry_id: entryId }); // Still exists: not reopened. await suggestions.reopenOrphaned(user.id); - expect(rawRow(id).state).toBe('accepted'); + expect((await rawRow(id)).state).toBe('accepted'); - testDb.prepare('DELETE FROM journey_entries WHERE id = ?').run(entryId); + await deleteRows(t, JourneyEntries, { id: entryId }); await suggestions.reopenOrphaned(user.id); - expect(rawRow(id).state).toBe('new'); - expect(rawRow(id).accepted_journal_entry_id).toBeNull(); + expect((await rawRow(id)).state).toBe('new'); + expect((await rawRow(id)).accepted_journal_entry_id).toBeNull(); }); it('DVSREPO-013: journal target with a NULL accepted_journal_entry_id is reopened without needing a journey_entries lookup at all', async () => { const { user } = createUser(testDb); - const id = seed(user.id, { state: 'accepted', target: 'journal', accepted_journal_entry_id: null }); + const id = await seed(user.id, { state: 'accepted', target: 'journal', accepted_journal_entry_id: null }); await suggestions.reopenOrphaned(user.id); - expect(rawRow(id).state).toBe('new'); + expect((await rawRow(id)).state).toBe('new'); }); it('DVSREPO-014: an intact acceptance (place still exists) is left alone; another user\'s orphan is untouched', async () => { const { user } = createUser(testDb); const { user: stranger } = createUser(testDb); const trip = createTrip(testDb, user.id); - const placeId = seedPlace(trip.id); - const intact = seed(user.id, { state: 'accepted', target: 'place', accepted_place_id: placeId }); - const theirs = seed(stranger.id, { state: 'accepted', target: 'place', accepted_place_id: null }); + const placeId = await seedPlace(trip.id); + const intact = await seed(user.id, { state: 'accepted', target: 'place', accepted_place_id: placeId }); + const theirs = await seed(stranger.id, { state: 'accepted', target: 'place', accepted_place_id: null }); await suggestions.reopenOrphaned(user.id); - expect(rawRow(intact).state).toBe('accepted'); - expect(rawRow(theirs).state).toBe('accepted'); + expect((await rawRow(intact)).state).toBe('accepted'); + expect((await rawRow(theirs)).state).toBe('accepted'); }); }); @@ -232,8 +235,8 @@ describe('DawarichVisitSuggestionsRepository', () => { it('DVSREPO-020: every scalar column round-trips, matching a legacy SELECT *', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const wish = seedBucketItem(user.id); - const id = seed(user.id, { + const wish = await seedBucketItem(user.id); + const id = await seed(user.id, { trip_id: trip.id, matched_bucket_list_item_id: wish, accepted_bucket_list_item_id: wish, @@ -243,7 +246,8 @@ describe('DawarichVisitSuggestionsRepository', () => { }); const row = await suggestions.findForUser(id, user.id); - expect(row).toEqual(rawRow(id)); + // test-sql-allow: the row as SELECT * returns it is the oracle the repository read is held to. + expect(row).toEqual(testDb.prepare('SELECT * FROM dawarich_visit_suggestions WHERE id = ?').get(id)); // The persist(false) shadow FKs specifically — the trap this Kysely // full-row read exists to avoid. expect(row!.trip_id).toBe(trip.id); @@ -254,7 +258,7 @@ describe('DawarichVisitSuggestionsRepository', () => { it('DVSREPO-021: findForUser is scoped by id AND user_id; findByUserAndVisit by user_id AND source_visit_id', async () => { const { user } = createUser(testDb); const { user: stranger } = createUser(testDb); - const id = seed(stranger.id, { source_visit_id: 'abc' }); + const id = await seed(stranger.id, { source_visit_id: 'abc' }); expect(await suggestions.findForUser(id, user.id)).toBeNull(); expect(await suggestions.findByUserAndVisit(user.id, 'abc')).toBeNull(); expect((await suggestions.findByUserAndVisit(stranger.id, 'abc'))?.id).toBe(id); @@ -283,13 +287,13 @@ describe('DawarichVisitSuggestionsRepository', () => { first_seen_at: '2026-01-01T00:00:00Z', last_seen_at: '2026-01-01T00:00:00Z', }); - expect(rawRow(id)).toMatchObject({ state: 'new', name: 'Stop', country_code: 'DE', source_hash: 'h1' }); + expect(await rawRow(id)).toMatchObject({ state: 'new', name: 'Stop', country_code: 'DE', source_hash: 'h1' }); }); it('DVSREPO-031: updateNewVisit overwrites the full row and clears source_missing_at', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const id = seed(user.id, { source_missing_at: '2026-01-01T00:00:00Z', name: 'Old name' }); + const id = await seed(user.id, { source_missing_at: '2026-01-01T00:00:00Z', name: 'Old name' }); await suggestions.updateNewVisit(id, { name: 'New name', @@ -308,17 +312,17 @@ describe('DawarichVisitSuggestionsRepository', () => { last_seen_at: '2026-02-01T00:00:00Z', }); - const row = rawRow(id); + const row = await rawRow(id); expect(row).toMatchObject({ name: 'New name', source_missing_at: null, trip_id: trip.id, source_hash: 'h2' }); }); it('DVSREPO-032: refreshHash touches only source_hash/source_missing_at/last_seen_at — the user-visible fields survive', async () => { const { user } = createUser(testDb); - const id = seed(user.id, { name: 'Kept name', state: 'accepted', source_missing_at: '2026-01-01T00:00:00Z' }); + const id = await seed(user.id, { name: 'Kept name', state: 'accepted', source_missing_at: '2026-01-01T00:00:00Z' }); await suggestions.refreshHash(id, 'h3', '2026-03-01T00:00:00Z'); - expect(rawRow(id)).toMatchObject({ name: 'Kept name', state: 'accepted', source_hash: 'h3', source_missing_at: null, last_seen_at: '2026-03-01T00:00:00Z' }); + expect(await rawRow(id)).toMatchObject({ name: 'Kept name', state: 'accepted', source_hash: 'h3', source_missing_at: null, last_seen_at: '2026-03-01T00:00:00Z' }); }); }); @@ -326,8 +330,8 @@ describe('DawarichVisitSuggestionsRepository', () => { it('DVSREPO-040: candidates are scoped by user/trip and local_date bounds', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const inWindow = seed(user.id, { trip_id: trip.id, local_date: '2026-05-05' }); - seed(user.id, { trip_id: trip.id, local_date: '2019-01-01' }); + const inWindow = await seed(user.id, { trip_id: trip.id, local_date: '2026-05-05' }); + await seed(user.id, { trip_id: trip.id, local_date: '2019-01-01' }); const candidates = await suggestions.listCandidatesForWindow(user.id, trip.id, '2026-05-01', '2026-05-10'); expect(candidates.map((c) => c.id)).toEqual([inWindow]); @@ -335,20 +339,20 @@ describe('DawarichVisitSuggestionsRepository', () => { it('DVSREPO-041: deleteById removes exactly the named row', async () => { const { user } = createUser(testDb); - const id = seed(user.id); + const id = await seed(user.id); await suggestions.deleteById(id); - expect(rawRow(id)).toBeUndefined(); + expect(await storedRow(id)).toBeNull(); }); it('DVSREPO-042: stampMissingIfUnset preserves the FIRST-missing timestamp (COALESCE, not overwrite)', async () => { const { user } = createUser(testDb); - const id = seed(user.id, { source_missing_at: '2026-01-01T00:00:00Z' }); + const id = await seed(user.id, { source_missing_at: '2026-01-01T00:00:00Z' }); await suggestions.stampMissingIfUnset(id, '2026-06-01T00:00:00Z'); - expect(rawRow(id).source_missing_at).toBe('2026-01-01T00:00:00Z'); + expect((await rawRow(id)).source_missing_at).toBe('2026-01-01T00:00:00Z'); - const id2 = seed(user.id, { source_missing_at: null }); + const id2 = await seed(user.id, { source_missing_at: null }); await suggestions.stampMissingIfUnset(id2, '2026-06-01T00:00:00Z'); - expect(rawRow(id2).source_missing_at).toBe('2026-06-01T00:00:00Z'); + expect((await rawRow(id2)).source_missing_at).toBe('2026-06-01T00:00:00Z'); }); }); @@ -356,16 +360,16 @@ describe('DawarichVisitSuggestionsRepository', () => { it('DVSREPO-050: accepted_hash tracks CURRENT source_hash at UPDATE time, not a value bound earlier', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const placeId = seedPlace(trip.id); - const id = seed(user.id, { source_hash: 'hash-original' }); + const placeId = await seedPlace(trip.id); + const id = await seed(user.id, { source_hash: 'hash-original' }); // A concurrent write lands between "the caller decided to accept" and // the markAccepted call itself. - testDb.prepare('UPDATE dawarich_visit_suggestions SET source_hash = ? WHERE id = ?').run('hash-concurrent', id); + await updateRows(t, DawarichVisitSuggestions, { id }, { source_hash: 'hash-concurrent' }); await suggestions.markAccepted(id, { target: 'place', placeId, journalEntryId: null, bucketItemId: null }); - const row = rawRow(id); + const row = await rawRow(id); expect(row.source_hash).toBe('hash-concurrent'); expect(row.accepted_hash).toBe('hash-concurrent'); expect(row.state).toBe('accepted'); @@ -376,9 +380,9 @@ describe('DawarichVisitSuggestionsRepository', () => { describe('listHoldersOfWish/clearWishHolders/assignWishHolder (DSY11/12/13)', () => { it('DVSREPO-060: listHoldersOfWish excludes the caller\'s own source_visit_id', async () => { const { user } = createUser(testDb); - const wish = seedBucketItem(user.id); - const holder = seed(user.id, { source_visit_id: 'holder', matched_bucket_list_item_id: wish }); - seed(user.id, { source_visit_id: 'self', matched_bucket_list_item_id: wish }); + const wish = await seedBucketItem(user.id); + const holder = await seed(user.id, { source_visit_id: 'holder', matched_bucket_list_item_id: wish }); + await seed(user.id, { source_visit_id: 'self', matched_bucket_list_item_id: wish }); const holders = await suggestions.listHoldersOfWish(user.id, wish, 'self'); expect(holders.map((h) => h.id)).toEqual([holder]); @@ -387,32 +391,30 @@ describe('DawarichVisitSuggestionsRepository', () => { it('DVSREPO-061: clear-then-assign leaves exactly one holder, scoped to the caller', async () => { const { user } = createUser(testDb); const { user: other } = createUser(testDb); - const wish = seedBucketItem(user.id); - seed(user.id, { source_visit_id: 'a', matched_bucket_list_item_id: wish }); - seed(user.id, { source_visit_id: 'b', matched_bucket_list_item_id: wish }); - const winnerId = seed(user.id, { source_visit_id: 'c', matched_bucket_list_item_id: null }); - const otherHolder = seed(other.id, { source_visit_id: 'a', matched_bucket_list_item_id: wish }); + const wish = await seedBucketItem(user.id); + await seed(user.id, { source_visit_id: 'a', matched_bucket_list_item_id: wish }); + await seed(user.id, { source_visit_id: 'b', matched_bucket_list_item_id: wish }); + const winnerId = await seed(user.id, { source_visit_id: 'c', matched_bucket_list_item_id: null }); + const otherHolder = await seed(other.id, { source_visit_id: 'a', matched_bucket_list_item_id: wish }); await suggestions.clearWishHolders(user.id, wish); await suggestions.assignWishHolder(user.id, 'c', wish); - const all = testDb - .prepare('SELECT id, matched_bucket_list_item_id FROM dawarich_visit_suggestions WHERE matched_bucket_list_item_id = ? AND user_id = ?') - .all(wish, user.id) as { id: number; matched_bucket_list_item_id: number }[]; + const all = await findRows(t, DawarichVisitSuggestions, { matchedBucketListItem: wish, user: user.id }); expect(all.map((r) => r.id)).toEqual([winnerId]); // clearWishHolders is scoped by user_id — another user's row with the // SAME wish id (a coincidence, wishes aren't globally unique ids across // users here) is untouched. - expect(rawRow(otherHolder).matched_bucket_list_item_id).toBe(wish); + expect((await rawRow(otherHolder)).matched_bucket_list_item_id).toBe(wish); }); }); describe('matchedStayStart (DWS14)', () => { it('DVSREPO-070: the most recent started_at among the user\'s stays matched to that wish', async () => { const { user } = createUser(testDb); - const wish = seedBucketItem(user.id); - seed(user.id, { matched_bucket_list_item_id: wish, started_at: '2026-01-01T00:00:00Z' }); - seed(user.id, { matched_bucket_list_item_id: wish, started_at: '2026-06-01T00:00:00Z' }); + const wish = await seedBucketItem(user.id); + await seed(user.id, { matched_bucket_list_item_id: wish, started_at: '2026-01-01T00:00:00Z' }); + await seed(user.id, { matched_bucket_list_item_id: wish, started_at: '2026-06-01T00:00:00Z' }); expect(await suggestions.matchedStayStart(user.id, wish)).toBe('2026-06-01T00:00:00Z'); expect(await suggestions.matchedStayStart(user.id, 999999)).toBeNull(); diff --git a/server/tests/unit/db/repositories/PlaceShadowPicks.repository.test.ts b/server/tests/unit/db/repositories/PlaceShadowPicks.repository.test.ts index 3520cbc56..26697a476 100644 --- a/server/tests/unit/db/repositories/PlaceShadowPicks.repository.test.ts +++ b/server/tests/unit/db/repositories/PlaceShadowPicks.repository.test.ts @@ -8,6 +8,8 @@ import { createSnapshotTestDb } from '../../../helpers/db-mock'; import { resetTestDb } from '../../../helpers/test-db'; import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; import { PlaceShadowPicks } from '../../../../src/db/entities/PlaceShadowPicks.entity'; +import { findRow, findRows, updateRows } from '../../../helpers/factories/rows'; +import { dbNow } from '../../../../src/db/types'; import type { PlaceShadowPicksRepository, NewPlaceShadowPickRow } from '../../../../src/db/repositories/PlaceShadowPicks.repository'; const testDb = createSnapshotTestDb(); @@ -38,15 +40,23 @@ function row(overrides: Partial = {}): NewPlaceShadowPick }; } -function rawRow(id: number): unknown { - return testDb.prepare('SELECT * FROM place_shadow_picks WHERE id = ?').get(id); +function rawRow(id: number) { + return findRow(t, PlaceShadowPicks, { id }); } +/** Every stored pick, oldest first. */ +function storedPicks() { + return findRows(t, PlaceShadowPicks, {}, { id: 'asc' }); +} + +/** `created_at` as `datetime('now', '-N seconds')` would write it. */ +const secondsAgo = (seconds: number): string => dbNow(new Date(Date.now() - seconds * 1000)); + describe('PlaceShadowPicksRepository.insertPick', () => { it('PSPICKREPO-001: inserts exactly the given columns, nullable ones included', async () => { await picks.insertPick(row()); - const stored = testDb.prepare('SELECT * FROM place_shadow_picks').get() as { id: number }; - expect(rawRow(stored.id)).toMatchObject({ query: 'louvre', source: 'nominatim', live_rank: 0, live_count: 5, lang: null }); + const [stored] = await storedPicks(); + expect(await rawRow(stored.id)).toMatchObject({ query: 'louvre', source: 'nominatim', live_rank: 0, live_count: 5, lang: null }); }); }); @@ -63,7 +73,7 @@ describe('PlaceShadowPicksRepository.page', () => { it('PSPICKREPO-003: after excludes everything at or below that id', async () => { await picks.insertPick(row({ query: 'a' })); - const firstId = (testDb.prepare('SELECT id FROM place_shadow_picks').get() as { id: number }).id; + const firstId = (await storedPicks())[0].id; await picks.insertPick(row({ query: 'b' })); const page = await picks.page(firstId, 5); expect(page.map((r) => r.query)).toEqual(['b']); @@ -78,8 +88,9 @@ describe('PlaceShadowPicksRepository.totals / countBySource / countByLiveRank', it('PSPICKREPO-005: totals reports COUNT/MIN/MAX across every row', async () => { await picks.insertPick(row()); await picks.insertPick(row()); - testDb.prepare("UPDATE place_shadow_picks SET created_at = '2026-01-01 00:00:00' WHERE id = (SELECT MIN(id) FROM place_shadow_picks)").run(); - testDb.prepare("UPDATE place_shadow_picks SET created_at = '2026-06-01 00:00:00' WHERE id = (SELECT MAX(id) FROM place_shadow_picks)").run(); + const stored = await storedPicks(); + await updateRows(t, PlaceShadowPicks, { id: stored[0].id }, { created_at: '2026-01-01 00:00:00' }); + await updateRows(t, PlaceShadowPicks, { id: stored[stored.length - 1].id }, { created_at: '2026-06-01 00:00:00' }); const totals = await picks.totals(); expect(totals).toEqual({ total: 2, oldest: '2026-01-01 00:00:00', newest: '2026-06-01 00:00:00' }); }); @@ -108,6 +119,7 @@ describe('PlaceShadowPicksRepository.totals / countBySource / countByLiveRank', } const rows = await picks.countBySource(); const legacy = testDb + // test-sql-allow: the legacy statement is the oracle the repository read is held to. .prepare('SELECT source, COUNT(*) AS count FROM place_shadow_picks GROUP BY source ORDER BY count DESC') .all(); expect(rows).toEqual(legacy); @@ -141,20 +153,20 @@ describe('PlaceShadowPicksRepository.deleteAll / purgeOlderThan', () => { it('PSPICKREPO-009: purgeOlderThan removes exactly the rows older than the cutoff, proving the deleted set on a seeded table', async () => { await picks.insertPick(row({ query: 'old' })); await picks.insertPick(row({ query: 'new' })); - const [oldRow, newRow] = testDb.prepare('SELECT id, query FROM place_shadow_picks ORDER BY id ASC').all() as { id: number; query: string }[]; - testDb.prepare("UPDATE place_shadow_picks SET created_at = datetime('now', '-200 days') WHERE id = ?").run(oldRow.id); - testDb.prepare("UPDATE place_shadow_picks SET created_at = datetime('now', '-1 days') WHERE id = ?").run(newRow.id); + const [oldRow, newRow] = await storedPicks(); + await updateRows(t, PlaceShadowPicks, { id: oldRow.id }, { created_at: secondsAgo(200 * 86_400) }); + await updateRows(t, PlaceShadowPicks, { id: newRow.id }, { created_at: secondsAgo(86_400) }); const removed = await picks.purgeOlderThan(180); expect(removed).toBe(1); - const survivors = testDb.prepare('SELECT query FROM place_shadow_picks').all() as { query: string }[]; + const survivors = await storedPicks(); expect(survivors.map((r) => r.query)).toEqual(['new']); }); it('PSPICKREPO-010: purgeOlderThan(0) removes rows created before right now, leaving nothing newer than the boundary', async () => { await picks.insertPick(row()); - const { id } = testDb.prepare('SELECT id FROM place_shadow_picks').get() as { id: number }; - testDb.prepare("UPDATE place_shadow_picks SET created_at = datetime('now', '-1 seconds') WHERE id = ?").run(id); + const [{ id }] = await storedPicks(); + await updateRows(t, PlaceShadowPicks, { id }, { created_at: secondsAgo(1) }); expect(await picks.purgeOlderThan(0)).toBe(1); }); }); diff --git a/server/tests/unit/mcp/tools-budget-advanced.test.ts b/server/tests/unit/mcp/tools-budget-advanced.test.ts index 004278756..28d16cd41 100644 --- a/server/tests/unit/mcp/tools-budget-advanced.test.ts +++ b/server/tests/unit/mcp/tools-budget-advanced.test.ts @@ -32,6 +32,13 @@ import type { TripAccess } from '../../../src/db/repositories/Trips.repository'; import type { User } from '../../../src/types'; import { createTestUnitOfWork, createTestAppSettingsRepo } from '../../helpers/test-uow'; import { budgetRepoArgs } from '../../helpers/budget-repos'; +import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; +import { countRows, updateRows } from '../../helpers/factories/rows'; +import { addBudgetItemMember, addBudgetItemPayer } from '../../helpers/factories/budget'; +import { BudgetItemMembers } from '../../../src/db/entities/BudgetItemMembers.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'; beforeEach(() => { resetTestDb(testDb); @@ -43,8 +50,15 @@ beforeEach(() => { vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('offline'); })); }); -afterAll(() => { +let orm: TestOrm; + +beforeAll(async () => { + orm = await createTestOrm(testDb); +}); + +afterAll(async () => { vi.unstubAllGlobals(); + await orm.close(); testDb.close(); }); @@ -102,7 +116,7 @@ describe('Tool: set_budget_item_members', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const item = createBudgetItem(testDb, trip.id); - testDb.prepare('INSERT INTO budget_item_members (budget_item_id, user_id) VALUES (?, ?)').run(item.id, user.id); + await addBudgetItemMember(orm, item.id, user.id); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'set_budget_item_members', @@ -110,8 +124,7 @@ describe('Tool: set_budget_item_members', () => { }); const data = parseToolResult(result) as any; expect(data.item).toBeDefined(); - const remaining = testDb.prepare('SELECT count(*) as cnt FROM budget_item_members WHERE budget_item_id = ?').get(item.id) as any; - expect(remaining.cnt).toBe(0); + expect(await countRows(orm, BudgetItemMembers, { budgetItem: item.id })).toBe(0); }); }); @@ -206,7 +219,7 @@ describe('Tool: toggle_budget_member_paid', () => { const trip = createTrip(testDb, user.id); const item = createBudgetItem(testDb, trip.id, { total_price: 200 }); // Add member first - testDb.prepare('INSERT INTO budget_item_members (budget_item_id, user_id, paid) VALUES (?, ?, 0)').run(item.id, user.id); + await addBudgetItemMember(orm, item.id, user.id, { paid: 0 }); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'toggle_budget_member_paid', @@ -291,8 +304,7 @@ describe('Settlement tools', () => { expect(deleted.success).toBe(true); expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'budget:settlement-deleted', expect.any(Object)); - const remaining = testDb.prepare('SELECT count(*) as cnt FROM budget_settlements WHERE trip_id = ?').get(trip.id) as any; - expect(remaining.cnt).toBe(0); + expect(await countRows(orm, BudgetSettlements, { trip: trip.id })).toBe(0); }); }); @@ -326,13 +338,11 @@ describe('Settlement tools', () => { // The fixture carries the row the fix is about: a total nobody has paid. const { user, other, trip } = tripWithTwo(); const paid = createBudgetItem(testDb, trip.id, { total_price: 100 }); - testDb.prepare('INSERT INTO budget_item_members (budget_item_id, user_id, paid) VALUES (?, ?, 0), (?, ?, 0)') - .run(paid.id, user.id, paid.id, other.id); - testDb.prepare('INSERT INTO budget_item_payers (budget_item_id, user_id, amount) VALUES (?, ?, ?)') - .run(paid.id, user.id, 100); + await addBudgetItemMember(orm, paid.id, user.id, { paid: 0 }); + await addBudgetItemMember(orm, paid.id, other.id, { paid: 0 }); + await addBudgetItemPayer(orm, paid.id, user.id, 100); const unpaid = createBudgetItem(testDb, trip.id, { total_price: 40 }); - testDb.prepare('INSERT INTO budget_item_members (budget_item_id, user_id, paid) VALUES (?, ?, 0)') - .run(unpaid.id, other.id); + await addBudgetItemMember(orm, unpaid.id, other.id, { paid: 0 }); const controller = new BudgetController( new BudgetService(new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), new ExchangeRatesService(), new RealtimeService(), await createTestUnitOfWork(testDb), ...(await budgetRepoArgs(testDb))), @@ -362,13 +372,12 @@ describe('Settlement tools', () => { const spy = vi.spyOn(ExchangeRatesService.prototype, 'getRates').mockImplementation(async (b: string) => quotes[b] ?? null); try { const { user, other, trip } = tripWithTwo(); - testDb.prepare("UPDATE trips SET currency = 'EUR' WHERE id = ?").run(trip.id); + await updateRows(orm, Trips, { id: trip.id }, { currency: 'EUR' }); const item = createBudgetItem(testDb, trip.id, { total_price: 12345.67 }); - testDb.prepare("UPDATE budget_items SET currency = 'USD', exchange_rate = 1.1398 WHERE id = ?").run(item.id); - testDb.prepare('INSERT INTO budget_item_members (budget_item_id, user_id, paid) VALUES (?, ?, 0), (?, ?, 0)') - .run(item.id, user.id, item.id, other.id); - testDb.prepare('INSERT INTO budget_item_payers (budget_item_id, user_id, amount) VALUES (?, ?, ?)') - .run(item.id, user.id, 12345.67); + await updateRows(orm, BudgetItems, { id: item.id }, { currency: 'USD', exchange_rate: 1.1398 }); + await addBudgetItemMember(orm, item.id, user.id, { paid: 0 }); + await addBudgetItemMember(orm, item.id, other.id, { paid: 0 }); + await addBudgetItemPayer(orm, item.id, user.id, 12345.67); const controller = new BudgetController( new BudgetService(new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), new ExchangeRatesService(), new RealtimeService(), await createTestUnitOfWork(testDb), ...(await budgetRepoArgs(testDb))), @@ -398,10 +407,9 @@ describe('Settlement tools', () => { const { user, other, trip } = tripWithTwo(); // user paid 100 for an item split between both → other owes user 50. const item = createBudgetItem(testDb, trip.id, { total_price: 100 }); - testDb.prepare('INSERT INTO budget_item_members (budget_item_id, user_id, paid) VALUES (?, ?, 0), (?, ?, 0)') - .run(item.id, user.id, item.id, other.id); - testDb.prepare('INSERT INTO budget_item_payers (budget_item_id, user_id, amount) VALUES (?, ?, ?)') - .run(item.id, user.id, 100); + await addBudgetItemMember(orm, item.id, user.id, { paid: 0 }); + await addBudgetItemMember(orm, item.id, other.id, { paid: 0 }); + await addBudgetItemPayer(orm, item.id, user.id, 100); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'get_settlement_summary', arguments: { tripId: trip.id } }); const data = parseToolResult(result) as any; diff --git a/server/tests/unit/mcp/tools-collab-polls-chat.test.ts b/server/tests/unit/mcp/tools-collab-polls-chat.test.ts index 5230f1855..db618c5bf 100644 --- a/server/tests/unit/mcp/tools-collab-polls-chat.test.ts +++ b/server/tests/unit/mcp/tools-collab-polls-chat.test.ts @@ -29,6 +29,12 @@ import { createUser, createTrip } from '../../helpers/factories'; import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness'; import { setAddonEnabled } from '../../helpers/test-db'; import { ADDON_IDS } from '../../../src/addons'; +import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; +import { countRows, findRow, insertRow } from '../../helpers/factories/rows'; +import { makeCollabMessage } from '../../helpers/factories/collab'; +import { CollabPolls } from '../../../src/db/entities/CollabPolls.entity'; +import { CollabPollVotes } from '../../../src/db/entities/CollabPollVotes.entity'; +import { TripMembers } from '../../../src/db/entities/TripMembers.entity'; beforeEach(() => { setAddonEnabled(testDb, ADDON_IDS.COLLAB, true); @@ -37,7 +43,14 @@ beforeEach(() => { delete process.env.DEMO_MODE; }); -afterAll(() => { +let orm: TestOrm; + +beforeAll(async () => { + orm = await createTestOrm(testDb); +}); + +afterAll(async () => { + await orm.close(); testDb.close(); }); @@ -141,9 +154,7 @@ describe('Tool: vote_collab_poll', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); // Create a poll directly in the DB - const pollId = (testDb.prepare( - `INSERT INTO collab_polls (trip_id, user_id, question, options, created_at) VALUES (?, ?, ?, ?, datetime('now'))` - ).run(trip.id, user.id, 'Best city?', JSON.stringify(['Paris', 'Rome'])) as any).lastInsertRowid; + const pollId = await insertRow(orm, CollabPolls, { trip: trip.id, user: user.id, question: 'Best city?', options: JSON.stringify(['Paris', 'Rome']) }); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ @@ -160,9 +171,7 @@ describe('Tool: vote_collab_poll', () => { process.env.DEMO_MODE = 'true'; const { user } = createUser(testDb, { email: 'demo@nomad.app' }); const trip = createTrip(testDb, user.id); - const pollId = (testDb.prepare( - `INSERT INTO collab_polls (trip_id, user_id, question, options, created_at) VALUES (?, ?, ?, ?, datetime('now'))` - ).run(trip.id, user.id, 'Best city?', JSON.stringify(['Paris', 'Rome'])) as any).lastInsertRowid; + const pollId = await insertRow(orm, CollabPolls, { trip: trip.id, user: user.id, question: 'Best city?', options: JSON.stringify(['Paris', 'Rome']) }); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ @@ -170,7 +179,7 @@ describe('Tool: vote_collab_poll', () => { arguments: { tripId: trip.id, pollId: Number(pollId), optionIndex: 0 }, }); expect(result.isError).toBe(true); - expect(testDb.prepare('SELECT COUNT(*) as c FROM collab_poll_votes WHERE poll_id = ?').get(pollId)).toEqual({ c: 0 }); + expect(await countRows(orm, CollabPollVotes, { poll: pollId })).toBe(0); }); }); @@ -196,9 +205,7 @@ describe('Tool: close_collab_poll', () => { it('sets closed flag and broadcasts collab:poll:closed', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const pollId = (testDb.prepare( - `INSERT INTO collab_polls (trip_id, user_id, question, options, created_at) VALUES (?, ?, ?, ?, datetime('now'))` - ).run(trip.id, user.id, 'Vote now?', JSON.stringify(['Yes', 'No'])) as any).lastInsertRowid; + const pollId = await insertRow(orm, CollabPolls, { trip: trip.id, user: user.id, question: 'Vote now?', options: JSON.stringify(['Yes', 'No']) }); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ @@ -242,9 +249,7 @@ describe('Tool: delete_collab_poll', () => { it('removes poll and broadcasts collab:poll:deleted', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const pollId = (testDb.prepare( - `INSERT INTO collab_polls (trip_id, user_id, question, options, created_at) VALUES (?, ?, ?, ?, datetime('now'))` - ).run(trip.id, user.id, 'Delete me?', JSON.stringify(['Yes', 'No'])) as any).lastInsertRowid; + const pollId = await insertRow(orm, CollabPolls, { trip: trip.id, user: user.id, question: 'Delete me?', options: JSON.stringify(['Yes', 'No']) }); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ @@ -254,7 +259,7 @@ describe('Tool: delete_collab_poll', () => { const data = parseToolResult(result) as any; expect(data.success).toBe(true); expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'collab:poll:deleted', expect.objectContaining({ pollId: Number(pollId) })); - expect(testDb.prepare('SELECT id FROM collab_polls WHERE id = ?').get(Number(pollId))).toBeUndefined(); + expect(await findRow(orm, CollabPolls, { id: Number(pollId) })).toBeNull(); }); }); @@ -321,9 +326,7 @@ describe('Tool: send_collab_message', () => { it('sends message with replyTo when parent exists', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const msgId = (testDb.prepare( - `INSERT INTO collab_messages (trip_id, user_id, text, created_at) VALUES (?, ?, ?, datetime('now'))` - ).run(trip.id, user.id, 'Original message') as any).lastInsertRowid; + const msgId = (await makeCollabMessage(orm, trip.id, user.id, { text: 'Original message' })).id; await withHarness(user.id, async (h) => { const result = await h.client.callTool({ @@ -367,9 +370,7 @@ describe('Tool: delete_collab_message', () => { it('soft-deletes message and broadcasts collab:message:deleted', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const msgId = (testDb.prepare( - `INSERT INTO collab_messages (trip_id, user_id, text, created_at) VALUES (?, ?, ?, datetime('now'))` - ).run(trip.id, user.id, 'To be deleted') as any).lastInsertRowid; + const msgId = (await makeCollabMessage(orm, trip.id, user.id, { text: 'To be deleted' })).id; await withHarness(user.id, async (h) => { const result = await h.client.callTool({ @@ -387,10 +388,8 @@ describe('Tool: delete_collab_message', () => { const { user: other } = createUser(testDb); const trip = createTrip(testDb, user.id); // Add other as trip member - testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(trip.id, other.id); - const msgId = (testDb.prepare( - `INSERT INTO collab_messages (trip_id, user_id, text, created_at) VALUES (?, ?, ?, datetime('now'))` - ).run(trip.id, user.id, 'Owner message') as any).lastInsertRowid; + await insertRow(orm, TripMembers, { trip: trip.id, user: other.id }); + const msgId = (await makeCollabMessage(orm, trip.id, user.id, { text: 'Owner message' })).id; await withHarness(other.id, async (h) => { const result = await h.client.callTool({ @@ -420,9 +419,7 @@ describe('Tool: react_collab_message', () => { it('toggles reaction and broadcasts collab:message:reacted', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); - const msgId = (testDb.prepare( - `INSERT INTO collab_messages (trip_id, user_id, text, created_at) VALUES (?, ?, ?, datetime('now'))` - ).run(trip.id, user.id, 'React to me') as any).lastInsertRowid; + const msgId = (await makeCollabMessage(orm, trip.id, user.id, { text: 'React to me' })).id; await withHarness(user.id, async (h) => { const result = await h.client.callTool({ diff --git a/server/tests/unit/nest/notification-preferences.service.test.ts b/server/tests/unit/nest/notification-preferences.service.test.ts index 5f9c9e543..8aa199658 100644 --- a/server/tests/unit/nest/notification-preferences.service.test.ts +++ b/server/tests/unit/nest/notification-preferences.service.test.ts @@ -40,9 +40,22 @@ import { registerBuiltinChannels } from '../../../src/nest/notifications/channel import { NtfyService } from '../../../src/nest/notifications/transports/ntfy.service'; import { WebhookService } from '../../../src/nest/notifications/transports/webhook.service'; import { __resetChannelsForTest } from '../../../src/nest/notifications/channel-registry'; -import { createTestUnitOfWork, createTestAppSettingsRepo, createTestSettingsRepo, createTestUsersRepo } from '../../helpers/test-uow'; +import { createTestUnitOfWork, createTestAppSettingsRepo, createTestSettingsRepo, createTestUsersRepo, sharedTestOrm } from '../../helpers/test-uow'; import { createTestNotificationChannelPreferencesRepo } from '../../helpers/notifications-repos'; import { makeWebPushService } from '../../helpers/notifications'; +import { countRows, findRow, insertRow } from '../../helpers/factories/rows'; +import { AppSettings } from '../../../src/db/entities/AppSettings.entity'; +import { NotificationChannelPreferences } from '../../../src/db/entities/NotificationChannelPreferences.entity'; + +/** The stored per-user preference row, or null when the cell is at its default. */ +async function storedPref(userId: number, eventType: string, channel: string) { + return findRow(await sharedTestOrm(testDb), NotificationChannelPreferences, { user: userId, event_type: eventType, channel }); +} + +/** The stored app setting row, or null. */ +async function storedAppSetting(key: string) { + return findRow(await sharedTestOrm(testDb), AppSettings, { key }); +} // Built in beforeAll: MailerService/WebhookService/NtfyService now take // repositories, resolved async through test-uow.ts's memoised factories. @@ -103,9 +116,12 @@ describe('isEnabledForEvent', () => { it('NPREF-002 — returns true when row exists with enabled=1', async () => { const { user } = createUser(testDb); - testDb.prepare( - 'INSERT INTO notification_channel_preferences (user_id, event_type, channel, enabled) VALUES (?, ?, ?, 1)' - ).run(user.id, 'trip_invite', 'email'); + await insertRow(await sharedTestOrm(testDb), NotificationChannelPreferences, { + user: user.id, + event_type: 'trip_invite', + channel: 'email', + enabled: 1, + }); expect(await isEnabledForEvent(user.id, 'trip_invite', 'email')).toBe(true); }); @@ -206,10 +222,8 @@ describe('setPreferences', () => { it('NPREF-012 — disabling a preference inserts a row with enabled=0', async () => { const { user } = createUser(testDb); await setPreferences(user.id, { trip_invite: { email: false } }); - const row = testDb.prepare( - 'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?' - ).get(user.id, 'trip_invite', 'email') as { enabled: number } | undefined; - expect(row).toBeDefined(); + const row = await storedPref(user.id, 'trip_invite', 'email'); + expect(row).not.toBeNull(); expect(row!.enabled).toBe(0); }); @@ -219,11 +233,9 @@ describe('setPreferences', () => { disableNotificationPref(testDb, user.id, 'trip_invite', 'email'); // Then re-enable await setPreferences(user.id, { trip_invite: { email: true } }); - const row = testDb.prepare( - 'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?' - ).get(user.id, 'trip_invite', 'email'); + const row = await storedPref(user.id, 'trip_invite', 'email'); // Row should be deleted — default is enabled - expect(row).toBeUndefined(); + expect(row).toBeNull(); }); it('NPREF-014 — bulk update handles multiple event+channel combos', async () => { @@ -237,10 +249,7 @@ describe('setPreferences', () => { expect(await isEnabledForEvent(user.id, 'trip_invite', 'webhook')).toBe(false); expect(await isEnabledForEvent(user.id, 'booking_change', 'email')).toBe(false); // trip_reminder webhook was set to true → no row, default enabled - const row = testDb.prepare( - 'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?' - ).get(user.id, 'trip_reminder', 'webhook'); - expect(row).toBeUndefined(); + expect(await storedPref(user.id, 'trip_reminder', 'webhook')).toBeNull(); }); }); @@ -314,21 +323,18 @@ describe('setAdminPreferences', () => { const { user } = createAdmin(testDb); await setAdminPreferences(user.id, { version_available: { email: false } }); expect(await getAdminGlobalPref('version_available', 'email')).toBe(false); - const row = testDb.prepare("SELECT value FROM app_settings WHERE key = ?").get('admin_notif_pref_version_available_email') as { value: string } | undefined; + const row = await storedAppSetting('admin_notif_pref_version_available_email'); expect(row?.value).toBe('0'); }); it('NPREF-023 — disabling inapp for version_available stores per-user row in notification_channel_preferences', async () => { const { user } = createAdmin(testDb); await setAdminPreferences(user.id, { version_available: { inapp: false } }); - const row = testDb.prepare( - 'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?' - ).get(user.id, 'version_available', 'inapp') as { enabled: number } | undefined; - expect(row).toBeDefined(); + const row = await storedPref(user.id, 'version_available', 'inapp'); + expect(row).not.toBeNull(); expect(row!.enabled).toBe(0); // Global app_settings should NOT have an inapp key - const globalRow = testDb.prepare("SELECT value FROM app_settings WHERE key = ?").get('admin_notif_pref_version_available_inapp'); - expect(globalRow).toBeUndefined(); + expect(await storedAppSetting('admin_notif_pref_version_available_inapp')).toBeNull(); }); it('NPREF-024 — re-enabling inapp removes the disabled per-user row', async () => { @@ -337,10 +343,7 @@ describe('setAdminPreferences', () => { disableNotificationPref(testDb, user.id, 'version_available', 'inapp'); // Then re-enable via setAdminPreferences await setAdminPreferences(user.id, { version_available: { inapp: true } }); - const row = testDb.prepare( - 'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?' - ).get(user.id, 'version_available', 'inapp'); - expect(row).toBeUndefined(); + expect(await storedPref(user.id, 'version_available', 'inapp')).toBeNull(); }); it('NPREF-025 — enabling email stores global pref as "1" in app_settings', async () => { @@ -349,7 +352,7 @@ describe('setAdminPreferences', () => { await setAdminPreferences(user.id, { version_available: { email: false } }); await setAdminPreferences(user.id, { version_available: { email: true } }); expect(await getAdminGlobalPref('version_available', 'email')).toBe(true); - const row = testDb.prepare("SELECT value FROM app_settings WHERE key = ?").get('admin_notif_pref_version_available_email') as { value: string } | undefined; + const row = await storedAppSetting('admin_notif_pref_version_available_email'); expect(row?.value).toBe('1'); }); @@ -400,7 +403,7 @@ describe('instance defaults', () => { expect(await isEnabledForEvent(user.id, 'trip_invite', 'email')).toBe(true); // Switching back to the default leaves no row behind. await setPreferences(user.id, { trip_invite: { email: false } }); - expect(testDb.prepare('SELECT COUNT(*) AS n FROM notification_channel_preferences WHERE user_id = ?').get(user.id)).toEqual({ n: 0 }); + expect(await countRows(await sharedTestOrm(testDb), NotificationChannelPreferences, { user: user.id })).toBe(0); }); it('NPREF-030 — a blocked cell is off, locked in the matrix, and a user cannot turn it on', async () => { @@ -417,7 +420,7 @@ describe('instance defaults', () => { const { user: admin } = createAdmin(testDb); await svc.setInstanceDefaults({ version_available: { inapp: 'blocked' }, trip_invite: { carrier_pigeon: 'off' } } as never); expect(await isEnabledForEvent(admin.id, 'version_available', 'inapp')).toBe(true); - expect(testDb.prepare("SELECT COUNT(*) AS n FROM app_settings WHERE key LIKE 'notif_default_%'").get()).toEqual({ n: 0 }); + expect(await countRows(await sharedTestOrm(testDb), AppSettings, { key: { $like: 'notif_default_%' } })).toBe(0); }); it('NPREF-032 — the defaults matrix lists user events only, "on" stores nothing', async () => { @@ -428,6 +431,6 @@ describe('instance defaults', () => { expect(before.defaults.trip_invite?.email).toBe('off'); expect(before.defaults.trip_invite?.inapp).toBe('on'); await svc.setInstanceDefaults({ trip_invite: { email: 'on' } }); - expect(testDb.prepare("SELECT COUNT(*) AS n FROM app_settings WHERE key LIKE 'notif_default_%'").get()).toEqual({ n: 0 }); + expect(await countRows(await sharedTestOrm(testDb), AppSettings, { key: { $like: 'notif_default_%' } })).toBe(0); }); }); diff --git a/server/tests/unit/nest/notifications/vapid-keys.service.test.ts b/server/tests/unit/nest/notifications/vapid-keys.service.test.ts index 427b41863..64bfc5e71 100644 --- a/server/tests/unit/nest/notifications/vapid-keys.service.test.ts +++ b/server/tests/unit/nest/notifications/vapid-keys.service.test.ts @@ -40,6 +40,9 @@ import { db as testDb } from '../../../../src/db/database'; import type { MikroORM } from '@mikro-orm/core'; import { resetTestDb } from '../../../helpers/test-db'; import { createTestAppSettingsRepo, createTestUnitOfWork, sharedTestOrm } from '../../../helpers/test-uow'; +import { insertRows, updateRows } from '../../../helpers/factories/rows'; +import { readAppSetting } from '../../../helpers/factories/settings'; +import { AppSettings } from '../../../../src/db/entities/AppSettings.entity'; import type { AppSettingsRepository } from '../../../../src/db/repositories/AppSettings.repository'; import type { UnitOfWork } from '../../../../src/nest/database/unit-of-work'; import { decrypt_api_key, encrypt_api_key } from '../../../../src/nest/common/crypto/apiKeyCrypto'; @@ -67,9 +70,18 @@ function make(repo: AppSettingsRepository = appSettings): VapidKeysService { /** An enc:v1: value the current ENCRYPTION_KEY cannot open. */ const UNREADABLE = 'enc:v1:bm90IGEgY2lwaGVydGV4dA'; -function setting(key: string): string | undefined { - return (testDb.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined) - ?.value; +async function setting(key: string): Promise { + return (await readAppSetting(await sharedTestOrm(testDb), key)) ?? undefined; +} + +/** Overwrites a stored setting in place, as a restore or another instance would. */ +async function overwriteSetting(key: string, value: string): Promise { + await updateRows(await sharedTestOrm(testDb), AppSettings, { key }, { value }); +} + +/** Stores settings rows that are not there yet, the way another writer would. */ +async function storeSettings(rows: Array<{ key: string; value: string }>): Promise { + await insertRows(await sharedTestOrm(testDb), AppSettings, rows); } beforeAll(async () => { @@ -108,8 +120,8 @@ describe('VapidKeysService key pair', () => { const keys = await svc.getKeys(); expect(keys.source).toBe('database'); expect(isVapidKeyPair(keys.publicKey, keys.privateKey)).toBe(true); - expect(setting(VAPID_PUBLIC_KEY_SETTING)).toBe(keys.publicKey); - const stored = setting(VAPID_PRIVATE_KEY_SETTING)!; + expect(await setting(VAPID_PUBLIC_KEY_SETTING)).toBe(keys.publicKey); + const stored = (await setting(VAPID_PRIVATE_KEY_SETTING))!; expect(stored.startsWith('enc:v1:')).toBe(true); expect(decrypt_api_key(stored)).toBe(keys.privateKey); expect(logInfo).toHaveBeenCalledTimes(1); @@ -118,7 +130,7 @@ describe('VapidKeysService key pair', () => { const again = make(); expect(await again.getKeys()).toEqual(keys); expect(await svc.getPublicKey()).toBe(keys.publicKey); - expect(setting(VAPID_PRIVATE_KEY_SETTING)).toBe(stored); + expect(await setting(VAPID_PRIVATE_KEY_SETTING)).toBe(stored); expect(logInfo).toHaveBeenCalledTimes(1); }); @@ -126,15 +138,16 @@ describe('VapidKeysService key pair', () => { const theirs = generateVapidKeyPair(); const svc = make(); const transactional = uow.transactional.bind(uow); - const spy = vi.spyOn(uow, 'transactional').mockImplementationOnce((fn) => { - testDb - .prepare('INSERT INTO app_settings (key, value) VALUES (?, ?), (?, ?)') - .run(VAPID_PUBLIC_KEY_SETTING, theirs.publicKey, VAPID_PRIVATE_KEY_SETTING, encrypt_api_key(theirs.privateKey)); + const spy = vi.spyOn(uow, 'transactional').mockImplementationOnce(async (fn) => { + await storeSettings([ + { key: VAPID_PUBLIC_KEY_SETTING, value: theirs.publicKey }, + { key: VAPID_PRIVATE_KEY_SETTING, value: encrypt_api_key(theirs.privateKey) }, + ]); return transactional(fn); }); try { expect(await svc.getKeys()).toEqual({ ...theirs, source: 'database' }); - expect(setting(VAPID_PUBLIC_KEY_SETTING)).toBe(theirs.publicKey); + expect(await setting(VAPID_PUBLIC_KEY_SETTING)).toBe(theirs.publicKey); expect(logInfo).not.toHaveBeenCalled(); } finally { spy.mockRestore(); @@ -143,8 +156,8 @@ describe('VapidKeysService key pair', () => { it('VKEY-002: bootstrap creates the pair, so the public-key GET only reads', async () => { await make().onApplicationBootstrap(); - expect(setting(VAPID_PUBLIC_KEY_SETTING)).toBeTruthy(); - expect(setting(VAPID_PRIVATE_KEY_SETTING)).toBeTruthy(); + expect(await setting(VAPID_PUBLIC_KEY_SETTING)).toBeTruthy(); + expect(await setting(VAPID_PRIVATE_KEY_SETTING)).toBeTruthy(); }); it('VKEY-003: a bootstrap failure is logged, not thrown', async () => { @@ -164,13 +177,13 @@ describe('VapidKeysService key pair', () => { vi.stubEnv('VAPID_PRIVATE_KEY', pair.privateKey); const keys = await make().getKeys(); expect(keys).toEqual({ ...pair, source: 'env' }); - expect(setting(VAPID_PUBLIC_KEY_SETTING)).toBeUndefined(); + expect(await setting(VAPID_PUBLIC_KEY_SETTING)).toBeUndefined(); expect(logError).not.toHaveBeenCalled(); }); it('VKEY-005: a mismatched environment pair turns push off, reported once, and the stored pair is not used', async () => { const stored = await make().getKeys(); - const ciphertext = setting(VAPID_PRIVATE_KEY_SETTING); + const ciphertext = await setting(VAPID_PRIVATE_KEY_SETTING); vi.stubEnv('VAPID_PUBLIC_KEY', generateVapidKeyPair().publicKey); vi.stubEnv('VAPID_PRIVATE_KEY', generateVapidKeyPair().privateKey); const svc = make(); @@ -182,14 +195,14 @@ describe('VapidKeysService key pair', () => { expect.stringContaining('Web Push is off: VAPID_PUBLIC_KEY does not belong to VAPID_PRIVATE_KEY'), ); // Both stored rows are exactly as they were, for when the variables go again. - expect(setting(VAPID_PUBLIC_KEY_SETTING)).toBe(stored.publicKey); - expect(setting(VAPID_PRIVATE_KEY_SETTING)).toBe(ciphertext); + expect(await setting(VAPID_PUBLIC_KEY_SETTING)).toBe(stored.publicKey); + expect(await setting(VAPID_PRIVATE_KEY_SETTING)).toBe(ciphertext); expect(logInfo).toHaveBeenCalledTimes(1); }); it('VKEY-006: half a pair in the environment turns push off over a stored pair, and is reported again after a fix and relapse', async () => { const stored = await make().getKeys(); - const ciphertext = setting(VAPID_PRIVATE_KEY_SETTING); + const ciphertext = await setting(VAPID_PRIVATE_KEY_SETTING); const pair = generateVapidKeyPair(); const svc = make(); vi.stubEnv('VAPID_PRIVATE_KEY', pair.privateKey); @@ -209,8 +222,8 @@ describe('VapidKeysService key pair', () => { expect(logError).toHaveBeenLastCalledWith( expect.stringContaining('VAPID_PUBLIC_KEY is set without its other half'), ); - expect(setting(VAPID_PUBLIC_KEY_SETTING)).toBe(stored.publicKey); - expect(setting(VAPID_PRIVATE_KEY_SETTING)).toBe(ciphertext); + expect(await setting(VAPID_PUBLIC_KEY_SETTING)).toBe(stored.publicKey); + expect(await setting(VAPID_PRIVATE_KEY_SETTING)).toBe(ciphertext); // Both variables removed on purpose: the stored pair, as on any install without them. vi.stubEnv('VAPID_PUBLIC_KEY', ''); @@ -230,8 +243,8 @@ describe('VapidKeysService key pair', () => { await make().onApplicationBootstrap(); expect(await svc.isAvailable()).toBe(false); await expect(svc.getKeys()).rejects.toThrow(PushUnavailableError); - expect(setting(VAPID_PUBLIC_KEY_SETTING)).toBeUndefined(); - expect(setting(VAPID_PRIVATE_KEY_SETTING)).toBeUndefined(); + expect(await setting(VAPID_PUBLIC_KEY_SETTING)).toBeUndefined(); + expect(await setting(VAPID_PRIVATE_KEY_SETTING)).toBeUndefined(); expect(logInfo).not.toHaveBeenCalled(); // Once per instance, with what to do and without the key. expect(logError).toHaveBeenCalledTimes(2); @@ -246,7 +259,7 @@ describe('VapidKeysService key pair', () => { expect(logError).toHaveBeenLastCalledWith( expect.stringContaining('Web Push is off: VAPID_PUBLIC_KEY does not belong to VAPID_PRIVATE_KEY'), ); - expect(setting(VAPID_PUBLIC_KEY_SETTING)).toBeUndefined(); + expect(await setting(VAPID_PUBLIC_KEY_SETTING)).toBeUndefined(); // The pair fixed: the same key as before, so every device receives again as it was. vi.stubEnv('VAPID_PRIVATE_KEY', pair.privateKey); @@ -255,12 +268,12 @@ describe('VapidKeysService key pair', () => { it('VKEY-006c: a broken environment pair over an unusable stored pair names the variables and touches no row', async () => { await make().getKeys(); - testDb.prepare('UPDATE app_settings SET value = ? WHERE key = ?').run(UNREADABLE, VAPID_PRIVATE_KEY_SETTING); + await overwriteSetting(VAPID_PRIVATE_KEY_SETTING, UNREADABLE); vi.stubEnv('VAPID_PUBLIC_KEY', generateVapidKeyPair().publicKey); const svc = make(); await expect(svc.getKeys()).rejects.toThrow(PushUnavailableError); await expect(svc.getKeys()).rejects.toThrow(PushUnavailableError); - expect(setting(VAPID_PRIVATE_KEY_SETTING)).toBe(UNREADABLE); + expect(await setting(VAPID_PRIVATE_KEY_SETTING)).toBe(UNREADABLE); // The variables are what push would sign with, so they are what the log names. expect(logError).toHaveBeenCalledTimes(1); expect(logError).toHaveBeenCalledWith( @@ -271,17 +284,17 @@ describe('VapidKeysService key pair', () => { it('VKEY-007: a stored private key that no longer decrypts is kept, and push is off until the key is back', async () => { const svc = make(); const original = await svc.getKeys(); - const ciphertext = setting(VAPID_PRIVATE_KEY_SETTING)!; + const ciphertext = (await setting(VAPID_PRIVATE_KEY_SETTING))!; // What a restore under a different ENCRYPTION_KEY looks like from here. - testDb.prepare('UPDATE app_settings SET value = ? WHERE key = ?').run(UNREADABLE, VAPID_PRIVATE_KEY_SETTING); + await overwriteSetting(VAPID_PRIVATE_KEY_SETTING, UNREADABLE); logInfo.mockClear(); const broken = make(); await expect(broken.getKeys()).rejects.toThrow(PushUnavailableError); await expect(broken.getPublicKey()).rejects.toThrow(PushUnavailableError); // Nothing was replaced: both rows are exactly as they were. - expect(setting(VAPID_PUBLIC_KEY_SETTING)).toBe(original.publicKey); - expect(setting(VAPID_PRIVATE_KEY_SETTING)).toBe(UNREADABLE); + expect(await setting(VAPID_PUBLIC_KEY_SETTING)).toBe(original.publicKey); + expect(await setting(VAPID_PRIVATE_KEY_SETTING)).toBe(UNREADABLE); expect(logInfo).not.toHaveBeenCalled(); // Said once, with the way out, and without any key material. expect(logError).toHaveBeenCalledTimes(1); @@ -292,62 +305,56 @@ describe('VapidKeysService key pair', () => { expect(line).not.toContain(ciphertext); // The right key again (here: the original ciphertext back) brings the same pair back. - testDb.prepare('UPDATE app_settings SET value = ? WHERE key = ?').run(ciphertext, VAPID_PRIVATE_KEY_SETTING); + await overwriteSetting(VAPID_PRIVATE_KEY_SETTING, ciphertext); expect(await broken.getKeys()).toEqual(original); }); it('VKEY-008: half a stored pair is not completed with a new pair either', async () => { const publicKey = generateVapidKeyPair().publicKey; - testDb.prepare('INSERT INTO app_settings (key, value) VALUES (?, ?)').run(VAPID_PUBLIC_KEY_SETTING, publicKey); + await storeSettings([{ key: VAPID_PUBLIC_KEY_SETTING, value: publicKey }]); await expect(make().getKeys()).rejects.toThrow(PushUnavailableError); - expect(setting(VAPID_PUBLIC_KEY_SETTING)).toBe(publicKey); - expect(setting(VAPID_PRIVATE_KEY_SETTING)).toBeUndefined(); + expect(await setting(VAPID_PUBLIC_KEY_SETTING)).toBe(publicKey); + expect(await setting(VAPID_PRIVATE_KEY_SETTING)).toBeUndefined(); expect(logError).toHaveBeenCalledWith(expect.stringContaining('only one half of the stored VAPID key pair')); expect(logError).toHaveBeenCalledWith(expect.not.stringContaining(publicKey)); }); it('VKEY-008b: only the private half stored counts as incomplete too', async () => { const { privateKey } = generateVapidKeyPair(); - testDb - .prepare('INSERT INTO app_settings (key, value) VALUES (?, ?)') - .run(VAPID_PRIVATE_KEY_SETTING, encrypt_api_key(privateKey)); + await storeSettings([{ key: VAPID_PRIVATE_KEY_SETTING, value: encrypt_api_key(privateKey) }]); await expect(make().getKeys()).rejects.toThrow(PushUnavailableError); - expect(setting(VAPID_PUBLIC_KEY_SETTING)).toBeUndefined(); + expect(await setting(VAPID_PUBLIC_KEY_SETTING)).toBeUndefined(); }); it('VKEY-008c: halves that decrypt but do not belong together are refused, not replaced', async () => { const stored = generateVapidKeyPair(); - testDb - .prepare('INSERT INTO app_settings (key, value) VALUES (?, ?), (?, ?)') - .run( - VAPID_PUBLIC_KEY_SETTING, - stored.publicKey, - VAPID_PRIVATE_KEY_SETTING, - encrypt_api_key(generateVapidKeyPair().privateKey), - ); + await storeSettings([ + { key: VAPID_PUBLIC_KEY_SETTING, value: stored.publicKey }, + { key: VAPID_PRIVATE_KEY_SETTING, value: encrypt_api_key(generateVapidKeyPair().privateKey) }, + ]); await expect(make().getKeys()).rejects.toThrow(PushUnavailableError); - expect(setting(VAPID_PUBLIC_KEY_SETTING)).toBe(stored.publicKey); + expect(await setting(VAPID_PUBLIC_KEY_SETTING)).toBe(stored.publicKey); expect(logError).toHaveBeenCalledWith(expect.stringContaining('does not belong to the stored public key')); }); it('VKEY-008d: an unusable pair is reported once per state, and again after it was fixed and broke again', async () => { const svc = make(); await svc.getKeys(); - const ciphertext = setting(VAPID_PRIVATE_KEY_SETTING)!; - testDb.prepare('UPDATE app_settings SET value = ? WHERE key = ?').run(UNREADABLE, VAPID_PRIVATE_KEY_SETTING); + const ciphertext = (await setting(VAPID_PRIVATE_KEY_SETTING))!; + await overwriteSetting(VAPID_PRIVATE_KEY_SETTING, UNREADABLE); for (let i = 0; i < 3; i++) await expect(svc.getKeys()).rejects.toThrow(PushUnavailableError); expect(logError).toHaveBeenCalledTimes(1); - testDb.prepare('UPDATE app_settings SET value = ? WHERE key = ?').run(ciphertext, VAPID_PRIVATE_KEY_SETTING); + await overwriteSetting(VAPID_PRIVATE_KEY_SETTING, ciphertext); await svc.getKeys(); - testDb.prepare('UPDATE app_settings SET value = ? WHERE key = ?').run(UNREADABLE, VAPID_PRIVATE_KEY_SETTING); + await overwriteSetting(VAPID_PRIVATE_KEY_SETTING, UNREADABLE); await expect(svc.getKeys()).rejects.toThrow(PushUnavailableError); expect(logError).toHaveBeenCalledTimes(2); }); it('VKEY-008e: bootstrap over an unusable pair logs the reason once and does not throw', async () => { await make().getKeys(); - testDb.prepare('UPDATE app_settings SET value = ? WHERE key = ?').run(UNREADABLE, VAPID_PRIVATE_KEY_SETTING); + await overwriteSetting(VAPID_PRIVATE_KEY_SETTING, UNREADABLE); logError.mockClear(); await expect(make().onApplicationBootstrap()).resolves.toBeUndefined(); expect(logError).toHaveBeenCalledTimes(1); @@ -356,7 +363,7 @@ describe('VapidKeysService key pair', () => { it('VKEY-008f: a consistent environment pair still works while the stored one is unusable', async () => { await make().getKeys(); - testDb.prepare('UPDATE app_settings SET value = ? WHERE key = ?').run(UNREADABLE, VAPID_PRIVATE_KEY_SETTING); + await overwriteSetting(VAPID_PRIVATE_KEY_SETTING, UNREADABLE); const pair = generateVapidKeyPair(); vi.stubEnv('VAPID_PUBLIC_KEY', pair.publicKey); vi.stubEnv('VAPID_PRIVATE_KEY', pair.privateKey); @@ -364,7 +371,7 @@ describe('VapidKeysService key pair', () => { expect(await svc.getKeys()).toEqual({ ...pair, source: 'env' }); expect(await svc.isAvailable()).toBe(true); // The stored rows are still exactly what was there. - expect(setting(VAPID_PRIVATE_KEY_SETTING)).toBe(UNREADABLE); + expect(await setting(VAPID_PRIVATE_KEY_SETTING)).toBe(UNREADABLE); }); it('VKEY-008g: isAvailable only reads: it creates nothing, and never touches an unusable pair', async () => { @@ -372,17 +379,17 @@ describe('VapidKeysService key pair', () => { // Neither row yet: available, since the next send or subscribe creates the // pair, but nothing is written now (the preference matrix GET asks this). expect(await svc.isAvailable()).toBe(true); - expect(setting(VAPID_PUBLIC_KEY_SETTING)).toBeUndefined(); - expect(setting(VAPID_PRIVATE_KEY_SETTING)).toBeUndefined(); + expect(await setting(VAPID_PUBLIC_KEY_SETTING)).toBeUndefined(); + expect(await setting(VAPID_PRIVATE_KEY_SETTING)).toBeUndefined(); expect(logInfo).not.toHaveBeenCalled(); const { publicKey } = await svc.getKeys(); expect(await svc.isAvailable()).toBe(true); - testDb.prepare('UPDATE app_settings SET value = ? WHERE key = ?').run(UNREADABLE, VAPID_PRIVATE_KEY_SETTING); + await overwriteSetting(VAPID_PRIVATE_KEY_SETTING, UNREADABLE); expect(await svc.isAvailable()).toBe(false); expect(await svc.isAvailable()).toBe(false); - expect(setting(VAPID_PUBLIC_KEY_SETTING)).toBe(publicKey); - expect(setting(VAPID_PRIVATE_KEY_SETTING)).toBe(UNREADABLE); + expect(await setting(VAPID_PUBLIC_KEY_SETTING)).toBe(publicKey); + expect(await setting(VAPID_PRIVATE_KEY_SETTING)).toBe(UNREADABLE); expect(logError).toHaveBeenCalledTimes(1); }); diff --git a/server/tests/unit/nest/school-holidays.service.test.ts b/server/tests/unit/nest/school-holidays.service.test.ts index 09998c989..4e89edc00 100644 --- a/server/tests/unit/nest/school-holidays.service.test.ts +++ b/server/tests/unit/nest/school-holidays.service.test.ts @@ -21,6 +21,10 @@ import { createTestSchoolHolidayPeriodsRepo, createTestVacayHolidayCalendarsRepo, } from '../../helpers/school-holidays-repos'; import type { TestOrm } from '../../helpers/test-orm'; +import { deleteRows, findRows, insertRow } from '../../helpers/factories/rows'; +import { makeVacayPlan } from '../../helpers/factories/vacay'; +import { SchoolHolidayPeriods } from '../../../src/db/entities/SchoolHolidayPeriods.entity'; +import { VacayHolidayCalendars } from '../../../src/db/entities/VacayHolidayCalendars.entity'; import { SchoolHolidaysService } from '../../../src/nest/school-holidays/school-holidays.service'; const testDb = createSnapshotTestDb(); @@ -68,7 +72,9 @@ describe('catalog / region parity', () => { await seedRegion(); await svc.createCountry({ code: 'DE', name: 'Germany' }); + // test-sql-allow: the legacy statement is the oracle the service read is held to. const legacyCountries = testDb.prepare('SELECT code, name FROM school_holiday_countries ORDER BY name, code').all(); + // test-sql-allow: the legacy statement is the oracle the service read is held to. const legacyRegions = testDb.prepare("SELECT *, country || '-MANUAL-' || id AS code FROM school_holiday_regions ORDER BY name, id").all(); expect(await svc.catalog()).toEqual({ countries: legacyCountries, regions: legacyRegions }); @@ -77,8 +83,10 @@ describe('catalog / region parity', () => { it('SH-SVC-002: region() is full-key identical to the legacy region + periods SELECTs, including the synthesized code column', async () => { const region = await seedRegion(); + // test-sql-allow: the legacy statement is the oracle the service read is held to. const legacyRegion = testDb.prepare("SELECT *, country || '-MANUAL-' || id AS code FROM school_holiday_regions WHERE id = ?").get(region.id); const legacyHolidays = testDb + // test-sql-allow: the legacy statement is the oracle the service read is held to. .prepare('SELECT name, start_date AS startDate, end_date AS endDate FROM school_holiday_periods WHERE region_id = ? ORDER BY start_date, end_date, name') .all(region.id); @@ -150,8 +158,8 @@ describe('deleteRegion cross-domain integrity guard (existsForSchoolRegion)', () it('SH-SVC-009: refused while a vacay_holiday_calendars row of type school_holiday still references the region', async () => { const region = await seedRegion(); const { user } = createUser(testDb); - const plan = testDb.prepare('INSERT INTO vacay_plans (owner_id) VALUES (?)').run(user.id); - testDb.prepare("INSERT INTO vacay_holiday_calendars (plan_id, type, region) VALUES (?, 'school_holiday', ?)").run(plan.lastInsertRowid, region.code); + const plan = await makeVacayPlan(t, user.id); + await insertRow(t, VacayHolidayCalendars, { plan: plan.id, type: 'school_holiday', region: region.code }); await expect(svc.deleteRegion(region.id, region.revision)).rejects.toThrow(ConflictException); // Untouched — the region and its periods are still there. @@ -161,8 +169,8 @@ describe('deleteRegion cross-domain integrity guard (existsForSchoolRegion)', () it('SH-SVC-010: a calendar of a DIFFERENT type referencing the same code string does not block deletion (type is part of the guard)', async () => { const region = await seedRegion(); const { user } = createUser(testDb); - const plan = testDb.prepare('INSERT INTO vacay_plans (owner_id) VALUES (?)').run(user.id); - testDb.prepare("INSERT INTO vacay_holiday_calendars (plan_id, type, region) VALUES (?, 'public_holiday', ?)").run(plan.lastInsertRowid, region.code); + const plan = await makeVacayPlan(t, user.id); + await insertRow(t, VacayHolidayCalendars, { plan: plan.id, type: 'public_holiday', region: region.code }); await expect(svc.deleteRegion(region.id, region.revision)).resolves.toEqual({ success: true }); }); @@ -170,12 +178,12 @@ describe('deleteRegion cross-domain integrity guard (existsForSchoolRegion)', () it('SH-SVC-011: succeeds once no referencing calendar remains, and removes the region and its periods', async () => { const region = await seedRegion(); const { user } = createUser(testDb); - const plan = testDb.prepare('INSERT INTO vacay_plans (owner_id) VALUES (?)').run(user.id); - testDb.prepare("INSERT INTO vacay_holiday_calendars (plan_id, type, region) VALUES (?, 'school_holiday', ?)").run(plan.lastInsertRowid, region.code); - testDb.exec('DELETE FROM vacay_holiday_calendars'); + const plan = await makeVacayPlan(t, user.id); + await insertRow(t, VacayHolidayCalendars, { plan: plan.id, type: 'school_holiday', region: region.code }); + await deleteRows(t, VacayHolidayCalendars); await expect(svc.deleteRegion(region.id, region.revision)).resolves.toEqual({ success: true }); - expect(testDb.prepare('SELECT * FROM school_holiday_periods WHERE region_id = ?').all(region.id)).toEqual([]); + expect(await findRows(t, SchoolHolidayPeriods, { region: region.id })).toEqual([]); await expect(svc.region(region.id)).rejects.toThrow('not found'); }); }); diff --git a/server/tests/unit/nest/tours.service.test.ts b/server/tests/unit/nest/tours.service.test.ts index 62b258b0f..f00fa5dd3 100644 --- a/server/tests/unit/nest/tours.service.test.ts +++ b/server/tests/unit/nest/tours.service.test.ts @@ -27,6 +27,11 @@ import type { PlacesService } from '../../../src/nest/places/places.service'; import { resetTestDb } from '../../helpers/test-db'; import { createPlace, createTrip, createUser } from '../../helpers/factories'; import { createTestPlacesRepo, createTestUnitOfWork, sharedTestOrm } from '../../helpers/test-uow'; +import type { EntityClass } from '@mikro-orm/core'; +import { countRows, deleteRows, findRow, findRows } from '../../helpers/factories/rows'; +import { Places } from '../../../src/db/entities/Places.entity'; +import { Tours } from '../../../src/db/entities/Tours.entity'; +import { TourWaypoints } from '../../../src/db/entities/TourWaypoints.entity'; import { createTestToursRepo, createTestTourTypesRepo, createTestTourWaypointsRepo, createTour } from '../../helpers/tours-repos'; const request: TourCreateRequest = { @@ -52,7 +57,12 @@ let service: ToursService; let tripId: string; let otherTripId: string; -const count = (table: string) => (testDb.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get() as { n: number }).n; +const count = async (entity: EntityClass) => countRows(await sharedTestOrm(testDb), entity); + +/** The first stored place and tour, the only ones most cases leave behind. */ +const firstPlace = async () => (await findRows(await sharedTestOrm(testDb), Places, {}, { id: 'asc' }))[0]; +const firstTour = async () => (await findRows(await sharedTestOrm(testDb), Tours, {}, { place: 'asc' }))[0]; +const placeById = async (id: number) => findRow(await sharedTestOrm(testDb), Places, { id }); beforeAll(async () => { service = new ToursService( @@ -86,9 +96,14 @@ describe('ToursService planner creation', () => { const result = await service.createTour(tripId, request, 'socket-1'); const placeId = result.tour.place_id; - const place = testDb.prepare('SELECT * FROM places WHERE id = ?').get(placeId) as Record; - const tour = testDb.prepare('SELECT * FROM tours WHERE place_id = ?').get(placeId) as Record; - const points = testDb.prepare('SELECT role, sequence FROM tour_waypoints WHERE place_id = ? ORDER BY sequence').all(placeId); + const orm = await sharedTestOrm(testDb); + const place = await findRow(orm, Places, { id: placeId }); + const tour = await findRow(orm, Tours, { place: placeId }); + const points = (await findRows(orm, TourWaypoints, { place: placeId }, { sequence: 'asc' })).map((w) => ({ + role: w.role, + sequence: w.sequence, + })); + if (!place || !tour) throw new Error('createTour should have stored the place and its tour'); expect(place).toMatchObject({ trip_id: Number(tripId), name: 'Ridge walk', lat: 48, lng: 11, transport_mode: 'walking' }); expect(JSON.parse(String(place.route_geometry))).toEqual(request.route_geometry); @@ -110,9 +125,9 @@ describe('ToursService planner creation', () => { it('TOURS-SVC-002: rolls the owning place and facet back when a waypoint insert fails', async () => { await expect(service.createTour(tripId, duplicateSequence)).rejects.toThrow(); - expect(count('places')).toBe(0); - expect(count('tours')).toBe(0); - expect(count('tour_waypoints')).toBe(0); + expect(await count(Places)).toBe(0); + expect(await count(Tours)).toBe(0); + expect(await count(TourWaypoints)).toBe(0); expect(broadcast).not.toHaveBeenCalled(); }); @@ -121,10 +136,10 @@ describe('ToursService planner creation', () => { const result = await service.createTour(tripId, validated); - expect(count('places')).toBe(1); - expect(count('tours')).toBe(1); - expect(count('tour_waypoints')).toBe(2); - expect(testDb.prepare('SELECT duration FROM tours').get()).toEqual({ duration: 60 }); + expect(await count(Places)).toBe(1); + expect(await count(Tours)).toBe(1); + expect(await count(TourWaypoints)).toBe(2); + expect((await firstTour()).duration).toBe(60); expect(result.tour).toMatchObject({ name: 'Ridge walk', planned: false }); }); @@ -141,8 +156,8 @@ describe('ToursService planner creation', () => { await expect(service.createTour(tripId, bike)).rejects.toBeInstanceOf(BadRequestException); - expect(count('places')).toBe(0); - expect(count('tours')).toBe(0); + expect(await count(Places)).toBe(0); + expect(await count(Tours)).toBe(0); expect(broadcast).not.toHaveBeenCalled(); }); @@ -153,7 +168,8 @@ describe('ToursService planner creation', () => { await expect(service.updateTour(tripId, String(created.tour.place_id), bike)).rejects.toBeInstanceOf(BadRequestException); - expect(testDb.prepare('SELECT t.tour_type, p.name FROM tours t JOIN places p ON p.id = t.place_id').get()) + const storedTour = await firstTour(); + expect({ tour_type: storedTour.tour_type, name: (await placeById(Number(storedTour.place_id)))?.name }) .toEqual({ tour_type: 'hike', name: 'Ridge walk' }); expect(broadcast).not.toHaveBeenCalled(); }); @@ -167,8 +183,8 @@ describe('ToursService planner creation', () => { const result = await service.createTour(tripId, request); expect(result.tour.name).toBe('Ridge walk'); - expect(count('places')).toBe(1); - expect(count('tours')).toBe(1); + expect(await count(Places)).toBe(1); + expect(await count(Tours)).toBe(1); expect(broadcast).toHaveBeenCalledTimes(2); expect(warn).toHaveBeenCalledOnce(); }); @@ -192,7 +208,7 @@ describe('ToursService reads', () => { expect(await service.getTour(tripId, placeId)).toEqual(created); await expect(service.getTour(otherTripId, placeId)).rejects.toThrow('Tour not found'); - testDb.prepare('DELETE FROM tour_waypoints WHERE place_id = ?').run(created.tour.place_id); + await deleteRows(await sharedTestOrm(testDb), TourWaypoints, { place: created.tour.place_id }); const legacyGpx = await service.getTour(tripId, placeId); expect(legacyGpx.tour.has_waypoints).toBe(false); expect(legacyGpx.waypoints).toEqual([ @@ -200,7 +216,7 @@ describe('ToursService reads', () => { { lat: 48.02, lng: 11.04, role: 'end', sequence: 1 }, ]); // The fallback is read-only: nothing was backfilled. - expect(count('tour_waypoints')).toBe(0); + expect(await count(TourWaypoints)).toBe(0); }); it('TOURS-SVC-004b: a plain place, a malformed id and a geometry-less tour', async () => { @@ -257,10 +273,12 @@ describe('ToursService updates', () => { expect(result.tour).toMatchObject({ place_id: created.tour.place_id, name: 'Updated ridge walk' }); expect(result.waypoints).toEqual(update.waypoints); - expect(testDb.prepare('SELECT name, lat, lng, transport_mode FROM places').get()).toEqual({ + const storedPlace = await firstPlace(); + expect({ name: storedPlace.name, lat: storedPlace.lat, lng: storedPlace.lng, transport_mode: storedPlace.transport_mode }).toEqual({ name: 'Updated ridge walk', lat: 49, lng: 12, transport_mode: 'walking', }); - expect(testDb.prepare('SELECT duration, elevation_gain, elevation_loss FROM tours').get()).toEqual({ + const storedTour = await firstTour(); + expect({ duration: storedTour.duration, elevation_gain: storedTour.elevation_gain, elevation_loss: storedTour.elevation_loss }).toEqual({ duration: 45, elevation_gain: 60, elevation_loss: 0, }); expect(broadcast).toHaveBeenNthCalledWith(1, tripId, 'tours:changed', { placeIds: [created.tour.place_id] }, 'socket-2'); @@ -276,7 +294,7 @@ describe('ToursService updates', () => { await expect(service.updateTour(tripId, String(created.tour.place_id), { ...duplicateSequence, name: 'Must roll back' })).rejects.toThrow(); - expect(testDb.prepare('SELECT name FROM places').get()).toEqual({ name: 'Ridge walk' }); + expect((await firstPlace()).name).toBe('Ridge walk'); expect((await service.getTour(tripId, String(created.tour.place_id))).waypoints).toEqual(request.waypoints); expect(broadcast).not.toHaveBeenCalled(); }); @@ -287,7 +305,8 @@ describe('ToursService updates', () => { await expect(service.updateTour(tripId, String(created.tour.place_id), update, 'socket')).rejects.toThrow('Tour not found'); - expect(testDb.prepare('SELECT trip_id, name FROM places WHERE id = ?').get(created.tour.place_id)) + const untouched = await placeById(Number(created.tour.place_id)); + expect({ trip_id: untouched?.trip_id, name: untouched?.name }) .toEqual({ trip_id: Number(otherTripId), name: 'Ridge walk' }); expect((await service.getTour(otherTripId, String(created.tour.place_id))).waypoints).toEqual(request.waypoints); expect(broadcast).not.toHaveBeenCalled(); @@ -300,8 +319,8 @@ describe('ToursService updates', () => { await expect(service.updateTour(tripId, 'abc', update)).rejects.toThrow('Tour not found'); await expect(service.updateTour('abc', String(plain.id), update)).rejects.toThrow('Tour not found'); - expect(testDb.prepare('SELECT name FROM places WHERE id = ?').get(plain.id)).toEqual({ name: 'Plain' }); - expect(count('tours')).toBe(0); + expect((await placeById(plain.id))?.name).toBe('Plain'); + expect(await count(Tours)).toBe(0); expect(broadcast).not.toHaveBeenCalled(); }); });