test(server): seed the files and dawarich sync service specs through the factories

Converts the raw prepare() fixtures and reads in the files service spec and
the dawarich sync service spec to the ORM factories and readers.

The dawarich service stub the sync spec hands in now reads and writes its
connection rows through the ORM and is async, the shape the real service
has. Writes that set accepted_hash from the row's own source_hash read the
row first and write the value back, since a native update cannot reference
another column. The files spec drops the raw canAccessTrip and isOwner on
its database mock, which nothing reads any more, and keeps the legacy
FILE_SELECT statements it is held to behind allow markers.
This commit is contained in:
Maurice
2026-10-08 16:54:07 +02:00
parent 63bcf61a2b
commit 345d459e5a
2 changed files with 211 additions and 234 deletions
@@ -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<DawarichCreds | null> => {
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<void> => {
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<number[]> =>
(
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<SuggestionRow[]> {
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<SuggestionRow> {
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<void> {
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<number> {
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<number> {
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);
+51 -44
View File
@@ -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<number> {
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<string, unknown>[]).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<string, unknown>;
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<string, unknown>).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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
// 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<string, unknown>;
const activeRow = (await svc.listFiles(trip.id, false) as Record<string, unknown>[]).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