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(); }); });