diff --git a/server/tests/integration/plugins/plugin-runtime.task2-conversion.test.ts b/server/tests/integration/plugins/plugin-runtime.task2-conversion.test.ts index 74fea082a..1ae7b02ab 100644 --- a/server/tests/integration/plugins/plugin-runtime.task2-conversion.test.ts +++ b/server/tests/integration/plugins/plugin-runtime.task2-conversion.test.ts @@ -28,6 +28,7 @@ import { UnitOfWork } from '../../../src/nest/database/unit-of-work'; import { createTestAddonsService } from '../../helpers/test-addons'; import { createSnapshotTestDb } from '../../helpers/db-mock'; import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; +import { countRows, findRow, insertRow, updateRows } from '../../helpers/factories/rows'; import { AuditLog } from '../../../src/db/entities/AuditLog.entity'; import { Users } from '../../../src/db/entities/Users.entity'; import { Plugins } from '../../../src/db/entities/Plugins.entity'; @@ -97,22 +98,28 @@ function buildPluginsService(): PluginsService { ); } -function seedPlugin(id: string, overrides: Partial<{ enabled: number; granted_permissions: string; permissions: string; trek_range: string; api_version: number; version: string; author_pubkey: string }> = {}) { - testDb - .prepare( - `INSERT INTO plugins (id, name, status, enabled, version, trek_range, api_version, permissions, granted_permissions, capabilities, config, author_pubkey) - VALUES (?, ?, 'inactive', ?, ?, ?, ?, ?, ?, '{}', '{}', ?)`, - ) - .run( - id, id, - overrides.enabled ?? 0, - overrides.version ?? '1.0.0', - overrides.trek_range ?? '>=3.0.0', - overrides.api_version ?? 1, - overrides.permissions ?? '["db:own"]', - overrides.granted_permissions ?? '["db:own"]', - overrides.author_pubkey ?? null, - ); +async function seedPlugin(id: string, overrides: Partial<{ enabled: number; granted_permissions: string; permissions: string; trek_range: string; api_version: number; version: string; author_pubkey: string }> = {}): Promise { + await insertRow(t, Plugins, { + id, + name: id, + status: 'inactive', + enabled: overrides.enabled ?? 0, + version: overrides.version ?? '1.0.0', + trek_range: overrides.trek_range ?? '>=3.0.0', + api_version: overrides.api_version ?? 1, + permissions: overrides.permissions ?? '["db:own"]', + granted_permissions: overrides.granted_permissions ?? '["db:own"]', + capabilities: '{}', + config: '{}', + author_pubkey: overrides.author_pubkey ?? null, + }); +} + +/** The plugin's stored row; fails the case when there is none. */ +async function pluginRow(id: string) { + const row = await findRow(t, Plugins, { id }); + if (!row) throw new Error(`no plugins row for ${id}`); + return row; } describe('Plan 3j Task 2 — R-install-gates named accept+refuse pairs (PR17/PR26/PR28)', () => { @@ -121,7 +128,7 @@ describe('Plan 3j Task 2 — R-install-gates named accept+refuse pairs (PR17/PR2 describe('PR17 — version-range + permission-grant gate (assertActivatable)', () => { it('INSTALL-GATE-PR17-ACCEPT: a plugin whose declared range admits this host, with no permission widening, passes the gate (activate resolves, never PluginDependencyError)', async () => { - seedPlugin('pr17-accept', { trek_range: '>=3.0.0 <99.0.0' }); + await seedPlugin('pr17-accept', { trek_range: '>=3.0.0 <99.0.0' }); // The gate itself (assertActivatable, reading PR17's row through // PluginsRepository.findActivationGate) is the thing under test — the actual // child spawn is stubbed out (already covered end to end by @@ -134,7 +141,7 @@ describe('Plan 3j Task 2 — R-install-gates named accept+refuse pairs (PR17/PR2 }); it('INSTALL-GATE-PR17-REFUSE: a plugin whose declared range excludes this host is refused, never spawned', async () => { - seedPlugin('pr17-refuse', { trek_range: '>=99.0.0' }); + await seedPlugin('pr17-refuse', { trek_range: '>=99.0.0' }); const err = await rt.activate('pr17-refuse').catch((e) => e); expect(err).toBeInstanceOf(PluginDependencyError); expect(err).toMatchObject({ code: 'TREK_VERSION_INCOMPATIBLE' }); @@ -147,13 +154,13 @@ describe('Plan 3j Task 2 — R-install-gates named accept+refuse pairs (PR17/PR2 ({ resolveVersion: vi.fn(async () => ({ version: '2.0.0' })), install: vi.fn(async (id: string) => { - testDb.prepare('UPDATE plugins SET permissions = ? WHERE id = ?').run(JSON.stringify(perms), id); + await updateRows(t, Plugins, { id }, { permissions: JSON.stringify(perms) }); return { id, version: '2.0.0' }; }), }) as unknown as import('../../../src/nest/plugins/registry/registry.service').PluginRegistryService; it('INSTALL-GATE-PR26-ACCEPT: no new permissions on update -> activated: true, PR26 snapshot read via the repository', async () => { - seedPlugin('pr26-accept2', { enabled: 1, permissions: '["db:own"]', granted_permissions: '["db:own"]' }); + await seedPlugin('pr26-accept2', { enabled: 1, permissions: '["db:own"]', granted_permissions: '["db:own"]' }); const audit = new AuditService(t.repo(AuditLog), t.repo(Users)); const addons = await createTestAddonsService(testDb); const userSettings = new PluginUserSettingsService(t.repo(PluginSettingsFields), t.repo(PluginUserConfig)); @@ -175,7 +182,7 @@ describe('Plan 3j Task 2 — R-install-gates named accept+refuse pairs (PR17/PR2 }); it('INSTALL-GATE-PR26-REFUSE: a widened permission set on update leaves the plugin inactive with the delta reported', async () => { - seedPlugin('pr26-refuse', { enabled: 1, permissions: '["db:own"]', granted_permissions: '["db:own"]' }); + await seedPlugin('pr26-refuse', { enabled: 1, permissions: '["db:own"]', granted_permissions: '["db:own"]' }); const audit = new AuditService(t.repo(AuditLog), t.repo(Users)); const addons = await createTestAddonsService(testDb); const userSettings = new PluginUserSettingsService(t.repo(PluginSettingsFields), t.repo(PluginUserConfig)); @@ -203,13 +210,13 @@ describe('Plan 3j Task 2 — R-install-gates named accept+refuse pairs (PR17/PR2 ({ assertRetrustable: vi.fn(async (_id: string, key: string) => ({ authorPublicKey: key })), install: vi.fn(async (_id: string, opts?: { retrustKey?: string }) => { - testDb.prepare("UPDATE plugins SET author_pubkey = ?, version = '2.0.0' WHERE id = ?").run(opts?.retrustKey, id); + await updateRows(t, Plugins, { id }, { author_pubkey: opts?.retrustKey, version: '2.0.0' }); return { id, version: '2.0.0' }; }), }) as unknown as import('../../../src/nest/plugins/registry/registry.service').PluginRegistryService; it('INSTALL-GATE-PR28-ACCEPT: a genuinely rotated key is pinned, read through PluginsRepository.findAuthorPubkey', async () => { - seedPlugin('pr28-accept', { enabled: 1, author_pubkey: 'OLDKEY' }); + await seedPlugin('pr28-accept', { enabled: 1, author_pubkey: 'OLDKEY' }); const audit = new AuditService(t.repo(AuditLog), t.repo(Users)); const addons = await createTestAddonsService(testDb); const userSettings = new PluginUserSettingsService(t.repo(PluginSettingsFields), t.repo(PluginUserConfig)); @@ -228,12 +235,12 @@ describe('Plan 3j Task 2 — R-install-gates named accept+refuse pairs (PR17/PR2 vi.spyOn((rtR as any).supervisor, 'disable').mockResolvedValue(undefined); await rtR.retrust('pr28-accept', '2.0.0', 'NEWKEY', { userId: 1 }); expect(registry.assertRetrustable).toHaveBeenCalledWith('pr28-accept', 'NEWKEY'); - const row = testDb.prepare("SELECT author_pubkey FROM plugins WHERE id='pr28-accept'").get() as { author_pubkey: string }; + const row = await pluginRow('pr28-accept'); expect(row.author_pubkey).toBe('NEWKEY'); }); it('INSTALL-GATE-PR28-REFUSE: an invalid signature is not re-trustable — the pinned key is untouched', async () => { - seedPlugin('pr28-refuse', { enabled: 1, author_pubkey: 'OLDKEY' }); + await seedPlugin('pr28-refuse', { enabled: 1, author_pubkey: 'OLDKEY' }); const audit = new AuditService(t.repo(AuditLog), t.repo(Users)); const addons = await createTestAddonsService(testDb); const userSettings = new PluginUserSettingsService(t.repo(PluginSettingsFields), t.repo(PluginUserConfig)); @@ -249,7 +256,7 @@ describe('Plan 3j Task 2 — R-install-gates named accept+refuse pairs (PR17/PR2 ); await expect(rtR.retrust('pr28-refuse', '2.0.0', 'NEWKEY', { userId: 1 })).rejects.toThrow(/nothing to re-trust/); expect(registry.install).not.toHaveBeenCalled(); - const row = testDb.prepare("SELECT author_pubkey FROM plugins WHERE id='pr28-refuse'").get() as { author_pubkey: string }; + const row = await pluginRow('pr28-refuse'); expect(row.author_pubkey).toBe('OLDKEY'); }); }); @@ -259,11 +266,11 @@ describe('R-uninstall: the cascade is one transaction', () => { it('CASCADE-PARTIAL-001: a failure partway through the cascade rolls every earlier delete back, and the data directory stays', async () => { const rt = await buildRuntime(); const id = 'cascade-partial'; - seedPlugin(id); - testDb.prepare("INSERT INTO plugin_settings_fields (plugin_id, field_key, scope) VALUES (?, 'k', 'instance')").run(id); - testDb.prepare("INSERT INTO plugin_scheduled_tasks (plugin_id, name, due_at) VALUES (?, 'poll', 0)").run(id); - testDb.prepare("INSERT INTO plugin_error_log (plugin_id, level, message) VALUES (?, 'error', 'x')").run(id); - testDb.prepare("INSERT INTO plugin_entity_metadata (plugin_id, entity_type, entity_id, key, value) VALUES (?, 'place', 1, 'k', 'v')").run(id); + await seedPlugin(id); + await insertRow(t, PluginSettingsFields, { plugin_id: id, field_key: 'k', scope: 'instance' }); + await insertRow(t, PluginScheduledTasks, { plugin_id: id, name: 'poll', due_at: 0 }); + await insertRow(t, PluginErrorLog, { plugin_id: id, level: 'error', message: 'x' }); + await insertRow(t, PluginEntityMetadata, { plugin_id: id, entity_type: 'place', entity_id: 1, key: 'k', value: 'v' }); // Simulate a crash INSIDE the cascade, between plugin_scheduled_tasks (deletes // cleanly, unconditional, no try/catch) and plugin_error_log (the next @@ -278,15 +285,16 @@ describe('R-uninstall: the cascade is one transaction', () => { await expect(rt.uninstall(id, true)).rejects.toThrow(/simulated mid-cascade crash/); spy.mockRestore(); - // Everything before the simulated crash point was rolled back with it: a half - // uninstall used to leave settings, tasks and metadata for a later plugin that - // reuses the id to inherit. - for (const table of ['plugin_settings_fields', 'plugin_scheduled_tasks', 'plugin_error_log', 'plugin_entity_metadata']) { - expect(testDb.prepare(`SELECT COUNT(*) c FROM ${table} WHERE plugin_id=?`).get(id)).toMatchObject({ c: 1 }); - } - expect(testDb.prepare("SELECT COUNT(*) c FROM plugins WHERE id=?").get(id)).toMatchObject({ c: 1 }); - // The data directory goes only after the rows commit. - expect(fs.existsSync(dataDir)).toBe(true); + // Everything before the simulated crash point was rolled back with it: a half + // uninstall used to leave settings, tasks and metadata for a later plugin that + // reuses the id to inherit. + expect(await countRows(t, PluginSettingsFields, { plugin_id: id })).toBe(1); + expect(await countRows(t, PluginScheduledTasks, { plugin_id: id })).toBe(1); + expect(await countRows(t, PluginErrorLog, { plugin_id: id })).toBe(1); + expect(await countRows(t, PluginEntityMetadata, { plugin_id: id })).toBe(1); + expect(await countRows(t, Plugins, { id })).toBe(1); + // The data directory goes only after the rows commit. + expect(fs.existsSync(dataDir)).toBe(true); }); }); @@ -294,8 +302,8 @@ describe('Plan 3j Task 2 — R-uninstall\'s ONE named transaction: PR22/PR23 egr it('EGRESS-TX-ROLLBACK-001: a simulated failure mid-write leaves the OLD egress-host set intact, not partially cleared', async () => { const rt = await buildRuntime(); const id = 'egress-tx'; - seedPlugin(id, { permissions: '["http:outbound"]', granted_permissions: '["http:outbound"]' }); - testDb.prepare("UPDATE plugins SET operator_egress = 1 WHERE id = ?").run(id); + await seedPlugin(id, { permissions: '["http:outbound"]', granted_permissions: '["http:outbound"]' }); + await updateRows(t, Plugins, { id }, { operator_egress: 1 }); await rt.setOperatorEgressHosts(id, ['old-a.example.com', 'old-b.example.com']); expect((await rt.operatorEgressHosts(id)).sort()).toEqual(['old-a.example.com', 'old-b.example.com']); @@ -329,9 +337,9 @@ describe('Plan 3j Task 2 — PS7/PS9/PS11: the triple-duplicate SELECT collapses it('PS-USERCONFIG-PARITY-001: getUserConfig, setUserConfig\'s pre-write check and getUserConfigDecrypted all read the SAME stored row identically', async () => { const svc = buildPluginsService(); const id = 'ps-parity'; - seedPlugin(id); - testDb.prepare("INSERT INTO plugin_settings_fields (plugin_id, field_key, scope, secret) VALUES (?, 'apiKey', 'user', 1)").run(id); - testDb.prepare("INSERT INTO plugin_settings_fields (plugin_id, field_key, scope, secret) VALUES (?, 'units', 'user', 0)").run(id); + await seedPlugin(id); + await insertRow(t, PluginSettingsFields, { plugin_id: id, field_key: 'apiKey', scope: 'user', secret: 1 }); + await insertRow(t, PluginSettingsFields, { plugin_id: id, field_key: 'units', scope: 'user', secret: 0 }); await svc.updateUserConfig(id, 7, { apiKey: 'sk-live', units: 'metric' }); @@ -362,8 +370,8 @@ describe('Plan 3j Task 2 — two racing ticks (scheduler claim/re-arm + erasure it('RACE-SCHED-001: two concurrent fireDueScheduled passes deliver a due one-shot task exactly once', async () => { const pluginId = 'race-sched'; - seedPlugin(pluginId, { enabled: 1 }); - testDb.prepare('INSERT INTO plugin_scheduled_tasks (plugin_id, name, due_at) VALUES (?, ?, ?)').run(pluginId, 'once', Date.now() - 1000); + await seedPlugin(pluginId, { enabled: 1 }); + await insertRow(t, PluginScheduledTasks, { plugin_id: pluginId, name: 'once', due_at: Date.now() - 1000 }); // eslint-disable-next-line @typescript-eslint/no-explicit-any const supervisor = (rt as any).supervisor; @@ -381,8 +389,8 @@ describe('Plan 3j Task 2 — two racing ticks (scheduler claim/re-arm + erasure // eslint-disable-next-line @typescript-eslint/no-explicit-any await Promise.all([(rt as any).fireDueScheduled(), (rt as any).fireDueScheduled()]); expect(delivered).toBe(1); // claimed (re-armed/deleted) by exactly one pass - const row = testDb.prepare('SELECT id FROM plugin_scheduled_tasks WHERE plugin_id=? AND name=?').get(pluginId, 'once'); - expect(row).toBeUndefined(); // deleted, not left dangling or double-inserted + const row = await findRow(t, PluginScheduledTasks, { plugin_id: pluginId, name: 'once' }); + expect(row).toBeNull(); // deleted, not left dangling or double-inserted } finally { invokeSpy.mockRestore(); supervisor.running.delete(pluginId); @@ -395,9 +403,9 @@ describe('Plan 3j Task 2 — two racing ticks (scheduler claim/re-arm + erasure // (`rearm`'s claim guard) went untested, and both mutations dropping IT survived // (M6/M6b). Same shape as RACE-SCHED-001, on a task with `every_ms` set instead. const pluginId = 'race-sched-recur'; - seedPlugin(pluginId, { enabled: 1 }); + await seedPlugin(pluginId, { enabled: 1 }); const everyMs = 60_000; - testDb.prepare('INSERT INTO plugin_scheduled_tasks (plugin_id, name, due_at, every_ms) VALUES (?, ?, ?, ?)').run(pluginId, 'recurring', Date.now() - 1000, everyMs); + await insertRow(t, PluginScheduledTasks, { plugin_id: pluginId, name: 'recurring', due_at: Date.now() - 1000, every_ms: everyMs }); // eslint-disable-next-line @typescript-eslint/no-explicit-any const supervisor = (rt as any).supervisor; @@ -419,8 +427,8 @@ describe('Plan 3j Task 2 — two racing ticks (scheduler claim/re-arm + erasure // `claimed = true` and both invoke — this is the assertion RACE-SCHED-001's own // shape proves for the one-shot branch, now proven for the recurring one. expect(delivered).toBe(1); - const row = testDb.prepare('SELECT due_at FROM plugin_scheduled_tasks WHERE plugin_id=? AND name=?').get(pluginId, 'recurring') as { due_at: number } | undefined; - expect(row).toBeDefined(); // a recurring task is re-armed, never deleted + const row = await findRow(t, PluginScheduledTasks, { plugin_id: pluginId, name: 'recurring' }); + expect(row).not.toBeNull(); // a recurring task is re-armed, never deleted expect(row?.due_at).toBeGreaterThanOrEqual(before + everyMs); // moved forward, not left due in the past } finally { invokeSpy.mockRestore(); @@ -430,8 +438,8 @@ describe('Plan 3j Task 2 — two racing ticks (scheduler claim/re-arm + erasure it('RACE-ERASURE-001: two concurrent drains coalesce onto the SAME in-flight pass (drainUserErasures\' own promise-sharing) — one ACK, one drop', async () => { const pluginId = 'race-erasure'; - seedPlugin(pluginId, { enabled: 1 }); - testDb.prepare('INSERT INTO plugin_user_erasure_queue (plugin_id, user_id) VALUES (?, ?)').run(pluginId, 99); + await seedPlugin(pluginId, { enabled: 1 }); + await insertRow(t, PluginUserErasureQueue, { plugin_id: pluginId, user_id: 99 }); // eslint-disable-next-line @typescript-eslint/no-explicit-any const supervisor = (rt as any).supervisor; @@ -447,8 +455,7 @@ describe('Plan 3j Task 2 — two racing ticks (scheduler claim/re-arm + erasure // eslint-disable-next-line @typescript-eslint/no-explicit-any await Promise.all([(rt as any).drainUserErasures(), (rt as any).drainUserErasures()]); expect(delivered).toBe(1); // drainUserErasures' own in-flight coalescing, unchanged by this task's conversion - const row = testDb.prepare('SELECT id FROM plugin_user_erasure_queue WHERE plugin_id=? AND user_id=?').get(pluginId, 99); - expect(row).toBeUndefined(); + expect(await findRow(t, PluginUserErasureQueue, { plugin_id: pluginId, user_id: 99 })).toBeNull(); } finally { deliverSpy.mockRestore(); supervisor.running.delete(pluginId); diff --git a/server/tests/unit/mcp/tools-atlas-expanded.test.ts b/server/tests/unit/mcp/tools-atlas-expanded.test.ts index c7455426e..48e8844e9 100644 --- a/server/tests/unit/mcp/tools-atlas-expanded.test.ts +++ b/server/tests/unit/mcp/tools-atlas-expanded.test.ts @@ -27,6 +27,17 @@ import { createUser, createBucketListItem, createVisitedCountry, createTrip, cre 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, upsertRow } from '../../helpers/factories/rows'; +import { makePlace } from '../../helpers/factories/places'; +import { makeTrip } from '../../helpers/factories/trips'; +import { markCountryVisited } from '../../helpers/factories/atlas'; +import { BucketList } from '../../../src/db/entities/BucketList.entity'; +import { HiddenRegions } from '../../../src/db/entities/HiddenRegions.entity'; +import { PlaceRegions } from '../../../src/db/entities/PlaceRegions.entity'; +import { Places } from '../../../src/db/entities/Places.entity'; +import { ReservationEndpoints } from '../../../src/db/entities/ReservationEndpoints.entity'; +import { VisitedRegions } from '../../../src/db/entities/VisitedRegions.entity'; beforeEach(() => { setAddonEnabled(testDb, ADDON_IDS.ATLAS, true); @@ -35,7 +46,14 @@ beforeEach(() => { delete process.env.DEMO_MODE; }); -afterAll(() => { +let orm: TestOrm; + +beforeAll(async () => { + orm = await createTestOrm(testDb); +}); + +afterAll(async () => { + await orm.close(); testDb.close(); }); @@ -53,25 +71,32 @@ async function withResourceHarness(userId: number, fn: (h: McpHarness) => Promis } // A place carrying an address and coordinates, which createPlace does not write. -function insertPlace(tripId: number, name: string, address: string | null, lat: number, lng: number): number { - const r = testDb - .prepare('INSERT INTO places (trip_id, name, address, lat, lng) VALUES (?, ?, ?, ?, ?)') - .run(tripId, name, address, lat, lng); - return r.lastInsertRowid as number; +async function insertPlace(tripId: number, name: string, address: string | null, lat: number, lng: number): Promise { + return (await makePlace(orm, tripId, { name, address, lat, lng, category: null })).id; } // The geocoder's answer for a place, pre-seeded so visitedRegions() reads the cache // and never reaches for Nominatim (same trick as ATLAS-UNIT-020). -function cacheRegion(placeId: number, countryCode: string, regionCode: string, regionName: string): void { - testDb - .prepare('INSERT OR REPLACE INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, ?, ?, ?)') - .run(placeId, countryCode, regionCode, regionName); +async function cacheRegion(placeId: number, countryCode: string, regionCode: string, regionName: string): Promise { + await upsertRow(orm, PlaceRegions, { place: placeId, country_code: countryCode, region_code: regionCode, region_name: regionName }); } -function insertEndpoint(reservationId: number, role: 'from' | 'to', sequence: number, lat: number, lng: number): void { - testDb - .prepare('INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, lat, lng) VALUES (?, ?, ?, ?, ?, ?)') - .run(reservationId, role, sequence, `Endpoint ${sequence}`, lat, lng); +async function insertEndpoint(reservationId: number, role: 'from' | 'to', sequence: number, lat: number, lng: number): Promise { + await insertRow(orm, ReservationEndpoints, { reservation: reservationId, role, sequence, name: `Endpoint ${sequence}`, lat, lng }); +} + +/** Marks a region by hand, the way the region route stores it. */ +async function markRegion(userId: number, regionCode: string, regionName: string, countryCode: string): Promise { + await insertRow(orm, VisitedRegions, { user: userId, region_code: regionCode, region_name: regionName, country_code: countryCode }); +} + +/** A wish with no coordinates; returns its id. */ +async function insertWish(userId: number, name: string, targetDate?: string): Promise { + return insertRow(orm, BucketList, { + user: userId, + name, + ...(targetDate !== undefined ? { target_date: targetDate } : { lat: null, lng: null }), + }); } // Trips have to sit in the past for their countries and regions to count as @@ -105,10 +130,10 @@ describe('Tool: get_atlas_stats', () => { it('carries the passport figures REST answers with: the cities by name and the distance flown', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Rome', start_date: PAST_START, end_date: PAST_END }); - insertPlace(trip.id, 'Colosseum', 'Colosseum, Rome, Italy', 41.8902, 12.4922); + await insertPlace(trip.id, 'Colosseum', 'Colosseum, Rome, Italy', 41.8902, 12.4922); const flight = createReservation(testDb, trip.id, { type: 'flight', title: 'FCO-JFK' }); - insertEndpoint(flight.id, 'from', 0, 41.8003, 12.2389); - insertEndpoint(flight.id, 'to', 1, 40.6413, -73.7781); + await insertEndpoint(flight.id, 'from', 0, 41.8003, 12.2389); + await insertEndpoint(flight.id, 'to', 1, 40.6413, -73.7781); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'get_atlas_stats', arguments: {} }); @@ -120,8 +145,7 @@ describe('Tool: get_atlas_stats', () => { expect(data.travel.countries).toContain('US'); // Rome to New York, so a four-figure number rather than a rounding artefact. expect(data.travel.totalDistanceKm).toBeGreaterThan(6000); - const places = testDb.prepare('SELECT COUNT(*) AS c FROM places WHERE trip_id = ?').get(trip.id) as { c: number }; - expect(data.travel.totalPlaces).toBe(places.c); + expect(data.travel.totalPlaces).toBe(await countRows(orm, Places, { trip: trip.id })); // Rendering data stays out unless asked for. expect(data.travel.coords).toBeUndefined(); }); @@ -130,16 +154,14 @@ describe('Tool: get_atlas_stats', () => { it('include_coords adds the per-place coordinates the dashboard map plots', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Rome', start_date: PAST_START, end_date: PAST_END }); - insertPlace(trip.id, 'Colosseum', 'Colosseum, Rome, Italy', 41.8902, 12.4922); - insertPlace(trip.id, 'Pantheon', 'Pantheon, Rome, Italy', 41.8986, 12.4769); + await insertPlace(trip.id, 'Colosseum', 'Colosseum, Rome, Italy', 41.8902, 12.4922); + await insertPlace(trip.id, 'Pantheon', 'Pantheon, Rome, Italy', 41.8986, 12.4769); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'get_atlas_stats', arguments: { include_coords: true } }); const data = parseToolResult(result) as any; - const withCoords = testDb - .prepare('SELECT COUNT(*) AS c FROM places WHERE trip_id = ? AND lat IS NOT NULL AND lng IS NOT NULL') - .get(trip.id) as { c: number }; - expect(data.travel.coords).toHaveLength(withCoords.c); + const withCoords = await countRows(orm, Places, { trip: trip.id, lat: { $ne: null }, lng: { $ne: null } }); + expect(data.travel.coords).toHaveLength(withCoords); expect(data.travel.coords[0]).toEqual({ lat: 41.8902, lng: 12.4922 }); }); }); @@ -170,9 +192,7 @@ describe('Tool: list_visited_regions', () => { it('groups a manual mark under its country, in the shape the map reads', async () => { const { user } = createUser(testDb); - testDb.prepare( - 'INSERT INTO visited_regions (user_id, region_code, region_name, country_code) VALUES (?, ?, ?, ?)' - ).run(user.id, 'FR-75', 'Paris', 'FR'); + await markRegion(user.id, 'FR-75', 'Paris', 'FR'); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'list_visited_regions', arguments: {} }); const data = parseToolResult(result) as any; @@ -185,8 +205,8 @@ describe('Tool: list_visited_regions', () => { it('reports regions derived from trip places, which the manual table never held', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Rome', start_date: PAST_START, end_date: PAST_END }); - cacheRegion(insertPlace(trip.id, 'Colosseum', null, 41.8902, 12.4922), 'IT', 'IT-62', 'Lazio'); - cacheRegion(insertPlace(trip.id, 'Pantheon', null, 41.8986, 12.4769), 'IT', 'IT-62', 'Lazio'); + await cacheRegion(await insertPlace(trip.id, 'Colosseum', null, 41.8902, 12.4922), 'IT', 'IT-62', 'Lazio'); + await cacheRegion(await insertPlace(trip.id, 'Pantheon', null, 41.8986, 12.4769), 'IT', 'IT-62', 'Lazio'); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'list_visited_regions', arguments: {} }); @@ -194,22 +214,19 @@ describe('Tool: list_visited_regions', () => { expect(data.regions.IT).toEqual([{ code: 'IT-62', name: 'Lazio', placeCount: 2, status: 'visited' }]); // Nothing was ever marked by hand, so the old manual-only read had no row // to answer with at all. - const marked = testDb.prepare('SELECT COUNT(*) AS c FROM visited_regions WHERE user_id = ?').get(user.id) as { c: number }; - expect(marked.c).toBe(0); + expect(await countRows(orm, VisitedRegions, { user: user.id })).toBe(0); }); }); it('drops a region the user dismissed, matching the map', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Rome', start_date: PAST_START, end_date: PAST_END }); - cacheRegion(insertPlace(trip.id, 'Colosseum', null, 41.8902, 12.4922), 'IT', 'IT-62', 'Lazio'); + await cacheRegion(await insertPlace(trip.id, 'Colosseum', null, 41.8902, 12.4922), 'IT', 'IT-62', 'Lazio'); await withHarness(user.id, async (h) => { const unmark = await h.client.callTool({ name: 'unmark_region_visited', arguments: { regionCode: 'IT-62' } }); expect(parseToolResult(unmark)).toEqual({ success: true }); - const tombstone = testDb - .prepare('SELECT region_code FROM hidden_regions WHERE user_id = ? AND region_code = ?') - .get(user.id, 'IT-62'); + const tombstone = await findRow(orm, HiddenRegions, { user: user.id, region_code: 'IT-62' }); expect(tombstone).toBeTruthy(); const result = await h.client.callTool({ name: 'list_visited_regions', arguments: {} }); @@ -257,10 +274,11 @@ describe('Tool: locate_atlas_region', () => { countryCode: located.country_code, }, }); - const row = testDb - .prepare('SELECT region_code, country_code FROM visited_regions WHERE user_id = ?') - .get(user.id) as { region_code: string; country_code: string }; - expect(row).toEqual({ region_code: located.region_code, country_code: 'IT' }); + const row = await findRow(orm, VisitedRegions, { user: user.id }); + expect({ region_code: row?.region_code, country_code: row?.country_code }).toEqual({ + region_code: located.region_code, + country_code: 'IT', + }); }); }); @@ -309,8 +327,7 @@ describe('Tool: mark_region_visited', () => { arguments: { regionCode: 'jp-13' }, }); expect(parseToolResult(unmark)).toEqual({ success: true }); - const row = testDb.prepare('SELECT 1 FROM visited_regions WHERE user_id = ?').get(user.id); - expect(row).toBeUndefined(); + expect(await findRow(orm, VisitedRegions, { user: user.id })).toBeNull(); }); }); @@ -328,7 +345,7 @@ describe('Tool: mark_region_visited', () => { expect(data.region.name).toBe('California'); expect(data.region.country_code).toBe('US'); expect(data.region.manuallyMarked).toBe(true); - const row = testDb.prepare('SELECT * FROM visited_regions WHERE user_id = ? AND region_code = ?').get(user.id, 'US-CA'); + const row = await findRow(orm, VisitedRegions, { user: user.id, region_code: 'US-CA' }); expect(row).toBeTruthy(); }); }); @@ -353,9 +370,7 @@ describe('Tool: mark_region_visited', () => { describe('Tool: unmark_region_visited', () => { it('removes region and returns success', async () => { const { user } = createUser(testDb); - testDb.prepare( - 'INSERT INTO visited_regions (user_id, region_code, region_name, country_code) VALUES (?, ?, ?, ?)' - ).run(user.id, 'IT-LO', 'Lombardy', 'IT'); + await markRegion(user.id, 'IT-LO', 'Lombardy', 'IT'); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'unmark_region_visited', @@ -363,8 +378,7 @@ describe('Tool: unmark_region_visited', () => { }); const data = parseToolResult(result) as any; expect(data.success).toBe(true); - const row = testDb.prepare('SELECT * FROM visited_regions WHERE user_id = ? AND region_code = ?').get(user.id, 'IT-LO'); - expect(row).toBeUndefined(); + expect(await findRow(orm, VisitedRegions, { user: user.id, region_code: 'IT-LO' })).toBeNull(); }); }); @@ -401,9 +415,9 @@ describe('Tool: get_country_atlas_places', () => { it('uppercases the country code like REST does (post-fold quirk fix)', async () => { const { user } = createUser(testDb); - testDb.prepare('INSERT INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(user.id, 'JP'); + await markCountryVisited(orm, user.id, 'JP'); // A trip so countryPlaces reaches the visited_countries lookup pre-quirk-fix too. - testDb.prepare('INSERT INTO trips (user_id, title) VALUES (?, ?)').run(user.id, 'Japan'); + await makeTrip(orm, user.id, { title: 'Japan' }); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'get_country_atlas_places', @@ -423,10 +437,7 @@ describe('Tool: get_country_atlas_places', () => { describe('Tool: update_bucket_list_item', () => { it('updates notes and returns item', async () => { const { user } = createUser(testDb); - const r = testDb.prepare( - 'INSERT INTO bucket_list (user_id, name, lat, lng) VALUES (?, ?, NULL, NULL)' - ).run(user.id, 'Visit Tokyo'); - const itemId = r.lastInsertRowid as number; + const itemId = await insertWish(user.id, 'Visit Tokyo'); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'update_bucket_list_item', @@ -440,10 +451,7 @@ describe('Tool: update_bucket_list_item', () => { it('updates name of existing item', async () => { const { user } = createUser(testDb); - const r = testDb.prepare( - 'INSERT INTO bucket_list (user_id, name, lat, lng) VALUES (?, ?, NULL, NULL)' - ).run(user.id, 'Old Name'); - const itemId = r.lastInsertRowid as number; + const itemId = await insertWish(user.id, 'Old Name'); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'update_bucket_list_item', @@ -467,27 +475,23 @@ describe('Tool: update_bucket_list_item', () => { it('returns isError when the edit would land on another wish (#1898)', async () => { const { user } = createUser(testDb); - const insert = testDb.prepare('INSERT INTO bucket_list (user_id, name, target_date) VALUES (?, ?, ?)'); - insert.run(user.id, 'Japan', '2027-05'); - const itemId = insert.run(user.id, 'Japan', '2028-09').lastInsertRowid as number; + await insertWish(user.id, 'Japan', '2027-05'); + const itemId = await insertWish(user.id, 'Japan', '2028-09'); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'update_bucket_list_item', arguments: { itemId, target_date: '2027-05' }, }); expect(result.isError).toBe(true); - const row = testDb.prepare('SELECT target_date FROM bucket_list WHERE id = ?').get(itemId) as { target_date: string }; - expect(row.target_date).toBe('2028-09'); + const row = await findRow(orm, BucketList, { id: itemId }); + expect(row?.target_date).toBe('2028-09'); }); }); it('blocks demo user', async () => { process.env.DEMO_MODE = 'true'; const { user } = createUser(testDb, { email: 'demo@nomad.app' }); - const r = testDb.prepare( - 'INSERT INTO bucket_list (user_id, name, lat, lng) VALUES (?, ?, NULL, NULL)' - ).run(user.id, 'Bucket Item'); - const itemId = r.lastInsertRowid as number; + const itemId = await insertWish(user.id, 'Bucket Item'); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'update_bucket_list_item', @@ -529,11 +533,9 @@ describe('Resource: trek://atlas/regions', () => { it('returns marked and derived regions together, as the tool does', async () => { const { user } = createUser(testDb); - testDb.prepare( - 'INSERT INTO visited_regions (user_id, region_code, region_name, country_code) VALUES (?, ?, ?, ?)' - ).run(user.id, 'ES-CT', 'Catalonia', 'ES'); + await markRegion(user.id, 'ES-CT', 'Catalonia', 'ES'); const trip = createTrip(testDb, user.id, { title: 'Rome', start_date: PAST_START, end_date: PAST_END }); - cacheRegion(insertPlace(trip.id, 'Colosseum', null, 41.8902, 12.4922), 'IT', 'IT-62', 'Lazio'); + await cacheRegion(await insertPlace(trip.id, 'Colosseum', null, 41.8902, 12.4922), 'IT', 'IT-62', 'Lazio'); await withResourceHarness(user.id, async (h) => { const result = await h.client.readResource({ uri: 'trek://atlas/regions' }); diff --git a/server/tests/unit/mcp/tools-notes.test.ts b/server/tests/unit/mcp/tools-notes.test.ts index 0e06fee0c..c7960ace8 100644 --- a/server/tests/unit/mcp/tools-notes.test.ts +++ b/server/tests/unit/mcp/tools-notes.test.ts @@ -32,6 +32,12 @@ vi.mock('fs', async (importOriginal) => { import { resetTestDb } from '../../helpers/test-db'; import { createUser, createTrip, createDay, createDayNote, createCollabNote } from '../../helpers/factories'; import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness'; +import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; +import { countRows, findRow, updateRows } from '../../helpers/factories/rows'; +import { makeTripFile } from '../../helpers/factories/files'; +import { CollabNotes } from '../../../src/db/entities/CollabNotes.entity'; +import { DayNotes } from '../../../src/db/entities/DayNotes.entity'; +import { TripFiles } from '../../../src/db/entities/TripFiles.entity'; beforeEach(() => { resetTestDb(testDb); @@ -40,10 +46,31 @@ beforeEach(() => { delete process.env.DEMO_MODE; }); -afterAll(() => { +let orm: TestOrm; + +beforeAll(async () => { + orm = await createTestOrm(testDb); +}); + +afterAll(async () => { + await orm.close(); testDb.close(); }); +/** The stored day note; fails the case when it is gone. */ +async function dayNote(id: number) { + const row = await findRow(orm, DayNotes, { id }); + if (!row) throw new Error(`no day note ${id}`); + return row; +} + +/** The stored collab note; fails the case when it is gone. */ +async function collabNote(id: number) { + const row = await findRow(orm, CollabNotes, { id }); + if (!row) throw new Error(`no collab note ${id}`); + return row; +} + async function withHarness(userId: number, fn: (h: McpHarness) => Promise) { const h = await createMcpHarness({ userId, withResources: false }); try { await fn(h); } finally { await h.cleanup(); } @@ -104,7 +131,7 @@ describe('Tool: create_day_note', () => { arguments: { tripId: trip.id, dayId: day.id, text: 'Ferry leaves early', color: '#dc2626', sort_order: 2 }, }); const data = parseToolResult(result) as any; - const row = testDb.prepare('SELECT color, sort_order FROM day_notes WHERE id = ?').get(data.note.id) as any; + const row = await dayNote(data.note.id); expect(row.color).toBe('#dc2626'); expect(row.sort_order).toBe(2); }); @@ -120,7 +147,7 @@ describe('Tool: create_day_note', () => { arguments: { tripId: trip.id, dayId: day.id, text: 'A note' }, }); const data = parseToolResult(result) as any; - const row = testDb.prepare('SELECT color, sort_order FROM day_notes WHERE id = ?').get(data.note.id) as any; + const row = await dayNote(data.note.id); expect(row.color).toBeNull(); expect(row.sort_order).toBe(9999); }); @@ -136,7 +163,7 @@ describe('Tool: create_day_note', () => { arguments: { tripId: trip.id, dayId: day.id, text: 'A note', color: '#ff0000' }, }); expect(result.isError).toBe(true); - expect(testDb.prepare('SELECT COUNT(*) AS n FROM day_notes WHERE day_id = ?').get(day.id)).toEqual({ n: 0 }); + expect(await countRows(orm, DayNotes, { day: day.id })).toBe(0); }); }); @@ -210,7 +237,7 @@ describe('Tool: update_day_note', () => { name: 'update_day_note', arguments: { tripId: trip.id, dayId: day.id, noteId: note.id, color: '#16a34a', sort_order: 1.5 }, }); - const row = testDb.prepare('SELECT color, sort_order FROM day_notes WHERE id = ?').get(note.id) as any; + const row = await dayNote(note.id); expect(row.color).toBe('#16a34a'); expect(row.sort_order).toBe(1.5); }); @@ -221,13 +248,13 @@ describe('Tool: update_day_note', () => { const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id); const note = createDayNote(testDb, day.id, trip.id); - testDb.prepare('UPDATE day_notes SET color = ? WHERE id = ?').run('#2563eb', note.id); + await updateRows(orm, DayNotes, { id: note.id }, { color: '#2563eb' }); await withHarness(user.id, async (h) => { await h.client.callTool({ name: 'update_day_note', arguments: { tripId: trip.id, dayId: day.id, noteId: note.id, color: null }, }); - expect((testDb.prepare('SELECT color FROM day_notes WHERE id = ?').get(note.id) as any).color).toBeNull(); + expect((await dayNote(note.id)).color).toBeNull(); }); }); @@ -236,13 +263,13 @@ describe('Tool: update_day_note', () => { const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id); const note = createDayNote(testDb, day.id, trip.id, { sort_order: 3 }); - testDb.prepare('UPDATE day_notes SET color = ? WHERE id = ?').run('#9333ea', note.id); + await updateRows(orm, DayNotes, { id: note.id }, { color: '#9333ea' }); await withHarness(user.id, async (h) => { await h.client.callTool({ name: 'update_day_note', arguments: { tripId: trip.id, dayId: day.id, noteId: note.id, text: 'Only the text changed' }, }); - const row = testDb.prepare('SELECT color, sort_order FROM day_notes WHERE id = ?').get(note.id) as any; + const row = await dayNote(note.id); expect(row.color).toBe('#9333ea'); expect(row.sort_order).toBe(3); }); @@ -259,7 +286,7 @@ describe('Tool: update_day_note', () => { arguments: { tripId: trip.id, dayId: day.id, noteId: note.id, color: 'red' }, }); expect(result.isError).toBe(true); - expect((testDb.prepare('SELECT color FROM day_notes WHERE id = ?').get(note.id) as any).color).toBeNull(); + expect((await dayNote(note.id)).color).toBeNull(); }); }); @@ -311,7 +338,7 @@ describe('Tool: delete_day_note', () => { const result = await h.client.callTool({ name: 'delete_day_note', arguments: { tripId: trip.id, dayId: day.id, noteId: note.id } }); const data = parseToolResult(result) as any; expect(data.success).toBe(true); - expect(testDb.prepare('SELECT id FROM day_notes WHERE id = ?').get(note.id)).toBeUndefined(); + expect(await findRow(orm, DayNotes, { id: note.id })).toBeNull(); }); }); @@ -469,7 +496,7 @@ describe('Tool: create_collab_note', () => { }); const data = parseToolResult(result) as any; expect(data.note.website).toBe('https://example.com/tour'); - expect((testDb.prepare('SELECT website FROM collab_notes WHERE id = ?').get(data.note.id) as any).website) + expect((await collabNote(data.note.id)).website) .toBe('https://example.com/tour'); }); }); @@ -480,7 +507,7 @@ describe('Tool: create_collab_note', () => { await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'create_collab_note', arguments: { tripId: trip.id, title: 'Plain note' } }); const data = parseToolResult(result) as any; - expect((testDb.prepare('SELECT website FROM collab_notes WHERE id = ?').get(data.note.id) as any).website).toBeNull(); + expect((await collabNote(data.note.id)).website).toBeNull(); }); }); @@ -534,7 +561,7 @@ describe('Tool: update_collab_note', () => { name: 'update_collab_note', arguments: { tripId: trip.id, noteId: note.id, website: 'https://example.com/museum' }, }); - expect((testDb.prepare('SELECT website FROM collab_notes WHERE id = ?').get(note.id) as any).website) + expect((await collabNote(note.id)).website) .toBe('https://example.com/museum'); }); }); @@ -543,10 +570,10 @@ describe('Tool: update_collab_note', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const note = createCollabNote(testDb, trip.id, user.id); - testDb.prepare('UPDATE collab_notes SET website = ? WHERE id = ?').run('https://example.com/old', note.id); + await updateRows(orm, CollabNotes, { id: note.id }, { website: 'https://example.com/old' }); await withHarness(user.id, async (h) => { await h.client.callTool({ name: 'update_collab_note', arguments: { tripId: trip.id, noteId: note.id, website: null } }); - expect((testDb.prepare('SELECT website FROM collab_notes WHERE id = ?').get(note.id) as any).website).toBeNull(); + expect((await collabNote(note.id)).website).toBeNull(); }); }); @@ -554,10 +581,10 @@ describe('Tool: update_collab_note', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const note = createCollabNote(testDb, trip.id, user.id); - testDb.prepare('UPDATE collab_notes SET website = ? WHERE id = ?').run('https://example.com/keep', note.id); + await updateRows(orm, CollabNotes, { id: note.id }, { website: 'https://example.com/keep' }); await withHarness(user.id, async (h) => { await h.client.callTool({ name: 'update_collab_note', arguments: { tripId: trip.id, noteId: note.id, title: 'Renamed' } }); - expect((testDb.prepare('SELECT website FROM collab_notes WHERE id = ?').get(note.id) as any).website) + expect((await collabNote(note.id)).website) .toBe('https://example.com/keep'); }); }); @@ -572,7 +599,7 @@ describe('Tool: update_collab_note', () => { arguments: { tripId: trip.id, noteId: note.id, website: `https://example.com/${'x'.repeat(600)}` }, }); expect(result.isError).toBe(true); - expect((testDb.prepare('SELECT website FROM collab_notes WHERE id = ?').get(note.id) as any).website).toBeNull(); + expect((await collabNote(note.id)).website).toBeNull(); }); }); @@ -620,7 +647,7 @@ describe('Tool: delete_collab_note', () => { const result = await h.client.callTool({ name: 'delete_collab_note', arguments: { tripId: trip.id, noteId: note.id } }); const data = parseToolResult(result) as any; expect(data.success).toBe(true); - expect(testDb.prepare('SELECT id FROM collab_notes WHERE id = ?').get(note.id)).toBeUndefined(); + expect(await findRow(orm, CollabNotes, { id: note.id })).toBeNull(); }); }); @@ -629,9 +656,13 @@ describe('Tool: delete_collab_note', () => { const trip = createTrip(testDb, user.id); const note = createCollabNote(testDb, trip.id, user.id); // Insert a trip_file linked to this note - testDb.prepare( - `INSERT INTO trip_files (trip_id, note_id, filename, original_name, mime_type, file_size) VALUES (?, ?, ?, ?, ?, ?)` - ).run(trip.id, note.id, 'test-file.pdf', 'document.pdf', 'application/pdf', 1024); + await makeTripFile(orm, trip.id, { + note: note.id, + filename: 'test-file.pdf', + original_name: 'document.pdf', + mime_type: 'application/pdf', + file_size: 1024, + }); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'delete_collab_note', arguments: { tripId: trip.id, noteId: note.id } }); @@ -639,9 +670,9 @@ describe('Tool: delete_collab_note', () => { }); // trip_files rows are deleted as part of the transaction - expect(testDb.prepare('SELECT id FROM trip_files WHERE note_id = ?').all(note.id)).toHaveLength(0); + expect(await countRows(orm, TripFiles, { note: note.id })).toBe(0); // note itself is deleted - expect(testDb.prepare('SELECT id FROM collab_notes WHERE id = ?').get(note.id)).toBeUndefined(); + expect(await findRow(orm, CollabNotes, { id: note.id })).toBeNull(); }); it('broadcasts collab:note:deleted event', async () => { diff --git a/server/tests/unit/nest/day-removal.service.test.ts b/server/tests/unit/nest/day-removal.service.test.ts index 7748b3d2e..3e6615441 100644 --- a/server/tests/unit/nest/day-removal.service.test.ts +++ b/server/tests/unit/nest/day-removal.service.test.ts @@ -11,16 +11,8 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } vi.mock('../../../src/db/database', async () => { const { createSnapshotTestDb } = await import('../../helpers/db-mock'); const db = createSnapshotTestDb(); - return { - db, - closeDb: () => {}, - reinitialize: () => {}, - getPlaceWithTags: () => null, - canAccessTrip: (tripId: number | string, userId: number) => - db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId), - isOwner: (tripId: number | string, userId: number) => - !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId), - }; + // Trip access reads through TripsRepository now; the module only hands out the handle. + return { db, closeDb: () => {}, reinitialize: () => {} }; }); vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret', @@ -56,6 +48,19 @@ import { createTestJourneyPhotosRepo, createTestJourneyEntryPhotosRepo, } from '../../helpers/journey-repos'; import { createTestToursRepo } from '../../helpers/tours-repos'; +import type { EntityClass, FilterQuery } from '@mikro-orm/core'; +import { countRows, deleteRows, findRow, findRows, insertRow } from '../../helpers/factories/rows'; +import { makeReservation } from '../../helpers/factories/reservations'; +import { makeBudgetItem } from '../../helpers/factories/budget'; +import { BudgetItems } from '../../../src/db/entities/BudgetItems.entity'; +import { DayAccommodations } from '../../../src/db/entities/DayAccommodations.entity'; +import { DayAssignments } from '../../../src/db/entities/DayAssignments.entity'; +import { Days } from '../../../src/db/entities/Days.entity'; +import { Places } from '../../../src/db/entities/Places.entity'; +import { Reservations } from '../../../src/db/entities/Reservations.entity'; +import { ReservationEndpoints } from '../../../src/db/entities/ReservationEndpoints.entity'; +import { RoadtripDayBoundaries } from '../../../src/db/entities/RoadtripDayBoundaries.entity'; +import { Trips } from '../../../src/db/entities/Trips.entity'; let days: DaysService; let journey: JourneyDomainService; @@ -128,27 +133,44 @@ afterAll(() => { type Row = { id: number; day_number: number; date: string | null }; -const dayRows = (tripId: number): Row[] => - testDb.prepare('SELECT id, day_number, date FROM days WHERE trip_id = ? ORDER BY day_number').all(tripId) as Row[]; +const orm = () => sharedTestOrm(testDb); -const range = (tripId: number) => - testDb.prepare('SELECT start_date, end_date FROM trips WHERE id = ?').get(tripId) as { start_date: string | null; end_date: string | null }; +const dayRows = async (tripId: number): Promise => + (await findRows(await orm(), Days, { trip: tripId }, { day_number: 'asc' })).map((d) => ({ + id: d.id, + day_number: d.day_number, + date: d.date ?? null, + })); -const reservationRow = (id: number) => - testDb.prepare('SELECT day_id, end_day_id, reservation_time, reservation_end_time FROM reservations WHERE id = ?').get(id) as - { day_id: number | null; end_day_id: number | null; reservation_time: string | null; reservation_end_time: string | null } | undefined; +const range = async (tripId: number) => { + const trip = await findRow(await orm(), Trips, { id: tripId }); + if (!trip) throw new Error(`no trip ${tripId}`); + return { start_date: trip.start_date ?? null, end_date: trip.end_date ?? null }; +}; -function booking(tripId: number, dayId: number | null, time: string | null, extra: { endDayId?: number; endTime?: string } = {}): number { - return Number(testDb.prepare( - 'INSERT INTO reservations (trip_id, day_id, end_day_id, title, type, reservation_time, reservation_end_time) VALUES (?, ?, ?, ?, ?, ?, ?)', - ).run(tripId, dayId, extra.endDayId ?? null, 'Booking', 'restaurant', time, extra.endTime ?? null).lastInsertRowid); +/** The booking's day links and times, or undefined once it is gone. */ +const reservationRow = async (id: number) => { + const r = await findRow(await orm(), Reservations, { id }); + if (!r) return undefined; + return { day_id: r.day_id ?? null, end_day_id: r.end_day_id ?? null, reservation_time: r.reservation_time ?? null, reservation_end_time: r.reservation_end_time ?? null }; +}; + +async function booking(tripId: number, dayId: number | null, time: string | null, extra: { endDayId?: number; endTime?: string } = {}): Promise { + return (await makeReservation(await orm(), tripId, { + day: dayId, + endDay: extra.endDayId ?? null, + title: 'Booking', + type: 'restaurant', + reservation_time: time, + reservation_end_time: extra.endTime ?? null, + })).id; } /** A dated trip whose days the factory generated, one per date. */ -function datedTrip(start: string, end: string) { +async function datedTrip(start: string, end: string) { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { start_date: start, end_date: end }); - return { user, trip, rows: dayRows(trip.id) }; + return { user, trip, rows: await (await dayRows(trip.id)) }; } /** A booked night: the stay, its hotel booking, the stop on its check-in day and an expense. */ @@ -156,18 +178,44 @@ async function bookedNight(tripId: number, startDayId: number, endDayId: number) const place = createPlace(testDb, tripId, { name: 'Harbour Hotel' }); const { accommodation } = await accommodations.createAccommodation(tripId, { place_id: place.id, start_day_id: startDayId, end_day_id: endDayId }); const stayId = (accommodation as { id: number }).id; - const reservationId = (testDb.prepare('SELECT id FROM reservations WHERE accommodation_id = ?').get(stayId) as { id: number }).id; - const budgetId = Number(testDb.prepare( - 'INSERT INTO budget_items (trip_id, name, category, total_price, reservation_id) VALUES (?, ?, ?, ?, ?)', - ).run(tripId, 'Harbour Hotel', 'Accommodation', 240, reservationId).lastInsertRowid); - const stop = testDb.prepare('SELECT id, day_id FROM day_assignments WHERE accommodation_id = ?').get(stayId) as { id: number; day_id: number }; + const hotelBooking = await findRow(await orm(), Reservations, { accommodation_id: String(stayId) }); + if (!hotelBooking) throw new Error('createAccommodation should have booked the stay'); + const reservationId = hotelBooking.id; + const budgetId = (await makeBudgetItem(await orm(), tripId, { + name: 'Harbour Hotel', + category: 'Accommodation', + total_price: 240, + reservation: reservationId, + })).id; + const stopRow = await findRow(await orm(), DayAssignments, { accommodation_id: stayId }); + if (!stopRow) throw new Error('createAccommodation should have put a stop on the check-in day'); + const stop = { id: stopRow.id, day_id: stopRow.day_id }; return { place, stayId, reservationId, budgetId, stop }; } -function boundary(tripId: number, dayNumber: number, fromAssignmentId: number) { - testDb.prepare( - 'INSERT INTO roadtrip_day_boundaries (trip_id, day_number, from_assignment_id, to_assignment_id, fraction) VALUES (?, ?, ?, NULL, 1)', - ).run(tripId, dayNumber, fromAssignmentId); +async function boundary(tripId: number, dayNumber: number, fromAssignmentId: number): Promise { + await insertRow(await orm(), RoadtripDayBoundaries, { + trip: tripId, + day_number: dayNumber, + fromAssignment: fromAssignmentId, + toAssignment: null, + fraction: 1, + }); +} + +/** The trip's road trip boundaries in day order, as the service hands them back. */ +async function storedBoundaries(tripId: number) { + return (await findRows(await orm(), RoadtripDayBoundaries, { trip: tripId }, { day_number: 'asc' })).map((b) => ({ + day_number: b.day_number, + from_assignment_id: b.from_assignment_id, + to_assignment_id: b.to_assignment_id ?? null, + fraction: b.fraction, + })); +} + +/** Whether a row with the id is still stored. */ +async function exists(entity: EntityClass, id: number): Promise { + return (await countRows(await orm(), entity, { id } as FilterQuery)) > 0; } describe('DayRemovalService.remove', () => { @@ -178,129 +226,136 @@ describe('DayRemovalService.remove', () => { const result = await removal.remove(dateless.id, b.id, { userId: user.id }); - expect(dayRows(dateless.id)).toEqual([ + expect(await dayRows(dateless.id)).toEqual([ { id: a.id, day_number: 1, date: null }, { id: c.id, day_number: 2, date: null }, ]); expect(result).toMatchObject({ dayId: b.id, orderedIds: [a.id, c.id], endDate: null, boundaries: null, stayIds: [] }); // The extra day of a dated trip, the case the dialog was asked for: no date moves. - const dated = datedTrip('2026-03-01', '2026-03-02'); + const dated = await datedTrip('2026-03-01', '2026-03-02'); const spare = createDay(testDb, dated.trip.id); - const dinner = booking(dated.trip.id, dated.rows[1].id, '2026-03-02T19:00'); + const dinner = await booking(dated.trip.id, dated.rows[1].id, '2026-03-02T19:00'); await removal.remove(dated.trip.id, spare.id, { userId: dated.user.id }); - expect(dayRows(dated.trip.id)).toEqual(dated.rows); - expect(range(dated.trip.id)).toEqual({ start_date: '2026-03-01', end_date: '2026-03-02' }); - expect(reservationRow(dinner)?.reservation_time).toBe('2026-03-02T19:00'); + expect(await dayRows(dated.trip.id)).toEqual(dated.rows); + expect(await range(dated.trip.id)).toEqual({ start_date: '2026-03-01', end_date: '2026-03-02' }); + expect((await reservationRow(dinner))?.reservation_time).toBe('2026-03-02T19:00'); }); it('DAY-DEL-002 a dated day with a spare day after it: the dates stay on their slots and the bookings move along', async () => { - const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-03'); + const { user, trip, rows } = await datedTrip('2026-01-01', '2026-01-03'); const [d1, d2, d3] = rows; const spare = createDay(testDb, trip.id); - const flight = booking(trip.id, d3.id, '2026-01-03T10:00', { endDayId: d3.id, endTime: '2026-01-03T12:00' }); - const leg = Number(testDb.prepare( - 'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, lat, lng, local_date) VALUES (?, ?, ?, ?, ?, ?, ?)', - ).run(flight, 'from', 0, 'HEL', 60.31, 24.96, '2026-01-03').lastInsertRowid); + const flight = await booking(trip.id, d3.id, '2026-01-03T10:00', { endDayId: d3.id, endTime: '2026-01-03T12:00' }); + const leg = await insertRow(await orm(), ReservationEndpoints, { + reservation: flight, + role: 'from', + sequence: 0, + name: 'HEL', + lat: 60.31, + lng: 24.96, + local_date: '2026-01-03', + }); const result = await removal.remove(trip.id, d2.id, { userId: user.id }); - expect(dayRows(trip.id)).toEqual([ + expect(await dayRows(trip.id)).toEqual([ { id: d1.id, day_number: 1, date: '2026-01-01' }, { id: d3.id, day_number: 2, date: '2026-01-02' }, { id: spare.id, day_number: 3, date: '2026-01-03' }, ]); - expect(reservationRow(flight)).toMatchObject({ reservation_time: '2026-01-02T10:00', reservation_end_time: '2026-01-02T12:00' }); - expect(testDb.prepare('SELECT local_date FROM reservation_endpoints WHERE id = ?').get(leg)).toEqual({ local_date: '2026-01-02' }); + expect(await reservationRow(flight)).toMatchObject({ reservation_time: '2026-01-02T10:00', reservation_end_time: '2026-01-02T12:00' }); + expect((await findRow(await orm(), ReservationEndpoints, { id: leg }))?.local_date).toBe('2026-01-02'); // The spare day took the last date, so the trip keeps its range. - expect(range(trip.id)).toEqual({ start_date: '2026-01-01', end_date: '2026-01-03' }); + expect(await range(trip.id)).toEqual({ start_date: '2026-01-01', end_date: '2026-01-03' }); expect(result.endDate).toBeNull(); }); it('DAY-DEL-003 a dated day with no spare day left: the last date goes and the trip ends a day earlier', async () => { - const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-03'); + const { user, trip, rows } = await datedTrip('2026-01-01', '2026-01-03'); const [d1, d2, d3] = rows; const result = await removal.remove(trip.id, d1.id, { userId: user.id }); - expect(dayRows(trip.id)).toEqual([ + expect(await dayRows(trip.id)).toEqual([ { id: d2.id, day_number: 1, date: '2026-01-01' }, { id: d3.id, day_number: 2, date: '2026-01-02' }, ]); - expect(range(trip.id)).toEqual({ start_date: '2026-01-01', end_date: '2026-01-02' }); + expect(await range(trip.id)).toEqual({ start_date: '2026-01-01', end_date: '2026-01-02' }); expect(result.endDate).toBe('2026-01-02'); // The trip in list shape for the trip header, without its feed credential. expect(result.trip).toMatchObject({ id: trip.id, end_date: '2026-01-02', day_count: 2, is_owner: 1, feed_token: null }); }); it('DAY-DEL-004 the last day of a trip stays, and nothing is written', async () => { - const { user, trip, rows } = datedTrip('2026-05-01', '2026-05-01'); + const { user, trip, rows } = await datedTrip('2026-05-01', '2026-05-01'); const night = await bookedNight(trip.id, rows[0].id, rows[0].id); await expect(removal.remove(trip.id, rows[0].id, { userId: user.id })).rejects.toThrow(new DayDeleteError(LAST_DAY_MESSAGE)); - expect(dayRows(trip.id)).toEqual(rows); - expect(testDb.prepare('SELECT id FROM day_accommodations WHERE id = ?').get(night.stayId)).toBeDefined(); - expect(reservationRow(night.reservationId)).toBeDefined(); + expect(await dayRows(trip.id)).toEqual(rows); + expect(await exists(DayAccommodations, night.stayId)).toBe(true); + expect(await reservationRow(night.reservationId)).toBeDefined(); }); it('DAY-DEL-005 a stay checking in on the day is cancelled with its booking and that booking\'s expense', async () => { - const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-03'); + const { user, trip, rows } = await datedTrip('2026-01-01', '2026-01-03'); const night = await bookedNight(trip.id, rows[1].id, rows[2].id); expect(night.stop.day_id).toBe(rows[1].id); const result = await removal.remove(trip.id, rows[1].id, { userId: user.id }); - expect(testDb.prepare('SELECT id FROM day_accommodations WHERE id = ?').get(night.stayId)).toBeUndefined(); - expect(reservationRow(night.reservationId)).toBeUndefined(); - expect(testDb.prepare('SELECT id FROM budget_items WHERE id = ?').get(night.budgetId)).toBeUndefined(); + expect(await exists(DayAccommodations, night.stayId)).toBe(false); + expect(await reservationRow(night.reservationId)).toBeUndefined(); + expect(await exists(BudgetItems, night.budgetId)).toBe(false); // The hotel itself is a place and stays on the list. - expect(testDb.prepare('SELECT id FROM places WHERE id = ?').get(night.place.id)).toBeDefined(); + expect(await exists(Places, night.place.id)).toBe(true); expect(result).toMatchObject({ stayIds: [night.stayId], reservationIds: [night.reservationId], budgetItemIds: [night.budgetId] }); // Its stop sat on the deleted day, which day:deleted already covers. expect(result.mirrors[0].removed).toEqual([]); }); it('DAY-DEL-006 a stay checking out on the day is cancelled too, and its stop on the check-in day goes with it', async () => { - const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-03'); + const { user, trip, rows } = await datedTrip('2026-01-01', '2026-01-03'); const night = await bookedNight(trip.id, rows[0].id, rows[1].id); const result = await removal.remove(trip.id, rows[1].id, { userId: user.id }); - expect(testDb.prepare('SELECT id FROM day_accommodations WHERE id = ?').get(night.stayId)).toBeUndefined(); - expect(reservationRow(night.reservationId)).toBeUndefined(); - expect(testDb.prepare('SELECT id FROM day_assignments WHERE id = ?').get(night.stop.id)).toBeUndefined(); + expect(await exists(DayAccommodations, night.stayId)).toBe(false); + expect(await reservationRow(night.reservationId)).toBeUndefined(); + expect(await exists(DayAssignments, night.stop.id)).toBe(false); expect(result.mirrors[0].removed).toEqual([{ id: night.stop.id, dayId: rows[0].id }]); }); it('DAY-DEL-007 a stay that only runs across the day keeps standing', async () => { - const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-03'); + const { user, trip, rows } = await datedTrip('2026-01-01', '2026-01-03'); const night = await bookedNight(trip.id, rows[0].id, rows[2].id); const result = await removal.remove(trip.id, rows[1].id, { userId: user.id }); - expect(testDb.prepare('SELECT start_day_id, end_day_id FROM day_accommodations WHERE id = ?').get(night.stayId)) + const stay = await findRow(await orm(), DayAccommodations, { id: night.stayId }); + expect({ start_day_id: stay?.start_day_id, end_day_id: stay?.end_day_id }) .toEqual({ start_day_id: rows[0].id, end_day_id: rows[2].id }); - expect(reservationRow(night.reservationId)).toBeDefined(); + expect(await reservationRow(night.reservationId)).toBeDefined(); expect(result.stayIds).toEqual([]); }); it('DAY-DEL-008 bookings on the day are let go of, with their dates untouched', async () => { - const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-03'); + const { user, trip, rows } = await datedTrip('2026-01-01', '2026-01-03'); const [d1, d2] = rows; - const dinner = booking(trip.id, d2.id, '2026-01-02T19:00'); - const car = booking(trip.id, d1.id, '2026-01-01T09:00', { endDayId: d2.id, endTime: '2026-01-02T10:00' }); + const dinner = await booking(trip.id, d2.id, '2026-01-02T19:00'); + const car = await booking(trip.id, d1.id, '2026-01-01T09:00', { endDayId: d2.id, endTime: '2026-01-02T10:00' }); await removal.remove(trip.id, d2.id, { userId: user.id }); - expect(reservationRow(dinner)).toEqual({ day_id: null, end_day_id: null, reservation_time: '2026-01-02T19:00', reservation_end_time: null }); - expect(reservationRow(car)).toEqual({ day_id: d1.id, end_day_id: null, reservation_time: '2026-01-01T09:00', reservation_end_time: '2026-01-02T10:00' }); + expect(await reservationRow(dinner)).toEqual({ day_id: null, end_day_id: null, reservation_time: '2026-01-02T19:00', reservation_end_time: null }); + expect(await reservationRow(car)).toEqual({ day_id: d1.id, end_day_id: null, reservation_time: '2026-01-01T09:00', reservation_end_time: '2026-01-02T10:00' }); }); it('DAY-DEL-009 the road trip boundaries after the day move up with their days', async () => { - const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-04'); + const { user, trip, rows } = await datedTrip('2026-01-01', '2026-01-04'); const place = createPlace(testDb, trip.id); const stops = rows.map(r => createDayAssignment(testDb, r.id, place.id)); - rows.forEach((_, i) => boundary(trip.id, i + 1, stops[i].id)); + for (const [i] of rows.entries()) await boundary(trip.id, i + 1, stops[i].id); const result = await removal.remove(trip.id, rows[1].id, { userId: user.id }); @@ -309,76 +364,75 @@ describe('DayRemovalService.remove', () => { { day_number: 2, from_assignment_id: stops[2].id, to_assignment_id: null, fraction: 1 }, { day_number: 3, from_assignment_id: stops[3].id, to_assignment_id: null, fraction: 1 }, ]; - expect(testDb.prepare('SELECT day_number, from_assignment_id, to_assignment_id, fraction FROM roadtrip_day_boundaries WHERE trip_id = ? ORDER BY day_number').all(trip.id)) - .toEqual(expected); + expect(await storedBoundaries(trip.id)).toEqual(expected); expect(result.boundaries).toEqual(expected); }); it('DAY-DEL-010 deleting the day an insert slotted in gives back the trip as it was', async () => { - const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-03'); - const train = booking(trip.id, rows[2].id, '2026-01-03T08:00'); - const before = { rows: dayRows(trip.id), range: range(trip.id), train: reservationRow(train) }; + const { user, trip, rows } = await datedTrip('2026-01-01', '2026-01-03'); + const train = await booking(trip.id, rows[2].id, '2026-01-03T08:00'); + const before = { rows: (await dayRows(trip.id)), range: (await range(trip.id)), train: (await reservationRow(train)) }; const inserted = await days.insert(trip.id, 2); - expect(range(trip.id).end_date).toBe('2026-01-04'); + expect((await range(trip.id)).end_date).toBe('2026-01-04'); await removal.remove(trip.id, inserted.id, { userId: user.id }); - expect({ rows: dayRows(trip.id), range: range(trip.id), train: reservationRow(train) }).toEqual(before); + expect({ rows: (await dayRows(trip.id)), range: (await range(trip.id)), train: (await reservationRow(train)) }).toEqual(before); }); it('DAY-DEL-011 appending a day, moving it into a slot and deleting the day it pushed out keeps every date', async () => { - const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-03'); + const { user, trip, rows } = await datedTrip('2026-01-01', '2026-01-03'); const [d1, d2, d3] = rows; - const lunch = booking(trip.id, d2.id, '2026-01-02T12:00'); - const tour = booking(trip.id, d3.id, '2026-01-03T09:00'); + const lunch = await booking(trip.id, d2.id, '2026-01-02T12:00'); + const tour = await booking(trip.id, d3.id, '2026-01-03T09:00'); const appended = await days.create(trip.id); await days.reorder(trip.id, [d1.id, d2.id, appended.id, d3.id]); // The pushed-out day lost its date to the new one and sits at the end without one. - expect(dayRows(trip.id).at(-1)).toEqual({ id: d3.id, day_number: 4, date: null }); + expect((await dayRows(trip.id)).at(-1)).toEqual({ id: d3.id, day_number: 4, date: null }); await removal.remove(trip.id, d3.id, { userId: user.id }); - expect(dayRows(trip.id)).toEqual([ + expect(await dayRows(trip.id)).toEqual([ { id: d1.id, day_number: 1, date: '2026-01-01' }, { id: d2.id, day_number: 2, date: '2026-01-02' }, { id: appended.id, day_number: 3, date: '2026-01-03' }, ]); - expect(range(trip.id)).toEqual({ start_date: '2026-01-01', end_date: '2026-01-03' }); - expect(reservationRow(lunch)?.reservation_time).toBe('2026-01-02T12:00'); - expect(reservationRow(tour)?.reservation_time).toBe('2026-01-03T09:00'); + expect(await range(trip.id)).toEqual({ start_date: '2026-01-01', end_date: '2026-01-03' }); + expect((await reservationRow(lunch))?.reservation_time).toBe('2026-01-02T12:00'); + expect((await reservationRow(tour))?.reservation_time).toBe('2026-01-03T09:00'); }); it('DAY-DEL-012 a failure halfway rolls the whole delete back', async () => { - const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-03'); + const { user, trip, rows } = await datedTrip('2026-01-01', '2026-01-03'); const night = await bookedNight(trip.id, rows[1].id, rows[2].id); vi.spyOn(days, 'restampReservationDates').mockImplementation(() => { throw new Error('disk full'); }); await expect(removal.remove(trip.id, rows[1].id, { userId: user.id })).rejects.toThrow('disk full'); - expect(dayRows(trip.id)).toEqual(rows); - expect(testDb.prepare('SELECT id FROM day_accommodations WHERE id = ?').get(night.stayId)).toBeDefined(); - expect(reservationRow(night.reservationId)).toBeDefined(); - expect(testDb.prepare('SELECT id FROM budget_items WHERE id = ?').get(night.budgetId)).toBeDefined(); + expect(await dayRows(trip.id)).toEqual(rows); + expect(await exists(DayAccommodations, night.stayId)).toBe(true); + expect(await reservationRow(night.reservationId)).toBeDefined(); + expect(await exists(BudgetItems, night.budgetId)).toBe(true); }); it('DAY-DEL-013 the journey catches up after the commit, and a failure there does not undo the delete', async () => { - const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-02'); + const { user, trip, rows } = await datedTrip('2026-01-01', '2026-01-02'); const reconcile = vi.spyOn(journey, 'reconcileTripSkeletons').mockImplementation(() => { throw new Error('journey down'); }); const result = await removal.remove(trip.id, rows[0].id, { userId: user.id, socketId: 'sock-1' }); expect(reconcile).toHaveBeenCalledWith(trip.id, 'sock-1'); expect(result.orderedIds).toEqual([rows[1].id]); - expect(dayRows(trip.id)).toHaveLength(1); + expect(await dayRows(trip.id)).toHaveLength(1); }); it('DAY-DEL-015 a day of another trip is refused, and nothing is written', async () => { - const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-02'); - const other = datedTrip('2026-02-01', '2026-02-02'); + const { user, trip, rows } = await datedTrip('2026-01-01', '2026-01-02'); + const other = await datedTrip('2026-02-01', '2026-02-02'); await expect(removal.remove(trip.id, other.rows[0].id, { userId: user.id })).rejects.toThrow(new DayDeleteError('Day not found')); - expect(dayRows(trip.id)).toEqual(rows); - expect(dayRows(other.trip.id)).toEqual(other.rows); + expect(await dayRows(trip.id)).toEqual(rows); + expect(await dayRows(other.trip.id)).toEqual(other.rows); }); it('DAY-DEL-016 dated days on a trip without a range move their dates but leave the trip alone', async () => { @@ -389,13 +443,13 @@ describe('DayRemovalService.remove', () => { const result = await removal.remove(trip.id, a.id, { userId: user.id }); - expect(dayRows(trip.id)).toEqual([{ id: b.id, day_number: 1, date: '2026-04-01' }]); - expect(range(trip.id)).toEqual({ start_date: null, end_date: null }); + expect(await dayRows(trip.id)).toEqual([{ id: b.id, day_number: 1, date: '2026-04-01' }]); + expect(await range(trip.id)).toEqual({ start_date: null, end_date: null }); expect(result.endDate).toBeNull(); }); it('DAY-DEL-018 a cancelled stay does not announce the deleted day: neither its stop there nor its roads', async () => { - const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-03'); + const { user, trip, rows } = await datedTrip('2026-01-01', '2026-01-03'); const place = createPlace(testDb, trip.id); createDayAccommodation(testDb, trip.id, place.id, rows[1].id, rows[1].id); const stub = { @@ -419,28 +473,27 @@ describe('DayRemovalService.remove', () => { }); it('DAY-DEL-019 a hole left in the numbering closes too, and the boundaries follow their own days into it', async () => { - const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-05'); + const { user, trip, rows } = await datedTrip('2026-01-01', '2026-01-05'); const place = createPlace(testDb, trip.id); const stops = rows.map(r => createDayAssignment(testDb, r.id, place.id)); // Days 1, 2, 4 and 5: the third went through the old bare delete, which left // its number empty and one boundary on it. Another one sits past the last day. - boundary(trip.id, 2, stops[1].id); - boundary(trip.id, 3, stops[0].id); - boundary(trip.id, 4, stops[3].id); - boundary(trip.id, 5, stops[4].id); - boundary(trip.id, 7, stops[0].id); - testDb.prepare('DELETE FROM days WHERE id = ?').run(rows[2].id); + await boundary(trip.id, 2, stops[1].id); + await boundary(trip.id, 3, stops[0].id); + await boundary(trip.id, 4, stops[3].id); + await boundary(trip.id, 5, stops[4].id); + await boundary(trip.id, 7, stops[0].id); + await deleteRows(await orm(), Days, { id: rows[2].id }); const result = await removal.remove(trip.id, rows[1].id, { userId: user.id }); - expect(dayRows(trip.id).map(r => [r.id, r.day_number])).toEqual([[rows[0].id, 1], [rows[3].id, 2], [rows[4].id, 3]]); + expect((await dayRows(trip.id)).map(r => [r.id, r.day_number])).toEqual([[rows[0].id, 1], [rows[3].id, 2], [rows[4].id, 3]]); const expected = [ { day_number: 2, from_assignment_id: stops[3].id, to_assignment_id: null, fraction: 1 }, { day_number: 3, from_assignment_id: stops[4].id, to_assignment_id: null, fraction: 1 }, { day_number: 5, from_assignment_id: stops[0].id, to_assignment_id: null, fraction: 1 }, ]; - expect(testDb.prepare('SELECT day_number, from_assignment_id, to_assignment_id, fraction FROM roadtrip_day_boundaries WHERE trip_id = ? ORDER BY day_number').all(trip.id)) - .toEqual(expected); + expect(await storedBoundaries(trip.id)).toEqual(expected); expect(result.boundaries).toEqual(expected); }); }); @@ -454,10 +507,10 @@ describe('DayRemovalService.announce', () => { } it('DAY-DEL-014 fans out in one order: the day, the new order, the stays, their rows, the boundaries, the trip', async () => { - const { user, trip, rows } = datedTrip('2026-01-01', '2026-01-03'); + const { user, trip, rows } = await datedTrip('2026-01-01', '2026-01-03'); const night = await bookedNight(trip.id, rows[0].id, rows[1].id); const place = createPlace(testDb, trip.id); - boundary(trip.id, 3, createDayAssignment(testDb, rows[2].id, place.id).id); + await boundary(trip.id, 3, createDayAssignment(testDb, rows[2].id, place.id).id); const result = await removal.remove(trip.id, rows[1].id, { userId: user.id, socketId: 'sock' }); const mirror = vi.spyOn(accommodations, 'announceMirror'); const { sent, all, others } = recorder(); diff --git a/server/tests/unit/nest/doc-sync/doc-sync-config.service.test.ts b/server/tests/unit/nest/doc-sync/doc-sync-config.service.test.ts index 9fc77b874..227c5289e 100644 --- a/server/tests/unit/nest/doc-sync/doc-sync-config.service.test.ts +++ b/server/tests/unit/nest/doc-sync/doc-sync-config.service.test.ts @@ -59,7 +59,16 @@ import type { DocumentProviderRegistry } from '../../../../src/nest/doc-sync/doc // off, so the boolean discriminant stops discriminating. The domain's own // predicate is what every call site uses instead. import { docFailed } from '../../../../src/nest/doc-sync/document-provider'; -import { createTestUnitOfWork, createTestTripsRepo } from '../../../helpers/test-uow'; +import { createTestUnitOfWork, createTestTripsRepo, sharedTestOrm } from '../../../helpers/test-uow'; +import { countRows, deleteRows, findRow, findRows, insertRow, updateRows } from '../../../helpers/factories/rows'; +import { addTripMember } from '../../../helpers/factories/trips'; +import { DocumentConnections } from '../../../../src/db/entities/DocumentConnections.entity'; +import { DocumentProviders } from '../../../../src/db/entities/DocumentProviders.entity'; +import { DocumentSyncItems } from '../../../../src/db/entities/DocumentSyncItems.entity'; +import { TripDocumentLinks } from '../../../../src/db/entities/TripDocumentLinks.entity'; +import { TripMembers } from '../../../../src/db/entities/TripMembers.entity'; + +const orm = () => sharedTestOrm(testDb); import { createTestDocumentConnectionsRepo, createTestDocumentProviderFieldsRepo, @@ -129,17 +138,19 @@ beforeAll(async () => { ); }); -beforeEach(() => { +beforeEach(async () => { // The document_* tables are not in resetTestDb's list, and they reference // trips and users, so they go first, before the rows they hang off vanish. - testDb.exec('DELETE FROM document_sync_items; DELETE FROM trip_document_links; DELETE FROM document_connections;'); + await deleteRows(await orm(), DocumentSyncItems); + await deleteRows(await orm(), TripDocumentLinks); + await deleteRows(await orm(), DocumentConnections); resetTestDb(testDb); vi.clearAllMocks(); checkSsrf.mockResolvedValue({ allowed: true, isPrivate: false, resolvedIp: '203.0.113.10' }); OWNER = createUser(testDb, { username: 'owner', email: 'owner@test.local' }).user.id; MEMBER = createUser(testDb, { username: 'member', email: 'member@test.local' }).user.id; TRIP = createTrip(testDb, OWNER, { title: 'Japan' }).id; - testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(TRIP, MEMBER); + await addTripMember(await orm(), TRIP, MEMBER); }); afterAll(() => { @@ -177,10 +188,7 @@ describe('upsertConnection secrets', () => { const first = await connect(); const second = await connect({ baseUrl: 'https://paperless.example.com/', credentials: {} }); expect(second.id).toBe(first.id); - const count = testDb - .prepare('SELECT COUNT(*) AS n FROM document_connections WHERE trip_id = ?') - .get(TRIP) as { n: number }; - expect(count.n).toBe(1); + expect(await countRows(await orm(), DocumentConnections, { trip: TRIP })).toBe(1); }); /** @@ -324,7 +332,7 @@ describe('publicConnection', () => { it('reads a capabilities blob left behind by another build as unknown, not as a crash', async () => { const created = await connect(); - testDb.prepare('UPDATE document_connections SET capabilities = ? WHERE id = ?').run('{not json', created.id); + await updateRows(await orm(), DocumentConnections, { id: created.id }, { capabilities: '{not json' }); const view = await svc.publicConnection((await svc.getConnection(created.id)) as ConnectionRow); expect(view.capabilities).toBeNull(); }); @@ -359,7 +367,7 @@ describe('a secret the provider earned itself', () => { it('DOCSYNC-ENC-001: the raw document_connections.secrets column is enc:v1:-prefixed ciphertext, never plaintext, and round-trips', async () => { const conn = await nas(); - const raw = (testDb.prepare('SELECT secrets FROM document_connections WHERE id = ?').get(conn.id) as { secrets: string | null }).secrets; + const raw = (await findRow(await orm(), DocumentConnections, { id: conn.id }))?.secrets; expect(raw).toMatch(/^enc:v1:/); expect(raw).not.toContain('nas-pw'); @@ -491,7 +499,7 @@ describe('enabledProviderIds', () => { // Every document provider ships OFF, so an empty answer on a fresh install // is the correct one and a non-empty one would be a policy change. expect(await svc.enabledProviderIds()).toEqual([]); - testDb.prepare("UPDATE document_providers SET enabled = 1 WHERE id IN ('nextcloud', 'paperless')").run(); + await updateRows(await orm(), DocumentProviders, { id: { $in: ['nextcloud', 'paperless'] } }, { enabled: 1 }); expect(await svc.enabledProviderIds()).toEqual(['paperless', 'nextcloud']); }); }); @@ -524,27 +532,26 @@ describe('deleteConnection', () => { }); const doomedLink = await link(doomed.id, { scopeKey: 'tag:1' }); const keptLink = await link(kept.id, { scopeKey: 'folder:1' }); - testDb - .prepare('INSERT INTO document_sync_items (link_id, trip_id, trek_doc_uid) VALUES (?, ?, ?)') - .run(doomedLink.id, TRIP, 'uid-a'); + await insertRow(await orm(), DocumentSyncItems, { link: doomedLink.id, trip: TRIP, trek_doc_uid: 'uid-a' }); await svc.deleteConnection(doomed.id); expect(await svc.getConnection(doomed.id)).toBeUndefined(); expect((await svc.listLinks(TRIP)).map((l) => l.id)).toEqual([keptLink.id]); - expect(testDb.prepare('SELECT COUNT(*) AS n FROM document_sync_items').get()).toEqual({ n: 0 }); + expect(await countRows(await orm(), DocumentSyncItems)).toBe(0); }); }); describe('toRef', () => { it('names the row and not only its id, which a backup restored in place hands out again', async () => { const first = await connect(); - testDb.prepare("UPDATE document_connections SET created_at = '2026-09-01 08:00:00' WHERE id = ?").run(first.id); + await updateRows(await orm(), DocumentConnections, { id: first.id }, { created_at: '2026-09-01 08:00:00' }); const before = svc.toRef(await svc.getConnection(first.id) as ConnectionRow); // What the restore of an older backup does to this table: the row is gone // and the id sequence is back where it stood before the row was made. - testDb.prepare('DELETE FROM document_connections WHERE id = ?').run(first.id); + await deleteRows(await orm(), DocumentConnections, { id: first.id }); + // test-sql-allow: sqlite_sequence is the engine's own autoincrement table, which no entity maps. testDb.prepare("UPDATE sqlite_sequence SET seq = ? WHERE name = 'document_connections'").run(first.id - 1); const after = svc.toRef(await connect()); @@ -586,7 +593,7 @@ describe('toScopeRef', () => { ); expect(created.success).toBe(true); const row = created.success ? created.data : ({} as LinkRow); - testDb.prepare('UPDATE trip_document_links SET remote_cursor = ? WHERE id = ?').run('etag-9', row.id); + await updateRows(await orm(), TripDocumentLinks, { id: row.id }, { remote_cursor: 'etag-9' }); expect(svc.toScopeRef(await svc.getLink(row.id) as LinkRow)).toEqual({ linkId: row.id, @@ -679,7 +686,7 @@ describe('createLink', () => { const conn = await connect(); const bound = await link(conn.id); - const raw = (testDb.prepare('SELECT webhook_secret FROM trip_document_links WHERE id = ?').get(bound.id) as { webhook_secret: string | null }).webhook_secret; + const raw = (await findRow(await orm(), TripDocumentLinks, { id: bound.id }))?.webhook_secret; expect(raw).toMatch(/^enc:v1:/); const secret = svc.webhookSecret(bound); @@ -694,7 +701,7 @@ describe('publicLink', () => { // the one place a binding left behind can still get its name from. const conn = await connect(); const bound = await link(conn.id); - testDb.prepare("UPDATE document_providers SET enabled = 0 WHERE id = 'paperless'").run(); + await updateRows(await orm(), DocumentProviders, { id: 'paperless' }, { enabled: 0 }); expect(await svc.publicLink(bound, null)).toMatchObject({ providerId: 'paperless', providerName: 'Paperless-ngx' }); }); @@ -711,18 +718,17 @@ describe('deleteLink', () => { const conn = await connect(); const doomed = await link(conn.id, { scopeKey: 'tag:1' }); const kept = await link(conn.id, { scopeKey: 'tag:2' }); - const insert = testDb.prepare( - 'INSERT INTO document_sync_items (link_id, trip_id, trek_doc_uid) VALUES (?, ?, ?)', - ); - insert.run(doomed.id, TRIP, 'uid-a'); - insert.run(doomed.id, TRIP, 'uid-b'); - insert.run(kept.id, TRIP, 'uid-c'); + const insert = async (linkId: number, trekDocUid: string) => + insertRow(await orm(), DocumentSyncItems, { link: linkId, trip: TRIP, trek_doc_uid: trekDocUid }); + await insert(doomed.id, 'uid-a'); + await insert(doomed.id, 'uid-b'); + await insert(kept.id, 'uid-c'); await svc.deleteLink(doomed.id); expect(await svc.getLink(doomed.id)).toBeUndefined(); expect(await svc.getLink(kept.id)).toBeDefined(); - const rows = testDb.prepare('SELECT link_id FROM document_sync_items').all() as { link_id: number }[]; + const rows = await findRows(await orm(), DocumentSyncItems); expect(rows.map((r) => r.link_id)).toEqual([kept.id]); }); @@ -743,7 +749,7 @@ describe('markOrphanedLinks', () => { it('disables the binding whose credential owner left the trip', async () => { const orphan = await bind(MEMBER, 'paperless'); - testDb.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run(TRIP, MEMBER); + await deleteRows(await orm(), TripMembers, { trip: TRIP, user: MEMBER }); expect(await svc.markOrphanedLinks()).toBe(1); expect(await svc.getLink(orphan.id)).toMatchObject({ last_sync_state: 'orphaned', sync_enabled: 0 }); @@ -761,7 +767,7 @@ describe('markOrphanedLinks', () => { it('leaves a still-valid binding alone while orphaning the one beside it', async () => { const orphan = await bind(MEMBER, 'paperless'); const kept = await bind(OWNER, 'nextcloud'); - testDb.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run(TRIP, MEMBER); + await deleteRows(await orm(), TripMembers, { trip: TRIP, user: MEMBER }); expect(await svc.markOrphanedLinks()).toBe(1); expect((await svc.getLink(orphan.id))?.last_sync_state).toBe('orphaned'); @@ -808,7 +814,7 @@ describe('markOrphanedLinks', () => { it('counts nothing on a second sweep, so the job stays quiet after the first one', async () => { await bind(MEMBER, 'paperless'); - testDb.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run(TRIP, MEMBER); + await deleteRows(await orm(), TripMembers, { trip: TRIP, user: MEMBER }); expect(await svc.markOrphanedLinks()).toBe(1); expect(await svc.markOrphanedLinks()).toBe(0); }); @@ -816,7 +822,7 @@ describe('markOrphanedLinks', () => { it('does not touch a binding the user had already switched off', async () => { const paused = await bind(MEMBER, 'paperless'); await svc.updateLink(paused.id, { syncEnabled: false }); - testDb.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run(TRIP, MEMBER); + await deleteRows(await orm(), TripMembers, { trip: TRIP, user: MEMBER }); expect(await svc.markOrphanedLinks()).toBe(0); expect((await svc.getLink(paused.id))?.last_sync_state).toBe('never'); @@ -831,7 +837,7 @@ describe('isOrphaned', () => { it('answers yes for a paused binding whose owner left, which the sweep never marks', async () => { const paused = await bind(MEMBER); await svc.updateLink(paused.id, { syncEnabled: false }); - testDb.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run(TRIP, MEMBER); + await deleteRows(await orm(), TripMembers, { trip: TRIP, user: MEMBER }); expect(await svc.markOrphanedLinks()).toBe(0); expect(await svc.isOrphaned((await svc.getLink(paused.id))!)).toBe(true); @@ -841,7 +847,7 @@ describe('isOrphaned', () => { const kept = await bind(OWNER); expect(await svc.isOrphaned(kept)).toBe(false); - testDb.prepare("UPDATE trip_document_links SET last_sync_state = 'orphaned' WHERE id = ?").run(kept.id); + await updateRows(await orm(), TripDocumentLinks, { id: kept.id }, { last_sync_state: 'orphaned' }); expect(await svc.isOrphaned((await svc.getLink(kept.id))!)).toBe(true); }); @@ -854,7 +860,7 @@ describe('isOrphaned', () => { describe('switching an orphaned binding back on', () => { async function orphan(): Promise { const bound = await link((await connect({}, MEMBER)).id); - testDb.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run(TRIP, MEMBER); + await deleteRows(await orm(), TripMembers, { trip: TRIP, user: MEMBER }); await svc.markOrphanedLinks(); return bound; } @@ -869,7 +875,7 @@ describe('switching an orphaned binding back on', () => { it('lets it run again once its owner is back on the trip', async () => { const bound = await orphan(); - testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(TRIP, MEMBER); + await addTripMember(await orm(), TRIP, MEMBER); const res = await svc.updateLink(bound.id, { syncEnabled: true });