diff --git a/server/tests/unit/nest/dawarich-sync.service.test.ts b/server/tests/unit/nest/dawarich-sync.service.test.ts index d6e3e46aa..9377beb63 100644 --- a/server/tests/unit/nest/dawarich-sync.service.test.ts +++ b/server/tests/unit/nest/dawarich-sync.service.test.ts @@ -49,6 +49,11 @@ import type { DawarichConnectionsRepository } from '../../../src/db/repositories import type { TestOrm } from '../../helpers/test-orm'; import { createTestDawarichConnectionsRepo, createTestDawarichVisitSuggestionsRepo } from '../../helpers/dawarich-repos'; import { createTestTripsRepo, createTestUnitOfWork, sharedTestOrm } from '../../helpers/test-uow'; +import { deleteRows, findRow, findRows, insertRow, updateRows, upsertRow } from '../../helpers/factories/rows'; +import { addTripMember } from '../../helpers/factories/trips'; +import { DawarichConnections } from '../../../src/db/entities/DawarichConnections.entity'; +import { DawarichVisitSuggestions } from '../../../src/db/entities/DawarichVisitSuggestions.entity'; +import { Trips } from '../../../src/db/entities/Trips.entity'; import { DawarichError } from '../../../src/nest/integrations/dawarich.client'; import type { DawarichClient, DawarichCreds, DawarichVisitRaw } from '../../../src/nest/integrations/dawarich.client'; import type { DawarichService } from '../../../src/nest/integrations/dawarich.service'; @@ -72,31 +77,25 @@ const listVisits = vi.fn(); const client = { listVisits } as unknown as DawarichClient; /** Reads the same two columns the real service reads, so a row without a key is null. */ -const getCredentials = vi.fn((userId: number): DawarichCreds | null => { - const row = testDb - .prepare('SELECT url, api_key, allow_insecure_tls FROM dawarich_connections WHERE user_id = ?') - .get(userId) as { url: string | null; api_key: string | null; allow_insecure_tls: number } | undefined; +const getCredentials = vi.fn(async (userId: number): Promise => { + const row = await findRow(t, DawarichConnections, { user: userId }); if (!row?.url || !row?.api_key) return null; return { baseUrl: row.url, apiKey: row.api_key, allowInsecureTls: !!row.allow_insecure_tls }; }); /** Writes the result where the settings card reads it, exactly as the real service does. */ -const recordSyncResult = vi.fn((userId: number, state: DawarichSyncState, error: string | null): void => { - testDb - .prepare( - 'UPDATE dawarich_connections SET last_sync_at = ?, last_sync_state = ?, last_sync_error = ? WHERE user_id = ?', - ) - .run(new Date().toISOString(), state, error, userId); +const recordSyncResult = vi.fn(async (userId: number, state: DawarichSyncState, error: string | null): Promise => { + await updateRows(t, DawarichConnections, { user: userId }, { + last_sync_at: new Date().toISOString(), last_sync_state: state, last_sync_error: error, + }); }); -const listSyncableUserIds = vi.fn((): number[] => +const listSyncableUserIds = vi.fn(async (): Promise => ( - testDb - .prepare( - "SELECT user_id FROM dawarich_connections WHERE sync_enabled = 1 AND url IS NOT NULL AND url <> '' AND api_key IS NOT NULL", - ) - .all() as { user_id: number }[] - ).map((r) => r.user_id), + await findRows(t, DawarichConnections, { + sync_enabled: 1, url: { $ne: null, $nin: [''] }, api_key: { $ne: null }, + }) + ).map((r) => r.user_id as number), ); const storeCapabilities = vi.fn(); @@ -182,45 +181,46 @@ interface SuggestionRow { last_seen_at: string; } -function rows(userId = USER): SuggestionRow[] { - return testDb - .prepare('SELECT * FROM dawarich_visit_suggestions WHERE user_id = ? ORDER BY id') - .all(userId) as SuggestionRow[]; +async function rows(userId = USER): Promise { + return (await findRows(t, DawarichVisitSuggestions, { user: userId }, { id: 'asc' })) as SuggestionRow[]; } -function only(userId = USER): SuggestionRow { - const all = rows(userId); +async function only(userId = USER): Promise { + const all = await rows(userId); expect(all).toHaveLength(1); return all[0]; } -function connection(userId = USER): { last_sync_state: string; last_sync_error: string | null } { - return testDb - .prepare('SELECT last_sync_state, last_sync_error FROM dawarich_connections WHERE user_id = ?') - .get(userId) as { last_sync_state: string; last_sync_error: string | null }; +async function connection(userId = USER): Promise<{ last_sync_state: string; last_sync_error: string | null }> { + const row = await findRow(t, DawarichConnections, { user: userId }); + if (!row) throw new Error(`no dawarich connection for user ${userId}`); + return { last_sync_state: row.last_sync_state, last_sync_error: row.last_sync_error ?? null }; } -function connect( +async function connect( userId: number, opts: { url?: string | null; apiKey?: string | null; syncEnabled?: boolean } = {}, -): void { - testDb - .prepare( - 'INSERT OR REPLACE INTO dawarich_connections (user_id, url, api_key, allow_insecure_tls, sync_enabled) VALUES (?, ?, ?, 0, ?)', - ) - .run( - userId, - opts.url === undefined ? 'https://dawarich.test' : opts.url, - opts.apiKey === undefined ? 'secret-key' : opts.apiKey, - opts.syncEnabled === false ? 0 : 1, - ); +): Promise { + await upsertRow(t, DawarichConnections, { + user: userId, + url: opts.url === undefined ? 'https://dawarich.test' : opts.url, + api_key: opts.apiKey === undefined ? 'secret-key' : opts.apiKey, + allow_insecure_tls: 0, + sync_enabled: opts.syncEnabled === false ? 0 : 1, + }); } -function bucketItem(name: string, lat: number, lng: number, userId = USER): number { - const res = testDb - .prepare('INSERT INTO bucket_list (user_id, name, lat, lng) VALUES (?, ?, ?, ?)') - .run(userId, name, lat, lng); - return Number(res.lastInsertRowid); +async function bucketItem(name: string, lat: number, lng: number, userId = USER): Promise { + return insertRow(t, BucketList, { user: userId, name, lat, lng }); +} + +/** A suggestion row written straight in, as an earlier run or a stale state left it. */ +function seedSuggestion(row: { + source_visit_id: string; trip: number; name: string; lat: number | null; lng: number | null; + started_at: string; ended_at: string; duration_minutes: number; local_date: string; + state: string; source_hash: string; matchedBucketListItem?: number; +}): Promise { + return insertRow(t, DawarichVisitSuggestions, { user: USER, source_status: 'suggested', ...row }); } /** Every window of the next run answers with exactly these visits. */ @@ -247,7 +247,7 @@ beforeAll(async () => { ); }); -beforeEach(() => { +beforeEach(async () => { resetTestDb(testDb); // RESET_TABLES in tests/helpers/test-db.ts predates this domain and does not // list its two tables; foreign keys are off during the reset, so rows would @@ -261,7 +261,7 @@ beforeEach(() => { setAddonEnabled(testDb, 'dawarich', true); USER = createUser(testDb).user.id; TRIP = createTrip(testDb, USER, { start_date: TRIP_START, end_date: TRIP_END }).id; - connect(USER); + await connect(USER); }); afterAll(async () => { @@ -278,7 +278,7 @@ describe('DawarichSyncService — new visits', () => { const result = await svc.syncUser(USER); expect(result).toMatchObject({ state: 'ok', created: 1, updated: 0, missing: 0 }); - const row = only(); + const row = await only(); expect(row.source_visit_id).toBe('501'); expect(row.state).toBe('new'); expect(row.trip_id).toBe(TRIP); @@ -300,7 +300,7 @@ describe('DawarichSyncService — new visits', () => { await svc.syncUser(USER); - expect(only().country_code).toBe('DE'); + expect((await only()).country_code).toBe('DE'); }); it('DAWARICH-SYNC-003: prefers the country code the source sends over the resolved one', async () => { @@ -308,7 +308,7 @@ describe('DawarichSyncService — new visits', () => { await svc.syncUser(USER); - expect(only().country_code).toBe('AT'); + expect((await only()).country_code).toBe('AT'); }); it('DAWARICH-SYNC-004: skips a payload entry that is not a usable visit', async () => { @@ -328,7 +328,7 @@ describe('DawarichSyncService — new visits', () => { const result = await svc.syncUser(USER); expect(result.created).toBe(1); - expect(rows()).toHaveLength(1); + expect(await rows()).toHaveLength(1); }); }); @@ -356,7 +356,7 @@ describe('DawarichSyncService, neighbouring trips', () => { expect(listVisits).toHaveBeenCalledTimes(2); expect(result).toMatchObject({ state: 'ok', created: 1, missing: 0 }); - const row = only(); + const row = await only(); expect(row.trip_id).toBe(TRIP); expect(row.trip_id).not.toBe(next); }); @@ -365,39 +365,33 @@ describe('DawarichSyncService, neighbouring trips', () => { // What an earlier run left behind. Nobody acted on the row, so re-homing // it loses nothing, and the panel of the trip it belongs to fills in. const next = nextTrip(); - 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, state, source_hash) - VALUES (?, '911', ?, 'Hotel Adlon', ?, ?, ?, ?, 180, ?, 'suggested', 'new', 'stale')`, - ) - .run(USER, next, LAT, LNG, `${TRIP_END}T09:00:00Z`, `${TRIP_END}T12:00:00Z`, TRIP_END); + await seedSuggestion({ + source_visit_id: '911', trip: next, name: 'Hotel Adlon', lat: LAT, lng: LNG, + started_at: `${TRIP_END}T09:00:00Z`, ended_at: `${TRIP_END}T12:00:00Z`, duration_minutes: 180, + local_date: TRIP_END, state: 'new', source_hash: 'stale', + }); withVisits(lastDayVisit(911)); const result = await svc.syncUser(USER); expect(result).toMatchObject({ created: 0, missing: 0 }); - expect(only().trip_id).toBe(TRIP); + expect((await only()).trip_id).toBe(TRIP); }); it('DAWARICH-SYNC-076: a row the user already acted on keeps its trip', async () => { // An acceptance made a place on that trip. Moving the row out from under // it would leave the handled list pointing somewhere else than the place. const next = nextTrip(); - 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, state, source_hash) - VALUES (?, '912', ?, 'Hotel Adlon', ?, ?, ?, ?, 180, ?, 'suggested', 'accepted', 'stale')`, - ) - .run(USER, next, LAT, LNG, `${TRIP_END}T09:00:00Z`, `${TRIP_END}T12:00:00Z`, TRIP_END); + await seedSuggestion({ + source_visit_id: '912', trip: next, name: 'Hotel Adlon', lat: LAT, lng: LNG, + started_at: `${TRIP_END}T09:00:00Z`, ended_at: `${TRIP_END}T12:00:00Z`, duration_minutes: 180, + local_date: TRIP_END, state: 'accepted', source_hash: 'stale', + }); withVisits(lastDayVisit(912)); await svc.syncUser(USER); - expect(only().trip_id).toBe(next); + expect((await only()).trip_id).toBe(next); }); it('DAWARICH-SYNC-077: a stay in the slack before departure, inside no trip at all, stays with the window that found it', async () => { @@ -408,7 +402,7 @@ describe('DawarichSyncService, neighbouring trips', () => { await svc.syncUser(USER); - const row = only(); + const row = await only(); expect(row.local_date).toBe(eve); expect(row.trip_id).toBe(TRIP); }); @@ -423,7 +417,7 @@ describe('DawarichSyncService, neighbouring trips', () => { await svc.syncUser(USER); expect(listVisits).toHaveBeenCalledTimes(1); - expect(only().trip_id).toBe(TRIP); + expect((await only()).trip_id).toBe(TRIP); }); }); @@ -433,12 +427,12 @@ describe('DawarichSyncService — repeated runs', () => { it('DAWARICH-SYNC-010: a second run over identical data creates no duplicate', async () => { withVisits(visit({ id: 601 })); await svc.syncUser(USER); - const first = only(); + const first = await only(); const second = await svc.syncUser(USER); expect(second).toMatchObject({ state: 'ok', created: 0, updated: 0, missing: 0 }); - const row = only(); + const row = await only(); expect(row.id).toBe(first.id); expect(row.first_seen_at).toBe(first.first_seen_at); expect(row.source_hash).toBe(first.source_hash); @@ -447,13 +441,13 @@ describe('DawarichSyncService — repeated runs', () => { it('DAWARICH-SYNC-011: a changed visit rewrites a suggestion still in state "new"', async () => { withVisits(visit({ id: 602, name: 'Unnamed place' })); await svc.syncUser(USER); - const before = only(); + const before = await only(); withVisits(visit({ id: 602, name: 'Café Einstein', place: { latitude: 52.52, longitude: 13.38, id: 78 } })); const second = await svc.syncUser(USER); expect(second).toMatchObject({ created: 0, updated: 1 }); - const row = only(); + const row = await only(); expect(row.id).toBe(before.id); expect(row.name).toBe('Café Einstein'); expect(row.lat).toBeCloseTo(52.52, 4); @@ -464,20 +458,18 @@ describe('DawarichSyncService — repeated runs', () => { it('DAWARICH-SYNC-012: a changed visit in state "accepted" keeps the user text and only moves the hash', async () => { withVisits(visit({ id: 603, name: 'Hotel Adlon' })); await svc.syncUser(USER); - const before = only(); + const before = await only(); // What acceptance leaves behind: the user's own wording plus the hash they said yes to. - testDb - .prepare( - "UPDATE dawarich_visit_suggestions SET state = 'accepted', accepted_hash = source_hash, name = ? WHERE id = ?", - ) - .run('Our anniversary dinner', before.id); + await updateRows(t, DawarichVisitSuggestions, { id: before.id }, { + state: 'accepted', accepted_hash: before.source_hash, name: 'Our anniversary dinner', + }); withVisits(visit({ id: 603, name: 'Adlon Kempinski', place: { latitude: 52.4, longitude: 13.2, id: 79 } })); const second = await svc.syncUser(USER); expect(second).toMatchObject({ created: 0, updated: 1 }); - const row = only(); + const row = await only(); expect(row.state).toBe('accepted'); expect(row.name).toBe('Our anniversary dinner'); expect(row.lat).toBeCloseTo(LAT, 4); @@ -490,14 +482,14 @@ describe('DawarichSyncService — repeated runs', () => { it('DAWARICH-SYNC-013: a changed visit in state "dismissed" is likewise left alone', async () => { withVisits(visit({ id: 604, name: 'Petrol station' })); await svc.syncUser(USER); - const before = only(); - testDb.prepare("UPDATE dawarich_visit_suggestions SET state = 'dismissed' WHERE id = ?").run(before.id); + const before = await only(); + await updateRows(t, DawarichVisitSuggestions, { id: before.id }, { state: 'dismissed' }); withVisits(visit({ id: 604, name: 'Aral Tankstelle' })); const second = await svc.syncUser(USER); expect(second.updated).toBe(1); - const row = only(); + const row = await only(); expect(row.state).toBe('dismissed'); expect(row.name).toBe('Petrol station'); expect(row.source_hash).not.toBe(before.source_hash); @@ -506,7 +498,10 @@ describe('DawarichSyncService — repeated runs', () => { it('DAWARICH-SYNC-014: an unchanged accepted row is not counted as an update', async () => { withVisits(visit({ id: 605 })); await svc.syncUser(USER); - testDb.prepare("UPDATE dawarich_visit_suggestions SET state = 'accepted', accepted_hash = source_hash").run(); + // accepted_hash takes each row's own source_hash, so the rows are written one by one. + for (const row of await findRows(t, DawarichVisitSuggestions)) { + await updateRows(t, DawarichVisitSuggestions, { id: row.id }, { state: 'accepted', accepted_hash: row.source_hash }); + } const second = await svc.syncUser(USER); @@ -520,13 +515,13 @@ describe('DawarichSyncService — visits that vanish from the source', () => { it('DAWARICH-SYNC-020: an untouched suggestion is deleted when the source stops listing it', async () => { withVisits(visit({ id: 701 })); await svc.syncUser(USER); - expect(rows()).toHaveLength(1); + expect(await rows()).toHaveLength(1); withVisits(); const second = await svc.syncUser(USER); expect(second).toMatchObject({ created: 0, updated: 0, missing: 1 }); - expect(rows()).toHaveLength(0); + expect(await rows()).toHaveLength(0); }); it('DAWARICH-SYNC-021: an accepted suggestion survives and is only stamped source_missing_at', async () => { @@ -540,13 +535,13 @@ describe('DawarichSyncService — visits that vanish from the source', () => { }), ); await svc.syncUser(USER); - testDb.prepare("UPDATE dawarich_visit_suggestions SET state = 'accepted' WHERE source_visit_id = '703'").run(); + await updateRows(t, DawarichVisitSuggestions, { source_visit_id: '703' }, { state: 'accepted' }); withVisits(); const second = await svc.syncUser(USER); expect(second.missing).toBe(2); - const surviving = only(); + const surviving = await only(); expect(surviving.source_visit_id).toBe('703'); expect(surviving.state).toBe('accepted'); expect(surviving.name).toBe('Museumsinsel'); @@ -556,31 +551,31 @@ describe('DawarichSyncService — visits that vanish from the source', () => { it('DAWARICH-SYNC-022: a second empty run does not move an existing source_missing_at', async () => { withVisits(visit({ id: 704 })); await svc.syncUser(USER); - testDb.prepare("UPDATE dawarich_visit_suggestions SET state = 'accepted' WHERE source_visit_id = '704'").run(); + await updateRows(t, DawarichVisitSuggestions, { source_visit_id: '704' }, { state: 'accepted' }); withVisits(); await svc.syncUser(USER); - const firstStamp = only().source_missing_at; + const firstStamp = (await only()).source_missing_at; expect(firstStamp).not.toBeNull(); await svc.syncUser(USER); - expect(only().source_missing_at).toBe(firstStamp); + expect((await only()).source_missing_at).toBe(firstStamp); }); it('DAWARICH-SYNC-023: a visit that comes back clears the missing flag', async () => { withVisits(visit({ id: 705 })); await svc.syncUser(USER); - testDb.prepare("UPDATE dawarich_visit_suggestions SET state = 'accepted' WHERE source_visit_id = '705'").run(); + await updateRows(t, DawarichVisitSuggestions, { source_visit_id: '705' }, { state: 'accepted' }); withVisits(); await svc.syncUser(USER); - expect(only().source_missing_at).not.toBeNull(); + expect((await only()).source_missing_at).not.toBeNull(); withVisits(visit({ id: 705 })); await svc.syncUser(USER); - expect(only().source_missing_at).toBeNull(); + expect((await only()).source_missing_at).toBeNull(); }); it('DAWARICH-SYNC-024: a suggestion outside the fetched window is untouched by the reconciliation', async () => { @@ -588,21 +583,17 @@ describe('DawarichSyncService — visits that vanish from the source', () => { await svc.syncUser(USER); // Same user and trip, but a start date years before the window this trip asks about. - 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, state, source_hash) - VALUES (?, '999', ?, 'Ancient stay', ?, ?, '2019-01-01T10:00:00Z', '2019-01-01T12:00:00Z', 120, - '2019-01-01', 'suggested', 'new', 'deadbeef')`, - ) - .run(USER, TRIP, LAT, LNG); + await seedSuggestion({ + source_visit_id: '999', trip: TRIP, name: 'Ancient stay', lat: LAT, lng: LNG, + started_at: '2019-01-01T10:00:00Z', ended_at: '2019-01-01T12:00:00Z', duration_minutes: 120, + local_date: '2019-01-01', state: 'new', source_hash: 'deadbeef', + }); withVisits(); const second = await svc.syncUser(USER); expect(second.missing).toBe(1); - expect(only().source_visit_id).toBe('999'); + expect((await only()).source_visit_id).toBe('999'); }); }); @@ -610,43 +601,43 @@ describe('DawarichSyncService — visits that vanish from the source', () => { describe('DawarichSyncService — bucket-list matching', () => { it('DAWARICH-SYNC-030: links a wish that is both close enough and dwelt on long enough', async () => { - const wish = bucketItem('Brandenburger Tor', LAT + 0.001, LNG); // ~111 m + const wish = await bucketItem('Brandenburger Tor', LAT + 0.001, LNG); // ~111 m withVisits(visit({ id: 801, started_at: `${VISIT_DAY}T09:00:00Z`, ended_at: `${VISIT_DAY}T10:00:00Z` })); await svc.syncUser(USER); - expect(only().matched_bucket_list_item_id).toBe(wish); + expect((await only()).matched_bucket_list_item_id).toBe(wish); }); it('DAWARICH-SYNC-031: refuses a wish that is close but whose stay is too short', async () => { - bucketItem('Brandenburger Tor', LAT + 0.001, LNG); + await bucketItem('Brandenburger Tor', LAT + 0.001, LNG); // Ten minutes, under DAWARICH_BUCKET_MATCH_MIN_MINUTES. withVisits(visit({ id: 802, started_at: `${VISIT_DAY}T09:00:00Z`, ended_at: `${VISIT_DAY}T09:10:00Z` })); await svc.syncUser(USER); - const row = only(); + const row = await only(); expect(row.duration_minutes).toBe(10); expect(row.matched_bucket_list_item_id).toBeNull(); }); it('DAWARICH-SYNC-032: refuses a long stay that is inside the coarse box but beyond the radius', async () => { - bucketItem('Reichstag', LAT + 0.008, LNG); // ~890 m: inside the prefilter box, outside 250 m + await bucketItem('Reichstag', LAT + 0.008, LNG); // ~890 m: inside the prefilter box, outside 250 m withVisits(visit({ id: 803 })); await svc.syncUser(USER); - expect(only().matched_bucket_list_item_id).toBeNull(); + expect((await only()).matched_bucket_list_item_id).toBeNull(); }); it('DAWARICH-SYNC-033: picks the nearest wish when several are in range', async () => { - const far = bucketItem('Pariser Platz', LAT + 0.002, LNG); // ~222 m - const near = bucketItem('Brandenburger Tor', LAT + 0.0005, LNG); // ~56 m + const far = await bucketItem('Pariser Platz', LAT + 0.002, LNG); // ~222 m + const near = await bucketItem('Brandenburger Tor', LAT + 0.0005, LNG); // ~56 m withVisits(visit({ id: 804 })); await svc.syncUser(USER); - const matched = only().matched_bucket_list_item_id; + const matched = (await only()).matched_bucket_list_item_id; expect(matched).toBe(near); expect(matched).not.toBe(far); }); @@ -657,34 +648,34 @@ describe('DawarichSyncService — bucket-list matching', () => { // with a winner already held. The loop then has to keep what it has instead // of taking whatever it looked at last; both wishes are inside the radius, // so nothing else in the loop can decide it. - const near = bucketItem('Brandenburger Tor', LAT + 0.0005, LNG); // ~56 m - const far = bucketItem('Pariser Platz', LAT + 0.002, LNG); // ~222 m, still inside 250 m + const near = await bucketItem('Brandenburger Tor', LAT + 0.0005, LNG); // ~56 m + const far = await bucketItem('Pariser Platz', LAT + 0.002, LNG); // ~222 m, still inside 250 m withVisits(visit({ id: 808 })); await svc.syncUser(USER); - const matched = only().matched_bucket_list_item_id; + const matched = (await only()).matched_bucket_list_item_id; expect(matched).toBe(near); expect(matched).not.toBe(far); }); it('DAWARICH-SYNC-034: ignores a wish belonging to another user', async () => { const other = createUser(testDb).user.id; - bucketItem('Brandenburger Tor', LAT + 0.0005, LNG, other); + await bucketItem('Brandenburger Tor', LAT + 0.0005, LNG, other); withVisits(visit({ id: 805 })); await svc.syncUser(USER); - expect(only().matched_bucket_list_item_id).toBeNull(); + expect((await only()).matched_bucket_list_item_id).toBeNull(); }); it('DAWARICH-SYNC-035: a visit without coordinates matches nothing', async () => { - bucketItem('Brandenburger Tor', LAT, LNG); + await bucketItem('Brandenburger Tor', LAT, LNG); withVisits(visit({ id: 806, place: null })); await svc.syncUser(USER); - const row = only(); + const row = await only(); expect(row.lat).toBeNull(); expect(row.matched_bucket_list_item_id).toBeNull(); }); @@ -694,7 +685,7 @@ describe('DawarichSyncService — bucket-list matching', () => { // The café across the square from the museum satisfies the radius exactly as // the museum does; showing the same wish on both turns one achievement into // two claims and invites ticking it off from the wrong one. - const wish = bucketItem('Museum Ludwig', LAT, LNG); + const wish = await bucketItem('Museum Ludwig', LAT, LNG); withVisits( visit({ id: 810, name: 'Cafe Reichard', place: { latitude: LAT + 0.0018, longitude: LNG, id: 1 } }), visit({ id: 811, name: 'Museum Ludwig', place: { latitude: LAT + 0.0002, longitude: LNG, id: 2 } }), @@ -702,13 +693,13 @@ describe('DawarichSyncService — bucket-list matching', () => { await svc.syncUser(USER); - const all = rows(); + const all = await rows(); expect(all.find((r) => r.source_visit_id === '811')!.matched_bucket_list_item_id).toBe(wish); expect(all.find((r) => r.source_visit_id === '810')!.matched_bucket_list_item_id).toBeNull(); }); it('DAWARICH-SYNC-038: the order the visits arrive in does not decide who keeps the wish', async () => { - const wish = bucketItem('Museum Ludwig', LAT, LNG); + const wish = await bucketItem('Museum Ludwig', LAT, LNG); withVisits( visit({ id: 821, name: 'Museum Ludwig', place: { latitude: LAT + 0.0002, longitude: LNG, id: 2 } }), visit({ id: 822, name: 'Cafe Reichard', place: { latitude: LAT + 0.0018, longitude: LNG, id: 1 } }), @@ -716,7 +707,7 @@ describe('DawarichSyncService — bucket-list matching', () => { await svc.syncUser(USER); - const all = rows(); + const all = await rows(); expect(all.find((r) => r.source_visit_id === '821')!.matched_bucket_list_item_id).toBe(wish); expect(all.find((r) => r.source_visit_id === '822')!.matched_bucket_list_item_id).toBeNull(); }); @@ -726,7 +717,7 @@ describe('DawarichSyncService — bucket-list matching', () => { // same number rather than merely similar and the tie-break is the only // thing left to decide it. Standing somewhere for three hours is a better // answer to "were you there" than half an hour on the way past. - const wish = bucketItem('Museum Ludwig', LAT, LNG); + const wish = await bucketItem('Museum Ludwig', LAT, LNG); withVisits( visit({ id: 840, @@ -746,7 +737,7 @@ describe('DawarichSyncService — bucket-list matching', () => { await svc.syncUser(USER); - const all = rows(); + const all = await rows(); expect(all.find((r) => r.source_visit_id === '841')!.matched_bucket_list_item_id).toBe(wish); expect(all.find((r) => r.source_visit_id === '840')!.matched_bucket_list_item_id).toBeNull(); }); @@ -755,7 +746,7 @@ describe('DawarichSyncService — bucket-list matching', () => { // The mirror image of 068. Here the incoming stay is the short one, so the // claim has to be refused rather than won. Otherwise the answer would // depend on the order the payload happened to list them in. - const wish = bucketItem('Museum Ludwig', LAT, LNG); + const wish = await bucketItem('Museum Ludwig', LAT, LNG); withVisits( visit({ id: 850, @@ -775,7 +766,7 @@ describe('DawarichSyncService — bucket-list matching', () => { await svc.syncUser(USER); - const all = rows(); + const all = await rows(); expect(all.find((r) => r.source_visit_id === '850')!.matched_bucket_list_item_id).toBe(wish); expect(all.find((r) => r.source_visit_id === '851')!.matched_bucket_list_item_id).toBeNull(); }); @@ -786,21 +777,17 @@ describe('DawarichSyncService — bucket-list matching', () => { // next real stay take the wish; treating an unmeasurable holder as the // winner would freeze the match on a row nobody can act on. Dated well // outside the synced window so the reconciliation leaves it alone. - const wish = bucketItem('Brandenburger Tor', LAT, LNG); - 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, state, source_hash, matched_bucket_list_item_id) - VALUES (?, '990', ?, 'Stay without a position', NULL, NULL, '2019-01-01T10:00:00Z', - '2019-01-01T12:00:00Z', 120, '2019-01-01', 'suggested', 'new', 'deadbeef', ?)`, - ) - .run(USER, TRIP, wish); + const wish = await bucketItem('Brandenburger Tor', LAT, LNG); + await seedSuggestion({ + source_visit_id: '990', trip: TRIP, name: 'Stay without a position', lat: null, lng: null, + started_at: '2019-01-01T10:00:00Z', ended_at: '2019-01-01T12:00:00Z', duration_minutes: 120, + local_date: '2019-01-01', state: 'new', source_hash: 'deadbeef', matchedBucketListItem: wish, + }); withVisits(visit({ id: 842 })); await svc.syncUser(USER); - const all = rows(); + const all = await rows(); expect(all.find((r) => r.source_visit_id === '842')!.matched_bucket_list_item_id).toBe(wish); expect(all.find((r) => r.source_visit_id === '990')!.matched_bucket_list_item_id).toBeNull(); }); @@ -813,30 +800,19 @@ describe('DawarichSyncService — bucket-list matching', () => { // (assign the new one), in the SAME transaction — reversed, or run as two // independent statements, a concurrent read between them could observe // either two holders or zero. - const wish = bucketItem('Brandenburger Tor', LAT, LNG); - 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, state, source_hash, matched_bucket_list_item_id) - VALUES (?, '970', ?, 'Old holder A', ?, ?, '2019-01-01T10:00:00Z', '2019-01-01T12:00:00Z', 60, - '2019-01-01', 'suggested', 'new', 'deadbeef-a', ?)`, - ) - .run(USER, TRIP, LAT, LNG, wish); - 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, state, source_hash, matched_bucket_list_item_id) - VALUES (?, '971', ?, 'Old holder B', ?, ?, '2019-01-01T10:00:00Z', '2019-01-01T12:00:00Z', 60, - '2019-01-01', 'suggested', 'new', 'deadbeef-b', ?)`, - ) - .run(USER, TRIP, LAT, LNG, wish); + const wish = await bucketItem('Brandenburger Tor', LAT, LNG); + for (const [id, name, hash] of [['970', 'Old holder A', 'deadbeef-a'], ['971', 'Old holder B', 'deadbeef-b']]) { + await seedSuggestion({ + source_visit_id: id, trip: TRIP, name, lat: LAT, lng: LNG, + started_at: '2019-01-01T10:00:00Z', ended_at: '2019-01-01T12:00:00Z', duration_minutes: 60, + local_date: '2019-01-01', state: 'new', source_hash: hash, matchedBucketListItem: wish, + }); + } withVisits(visit({ id: 972 })); await svc.syncUser(USER); - const all = rows(); + const all = await rows(); const holders = all.filter((r) => r.matched_bucket_list_item_id === wish); expect(holders).toHaveLength(1); expect(holders[0]!.source_visit_id).toBe('972'); @@ -845,13 +821,13 @@ describe('DawarichSyncService — bucket-list matching', () => { it('DAWARICH-SYNC-036: a suggestion still in state "new" is re-matched on a later run', async () => { withVisits(visit({ id: 807 })); await svc.syncUser(USER); - expect(only().matched_bucket_list_item_id).toBeNull(); + expect((await only()).matched_bucket_list_item_id).toBeNull(); - const wish = bucketItem('Brandenburger Tor', LAT + 0.0005, LNG); + const wish = await bucketItem('Brandenburger Tor', LAT + 0.0005, LNG); withVisits(visit({ id: 807, name: 'Brandenburg Gate' })); await svc.syncUser(USER); - expect(only().matched_bucket_list_item_id).toBe(wish); + expect((await only()).matched_bucket_list_item_id).toBe(wish); }); }); @@ -874,7 +850,7 @@ describe('DawarichSyncService — syncUser result state', () => { expect(listVisits).toHaveBeenCalledTimes(2); expect(result.state).toBe('partial'); expect(recordSyncResult).toHaveBeenCalledWith(USER, 'partial', 'unreachable'); - expect(connection()).toMatchObject({ last_sync_state: 'partial', last_sync_error: 'unreachable' }); + expect(await connection()).toMatchObject({ last_sync_state: 'partial', last_sync_error: 'unreachable' }); }); it('DAWARICH-SYNC-041: reports "failed" when every trip fails', async () => { @@ -885,7 +861,7 @@ describe('DawarichSyncService — syncUser result state', () => { expect(listVisits).toHaveBeenCalledTimes(2); expect(result).toMatchObject({ state: 'failed', created: 0, updated: 0, missing: 0 }); - expect(connection()).toMatchObject({ last_sync_state: 'failed', last_sync_error: 'unauthorized' }); + expect(await connection()).toMatchObject({ last_sync_state: 'failed', last_sync_error: 'unauthorized' }); }); it('DAWARICH-SYNC-042: a throw that is not a DawarichError is recorded as "unreachable"', async () => { @@ -894,31 +870,27 @@ describe('DawarichSyncService — syncUser result state', () => { const result = await svc.syncUser(USER); expect(result.state).toBe('failed'); - expect(connection().last_sync_error).toBe('unreachable'); + expect((await connection()).last_sync_error).toBe('unreachable'); }); it('DAWARICH-SYNC-043: reports "ok" and clears the stored error on a clean run', async () => { - testDb - .prepare( - "UPDATE dawarich_connections SET last_sync_state = 'failed', last_sync_error = 'unreachable' WHERE user_id = ?", - ) - .run(USER); + await updateRows(t, DawarichConnections, { user: USER }, { last_sync_state: 'failed', last_sync_error: 'unreachable' }); withVisits(visit({ id: 902 })); const result = await svc.syncUser(USER); expect(result.state).toBe('ok'); - expect(connection()).toMatchObject({ last_sync_state: 'ok', last_sync_error: null }); + expect(await connection()).toMatchObject({ last_sync_state: 'ok', last_sync_error: null }); }); it('DAWARICH-SYNC-044: a user with no syncable trip is "ok", not a failure', async () => { - testDb.prepare('DELETE FROM trips WHERE id = ?').run(TRIP); + await deleteRows(t, Trips, { id: TRIP }); const result = await svc.syncUser(USER); expect(result).toMatchObject({ state: 'ok', created: 0, updated: 0, missing: 0 }); expect(listVisits).not.toHaveBeenCalled(); - expect(connection()).toMatchObject({ last_sync_state: 'ok', last_sync_error: null }); + expect(await connection()).toMatchObject({ last_sync_state: 'ok', last_sync_error: null }); }); it('DAWARICH-SYNC-072: a trip whose start date is not a date is skipped, not asked about', async () => { @@ -926,18 +898,18 @@ describe('DawarichSyncService — syncUser result state', () => { // filter and still is not a date. Without the window guard the request // would go out with a NaN boundary, which Dawarich reads as "everything", // and the answer would be the user's entire archive. - testDb.prepare("UPDATE trips SET start_date = '0000-00-00', end_date = NULL WHERE id = ?").run(TRIP); + await updateRows(t, Trips, { id: TRIP }, { start_date: '0000-00-00', end_date: null }); const result = await svc.syncUser(USER); expect(listVisits).not.toHaveBeenCalled(); // Nothing failed: there was simply nothing answerable to ask. expect(result).toMatchObject({ state: 'ok', created: 0, updated: 0, missing: 0 }); - expect(connection()).toMatchObject({ last_sync_state: 'ok', last_sync_error: null }); + expect(await connection()).toMatchObject({ last_sync_state: 'ok', last_sync_error: null }); }); it('DAWARICH-SYNC-045: an archived trip is not polled', async () => { - testDb.prepare('UPDATE trips SET is_archived = 1 WHERE id = ?').run(TRIP); + await updateRows(t, Trips, { id: TRIP }, { is_archived: 1 }); const result = await svc.syncUser(USER); @@ -948,8 +920,8 @@ describe('DawarichSyncService — syncUser result state', () => { it('DAWARICH-SYNC-046: a trip the user is only a member of is polled too', async () => { const owner = createUser(testDb).user.id; const shared = createTrip(testDb, owner, { start_date: dayOffset(-12), end_date: dayOffset(-10) }).id; - testDb.prepare('INSERT INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)').run(shared, USER, owner); - testDb.prepare('DELETE FROM trips WHERE id = ?').run(TRIP); + await addTripMember(t, shared, USER, owner); + await deleteRows(t, Trips, { id: TRIP }); withVisits(); const result = await svc.syncUser(USER); @@ -996,18 +968,18 @@ describe('DawarichSyncService — gates', () => { expect(result).toMatchObject({ state: 'failed', created: 0, updated: 0, missing: 0 }); expect(recordSyncResult).toHaveBeenCalledWith(USER, 'failed', 'addon_disabled'); - expect(connection()).toMatchObject({ last_sync_state: 'failed', last_sync_error: 'addon_disabled' }); + expect(await connection()).toMatchObject({ last_sync_state: 'failed', last_sync_error: 'addon_disabled' }); expect(listVisits).not.toHaveBeenCalled(); }); it('DAWARICH-SYNC-051: without a connection the run is stored as failed/not_connected', async () => { - connect(USER, { apiKey: null }); + await connect(USER, { apiKey: null }); const result = await svc.syncUser(USER); expect(result).toMatchObject({ state: 'failed', created: 0, updated: 0, missing: 0 }); expect(recordSyncResult).toHaveBeenCalledWith(USER, 'failed', 'not_connected'); - expect(connection()).toMatchObject({ last_sync_state: 'failed', last_sync_error: 'not_connected' }); + expect(await connection()).toMatchObject({ last_sync_state: 'failed', last_sync_error: 'not_connected' }); expect(listVisits).not.toHaveBeenCalled(); }); @@ -1033,7 +1005,7 @@ describe('DawarichSyncService — runSync', () => { it('DAWARICH-SYNC-061: one user blowing up does not stop the next one', async () => { const other = createUser(testDb).user.id; createTrip(testDb, other, { start_date: TRIP_START, end_date: TRIP_END }); - connect(other); + await connect(other); withVisits(); // A hard throw, i.e. the case syncUser does not catch for itself. getCredentials.mockImplementationOnce(() => { @@ -1053,7 +1025,7 @@ describe('DawarichSyncService — runSync', () => { // handler, out of the loop, and take every remaining user with it. const other = createUser(testDb).user.id; createTrip(testDb, other, { start_date: TRIP_START, end_date: TRIP_END }); - connect(other); + await connect(other); withVisits(); getCredentials.mockImplementationOnce(() => { throw 'credential store returned a string'; @@ -1065,7 +1037,7 @@ describe('DawarichSyncService — runSync', () => { }); it('DAWARICH-SYNC-062: skips a connection whose background sync is switched off', async () => { - connect(USER, { syncEnabled: false }); + await connect(USER, { syncEnabled: false }); withVisits(); await svc.runSync(); @@ -1119,9 +1091,7 @@ describe('DawarichSyncService — runSync', () => { // answer is what the connection currently holds. Answering "ok" would // clear a warning nobody fixed, and answering "never" would wipe the // history of a connection that has synced for months. - testDb - .prepare("UPDATE dawarich_connections SET last_sync_state = 'partial' WHERE user_id = ?") - .run(USER); + await updateRows(t, DawarichConnections, { user: USER }, { last_sync_state: 'partial' }); let release: (value: { visits: DawarichVisitRaw[]; truncated: boolean; version: string | null }) => void = () => {}; listVisits.mockReturnValue( @@ -1143,7 +1113,7 @@ describe('DawarichSyncService — runSync', () => { // row while the button press it triggered is still walking windows. There // is no stored state left to report, and `never` is the one the wire // contract allows. - testDb.prepare('DELETE FROM dawarich_connections WHERE user_id = ?').run(USER); + await deleteRows(t, DawarichConnections, { user: USER }); const first = svc.syncUser(USER); const second = await svc.syncUser(USER); diff --git a/server/tests/unit/nest/files.service.test.ts b/server/tests/unit/nest/files.service.test.ts index 138dfa2b0..93a013a90 100644 --- a/server/tests/unit/nest/files.service.test.ts +++ b/server/tests/unit/nest/files.service.test.ts @@ -26,14 +26,6 @@ vi.mock('../../../src/db/database', async () => { closeDb: () => {}, reinitialize: () => {}, getPlaceWithTags: () => null, - canAccessTrip: (tripId: unknown, 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: unknown, userId: number) => - !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId), }; }); @@ -60,6 +52,13 @@ import { createUser, createTrip, addTripMember, createPlace, createReservation, import type { TripAccess } from '../../../src/db/repositories/Trips.repository'; import { createTestUnitOfWork, createTestAppSettingsRepo, createTestReservationsRepo, createTestPlacesRepo, createTestDayAssignmentsRepo, createTestTripsRepo } from '../../helpers/test-uow'; import { createTestTripFilesRepo, createTestFileLinksRepo, createTestBudgetItemsRepo } from '../../helpers/files-repos'; +import { sharedTestOrm } from '../../helpers/test-uow'; +import type { TestOrm } from '../../helpers/test-orm'; +import { countRows, findRow, insertRow } from '../../helpers/factories/rows'; +import { makeCollabMessage } from '../../helpers/factories/collab'; +import { BudgetItems } from '../../../src/db/entities/BudgetItems.entity'; +import { FileLinks } from '../../../src/db/entities/FileLinks.entity'; +import { TripFiles } from '../../../src/db/entities/TripFiles.entity'; import type { TripFilesRepository } from '../../../src/db/repositories/TripFiles.repository'; import type { FileLinksRepository } from '../../../src/db/repositories/FileLinks.repository'; import type { PermissionsService } from '../../../src/nest/permissions/permissions.service'; @@ -102,8 +101,15 @@ const emStub = { getRepository } as unknown as EntityManager; let svc: FilesService; let tripFilesRepo: TripFilesRepository; let fileLinksRepo: FileLinksRepository; +let orm: TestOrm; + +/** An expense on the trip with only a name, the bare row a receipt hangs off. */ +function insertBudgetItem(tripId: number, name: string): Promise { + return insertRow(orm, BudgetItems, { trip: tripId, name }); +} beforeAll(async () => { + orm = await sharedTestOrm(testDb); tripFilesRepo = await createTestTripFilesRepo(testDb); fileLinksRepo = await createTestFileLinksRepo(testDb); svc = new FilesService( @@ -305,7 +311,7 @@ describe('listFiles', () => { expect(bareRow.linked_reservation_ids).toEqual([]); expect(bareRow.linked_place_ids).toEqual([]); - const item = Number(testDb.prepare('INSERT INTO budget_items (trip_id, name) VALUES (?, ?)').run(trip.id, 'Dinner').lastInsertRowid); + const item = await insertBudgetItem(trip.id, 'Dinner'); await svc.createFileLink(linked.id, { budget_item_id: item }); const withReceipt = (await svc.listFiles(trip.id, false) as Record[]).find((f) => f.id === linked.id); expect(withReceipt.linked_budget_item_ids).toEqual([item]); @@ -321,45 +327,45 @@ describe('listFiles', () => { describe('budget receipts', () => { function seedItem(tripId: number) { - return Number(testDb.prepare('INSERT INTO budget_items (trip_id, name) VALUES (?, ?)').run(tripId, 'Dinner').lastInsertRowid); + return insertBudgetItem(tripId, 'Dinner'); } it('FILE-SVC-040: an upload naming an expense gets its link row straight away', async () => { const { user, trip } = seedTrip(); - const item = seedItem(trip.id); + const item = await seedItem(trip.id); const file = await makeFile(trip.id, user.id, {}, { budget_item_id: item }); - expect(testDb.prepare('SELECT COUNT(*) c FROM file_links WHERE file_id = ? AND budget_item_id = ?').get(file.id, item)).toEqual({ c: 1 }); + expect(await countRows(orm, FileLinks, { file: file.id, budgetItem: item })).toBe(1); }); it('FILE-SVC-041: an upload without one writes no link at all', async () => { const { user, trip } = seedTrip(); const file = await makeFile(trip.id, user.id); - expect(testDb.prepare('SELECT COUNT(*) c FROM file_links WHERE file_id = ?').get(file.id)).toEqual({ c: 0 }); + expect(await countRows(orm, FileLinks, { file: file.id })).toBe(0); }); it('FILE-SVC-042: updateFile attaches to an expense and detaches on a falsy id', async () => { const { user, trip } = seedTrip(); - const item = seedItem(trip.id); + const item = await seedItem(trip.id); const file = await makeFile(trip.id, user.id); await svc.updateFile(file.id, file, { budget_item_id: item }); - expect(testDb.prepare('SELECT COUNT(*) c FROM file_links WHERE file_id = ? AND budget_item_id = ?').get(file.id, item)).toEqual({ c: 1 }); + expect(await countRows(orm, FileLinks, { file: file.id, budgetItem: item })).toBe(1); // Sending it twice must not double the row. await svc.updateFile(file.id, file, { budget_item_id: item }); - expect(testDb.prepare('SELECT COUNT(*) c FROM file_links WHERE file_id = ?').get(file.id)).toEqual({ c: 1 }); + expect(await countRows(orm, FileLinks, { file: file.id })).toBe(1); await svc.updateFile(file.id, file, { budget_item_id: null }); - expect(testDb.prepare('SELECT COUNT(*) c FROM file_links WHERE file_id = ? AND budget_item_id IS NOT NULL').get(file.id)).toEqual({ c: 0 }); + expect(await countRows(orm, FileLinks, { file: file.id, budgetItem: { $ne: null } })).toBe(0); }); it('FILE-SVC-043: leaving budget_item_id out touches no link', async () => { const { user, trip } = seedTrip(); - const item = seedItem(trip.id); + const item = await seedItem(trip.id); const file = await makeFile(trip.id, user.id, {}, { budget_item_id: item }); await svc.updateFile(file.id, file, { description: 'renamed' }); - expect(testDb.prepare('SELECT COUNT(*) c FROM file_links WHERE file_id = ? AND budget_item_id = ?').get(file.id, item)).toEqual({ c: 1 }); + expect(await countRows(orm, FileLinks, { file: file.id, budgetItem: item })).toBe(1); }); }); @@ -369,10 +375,10 @@ describe('createFile', () => { it('FILE-SVC-015: coerces falsy opts to NULL (|| null) and re-selects the formatted row', async () => { const { user, trip } = seedTrip(); const file = await makeFile(trip.id, user.id, {}, { place_id: '', reservation_id: undefined, description: '' }); - const row = testDb.prepare('SELECT * FROM trip_files WHERE id = ?').get(file.id) as Record; - expect(row.place_id).toBeNull(); - expect(row.reservation_id).toBeNull(); - expect(row.description).toBeNull(); + const row = await findRow(orm, TripFiles, { id: file.id }); + expect(row?.place_id).toBeNull(); + expect(row?.reservation_id).toBeNull(); + expect(row?.description).toBeNull(); expect(file.url).toBe(`/api/trips/${trip.id}/files/${file.id}/download`); expect((file as unknown as Record).uploaded_by_name).toBe(user.username); }); @@ -393,10 +399,10 @@ describe('createFile', () => { it('FILE-SVC-060 (R2): a failing file_links insert rolls back the trip_files row too', async () => { const { user, trip } = seedTrip(); - const item = Number(testDb.prepare('INSERT INTO budget_items (trip_id, name) VALUES (?, ?)').run(trip.id, 'Dinner').lastInsertRowid); + const item = await insertBudgetItem(trip.id, 'Dinner'); const spy = vi.spyOn(fileLinksRepo, 'insertIgnore').mockRejectedValueOnce(new Error('boom')); await expect(makeFile(trip.id, user.id, {}, { budget_item_id: item })).rejects.toThrow('boom'); - expect(testDb.prepare('SELECT COUNT(*) c FROM trip_files WHERE trip_id = ?').get(trip.id)).toEqual({ c: 0 }); + expect(await countRows(orm, TripFiles, { trip: trip.id })).toBe(0); spy.mockRestore(); }); }); @@ -426,15 +432,15 @@ describe('updateFile', () => { it('FILE-SVC-061 (R2): a failing file_links swap rolls back the description/place/reservation update too', async () => { const { user, trip } = seedTrip(); - const item = Number(testDb.prepare('INSERT INTO budget_items (trip_id, name) VALUES (?, ?)').run(trip.id, 'Dinner').lastInsertRowid); + const item = await insertBudgetItem(trip.id, 'Dinner'); const place = createPlace(testDb, trip.id); const file = await makeFile(trip.id, user.id, {}, { description: 'old' }); const current = (await svc.getFileById(file.id, trip.id))!; const spy = vi.spyOn(fileLinksRepo, 'insertIgnore').mockRejectedValueOnce(new Error('boom')); await expect(svc.updateFile(file.id, current, { description: 'new', place_id: String(place.id), budget_item_id: item })).rejects.toThrow('boom'); - const row = testDb.prepare('SELECT description, place_id FROM trip_files WHERE id = ?').get(file.id) as Record; - expect(row.description).toBe('old'); - expect(row.place_id).toBeNull(); + const row = await findRow(orm, TripFiles, { id: file.id }); + expect(row?.description).toBe('old'); + expect(row?.place_id).toBeNull(); spy.mockRestore(); }); }); @@ -453,8 +459,8 @@ describe('toggleStarred / softDeleteFile / restoreFile', () => { const { user, trip } = seedTrip(); const file = await makeFile(trip.id, user.id); await svc.softDeleteFile(file.id); - const row = testDb.prepare('SELECT deleted_at FROM trip_files WHERE id = ?').get(file.id) as Record; - expect(row.deleted_at).not.toBeNull(); + const row = await findRow(orm, TripFiles, { id: file.id }); + expect(row?.deleted_at).not.toBeNull(); }); it('FILE-SVC-021: restoreFile clears deleted_at and returns the formatted row', async () => { @@ -546,7 +552,7 @@ describe('emptyTrash', () => { storageDelete.mockResolvedValue(undefined); const spy = vi.spyOn(tripFilesRepo, 'deleteMany').mockRejectedValueOnce(new Error('boom')); await expect(svc.emptyTrash(trip.id)).rejects.toThrow('boom'); - expect(testDb.prepare('SELECT COUNT(*) c FROM trip_files WHERE id = ?').get(a.id)).toEqual({ c: 1 }); + expect(await countRows(orm, TripFiles, { id: a.id })).toBe(1); spy.mockRestore(); }); }); @@ -575,8 +581,8 @@ describe('findForeignLinkTarget', () => { expect(await svc.findForeignLinkTarget(mine.id, { reservation_id: myRes.id })).toBeNull(); // A receipt may only point at an expense on the same trip. - const foreignItem = Number(testDb.prepare('INSERT INTO budget_items (trip_id, name) VALUES (?, ?)').run(foreign.id, 'Foreign').lastInsertRowid); - const myItem = Number(testDb.prepare('INSERT INTO budget_items (trip_id, name) VALUES (?, ?)').run(mine.id, 'Mine').lastInsertRowid); + const foreignItem = await insertBudgetItem(foreign.id, 'Foreign'); + const myItem = await insertBudgetItem(mine.id, 'Mine'); expect(await svc.findForeignLinkTarget(mine.id, { budget_item_id: foreignItem })).toBe('budget_item_id'); expect(await svc.findForeignLinkTarget(mine.id, { budget_item_id: myItem })).toBeNull(); }); @@ -724,19 +730,17 @@ describe('read-model parity: raw SQL vs. the repository-backed reads', () => { const reservation = createReservation(testDb, trip.id); const place = createPlace(testDb, trip.id); const note = createCollabNote(testDb, trip.id, user.id); - const messageId = Number( - testDb.prepare('INSERT INTO collab_messages (trip_id, user_id, text) VALUES (?, ?, ?)').run(trip.id, user.id, 'hi').lastInsertRowid, - ); - // Raw insert, not svc.createFile: note_id/message_id are set by OTHER + const messageId = (await makeCollabMessage(orm, trip.id, user.id, { text: 'hi' })).id; + // Direct insert, not svc.createFile: note_id/message_id are set by OTHER // domains (collab), never by FilesService itself — this seeds every // persist(false) mirror at once to prove the Kysely `selectAll()` read // carries all six, the trap the class docstring names. - const inserted = testDb.prepare(` - INSERT INTO trip_files (trip_id, place_id, reservation_id, filename, original_name, file_size, mime_type, description, note_id, uploaded_by, starred, message_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run(trip.id, place.id, reservation.id, 'a.pdf', 'A.pdf', 10, 'application/pdf', 'a note', note.id, user.id, 1, messageId); - const id = Number(inserted.lastInsertRowid); + const id = await insertRow(orm, TripFiles, { + trip: trip.id, place: place.id, reservation: reservation.id, filename: 'a.pdf', original_name: 'A.pdf', file_size: 10, + mime_type: 'application/pdf', description: 'a note', note: note.id, uploadedByRef: user.id, starred: 1, message: messageId, + }); + // test-sql-allow: the raw statement is the legacy oracle this parity test holds the repository to. const legacy = testDb.prepare('SELECT * FROM trip_files WHERE id = ? AND trip_id = ?').get(id, trip.id); expect(await svc.getFileById(id, trip.id)).toEqual(legacy); }); @@ -745,7 +749,7 @@ describe('read-model parity: raw SQL vs. the repository-backed reads', () => { const { user, trip } = seedTrip(); const reservation = createReservation(testDb, trip.id, { title: 'Night train' }); const place = createPlace(testDb, trip.id); - const item = Number(testDb.prepare('INSERT INTO budget_items (trip_id, name) VALUES (?, ?)').run(trip.id, 'Dinner').lastInsertRowid); + const item = await insertBudgetItem(trip.id, 'Dinner'); // `linked_*_ids` come from `file_links` rows (FL8), a SEPARATE mechanism // from `trip_files.reservation_id`/`place_id` (FILE_SELECT's own join) — // one `createFileLink` call can attach several targets to the same file @@ -761,7 +765,9 @@ describe('read-model parity: raw SQL vs. the repository-backed reads', () => { LEFT JOIN reservations r ON f.reservation_id = r.id LEFT JOIN users u ON f.uploaded_by = u.id `; + // test-sql-allow: the raw statement is the legacy oracle this parity test holds the repository to. const legacyLive = testDb.prepare(`${FILE_SELECT} WHERE f.id = ?`).get(live.id) as Record; + // test-sql-allow: the raw statement is the legacy oracle this parity test holds the repository to. const legacyTrashed = testDb.prepare(`${FILE_SELECT} WHERE f.id = ?`).get(trashed.id) as Record; const activeRow = (await svc.listFiles(trip.id, false) as Record[]).find((f) => f.id === live.id)!; @@ -782,6 +788,7 @@ describe('read-model parity: raw SQL vs. the repository-backed reads', () => { const file = await makeFile(trip.id, user.id); await svc.createFileLink(file.id, { reservation_id: String(reservation.id) }); + // test-sql-allow: the raw statement is the legacy oracle this parity test holds the repository to. const legacy = testDb.prepare(` SELECT fl.*, r.title as reservation_title FROM file_links fl