From 732b951439567667edc9fa1ff69684b24106ed7b Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 8 Oct 2026 17:28:54 +0200 Subject: [PATCH] test(server): seed the document sync service spec through the factories Converts the raw prepare() fixtures and reads in the document sync service spec to the ORM factories and readers. The row builders (bindings, sync items, files, the connection) and the files service double write through insertRow and updateRows and are async now, the shape the real service has; the row readers go through findRow and findRows. Seeding that ran inside Array.from callbacks is a plain loop, so every row is written before the run it feeds starts. Three reads stay raw behind allow markers: the helper that takes a time from SQLite's own clock and the two checks that compare a stored timestamp with CURRENT_TIMESTAMP and julianday('now'), since the clock is what they are about. --- .../nest/doc-sync/doc-sync.service.test.ts | 879 +++++++++--------- 1 file changed, 433 insertions(+), 446 deletions(-) diff --git a/server/tests/unit/nest/doc-sync/doc-sync.service.test.ts b/server/tests/unit/nest/doc-sync/doc-sync.service.test.ts index 3068b7ab4..fd3079137 100644 --- a/server/tests/unit/nest/doc-sync/doc-sync.service.test.ts +++ b/server/tests/unit/nest/doc-sync/doc-sync.service.test.ts @@ -62,7 +62,19 @@ import type { AddonsService } from '../../../../src/nest/addons/addons.service'; import type { FilesService } from '../../../../src/nest/files/files.service'; import type { StorageService } from '../../../../src/nest/storage/storage.service'; import type { RealtimeService } from '../../../../src/nest/realtime/realtime.service'; -import { createTestUnitOfWork, createTestAppSettingsRepo, createTestTripsRepo } from '../../../helpers/test-uow'; +import { createTestUnitOfWork, createTestAppSettingsRepo, createTestTripsRepo, sharedTestOrm } from '../../../helpers/test-uow'; +import type { TestOrm } from '../../../helpers/test-orm'; +import { countRows, deleteRows, findRow, findRows, insertRow, updateRows } from '../../../helpers/factories/rows'; +import { dbNow } from '../../../../src/db/types/db-timestamp.type'; +import { CollabMessages } from '../../../../src/db/entities/CollabMessages.entity'; +import { CollabNotes } from '../../../../src/db/entities/CollabNotes.entity'; +import { DocumentConnections } from '../../../../src/db/entities/DocumentConnections.entity'; +import { DocumentProviders } from '../../../../src/db/entities/DocumentProviders.entity'; +import { DocumentSyncItems } from '../../../../src/db/entities/DocumentSyncItems.entity'; +import { FileLinks } from '../../../../src/db/entities/FileLinks.entity'; +import { Reservations } from '../../../../src/db/entities/Reservations.entity'; +import { TripDocumentLinks } from '../../../../src/db/entities/TripDocumentLinks.entity'; +import { TripFiles } from '../../../../src/db/entities/TripFiles.entity'; import { createTestDocumentConnectionsRepo, createTestDocumentProviderFieldsRepo, @@ -159,29 +171,26 @@ const storage = { } as unknown as StorageService; const files = { - getFileById: vi.fn((id: string | number, tripId: string | number) => - testDb.prepare('SELECT * FROM trip_files WHERE id = ? AND trip_id = ?').get(id, tripId)), - createFile: vi.fn(( + getFileById: vi.fn(async (id: string | number, tripId: string | number) => + (await findRow(orm, TripFiles, { id: Number(id), trip: Number(tripId) })) ?? undefined), + createFile: vi.fn(async ( tripId: string | number, file: { filename: string; originalname: string; size: number; mimetype: string }, uploadedBy: number, opts: { place_id?: string | number | null; reservation_id?: string | number | null; description?: string | null } = {}, ) => { - const info = testDb - .prepare( - `INSERT INTO trip_files (trip_id, place_id, reservation_id, filename, original_name, file_size, mime_type, description, uploaded_by) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ) - .run( - tripId, opts.place_id || null, opts.reservation_id || null, - file.filename, file.originalname, file.size, file.mimetype, opts.description || null, uploadedBy || null, - ); - return testDb.prepare('SELECT * FROM trip_files WHERE id = ?').get(info.lastInsertRowid); + const id = await insertRow(orm, TripFiles, { + trip: Number(tripId), place: opts.place_id ? Number(opts.place_id) : null, + reservation: opts.reservation_id ? Number(opts.reservation_id) : null, + filename: file.filename, original_name: file.originalname, file_size: file.size, mime_type: file.mimetype, + description: opts.description || null, uploadedByRef: uploadedBy || null, + }); + return findRow(orm, TripFiles, { id }); }), // Writes for real, like the two above: a double that answers but changes // nothing lets a missing call pass as a passing test. - softDeleteFile: vi.fn((id: string | number) => { - testDb.prepare('UPDATE trip_files SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?').run(id); + softDeleteFile: vi.fn(async (id: string | number) => { + await updateRows(orm, TripFiles, { id: Number(id) }, { deleted_at: dbNow() }); }), } as unknown as FilesService; @@ -193,6 +202,7 @@ let tripId: number; let connectionId: number; let service: DocSyncService; let config: DocSyncConfigService; +let orm: TestOrm; // ── Row builders ───────────────────────────────────────────────────────────── @@ -208,27 +218,21 @@ async function makeLink(over: { providerId?: string; } = {}): Promise { scopeSeq += 1; - const info = testDb - .prepare( - `INSERT INTO trip_document_links - (trip_id, connection_id, provider_id, remote_scope_key, remote_root_id, remote_root_path, remote_label, - direction, delete_policy, conflict_policy, sync_enabled, failure_count, next_attempt_at, created_by) - VALUES (?, ?, ?, ?, '1', '/TREK/japan', 'Japan 2026', ?, ?, ?, ?, ?, ?, ?)`, - ) - .run( - tripId, connectionId, over.providerId ?? 'paperless', `tag:${scopeSeq}`, - over.direction ?? 'both', over.deletePolicy ?? 'unlink', over.conflictPolicy ?? 'manual', - over.syncEnabled ?? 1, over.failureCount ?? 0, over.nextAttemptAt ?? null, ownerId, - ); + const id = await insertRow(orm, TripDocumentLinks, { + trip: tripId, connection: connectionId, provider_id: over.providerId ?? 'paperless', remote_scope_key: `tag:${scopeSeq}`, + remote_root_id: '1', remote_root_path: '/TREK/japan', remote_label: 'Japan 2026', + direction: over.direction ?? 'both', delete_policy: over.deletePolicy ?? 'unlink', conflict_policy: over.conflictPolicy ?? 'manual', + sync_enabled: over.syncEnabled ?? 1, failure_count: over.failureCount ?? 0, next_attempt_at: over.nextAttemptAt ?? null, + createdByRef: ownerId, + }); if (over.lastSyncAt !== undefined) { - testDb.prepare('UPDATE trip_document_links SET last_sync_at = ? WHERE id = ?') - .run(over.lastSyncAt, Number(info.lastInsertRowid)); + await updateRows(orm, TripDocumentLinks, { id }, { last_sync_at: over.lastSyncAt }); } - return (await config.getLink(Number(info.lastInsertRowid)))!; + return (await config.getLink(id))!; } let uidSeq = 0; -function seedItem(link: LinkRow, over: { +async function seedItem(link: LinkRow, over: { remoteId?: string; remoteName?: string; remoteVersion?: string; @@ -239,57 +243,44 @@ function seedItem(link: LinkRow, over: { remoteSize?: number; remoteModifiedAt?: string; remoteMissingAt?: string; -} = {}): number { +} = {}): Promise { uidSeq += 1; - const info = testDb - .prepare( - `INSERT INTO document_sync_items - (link_id, trip_id, file_id, trek_doc_uid, remote_id, remote_name, remote_version, - remote_size, remote_modified_at, content_sha256, pushed_sha256, state, remote_missing_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ) - .run( - link.id, link.trip_id, over.fileId ?? null, `uid-${uidSeq}`, - over.remoteId ?? null, over.remoteName ?? null, over.remoteVersion ?? null, - over.remoteSize ?? null, over.remoteModifiedAt ?? null, - over.contentSha256 ?? null, over.pushedSha256 ?? null, over.state ?? 'synced', - over.remoteMissingAt ?? null, - ); - return Number(info.lastInsertRowid); + return insertRow(orm, DocumentSyncItems, { + link: link.id, trip: link.trip_id, file: over.fileId ?? null, trek_doc_uid: `uid-${uidSeq}`, + remote_id: over.remoteId ?? null, remote_name: over.remoteName ?? null, remote_version: over.remoteVersion ?? null, + remote_size: over.remoteSize ?? null, remote_modified_at: over.remoteModifiedAt ?? null, + content_sha256: over.contentSha256 ?? null, pushed_sha256: over.pushedSha256 ?? null, state: over.state ?? 'synced', + remote_missing_at: over.remoteMissingAt ?? null, + }); } -function makeFile(over: { +async function makeFile(over: { name?: string; storageKey?: string; mime?: string; messageId?: number; noteId?: number; deletedAt?: string; -} = {}): number { - const info = testDb - .prepare( - `INSERT INTO trip_files (trip_id, filename, original_name, file_size, mime_type, uploaded_by, message_id, note_id, deleted_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ) - .run( - tripId, over.storageKey ?? `key-${over.name ?? 'boarding.pdf'}`, over.name ?? 'boarding.pdf', - FILE_BYTES.length, over.mime ?? 'application/pdf', ownerId, - over.messageId ?? null, over.noteId ?? null, over.deletedAt ?? null, - ); - return Number(info.lastInsertRowid); +} = {}): Promise { + return insertRow(orm, TripFiles, { + trip: tripId, filename: over.storageKey ?? `key-${over.name ?? 'boarding.pdf'}`, original_name: over.name ?? 'boarding.pdf', + file_size: FILE_BYTES.length, mime_type: over.mime ?? 'application/pdf', uploadedByRef: ownerId, + message: over.messageId ?? null, note: over.noteId ?? null, deleted_at: over.deletedAt ?? null, + }); } -const itemRow = (id: number) => - testDb.prepare('SELECT * FROM document_sync_items WHERE id = ?').get(id) as Record; -const itemRows = () => - testDb.prepare('SELECT * FROM document_sync_items ORDER BY id').all() as Array>; -const linkRow = (id: number) => - testDb.prepare('SELECT * FROM trip_document_links WHERE id = ?').get(id) as Record; -const fileRows = () => - testDb.prepare('SELECT * FROM trip_files ORDER BY id').all() as Array>; +const itemRow = async (id: number) => + (await findRow(orm, DocumentSyncItems, { id })) as Record; +const itemRows = async () => + (await findRows(orm, DocumentSyncItems, {}, { id: 'asc' })) as Array>; +const linkRow = async (id: number) => + (await findRow(orm, TripDocumentLinks, { id })) as Record; +const fileRows = async () => + (await findRows(orm, TripFiles, {}, { id: 'asc' })) as Array>; /** SQLite's own clock and format, so a comparison against CURRENT_TIMESTAMP means something. */ const sqlTime = (modifier: string): string => + // test-sql-allow: the timestamp has to come from SQLite's own clock, the one CURRENT_TIMESTAMP compares against. (testDb.prepare("SELECT datetime('now', ?) AS t").get(modifier) as { t: string }).t; /** @@ -300,7 +291,7 @@ const addons = { isAddonEnabled: vi.fn(() => true) }; /** The admin's per-provider switch, which is a real row rather than a double. */ const switchProvider = (id: string, on: boolean) => - testDb.prepare('UPDATE document_providers SET enabled = ? WHERE id = ?').run(on ? 1 : 0, id); + updateRows(orm, DocumentProviders, { id }, { enabled: on ? 1 : 0 }); // ── Suite ──────────────────────────────────────────────────────────────────── @@ -308,15 +299,12 @@ describe('DocSyncService', () => { beforeAll(async () => { spoolDir = fs.mkdtempSync(path.join(os.tmpdir(), 'trek-docsync-')); + orm = await sharedTestOrm(testDb); ownerId = createUser(testDb, { username: 'owner', email: 'owner@docsync.test' }).user.id; tripId = createTrip(testDb, ownerId, { title: 'Japan' }).id; - const info = testDb - .prepare( - `INSERT INTO document_connections (trip_id, provider_id, owner_user_id, base_url, secrets, settings) - VALUES (?, 'paperless', ?, 'https://paperless.example.com', NULL, '{}')`, - ) - .run(tripId, ownerId); - connectionId = Number(info.lastInsertRowid); + connectionId = await insertRow(orm, DocumentConnections, { + trip: tripId, provider: 'paperless', ownerUser: ownerId, base_url: 'https://paperless.example.com', secrets: null, settings: '{}', + }); const registry = new DocumentProviderRegistry([provider as unknown as DocumentProvider]); config = new DocSyncConfigService( @@ -351,17 +339,17 @@ describe('DocSyncService', () => { fs.rmSync(spoolDir, { recursive: true, force: true }); }); - beforeEach(() => { + beforeEach(async () => { vi.resetAllMocks(); capabilities = { ...BASE_CAPABILITIES }; - testDb.prepare('DELETE FROM document_sync_items').run(); - testDb.prepare('DELETE FROM trip_document_links').run(); - testDb.prepare('DELETE FROM trip_files').run(); - testDb.prepare('DELETE FROM collab_messages').run(); - testDb.prepare('DELETE FROM collab_notes').run(); + await deleteRows(orm, DocumentSyncItems); + await deleteRows(orm, TripDocumentLinks); + await deleteRows(orm, TripFiles); + await deleteRows(orm, CollabMessages); + await deleteRows(orm, CollabNotes); // Providers ship switched off; the fake one stands in for Paperless. - testDb.prepare('UPDATE document_providers SET enabled = 0').run(); - switchProvider('paperless', true); + await updateRows(orm, DocumentProviders, {}, { enabled: 0 }); + await switchProvider('paperless', true); for (const entry of fs.readdirSync(spoolDir)) fs.rmSync(path.join(spoolDir, entry), { force: true }); }); @@ -447,7 +435,7 @@ describe('DocSyncService', () => { // It would never run, so it would never get a last_sync_at and would take // one of the slots on every tick. const orphaned = await makeLink({ lastSyncAt: null }); - testDb.prepare("UPDATE trip_document_links SET last_sync_state = 'orphaned' WHERE id = ?").run(orphaned.id); + await updateRows(orm, TripDocumentLinks, { id: orphaned.id }, { last_sync_state: 'orphaned' }); const live = await makeLink({ lastSyncAt: sqlTime('-1 minute') }); expect((await service.dueLinks()).map((l) => l.id)).toEqual([live.id]); @@ -486,7 +474,7 @@ describe('DocSyncService', () => { expect(res.state).toBe('scope_lost'); expect(res.errorCode).toBe('scope_missing'); - expect(linkRow(link.id).last_sync_state).toBe('scope_lost'); + expect((await linkRow(link.id)).last_sync_state).toBe('scope_lost'); // Nothing is enumerated once the container is known to be gone, so a // deleted folder cannot be re-created and refilled. expect(provider.list).not.toHaveBeenCalled(); @@ -509,7 +497,7 @@ describe('DocSyncService', () => { const res = await service.syncLink(link); expect(res.state).toBe('needs_reauth'); - const row = linkRow(link.id); + const row = await linkRow(link.id); expect(row.last_sync_state).toBe('needs_reauth'); expect(row.last_sync_error).toBe('unauthorized'); }); @@ -521,7 +509,7 @@ describe('DocSyncService', () => { const res = await service.syncLink(link); expect(res.state).toBe('failed'); - expect(linkRow(link.id).last_sync_state).toBe('failed'); + expect((await linkRow(link.id)).last_sync_state).toBe('failed'); }); /** @@ -533,9 +521,11 @@ describe('DocSyncService', () => { */ it('abandons the entire run when most known documents vanish from one listing', async () => { const link = await makeLink(); - const itemIds = Array.from({ length: 10 }, (_, i) => - seedItem(link, { remoteId: `r${i}`, remoteName: `doc-${i}.pdf`, state: 'synced' })); - makeFile({ name: 'local-only.pdf' }); + const itemIds: number[] = []; + for (let i = 0; i < 10; i += 1) { + itemIds.push(await seedItem(link, { remoteId: `r${i}`, remoteName: `doc-${i}.pdf`, state: 'synced' })); + } + await makeFile({ name: 'local-only.pdf' }); provider.list.mockResolvedValueOnce(ok(listing([remoteDoc({ remoteId: 'r0', name: 'doc-0.pdf' })]))); const res = await service.syncLink(link); @@ -551,12 +541,12 @@ describe('DocSyncService', () => { expect(files.createFile).not.toHaveBeenCalled(); for (const id of itemIds) { - const row = itemRow(id); + const row = await itemRow(id); expect(row.state).toBe('synced'); expect(row.remote_missing_at).toBeNull(); } - expect(fileRows()).toHaveLength(1); - expect(linkRow(link.id).last_sync_error).toBe('mass_delete_guard'); + expect(await fileRows()).toHaveLength(1); + expect((await linkRow(link.id)).last_sync_error).toBe('mass_delete_guard'); }); /** @@ -575,8 +565,8 @@ describe('DocSyncService', () => { const res = await service.syncLink(link); expect(provider.fetch).not.toHaveBeenCalled(); - expect(fileRows()).toHaveLength(0); - expect(itemRows().map((r) => [r.remote_id, r.state, r.error_code])).toEqual([ + expect(await fileRows()).toHaveLength(0); + expect((await itemRows()).map((r) => [r.remote_id, r.state, r.error_code])).toEqual([ ['r-svg', 'rejected_type', 'unsupported_type'], ['r-html', 'rejected_type', 'unsupported_type'], ]); @@ -592,7 +582,7 @@ describe('DocSyncService', () => { await service.syncLink(link); expect(provider.fetch).not.toHaveBeenCalled(); - const row = itemRows()[0]; + const row = (await itemRows())[0]; expect(row.state).toBe('too_large'); expect(row.error_code).toBe('too_large'); }); @@ -605,12 +595,12 @@ describe('DocSyncService', () => { it('never uploads a document whose type the provider has said it will not take', async () => { const link = await makeLink(); capabilities = { ...BASE_CAPABILITIES, acceptedMimeTypes: ['application/pdf'] }; - const fileId = makeFile({ name: 'sunset.png', mime: 'image/png' }); + const fileId = await makeFile({ name: 'sunset.png', mime: 'image/png' }); await service.syncLink(link); expect(provider.push).not.toHaveBeenCalled(); - const row = itemRows()[0]; + const row = (await itemRows())[0]; expect(row.file_id).toBe(fileId); expect(row.state).toBe('rejected_type'); expect(row.error_code).toBe('unsupported_type'); @@ -624,12 +614,12 @@ describe('DocSyncService', () => { */ it('does not pull back the bytes it uploaded itself', async () => { const link = await makeLink(); - makeFile({ name: 'boarding.pdf' }); + await makeFile({ name: 'boarding.pdf' }); provider.list.mockResolvedValueOnce(ok(listing([]))); const firstRun = await service.syncLink(link); expect(firstRun.pushed).toBe(1); - const afterPush = itemRows()[0]; + const afterPush = (await itemRows())[0]; expect(afterPush.pushed_sha256).toBe(FILE_SHA); expect(afterPush.content_sha256).toBe(FILE_SHA); expect(afterPush.remote_id).toBe('r-pushed'); @@ -645,8 +635,8 @@ describe('DocSyncService', () => { expect(provider.fetch).not.toHaveBeenCalled(); expect(provider.push).toHaveBeenCalledTimes(1); expect(secondRun.pulled).toBe(0); - expect(fileRows()).toHaveLength(1); - expect(itemRows()[0].state).toBe('synced'); + expect(await fileRows()).toHaveLength(1); + expect((await itemRows())[0].state).toBe('synced'); }); it('refuses to move a pairing onto a document another file already owns', async () => { @@ -655,8 +645,8 @@ describe('DocSyncService', () => { // document, and the unique index on (link_id, remote_id) turned that // into a constraint error that aborted this run and every run after it. const link = await makeLink(); - makeFile({ name: 'boarding.pdf' }); - makeFile({ name: 'boarding-copy.pdf', storageKey: 'key-copy' }); + await makeFile({ name: 'boarding.pdf' }); + await makeFile({ name: 'boarding-copy.pdf', storageKey: 'key-copy' }); provider.list.mockResolvedValue(ok(listing([]))); const run = await service.syncLink(link); @@ -667,7 +657,7 @@ describe('DocSyncService', () => { expect(run.pushed).toBe(1); expect(run.conflicts).toBe(1); - const rows = itemRows(); + const rows = await itemRows(); expect(rows).toHaveLength(2); // The first file keeps the document. The second is on record as the one // that could not have it, with its bytes still in TREK. @@ -684,6 +674,7 @@ describe('DocSyncService', () => { await service.syncLink(link); const after = testDb + // test-sql-allow: compares against SQLite's own clock, which is what CURRENT_TIMESTAMP and the stored text are measured by. .prepare('SELECT failure_count, next_attempt_at > CURRENT_TIMESTAMP AS in_future FROM trip_document_links WHERE id = ?') .get(link.id) as { failure_count: number; in_future: number }; expect(after.failure_count).toBe(1); @@ -691,7 +682,7 @@ describe('DocSyncService', () => { provider.list.mockResolvedValueOnce(fail('unreachable')); await service.syncLink(await config.getLink(link.id)); - expect(linkRow(link.id).failure_count).toBe(2); + expect((await linkRow(link.id)).failure_count).toBe(2); }); it('clears the failure count and the wait as soon as a run succeeds', async () => { @@ -700,7 +691,7 @@ describe('DocSyncService', () => { const res = await service.syncLink(link); expect(res.state).toBe('ok'); - const row = linkRow(link.id); + const row = await linkRow(link.id); expect(row.failure_count).toBe(0); expect(row.next_attempt_at).toBeNull(); expect(row.remote_cursor).toBe('cursor-1'); @@ -714,22 +705,18 @@ describe('DocSyncService', () => { */ it('leaves chat and note attachments out of the upload entirely', async () => { const link = await makeLink(); - const messageId = Number(testDb - .prepare("INSERT INTO collab_messages (trip_id, user_id, text) VALUES (?, ?, 'hi')") - .run(tripId, ownerId).lastInsertRowid); - const noteId = Number(testDb - .prepare("INSERT INTO collab_notes (trip_id, user_id, title) VALUES (?, ?, 'note')") - .run(tripId, ownerId).lastInsertRowid); + const messageId = await insertRow(orm, CollabMessages, { trip: tripId, user: ownerId, text: 'hi' }); + const noteId = await insertRow(orm, CollabNotes, { trip: tripId, user: ownerId, title: 'note' }); - makeFile({ name: 'boarding.pdf' }); - makeFile({ name: 'chat-screenshot.pdf', storageKey: 'key-chat', messageId }); - makeFile({ name: 'note-attachment.pdf', storageKey: 'key-note', noteId }); + await makeFile({ name: 'boarding.pdf' }); + await makeFile({ name: 'chat-screenshot.pdf', storageKey: 'key-chat', messageId }); + await makeFile({ name: 'note-attachment.pdf', storageKey: 'key-note', noteId }); await service.syncLink(link); expect(provider.push).toHaveBeenCalledTimes(1); expect(provider.push.mock.calls[0][2].fileName).toBe('boarding.pdf'); - expect(itemRows()).toHaveLength(1); + expect(await itemRows()).toHaveLength(1); }); /** @@ -739,32 +726,32 @@ describe('DocSyncService', () => { */ it('records a document that vanished upstream without removing anything locally', async () => { const link = await makeLink(); - const fileId = makeFile({ name: 'boarding.pdf' }); - const itemId = seedItem(link, { remoteId: 'r1', remoteName: 'boarding.pdf', fileId, state: 'synced' }); + const fileId = await makeFile({ name: 'boarding.pdf' }); + const itemId = await seedItem(link, { remoteId: 'r1', remoteName: 'boarding.pdf', fileId, state: 'synced' }); const res = await service.syncLink(link); expect(res.missing).toBe(1); - const row = itemRow(itemId); + const row = await itemRow(itemId); expect(row.state).toBe('remote_missing'); expect(row.remote_missing_at).not.toBeNull(); - expect(fileRows().map((f) => f.id)).toEqual([fileId]); + expect((await fileRows()).map((f) => f.id)).toEqual([fileId]); expect(provider.trash).not.toHaveBeenCalled(); }); it('moves a deleted document to the provider recycle bin only when the binding says trash', async () => { const trashing = await makeLink({ deletePolicy: 'trash' }); - const fileId = makeFile({ name: 'boarding.pdf', deletedAt: '2026-09-18 08:00:00' }); - const itemId = seedItem(trashing, { remoteId: 'r1', remoteName: 'boarding.pdf', fileId, state: 'synced' }); + const fileId = await makeFile({ name: 'boarding.pdf', deletedAt: '2026-09-18 08:00:00' }); + const itemId = await seedItem(trashing, { remoteId: 'r1', remoteName: 'boarding.pdf', fileId, state: 'synced' }); provider.list.mockResolvedValue(ok(listing([remoteDoc({ remoteId: 'r1' })]))); await service.syncLink(trashing); expect(provider.trash).toHaveBeenCalledWith(expect.anything(), expect.anything(), 'r1'); - expect(itemRow(itemId).state).toBe('local_deleted'); + expect((await itemRow(itemId)).state).toBe('local_deleted'); const unlinking = await makeLink({ deletePolicy: 'unlink' }); - const otherFileId = makeFile({ name: 'other.pdf', storageKey: 'key-other', deletedAt: '2026-09-18 08:00:00' }); - seedItem(unlinking, { remoteId: 'r1', remoteName: 'boarding.pdf', fileId: otherFileId, state: 'synced' }); + const otherFileId = await makeFile({ name: 'other.pdf', storageKey: 'key-other', deletedAt: '2026-09-18 08:00:00' }); + await seedItem(unlinking, { remoteId: 'r1', remoteName: 'boarding.pdf', fileId: otherFileId, state: 'synced' }); provider.trash.mockClear(); await service.syncLink(unlinking); @@ -773,58 +760,58 @@ describe('DocSyncService', () => { it('carries a rename made in TREK over to the provider', async () => { const link = await makeLink(); - const fileId = makeFile({ name: 'new.pdf' }); - const itemId = seedItem(link, { + const fileId = await makeFile({ name: 'new.pdf' }); + const itemId = (await seedItem(link, { remoteId: 'r1', remoteName: 'old.pdf', remoteVersion: 'v1', fileId, state: 'synced', - }); + })); provider.list.mockResolvedValueOnce(ok(listing([remoteDoc({ remoteId: 'r1', name: 'old.pdf' })]))); await service.syncLink(link); expect(provider.rename).toHaveBeenCalledWith(expect.anything(), expect.anything(), 'r1', 'new.pdf'); - const row = itemRow(itemId); + const row = await itemRow(itemId); expect(row.remote_name).toBe('new.pdf'); expect(row.remote_version).toBe('v2'); }); it('carries a rename made at the provider over to the trip file', async () => { const link = await makeLink(); - const fileId = makeFile({ name: 'old.pdf' }); - const itemId = seedItem(link, { + const fileId = await makeFile({ name: 'old.pdf' }); + const itemId = (await seedItem(link, { remoteId: 'r1', remoteName: 'old.pdf', remoteVersion: 'v1', fileId, state: 'synced', - }); + })); provider.list.mockResolvedValueOnce(ok(listing([remoteDoc({ remoteId: 'r1', name: 'new-upstream.pdf' })]))); await service.syncLink(link); expect(provider.rename).not.toHaveBeenCalled(); - expect(fileRows()[0].original_name).toBe('new-upstream.pdf'); - expect(itemRow(itemId).remote_name).toBe('new-upstream.pdf'); + expect((await fileRows())[0].original_name).toBe('new-upstream.pdf'); + expect((await itemRow(itemId)).remote_name).toBe('new-upstream.pdf'); expect(realtime.broadcast).toHaveBeenCalledWith(tripId, 'file:updated', expect.anything()); }); it('hands a document both sides renamed to a person instead of picking one', async () => { const link = await makeLink(); - const fileId = makeFile({ name: 'mine.pdf' }); - const itemId = seedItem(link, { + const fileId = await makeFile({ name: 'mine.pdf' }); + const itemId = (await seedItem(link, { remoteId: 'r1', remoteName: 'old.pdf', remoteVersion: 'v1', fileId, state: 'synced', - }); + })); provider.list.mockResolvedValueOnce(ok(listing([remoteDoc({ remoteId: 'r1', name: 'theirs.pdf' })]))); const res = await service.syncLink(link); expect(res.conflicts).toBe(1); expect(provider.rename).not.toHaveBeenCalled(); - expect(itemRow(itemId).state).toBe('conflict'); + expect((await itemRow(itemId)).state).toBe('conflict'); expect((await service.issues(tripId)).map((r) => r.id)).toContain(itemId); }); it('settles a double rename TREK wins with a rename, not a second upload', async () => { const link = await makeLink({ conflictPolicy: 'trek_wins' }); - const fileId = makeFile({ name: 'mine.pdf' }); - const itemId = seedItem(link, { + const fileId = await makeFile({ name: 'mine.pdf' }); + const itemId = (await seedItem(link, { remoteId: 'r1', remoteName: 'old.pdf', remoteVersion: 'v1', fileId, state: 'synced', - }); + })); provider.list.mockResolvedValueOnce(ok(listing([remoteDoc({ remoteId: 'r1', name: 'theirs.pdf' })]))); const res = await service.syncLink(link); @@ -832,13 +819,13 @@ describe('DocSyncService', () => { expect(res.conflicts).toBe(0); expect(provider.push).not.toHaveBeenCalled(); expect(provider.rename).toHaveBeenCalledWith(expect.anything(), expect.anything(), 'r1', 'mine.pdf'); - expect(itemRow(itemId)).toMatchObject({ state: 'synced', remote_name: 'mine.pdf' }); + expect(await itemRow(itemId)).toMatchObject({ state: 'synced', remote_name: 'mine.pdf' }); provider.list.mockResolvedValueOnce(ok(listing([remoteDoc({ remoteId: 'r1', name: 'mine.pdf', remoteVersion: 'v2' })]))); await service.syncLink(await config.getLink(link.id)); expect(provider.rename).toHaveBeenCalledTimes(1); - expect(fileRows().map((f) => f.original_name)).toEqual(['mine.pdf']); + expect((await fileRows()).map((f) => f.original_name)).toEqual(['mine.pdf']); }); /** @@ -848,33 +835,33 @@ describe('DocSyncService', () => { */ it('takes a copy on record as missing off the list when it is back under a new name', async () => { const link = await makeLink(); - const fileId = makeFile({ name: 'old.pdf' }); - const itemId = seedItem(link, { + const fileId = await makeFile({ name: 'old.pdf' }); + const itemId = (await seedItem(link, { remoteId: 'r1', remoteName: 'old.pdf', remoteVersion: 'v1', fileId, state: 'remote_missing', remoteMissingAt: '2026-09-18 09:00:00', - }); + })); provider.list.mockResolvedValueOnce(ok(listing([remoteDoc({ remoteId: 'r1', name: 'new-upstream.pdf' })]))); await service.syncLink(link); - expect(fileRows()[0].original_name).toBe('new-upstream.pdf'); - expect(itemRow(itemId)).toMatchObject({ state: 'synced', remote_missing_at: null, remote_name: 'new-upstream.pdf' }); + expect((await fileRows())[0].original_name).toBe('new-upstream.pdf'); + expect(await itemRow(itemId)).toMatchObject({ state: 'synced', remote_missing_at: null, remote_name: 'new-upstream.pdf' }); expect(await service.issues(tripId)).toEqual([]); }); it('does the same when the name TREK gave it meanwhile is carried upstream', async () => { const link = await makeLink(); - const fileId = makeFile({ name: 'new.pdf' }); - const itemId = seedItem(link, { + const fileId = await makeFile({ name: 'new.pdf' }); + const itemId = (await seedItem(link, { remoteId: 'r1', remoteName: 'old.pdf', remoteVersion: 'v1', fileId, state: 'remote_missing', remoteMissingAt: '2026-09-18 09:00:00', - }); + })); provider.list.mockResolvedValueOnce(ok(listing([remoteDoc({ remoteId: 'r1', name: 'old.pdf' })]))); await service.syncLink(link); expect(provider.rename).toHaveBeenCalledWith(expect.anything(), expect.anything(), 'r1', 'new.pdf'); - expect(itemRow(itemId)).toMatchObject({ state: 'synced', remote_missing_at: null, remote_name: 'new.pdf' }); + expect(await itemRow(itemId)).toMatchObject({ state: 'synced', remote_missing_at: null, remote_name: 'new.pdf' }); }); /** @@ -895,8 +882,8 @@ describe('DocSyncService', () => { await service.syncLink(link); expect(storage.put).not.toHaveBeenCalled(); - expect(fileRows()).toHaveLength(0); - expect(itemRows()[0].state).toBe('too_large'); + expect(await fileRows()).toHaveLength(0); + expect((await itemRows())[0].state).toBe('too_large'); expect(fs.readdirSync(spoolDir)).toHaveLength(0); }); @@ -908,12 +895,12 @@ describe('DocSyncService', () => { const res = await service.syncLink(link); expect(res.state).toBe('partial'); - const row = itemRows()[0]; + const row = (await itemRows())[0]; expect(row.state).toBe('error'); expect(row.error_code).toBe('timeout'); expect(row.attempts).toBe(1); expect(row.file_id).toBeNull(); - expect(fileRows()).toHaveLength(0); + expect(await fileRows()).toHaveLength(0); expect(await service.issues(tripId)).toHaveLength(1); }); @@ -929,7 +916,7 @@ describe('DocSyncService', () => { await service.syncLink(link); await service.syncLink(link); - const row = itemRows()[0]; + const row = (await itemRows())[0]; expect(row.state).toBe('error'); expect(row.file_id).toBeNull(); expect(await service.issues(tripId)).toHaveLength(1); @@ -943,19 +930,19 @@ describe('DocSyncService', () => { await service.syncLink(link); expect(files.createFile).not.toHaveBeenCalled(); - expect(fileRows()).toHaveLength(0); - expect(itemRows()[0].state).toBe('error'); + expect(await fileRows()).toHaveLength(0); + expect((await itemRows())[0].state).toBe('error'); }); it('keeps the document anchor when an upload fails, so the retry is not a second document', async () => { const link = await makeLink(); - makeFile({ name: 'boarding.pdf' }); + await makeFile({ name: 'boarding.pdf' }); provider.push.mockResolvedValueOnce(fail('quota_exceeded')); const res = await service.syncLink(link); expect(res.errorCode).toBe('quota_exceeded'); - const row = itemRows()[0]; + const row = (await itemRows())[0]; expect(row.state).toBe('error'); expect(row.error_code).toBe('quota_exceeded'); expect(row.trek_doc_uid).toBeTruthy(); @@ -964,24 +951,24 @@ describe('DocSyncService', () => { it('refuses an upload the provider has told TREK is over its size limit', async () => { const link = await makeLink(); capabilities = { ...BASE_CAPABILITIES, maxUploadBytes: 10 }; - makeFile({ name: 'boarding.pdf' }); + await makeFile({ name: 'boarding.pdf' }); await service.syncLink(link); expect(provider.push).not.toHaveBeenCalled(); - expect(itemRows()[0].state).toBe('too_large'); + expect((await itemRows())[0].state).toBe('too_large'); }); it('records an upload whose bytes are missing from storage instead of throwing', async () => { const link = await makeLink(); - makeFile({ name: 'boarding.pdf' }); + await makeFile({ name: 'boarding.pdf' }); (storage.getStream as unknown as ReturnType).mockRejectedValueOnce(new Error('gone')); const res = await service.syncLink(link); expect(res.errorCode).toBe('provider_error'); expect(provider.push).not.toHaveBeenCalled(); - expect(itemRows()[0].state).toBe('error'); + expect((await itemRows())[0].state).toBe('error'); }); /** @@ -998,7 +985,7 @@ describe('DocSyncService', () => { expect(res.pulled).toBe(MAX_TRANSFERS_PER_RUN); expect(provider.fetch).toHaveBeenCalledTimes(MAX_TRANSFERS_PER_RUN); - expect(fileRows()).toHaveLength(MAX_TRANSFERS_PER_RUN); + expect(await fileRows()).toHaveLength(MAX_TRANSFERS_PER_RUN); }); it('records an unexpected crash as a failed run instead of letting it escape', async () => { @@ -1009,13 +996,13 @@ describe('DocSyncService', () => { expect(res.state).toBe('failed'); expect(res.errorCode).toBe('unknown'); - expect(linkRow(link.id).failure_count).toBe(1); + expect((await linkRow(link.id)).failure_count).toBe(1); }); it('fails a binding for a provider this build does not have', async () => { const link = await makeLink(); - testDb.prepare("UPDATE trip_document_links SET provider_id = 'papra' WHERE id = ?").run(link.id); - switchProvider('papra', true); + await updateRows(orm, TripDocumentLinks, { id: link.id }, { provider_id: 'papra' }); + await switchProvider('papra', true); const res = await service.syncLink(await config.getLink(link.id)); @@ -1048,8 +1035,8 @@ describe('DocSyncService', () => { it('leaves a binding whose provider is off out of the due list', async () => { await makeLink(); const on = await makeLink({ providerId: 'nextcloud' }); - switchProvider('paperless', false); - switchProvider('nextcloud', true); + await switchProvider('paperless', false); + await switchProvider('nextcloud', true); expect((await service.dueLinks()).map((l) => l.id)).toEqual([on.id]); }); @@ -1059,89 +1046,88 @@ describe('DocSyncService', () => { // on every tick for good. for (let i = 0; i < 25; i += 1) await makeLink({ lastSyncAt: null }); const on = await makeLink({ providerId: 'nextcloud', lastSyncAt: sqlTime('-1 minute') }); - switchProvider('paperless', false); - switchProvider('nextcloud', true); + await switchProvider('paperless', false); + await switchProvider('nextcloud', true); expect((await service.dueLinks()).map((l) => l.id)).toEqual([on.id]); }); it('stands down without touching the binding or its documents while the provider is off', async () => { const link = await makeLink({ failureCount: 2 }); - testDb.prepare("UPDATE trip_document_links SET last_sync_state = 'partial', last_sync_error = 'timeout' WHERE id = ?") - .run(link.id); - const itemId = seedItem(link, { remoteId: 'r1', remoteVersion: 'v1', state: 'error' }); - testDb.prepare('UPDATE document_sync_items SET attempts = 6 WHERE id = ?').run(itemId); - const linkBefore = linkRow(link.id); - const itemBefore = itemRow(itemId); - switchProvider('paperless', false); + await updateRows(orm, TripDocumentLinks, { id: link.id }, { last_sync_state: 'partial', last_sync_error: 'timeout' }); + const itemId = await seedItem(link, { remoteId: 'r1', remoteVersion: 'v1', state: 'error' }); + await updateRows(orm, DocumentSyncItems, { id: itemId }, { attempts: 6 }); + const linkBefore = await linkRow(link.id); + const itemBefore = await itemRow(itemId); + await switchProvider('paperless', false); const res = await service.syncLink(await config.getLink(link.id)); expect(res.state).toBe('disabled'); expect(provider.resolveScope).not.toHaveBeenCalled(); expect(provider.list).not.toHaveBeenCalled(); - expect(linkRow(link.id)).toEqual(linkBefore); - expect(itemRow(itemId)).toEqual(itemBefore); + expect(await linkRow(link.id)).toEqual(linkBefore); + expect(await itemRow(itemId)).toEqual(itemBefore); }); it('stands down the same way while the Documents addon is off', async () => { const link = await makeLink(); - const before = linkRow(link.id); + const before = await linkRow(link.id); addons.isAddonEnabled.mockReturnValue(false); const res = await service.syncLink(link); expect(res.state).toBe('disabled'); expect(provider.list).not.toHaveBeenCalled(); - expect(linkRow(link.id)).toEqual(before); + expect(await linkRow(link.id)).toEqual(before); }); it('stands down before it looks for the connection, so a gone one is not a failure either', async () => { const link = { ...(await makeLink()), connection_id: 999999 }; - switchProvider('paperless', false); + await switchProvider('paperless', false); expect((await service.syncLink(link)).state).toBe('disabled'); - expect(linkRow(link.id).failure_count).toBe(0); + expect((await linkRow(link.id)).failure_count).toBe(0); }); it('picks up where it stopped once the provider is back on', async () => { const link = await makeLink(); provider.list.mockResolvedValue(ok(listing([remoteDoc()]))); - switchProvider('paperless', false); + await switchProvider('paperless', false); await service.syncLink(link); - expect(fileRows()).toHaveLength(0); + expect(await fileRows()).toHaveLength(0); - switchProvider('paperless', true); + await switchProvider('paperless', true); expect((await service.dueLinks()).map((l) => l.id)).toEqual([link.id]); const res = await service.syncLink(await config.getLink(link.id)); expect(res).toMatchObject({ state: 'ok', pulled: 1 }); - expect(fileRows()).toHaveLength(1); + expect(await fileRows()).toHaveLength(1); }); it('never runs an orphaned binding, whoever asks', async () => { // Sync automatically switched back on puts sync_enabled to 1 again, and a // webhook or a resolved conflict calls syncLink without asking first. const link = await makeLink(); - testDb.prepare("UPDATE trip_document_links SET last_sync_state = 'orphaned' WHERE id = ?").run(link.id); - const before = linkRow(link.id); + await updateRows(orm, TripDocumentLinks, { id: link.id }, { last_sync_state: 'orphaned' }); + const before = await linkRow(link.id); const res = await service.syncLink(await config.getLink(link.id)); expect(res.state).toBe('orphaned'); expect(provider.resolveScope).not.toHaveBeenCalled(); expect(provider.list).not.toHaveBeenCalled(); - expect(linkRow(link.id)).toEqual(before); + expect(await linkRow(link.id)).toEqual(before); }); it('answers the same question for any caller that has to refuse up front', async () => { const link = await makeLink(); expect(await service.isSwitchedOff(link)).toBe(false); - switchProvider('paperless', false); + await switchProvider('paperless', false); expect(await service.isSwitchedOff(link)).toBe(true); - switchProvider('paperless', true); + await switchProvider('paperless', true); addons.isAddonEnabled.mockReturnValue(false); expect(await service.isSwitchedOff(link)).toBe(true); }); @@ -1150,9 +1136,9 @@ describe('DocSyncService', () => { describe('status', () => { it('reports the trip bindings together with a count per item state', async () => { const link = await makeLink(); - seedItem(link, { remoteId: 'r1', state: 'synced' }); - seedItem(link, { remoteId: 'r2', state: 'synced' }); - seedItem(link, { remoteId: 'r3', state: 'conflict' }); + await seedItem(link, { remoteId: 'r1', state: 'synced' }); + await seedItem(link, { remoteId: 'r2', state: 'synced' }); + await seedItem(link, { remoteId: 'r3', state: 'conflict' }); const status = await service.status(tripId); @@ -1163,8 +1149,8 @@ describe('DocSyncService', () => { it('says which bindings are paused because their provider is switched off', async () => { const off = await makeLink(); const on = await makeLink({ providerId: 'nextcloud' }); - switchProvider('paperless', false); - switchProvider('nextcloud', true); + await switchProvider('paperless', false); + await switchProvider('nextcloud', true); const links = (await service.status(tripId)).links as Array<{ id: number; providerOff: boolean }>; @@ -1176,9 +1162,9 @@ describe('DocSyncService', () => { describe('itemsForTrip', () => { it('M2 (Plan 3h Task 7 review): an empty `?state=` returns every item, not none — legacy truthiness, not an `undefined` check', async () => { const link = await makeLink(); - const fileId = makeFile(); - const withFile = seedItem(link, { remoteId: 'r1', state: 'synced', fileId }); - const withoutFile = seedItem(link, { remoteId: 'r2', state: 'conflict' }); + const fileId = await makeFile(); + const withFile = await seedItem(link, { remoteId: 'r1', state: 'synced', fileId }); + const withoutFile = await seedItem(link, { remoteId: 'r2', state: 'conflict' }); // `state === undefined ? listForTrip : listForTripByState` (the R2 // regression this ratchet pins) would treat `''` as a real filter @@ -1199,8 +1185,8 @@ describe('DocSyncService', () => { it('a real state value still narrows to that one state', async () => { const link = await makeLink(); - seedItem(link, { remoteId: 'r1', state: 'synced' }); - const conflicted = seedItem(link, { remoteId: 'r2', state: 'conflict' }); + await seedItem(link, { remoteId: 'r1', state: 'synced' }); + const conflicted = await seedItem(link, { remoteId: 'r2', state: 'conflict' }); const rows = await service.itemsForTrip(tripId, 'conflict'); @@ -1210,22 +1196,22 @@ describe('DocSyncService', () => { describe('resolveConflict', () => { /** A conflicted pairing: the provider moved on, and TREK holds its own copy. */ - function seedConflict(link: LinkRow): { itemId: number; fileId: number } { - const fileId = makeFile({ name: 'boarding.pdf' }); - const itemId = seedItem(link, { + async function seedConflict(link: LinkRow): Promise<{ itemId: number; fileId: number }> { + const fileId = await makeFile({ name: 'boarding.pdf' }); + const itemId = (await seedItem(link, { remoteId: 'r1', remoteName: 'boarding.pdf', remoteVersion: 'v1', fileId, state: 'conflict', contentSha256: 'agreed-hash', - }); + })); return { itemId, fileId }; } it('keeps both copies and gives each its own pairing when the user asks for both', async () => { const link = await makeLink(); - const { itemId, fileId } = seedConflict(link); + const { itemId, fileId } = await seedConflict(link); provider.list.mockResolvedValueOnce(ok(listing([ remoteDoc({ remoteId: 'r1', name: 'boarding.pdf', remoteVersion: 'v2' }), ]))); @@ -1236,10 +1222,10 @@ describe('DocSyncService', () => { // goes up as a second provider document. Neither side loses anything. expect(provider.fetch).toHaveBeenCalledTimes(1); expect(provider.push).toHaveBeenCalledTimes(1); - const filesAfter = fileRows(); + const filesAfter = await fileRows(); expect(filesAfter).toHaveLength(2); expect(filesAfter.map((f) => f.id)).toContain(fileId); - expect(itemRows()).toHaveLength(2); + expect(await itemRows()).toHaveLength(2); }); /** @@ -1257,7 +1243,7 @@ describe('DocSyncService', () => { // agreed to this", and the provider's copy comes down over the one the // user just chose to keep. const link = await makeLink(); - const { itemId } = seedConflict(link); + const { itemId } = await seedConflict(link); provider.list.mockResolvedValueOnce(ok(listing([ remoteDoc({ remoteId: 'r1', name: 'boarding.pdf', remoteVersion: 'v2' }), ]))); @@ -1265,12 +1251,12 @@ describe('DocSyncService', () => { await service.resolveConflict(itemId, 'trek'); expect(provider.fetch).not.toHaveBeenCalled(); - expect(fileRows()).toHaveLength(1); + expect(await fileRows()).toHaveLength(1); }); it('pulls the provider copy when the conflict was resolved in its favour', async () => { const link = await makeLink(); - const { itemId } = seedConflict(link); + const { itemId } = await seedConflict(link); provider.list.mockResolvedValueOnce(ok(listing([ remoteDoc({ remoteId: 'r1', name: 'boarding.pdf', remoteVersion: 'v2' }), ]))); @@ -1278,7 +1264,7 @@ describe('DocSyncService', () => { await service.resolveConflict(itemId, 'provider'); expect(provider.fetch).toHaveBeenCalledTimes(1); - expect(itemRow(itemId).state).toBe('synced'); + expect((await itemRow(itemId)).state).toBe('synced'); }); /** @@ -1288,22 +1274,22 @@ describe('DocSyncService', () => { * whatever the owner chose, the issue came back within minutes, forever. */ describe('a double rename', () => { - function seedRenameConflict(link: LinkRow) { - const fileId = makeFile({ name: 'Invoice.pdf' }); // renamed in TREK - const itemId = seedItem(link, { + async function seedRenameConflict(link: LinkRow) { + const fileId = await makeFile({ name: 'Invoice.pdf' }); // renamed in TREK + const itemId = (await seedItem(link, { remoteId: 'r1', remoteName: 'Bill.pdf', // what both agreed on remoteVersion: 'v1', fileId, state: 'conflict', contentSha256: 'agreed-hash', - }); + })); return { itemId, fileId }; } it('renames the provider copy to TREK name when TREK wins', async () => { const link = await makeLink(); - const { itemId } = seedRenameConflict(link); + const { itemId } = await seedRenameConflict(link); provider.list.mockResolvedValue(ok(listing([ remoteDoc({ remoteId: 'r1', name: 'Invoice.pdf', remoteVersion: 'v2' }), ]))); @@ -1313,20 +1299,20 @@ describe('DocSyncService', () => { expect(provider.rename).toHaveBeenCalledWith( expect.anything(), expect.anything(), 'r1', 'Invoice.pdf', ); - expect(itemRow(itemId).remote_name).toBe('Invoice.pdf'); + expect((await itemRow(itemId)).remote_name).toBe('Invoice.pdf'); }); it('takes the local rename back when the provider wins, so TREK follows next run', async () => { const link = await makeLink(); - const { itemId, fileId } = seedRenameConflict(link); + const { itemId, fileId } = await seedRenameConflict(link); provider.list.mockResolvedValue(ok(listing([ remoteDoc({ remoteId: 'r1', name: 'Rechnung.pdf', remoteVersion: 'v1' }), ]))); await service.resolveConflict(itemId, 'provider'); - const file = testDb.prepare('SELECT original_name FROM trip_files WHERE id = ?').get(fileId) as { original_name: string }; - expect(file.original_name).toBe('Rechnung.pdf'); + const file = await findRow(orm, TripFiles, { id: fileId }); + expect(file?.original_name).toBe('Rechnung.pdf'); }); it('takes the provider name back the way a download would have written it', async () => { @@ -1334,40 +1320,40 @@ describe('DocSyncService', () => { // rename of its own, and the conflict the owner had just settled came // straight back. const link = await makeLink(); - const fileId = makeFile({ name: 'Invoice.pdf' }); - const itemId = seedItem(link, { + const fileId = await makeFile({ name: 'Invoice.pdf' }); + const itemId = (await seedItem(link, { remoteId: 'r1', remoteName: 'Hotel: Kyoto.pdf', remoteVersion: 'v1', fileId, state: 'conflict', contentSha256: 'agreed-hash', - }); + })); provider.list.mockResolvedValue(ok(listing([ remoteDoc({ remoteId: 'r1', name: 'Hotel: Kyoto 2026.pdf', remoteVersion: 'v1' }), ]))); await service.resolveConflict(itemId, 'provider'); - expect(itemRow(itemId).state).not.toBe('conflict'); + expect((await itemRow(itemId)).state).not.toBe('conflict'); expect(provider.rename).not.toHaveBeenCalled(); - const file = testDb.prepare('SELECT original_name FROM trip_files WHERE id = ?').get(fileId) as { original_name: string }; - expect(file.original_name).toBe('Hotel_ Kyoto 2026.pdf'); + const file = await findRow(orm, TripFiles, { id: fileId }); + expect(file?.original_name).toBe('Hotel_ Kyoto 2026.pdf'); }); it('does not leave the row in conflict either way', async () => { for (const keep of ['trek', 'provider'] as const) { const link = await makeLink(); - const { itemId } = seedRenameConflict(link); + const { itemId } = await seedRenameConflict(link); provider.list.mockResolvedValue(ok(listing([ remoteDoc({ remoteId: 'r1', name: 'Rechnung.pdf', remoteVersion: 'v1' }), ]))); await service.resolveConflict(itemId, keep); - expect(itemRow(itemId).state, `keep=${keep} left the row in conflict`).not.toBe('conflict'); + expect((await itemRow(itemId)).state, `keep=${keep} left the row in conflict`).not.toBe('conflict'); } }); }); it('refuses to resolve a pairing that is not in conflict', async () => { const link = await makeLink(); - const itemId = seedItem(link, { remoteId: 'r1', state: 'synced' }); + const itemId = await seedItem(link, { remoteId: 'r1', state: 'synced' }); expect(await service.resolveConflict(itemId, 'both')).toBe(false); expect(provider.list).not.toHaveBeenCalled(); @@ -1380,7 +1366,7 @@ describe('DocSyncService', () => { const decidable = ['conflict', 'rejected_type', 'too_large', 'remote_missing', 'error']; const waiting = ['pending', 'synced', 'local_deleted']; for (const state of [...decidable, ...waiting]) { - seedItem(link, { remoteId: `r-${state}`, state }); + await seedItem(link, { remoteId: `r-${state}`, state }); } const states = (await service.issues(tripId)).map((r) => r.state as string).sort(); @@ -1409,29 +1395,29 @@ describe('DocSyncService', () => { * with every further edit. */ describe('pull_update replaces rather than accumulates', () => { - function seedSynced(link: LinkRow): { itemId: number; fileId: number } { - const fileId = makeFile({ name: 'invoice.pdf' }); - const itemId = seedItem(link, { + async function seedSynced(link: LinkRow): Promise<{ itemId: number; fileId: number }> { + const fileId = await makeFile({ name: 'invoice.pdf' }); + const itemId = (await seedItem(link, { remoteId: 'r1', remoteName: 'invoice.pdf', remoteVersion: 'v1', fileId, state: 'synced', contentSha256: 'agreed-hash', - }); + })); return { itemId, fileId }; } it('puts the superseded copy in the trash instead of leaving it beside the new one', async () => { const link = await makeLink(); - const { fileId } = seedSynced(link); + const { fileId } = await seedSynced(link); provider.list.mockResolvedValueOnce(ok(listing([ remoteDoc({ remoteId: 'r1', name: 'invoice.pdf', remoteVersion: 'v2' }), ]))); await service.syncLink(link); - const rows = fileRows(); + const rows = await fileRows(); const old = rows.find(r => Number(r.id) === fileId) as Record; const fresh = rows.find(r => Number(r.id) !== fileId) as Record; expect(old.deleted_at).not.toBeNull(); @@ -1441,19 +1427,19 @@ describe('DocSyncService', () => { it('leaves exactly one live document, so the file manager does not show two', async () => { const link = await makeLink(); - seedSynced(link); + await seedSynced(link); provider.list.mockResolvedValueOnce(ok(listing([ remoteDoc({ remoteId: 'r1', name: 'invoice.pdf', remoteVersion: 'v2' }), ]))); await service.syncLink(link); - expect(fileRows().filter(r => r.deleted_at === null)).toHaveLength(1); + expect((await fileRows()).filter(r => r.deleted_at === null)).toHaveLength(1); }); it('does not push the superseded copy back on the next run', async () => { const link = await makeLink(); - seedSynced(link); + await seedSynced(link); provider.list.mockResolvedValueOnce(ok(listing([ remoteDoc({ remoteId: 'r1', name: 'invoice.pdf', remoteVersion: 'v2' }), ]))); @@ -1476,7 +1462,7 @@ describe('DocSyncService', () => { await service.syncLink(link); - expect(fileRows().filter(r => r.deleted_at === null)).toHaveLength(1); + expect((await fileRows()).filter(r => r.deleted_at === null)).toHaveLength(1); }); /** @@ -1489,29 +1475,24 @@ describe('DocSyncService', () => { */ it('carries the attachments of the superseded copy over to the new revision', async () => { const link = await makeLink(); - const { itemId, fileId } = seedSynced(link); - const reservationId = Number( - testDb.prepare("INSERT INTO reservations (trip_id, title) VALUES (?, 'Hotel Kyoto')").run(tripId).lastInsertRowid, - ); - const otherReservationId = Number( - testDb.prepare("INSERT INTO reservations (trip_id, title) VALUES (?, 'Ryokan Hakone')").run(tripId).lastInsertRowid, - ); - testDb.prepare('UPDATE trip_files SET reservation_id = ?, description = ?, starred = 1 WHERE id = ?') - .run(reservationId, 'Booking confirmation', fileId); - testDb.prepare('INSERT INTO file_links (file_id, reservation_id) VALUES (?, ?)').run(fileId, otherReservationId); + const { itemId, fileId } = await seedSynced(link); + const reservationId = await insertRow(orm, Reservations, { trip: tripId, title: 'Hotel Kyoto' }); + const otherReservationId = await insertRow(orm, Reservations, { trip: tripId, title: 'Ryokan Hakone' }); + await updateRows(orm, TripFiles, { id: fileId }, { reservation: reservationId, description: 'Booking confirmation', starred: 1 }); + await insertRow(orm, FileLinks, { file: fileId, reservation: otherReservationId }); provider.list.mockResolvedValueOnce(ok(listing([ remoteDoc({ remoteId: 'r1', name: 'invoice.pdf', remoteVersion: 'v2' }), ]))); await service.syncLink(link); - const fresh = fileRows().find(r => Number(r.id) !== fileId) as Record; + const fresh = (await fileRows()).find(r => Number(r.id) !== fileId) as Record; expect(fresh).toMatchObject({ reservation_id: reservationId, description: 'Booking confirmation', starred: 1, deleted_at: null }); - expect(itemRow(itemId).file_id).toBe(Number(fresh.id)); - const links = testDb.prepare('SELECT reservation_id FROM file_links WHERE file_id = ? ORDER BY id').all(fresh.id) as Array<{ reservation_id: number }>; + expect((await itemRow(itemId)).file_id).toBe(Number(fresh.id)); + const links = await findRows(orm, FileLinks, { file: Number(fresh.id) }, { id: 'asc' }); expect(links.map(l => l.reservation_id)).toEqual([otherReservationId]); // The old copy keeps what it had: the trash shows it as it was. - expect(fileRows().find(r => Number(r.id) === fileId)).toMatchObject({ reservation_id: reservationId, starred: 1 }); + expect((await fileRows()).find(r => Number(r.id) === fileId)).toMatchObject({ reservation_id: reservationId, starred: 1 }); expect(realtime.broadcast).toHaveBeenCalledWith(tripId, 'file:created', { file: expect.objectContaining({ id: fresh.id, starred: 1 }) }); }); @@ -1527,19 +1508,19 @@ describe('DocSyncService', () => { */ it('keeps the file when the download turns out to be the bytes TREK already holds', async () => { const link = await makeLink(); - const fileId = makeFile({ name: 'invoice.pdf' }); - const itemId = seedItem(link, { + const fileId = await makeFile({ name: 'invoice.pdf' }); + const itemId = (await seedItem(link, { remoteId: 'r1', remoteName: 'invoice.pdf', remoteVersion: 'v1', fileId, state: 'synced', contentSha256: FILE_SHA, - }); + })); provider.list.mockResolvedValueOnce(ok(listing([ remoteDoc({ remoteId: 'r1', name: 'invoice.pdf', remoteVersion: 'v2', contentHash: null }), ]))); const res = await service.syncLink(link); - expect(fileRows()).toHaveLength(1); - expect(fileRows()[0]).toMatchObject({ id: fileId, deleted_at: null }); - expect(itemRow(itemId)).toMatchObject({ file_id: fileId, remote_version: 'v2', state: 'synced', content_sha256: FILE_SHA }); + expect(await fileRows()).toHaveLength(1); + expect((await fileRows())[0]).toMatchObject({ id: fileId, deleted_at: null }); + expect(await itemRow(itemId)).toMatchObject({ file_id: fileId, remote_version: 'v2', state: 'synced', content_sha256: FILE_SHA }); expect(storage.put).not.toHaveBeenCalled(); expect(fs.readdirSync(spoolDir)).toHaveLength(0); expect(res.pulled).toBe(0); @@ -1550,18 +1531,18 @@ describe('DocSyncService', () => { // Moving the agreed name here would read the store's rename as TREK's // own on the next run and send the old name back up. const link = await makeLink(); - const fileId = makeFile({ name: 'invoice.pdf' }); - const itemId = seedItem(link, { + const fileId = await makeFile({ name: 'invoice.pdf' }); + const itemId = (await seedItem(link, { remoteId: 'r1', remoteName: 'invoice.pdf', remoteVersion: 'v1', fileId, state: 'synced', contentSha256: FILE_SHA, - }); + })); provider.list.mockResolvedValueOnce(ok(listing([ remoteDoc({ remoteId: 'r1', name: 'hotel-invoice.pdf', remoteVersion: 'v2', contentHash: null }), ]))); await service.syncLink(link); - expect(itemRow(itemId)).toMatchObject({ remote_name: 'invoice.pdf', remote_version: 'v2' }); - expect(fileRows()[0].original_name).toBe('invoice.pdf'); + expect(await itemRow(itemId)).toMatchObject({ remote_name: 'invoice.pdf', remote_version: 'v2' }); + expect((await fileRows())[0].original_name).toBe('invoice.pdf'); provider.list.mockResolvedValueOnce(ok(listing([ remoteDoc({ remoteId: 'r1', name: 'hotel-invoice.pdf', remoteVersion: 'v2', contentHash: null }), @@ -1569,8 +1550,8 @@ describe('DocSyncService', () => { await service.syncLink(link); expect(provider.rename).not.toHaveBeenCalled(); - expect(fileRows()[0].original_name).toBe('hotel-invoice.pdf'); - expect(itemRow(itemId).remote_name).toBe('hotel-invoice.pdf'); + expect((await fileRows())[0].original_name).toBe('hotel-invoice.pdf'); + expect((await itemRow(itemId)).remote_name).toBe('hotel-invoice.pdf'); }); }); @@ -1588,8 +1569,8 @@ describe('DocSyncService', () => { const res = await service.syncLink(link); expect(provider.fetch).toHaveBeenCalledTimes(1); - expect(fileRows().map(r => r.original_name)).toEqual(['flug.mp4']); - expect(itemRows()[0].state).toBe('synced'); + expect((await fileRows()).map(r => r.original_name)).toEqual(['flug.mp4']); + expect((await itemRows())[0].state).toBe('synced'); expect(res.state).toBe('ok'); }); }); @@ -1604,14 +1585,14 @@ describe('DocSyncService', () => { * the same. */ describe('a download that fails', () => { - const settleBackoff = () => testDb.prepare('UPDATE document_sync_items SET next_attempt_at = NULL').run(); + const settleBackoff = () => updateRows(orm, DocumentSyncItems, {}, { next_attempt_at: null }); - function seedHeld(link: LinkRow): { itemId: number; fileId: number } { - const fileId = makeFile({ name: 'invoice.pdf' }); - const itemId = seedItem(link, { + async function seedHeld(link: LinkRow): Promise<{ itemId: number; fileId: number }> { + const fileId = await makeFile({ name: 'invoice.pdf' }); + const itemId = (await seedItem(link, { remoteId: 'r1', remoteName: 'invoice.pdf', remoteVersion: 'v1', fileId, state: 'synced', contentSha256: 'held-hash', remoteSize: 512, remoteModifiedAt: '2026-09-01T08:00:00Z', - }); + })); return { itemId, fileId }; } @@ -1621,13 +1602,13 @@ describe('DocSyncService', () => { it('keeps the version TREK holds on record, not the one it failed to get', async () => { const link = await makeLink(); - const { itemId, fileId } = seedHeld(link); + const { itemId, fileId } = await seedHeld(link); provider.list.mockResolvedValue(ok(listing([edited()]))); provider.fetch.mockResolvedValueOnce(fail('timeout')); await service.syncLink(link); - expect(itemRow(itemId)).toMatchObject({ + expect(await itemRow(itemId)).toMatchObject({ state: 'error', error_code: 'timeout', file_id: fileId, content_sha256: 'held-hash', remote_version: 'v1', remote_size: 512, remote_modified_at: '2026-09-01T08:00:00Z', }); @@ -1635,22 +1616,22 @@ describe('DocSyncService', () => { it('fetches the update again once the backoff is over and ends synced with the new bytes', async () => { const link = await makeLink(); - const { itemId, fileId } = seedHeld(link); + const { itemId, fileId } = await seedHeld(link); provider.list.mockResolvedValue(ok(listing([edited()]))); provider.fetch.mockResolvedValueOnce(fail('timeout')); await service.syncLink(link); - settleBackoff(); + await settleBackoff(); const res = await service.syncLink(await config.getLink(link.id)); expect(res).toMatchObject({ state: 'ok', pulled: 1 }); expect(provider.fetch).toHaveBeenCalledTimes(2); - const row = itemRow(itemId); + const row = await itemRow(itemId); expect(row).toMatchObject({ state: 'synced', error_code: null, attempts: 0, remote_version: 'v2', content_sha256: FILE_SHA, remote_size: 2048, remote_modified_at: '2026-09-18T10:00:00Z', }); - const live = fileRows().filter(r => r.deleted_at === null); + const live = (await fileRows()).filter(r => r.deleted_at === null); expect(live).toHaveLength(1); expect(Number(live[0].id)).toBe(Number(row.file_id)); expect(Number(row.file_id)).not.toBe(fileId); @@ -1659,7 +1640,7 @@ describe('DocSyncService', () => { it('is fetched again by "Sync now" without waiting out the backoff', async () => { const link = await makeLink(); - const { itemId } = seedHeld(link); + const { itemId } = await seedHeld(link); provider.list.mockResolvedValue(ok(listing([edited()]))); provider.fetch.mockResolvedValueOnce(fail('provider_error')); await service.syncLink(link); @@ -1668,7 +1649,7 @@ describe('DocSyncService', () => { await service.syncLink(await config.getLink(link.id)); expect(provider.fetch).toHaveBeenCalledTimes(2); - expect(itemRow(itemId)).toMatchObject({ state: 'synced', remote_version: 'v2', content_sha256: FILE_SHA }); + expect(await itemRow(itemId)).toMatchObject({ state: 'synced', remote_version: 'v2', content_sha256: FILE_SHA }); }); it('still retries a first download, which has no version to keep', async () => { @@ -1676,15 +1657,15 @@ describe('DocSyncService', () => { provider.list.mockResolvedValue(ok(listing([remoteDoc({ remoteId: 'r-new', name: 'fresh.pdf', remoteVersion: 'v1' })]))); provider.fetch.mockResolvedValueOnce(fail('timeout')); await service.syncLink(link); - expect(itemRows()[0]).toMatchObject({ state: 'error', file_id: null, remote_id: 'r-new', remote_version: null }); + expect((await itemRows())[0]).toMatchObject({ state: 'error', file_id: null, remote_id: 'r-new', remote_version: null }); - settleBackoff(); + await settleBackoff(); await service.syncLink(await config.getLink(link.id)); expect(provider.fetch).toHaveBeenCalledTimes(2); - expect(itemRows()).toHaveLength(1); - expect(itemRows()[0]).toMatchObject({ state: 'synced', remote_version: 'v1', content_sha256: FILE_SHA }); - expect(fileRows()).toHaveLength(1); + expect(await itemRows()).toHaveLength(1); + expect((await itemRows())[0]).toMatchObject({ state: 'synced', remote_version: 'v1', content_sha256: FILE_SHA }); + expect(await fileRows()).toHaveLength(1); }); }); @@ -1707,7 +1688,7 @@ describe('DocSyncService', () => { * backoff rather than the counter. */ async function failingRun(link: LinkRow) { - testDb.prepare("UPDATE document_sync_items SET next_attempt_at = NULL WHERE remote_id = 'r-flaky'").run(); + await updateRows(orm, DocumentSyncItems, { remote_id: 'r-flaky' }, { next_attempt_at: null }); provider.list.mockResolvedValueOnce(ok(listing([ remoteDoc({ remoteId: 'r-flaky', name: 'flaky.pdf', remoteVersion: 'v1' }), ]))); @@ -1717,34 +1698,33 @@ describe('DocSyncService', () => { /** One run in which it works. */ async function goodRun(link: LinkRow) { - testDb.prepare("UPDATE document_sync_items SET next_attempt_at = NULL WHERE remote_id = 'r-flaky'").run(); + await updateRows(orm, DocumentSyncItems, { remote_id: 'r-flaky' }, { next_attempt_at: null }); provider.list.mockResolvedValueOnce(ok(listing([ remoteDoc({ remoteId: 'r-flaky', name: 'flaky.pdf', remoteVersion: 'v1' }), ]))); await service.syncLink(link); } - const flakyRow = () => testDb - .prepare("SELECT * FROM document_sync_items WHERE remote_id = 'r-flaky'") - .get() as Record; + const flakyRow = async () => + (await findRow(orm, DocumentSyncItems, { remote_id: 'r-flaky' })) as Record; it('counts consecutive failures', async () => { const link = await makeLink(); await failingRun(link); - expect(flakyRow().attempts).toBe(1); + expect((await flakyRow()).attempts).toBe(1); await failingRun(link); - expect(flakyRow().attempts).toBe(2); + expect((await flakyRow()).attempts).toBe(2); }); it('clears the counter once a pass succeeds, so the limit means six in a row', async () => { const link = await makeLink(); await failingRun(link); await failingRun(link); - expect(flakyRow().attempts).toBe(2); + expect((await flakyRow()).attempts).toBe(2); await goodRun(link); - expect(flakyRow().attempts).toBe(0); + expect((await flakyRow()).attempts).toBe(0); }); it('schedules each failure further out than the last', async () => { @@ -1752,8 +1732,9 @@ describe('DocSyncService', () => { const waits: number[] = []; for (let i = 0; i < 3; i += 1) { await failingRun(link); - const row = flakyRow(); + const row = await flakyRow(); const secs = (testDb + // test-sql-allow: compares against SQLite's own clock, which is what CURRENT_TIMESTAMP and the stored text are measured by. .prepare("SELECT CAST((julianday(?) - julianday('now')) * 86400 AS INTEGER) AS s") .get(row.next_attempt_at) as { s: number }).s; waits.push(secs); @@ -1773,38 +1754,38 @@ describe('DocSyncService', () => { * ever deleted in TREK, months back included. */ describe('a deletion in TREK', () => { - function deletedPairing(link: LinkRow): { itemId: number; fileId: number } { - const fileId = makeFile({ name: 'boarding.pdf', deletedAt: '2026-09-18 08:00:00' }); - const itemId = seedItem(link, { + async function deletedPairing(link: LinkRow): Promise<{ itemId: number; fileId: number }> { + const fileId = await makeFile({ name: 'boarding.pdf', deletedAt: '2026-09-18 08:00:00' }); + const itemId = (await seedItem(link, { remoteId: 'r1', remoteName: 'boarding.pdf', remoteVersion: 'v1', fileId, state: 'synced', contentSha256: FILE_SHA, pushedSha256: FILE_SHA, - }); + })); return { itemId, fileId }; } it('does not reach back when the binding is switched from unlink to trash later', async () => { const link = await makeLink({ deletePolicy: 'unlink' }); - const { itemId } = deletedPairing(link); + const { itemId } = await deletedPairing(link); provider.list.mockResolvedValue(ok(listing([remoteDoc({ remoteId: 'r1' })]))); await service.syncLink(link); - expect(itemRow(itemId).state).toBe('local_deleted'); - expect(itemRow(itemId).remote_trashed_at).toBeNull(); + expect((await itemRow(itemId)).state).toBe('local_deleted'); + expect((await itemRow(itemId)).remote_trashed_at).toBeNull(); - testDb.prepare("UPDATE trip_document_links SET delete_policy = 'trash' WHERE id = ?").run(link.id); + await updateRows(orm, TripDocumentLinks, { id: link.id }, { delete_policy: 'trash' }); await service.syncLink(await config.getLink(link.id)); expect(provider.trash).not.toHaveBeenCalled(); - expect(itemRow(itemId).state).toBe('local_deleted'); + expect((await itemRow(itemId)).state).toBe('local_deleted'); }); it('bins the provider copy exactly once, even while the provider keeps listing it', async () => { const link = await makeLink({ deletePolicy: 'trash' }); - const { itemId } = deletedPairing(link); + const { itemId } = await deletedPairing(link); provider.list.mockResolvedValue(ok(listing([remoteDoc({ remoteId: 'r1' })]))); await service.syncLink(link); - expect(itemRow(itemId).remote_trashed_at).not.toBeNull(); + expect((await itemRow(itemId)).remote_trashed_at).not.toBeNull(); await service.syncLink(await config.getLink(link.id)); await service.syncLink(await config.getLink(link.id)); @@ -1813,7 +1794,7 @@ describe('DocSyncService', () => { it('does not ask again under an unchanged upstream either', async () => { const link = await makeLink({ deletePolicy: 'trash' }); - deletedPairing(link); + await deletedPairing(link); provider.list.mockResolvedValueOnce(ok(listing([remoteDoc({ remoteId: 'r1' })]))); await service.syncLink(link); @@ -1834,7 +1815,7 @@ describe('DocSyncService', () => { */ describe('a file taken back out of TREK trash', () => { const restore = (fileId: number) => - testDb.prepare('UPDATE trip_files SET deleted_at = NULL WHERE id = ?').run(fileId); + updateRows(orm, TripFiles, { id: fileId }, { deleted_at: null }); /** * A provider answering the way the WebDAV adapters do: asked with the @@ -1847,14 +1828,14 @@ describe('DocSyncService', () => { /** A synced pairing whose file was deleted in TREK and whose deletion one run has seen. */ async function deletedAndSeen(link: LinkRow): Promise<{ itemId: number; fileId: number }> { - const fileId = makeFile({ name: 'boarding.pdf', deletedAt: '2026-09-18 08:00:00' }); - const itemId = seedItem(link, { + const fileId = await makeFile({ name: 'boarding.pdf', deletedAt: '2026-09-18 08:00:00' }); + const itemId = (await seedItem(link, { remoteId: 'r1', remoteName: 'boarding.pdf', remoteVersion: 'v1', fileId, state: 'synced', contentSha256: FILE_SHA, pushedSha256: FILE_SHA, - }); + })); provider.list.mockResolvedValueOnce(ok(listing([remoteDoc({ remoteId: 'r1' })]))); await service.syncLink(link); - expect(itemRow(itemId).state).toBe('local_deleted'); + expect((await itemRow(itemId)).state).toBe('local_deleted'); return { itemId, fileId }; } @@ -1862,22 +1843,22 @@ describe('DocSyncService', () => { const link = await makeLink({ deletePolicy: 'unlink' }); const { itemId, fileId } = await deletedAndSeen(link); - restore(fileId); + await restore(fileId); provider.list.mockResolvedValueOnce(ok(listing([remoteDoc({ remoteId: 'r1' })]))); const run = await service.syncLink(await config.getLink(link.id)); expect(run.state).toBe('ok'); expect(provider.fetch).not.toHaveBeenCalled(); expect(provider.push).not.toHaveBeenCalled(); - expect(itemRow(itemId).state).toBe('synced'); - expect(fileRows().map((f) => [f.id, f.deleted_at])).toEqual([[fileId, null]]); + expect((await itemRow(itemId)).state).toBe('synced'); + expect((await fileRows()).map((f) => [f.id, f.deleted_at])).toEqual([[fileId, null]]); }); it('lets the ordinary rules bring down an edit made upstream in the meantime', async () => { const link = await makeLink({ deletePolicy: 'unlink' }); const { itemId, fileId } = await deletedAndSeen(link); - restore(fileId); + await restore(fileId); // An edit is new bytes, not only a new version marker: the same bytes under // a moved marker are kept as they are. const edited = Buffer.from('trek-document-bytes, revised upstream'); @@ -1886,30 +1867,30 @@ describe('DocSyncService', () => { const run = await service.syncLink(await config.getLink(link.id)); expect(run.pulled).toBe(1); - const row = itemRow(itemId); + const row = await itemRow(itemId); expect(row.state).toBe('synced'); expect(row.file_id).not.toBe(fileId); - expect(fileRows().filter((f) => f.deleted_at === null)).toHaveLength(1); + expect((await fileRows()).filter((f) => f.deleted_at === null)).toHaveLength(1); }); it('puts the file back at the provider when TREK was the one that binned it', async () => { const link = await makeLink({ deletePolicy: 'trash' }); const { itemId, fileId } = await deletedAndSeen(link); expect(provider.trash).toHaveBeenCalledTimes(1); - const uid = itemRow(itemId).trek_doc_uid; + const uid = (await itemRow(itemId)).trek_doc_uid; - restore(fileId); + await restore(fileId); provider.list.mockResolvedValueOnce(ok(listing([]))); const run = await service.syncLink(await config.getLink(link.id)); expect(run.pushed).toBe(1); expect(provider.push.mock.calls[0][2]).toMatchObject({ fileName: 'boarding.pdf', remoteId: undefined, trekDocUid: uid }); - const row = itemRow(itemId); + const row = await itemRow(itemId); expect(row.state).toBe('synced'); expect(row.remote_id).toBe('r-pushed'); expect(row.file_id).toBe(fileId); expect(row.remote_trashed_at).toBeNull(); - expect(itemRows()).toHaveLength(1); + expect(await itemRows()).toHaveLength(1); }); it('does not mistake the re-upload for a foreign change on the run after', async () => { @@ -1917,7 +1898,7 @@ describe('DocSyncService', () => { // reports it back under a new version marker. const link = await makeLink({ deletePolicy: 'trash' }); const { itemId, fileId } = await deletedAndSeen(link); - restore(fileId); + await restore(fileId); provider.list.mockResolvedValueOnce(ok(listing([]))); await service.syncLink(await config.getLink(link.id)); @@ -1929,40 +1910,43 @@ describe('DocSyncService', () => { expect(run.pulled).toBe(0); expect(provider.fetch).not.toHaveBeenCalled(); expect(provider.push).toHaveBeenCalledTimes(1); - expect(itemRow(itemId).state).toBe('synced'); + expect((await itemRow(itemId)).state).toBe('synced'); }); it('retries a re-upload that failed like any other unpaired file', async () => { const link = await makeLink({ deletePolicy: 'trash' }); const { itemId, fileId } = await deletedAndSeen(link); - restore(fileId); + await restore(fileId); provider.list.mockResolvedValue(ok(listing([]))); provider.push.mockResolvedValueOnce(fail('unreachable')); await service.syncLink(await config.getLink(link.id)); - const failed = itemRow(itemId); + const failed = await itemRow(itemId); expect(failed.state).toBe('error'); expect(failed.remote_id).toBeNull(); - testDb.prepare('UPDATE document_sync_items SET next_attempt_at = NULL WHERE id = ?').run(itemId); + await updateRows(orm, DocumentSyncItems, { id: itemId }, { next_attempt_at: null }); await service.syncLink(await config.getLink(link.id)); expect(provider.push).toHaveBeenCalledTimes(2); - expect(itemRow(itemId).state).toBe('synced'); - expect(itemRow(itemId).remote_id).toBe('r-pushed'); + expect((await itemRow(itemId)).state).toBe('synced'); + expect((await itemRow(itemId)).remote_id).toBe('r-pushed'); }); it('uploads many files TREK had binned instead of reading them as a mass deletion', async () => { const link = await makeLink({ deletePolicy: 'trash' }); - const fileIds = Array.from({ length: 10 }, (_, i) => - makeFile({ name: `doc-${i}.pdf`, storageKey: `key-${i}`, deletedAt: '2026-09-18 08:00:00' })); - fileIds.forEach((fileId, i) => - seedItem(link, { remoteId: `r${i}`, remoteName: `doc-${i}.pdf`, remoteVersion: 'v1', fileId, state: 'synced' })); + const fileIds: number[] = []; + for (let i = 0; i < 10; i += 1) { + fileIds.push(await makeFile({ name: `doc-${i}.pdf`, storageKey: `key-${i}`, deletedAt: '2026-09-18 08:00:00' })); + } + for (const [i, fileId] of fileIds.entries()) { + await seedItem(link, { remoteId: `r${i}`, remoteName: `doc-${i}.pdf`, remoteVersion: 'v1', fileId, state: 'synced' }); + } provider.list.mockResolvedValueOnce(ok(listing(fileIds.map((_, i) => remoteDoc({ remoteId: `r${i}`, name: `doc-${i}.pdf` }))))); await service.syncLink(link); expect(provider.trash).toHaveBeenCalledTimes(10); - for (const fileId of fileIds) restore(fileId); + for (const fileId of fileIds) await restore(fileId); let seq = 0; provider.push.mockImplementation(async () => { seq += 1; @@ -1974,7 +1958,7 @@ describe('DocSyncService', () => { expect(run.errorCode).toBeUndefined(); expect(run.state).toBe('ok'); expect(run.pushed).toBe(10); - expect(itemRows().every((r) => r.state === 'synced')).toBe(true); + expect((await itemRows()).every((r) => r.state === 'synced')).toBe(true); }); it('does not upload a copy TREK binned that was restored at the provider meanwhile', async () => { @@ -1986,16 +1970,16 @@ describe('DocSyncService', () => { provider.list.mockResolvedValueOnce(ok(listing([remoteDoc({ remoteId: 'r1' })]))); await service.syncLink(await config.getLink(link.id)); - expect(itemRow(itemId).remote_trashed_at).toBeNull(); + expect((await itemRow(itemId)).remote_trashed_at).toBeNull(); expect(provider.fetch).not.toHaveBeenCalled(); - restore(fileId); + await restore(fileId); provider.list.mockImplementationOnce(quietUnlessAsked([remoteDoc({ remoteId: 'r1' })])); await service.syncLink(await config.getLink(link.id)); expect(provider.push).not.toHaveBeenCalled(); - expect(itemRow(itemId).state).toBe('synced'); - expect(itemRow(itemId).remote_id).toBe('r1'); + expect((await itemRow(itemId)).state).toBe('synced'); + expect((await itemRow(itemId)).remote_id).toBe('r1'); }); it('asks a quiet binding for the whole listing and flags a copy that is gone', async () => { @@ -2006,14 +1990,14 @@ describe('DocSyncService', () => { const { itemId, fileId } = await deletedAndSeen(link); const visa = remoteDoc({ remoteId: 'r-visa', name: 'visa.pdf' }); - restore(fileId); + await restore(fileId); provider.list.mockImplementation(quietUnlessAsked([visa])); const run = await service.syncLink(await config.getLink(link.id)); expect(provider.list.mock.calls[1][1].cursor).toBeNull(); expect(run).toMatchObject({ state: 'ok', pulled: 1, pushed: 0, missing: 1 }); - expect(itemRow(itemId)).toMatchObject({ state: 'remote_missing', remote_trashed_at: null }); - expect(itemRow(itemId).remote_missing_at).not.toBeNull(); + expect(await itemRow(itemId)).toMatchObject({ state: 'remote_missing', remote_trashed_at: null }); + expect((await itemRow(itemId)).remote_missing_at).not.toBeNull(); // Once the restore is settled the binding goes back to its cursor. await service.syncLink(await config.getLink(link.id)); @@ -2026,16 +2010,20 @@ describe('DocSyncService', () => { // written, the next run planned the same, and the binding synced nothing // at all, new documents included, until its circuit opened. const link = await makeLink({ deletePolicy: 'unlink' }); - const inStep = Array.from({ length: 6 }, (_, i) => { - const fileId = makeFile({ name: `doc-${i}.pdf`, storageKey: `key-doc-${i}` }); - seedItem(link, { remoteId: `s${i}`, remoteName: `doc-${i}.pdf`, remoteVersion: 'v1', fileId, contentSha256: FILE_SHA }); - return remoteDoc({ remoteId: `s${i}`, name: `doc-${i}.pdf` }); - }); - const restored = Array.from({ length: 5 }, (_, i) => seedItem(link, { - remoteId: `g${i}`, remoteName: `gone-${i}.pdf`, remoteVersion: 'v1', state: 'local_deleted', contentSha256: FILE_SHA, - fileId: makeFile({ name: `gone-${i}.pdf`, storageKey: `key-gone-${i}` }), - })); - makeFile({ name: 'new-here.pdf', storageKey: 'key-new-here' }); + const inStep: RemoteDocument[] = []; + for (let i = 0; i < 6; i += 1) { + const fileId = await makeFile({ name: `doc-${i}.pdf`, storageKey: `key-doc-${i}` }); + await seedItem(link, { remoteId: `s${i}`, remoteName: `doc-${i}.pdf`, remoteVersion: 'v1', fileId, contentSha256: FILE_SHA }); + inStep.push(remoteDoc({ remoteId: `s${i}`, name: `doc-${i}.pdf` })); + } + const restored: number[] = []; + for (let i = 0; i < 5; i += 1) { + restored.push(await seedItem(link, { + remoteId: `g${i}`, remoteName: `gone-${i}.pdf`, remoteVersion: 'v1', state: 'local_deleted', contentSha256: FILE_SHA, + fileId: await makeFile({ name: `gone-${i}.pdf`, storageKey: `key-gone-${i}` }), + })); + } + await makeFile({ name: 'new-here.pdf', storageKey: 'key-new-here' }); const newThere = remoteDoc({ remoteId: 'r-new', name: 'new-there.pdf' }); provider.list.mockResolvedValueOnce(ok(listing([...inStep, newThere]))); @@ -2048,21 +2036,21 @@ describe('DocSyncService', () => { expect(first).toMatchObject({ state: 'ok', pulled: 1, pushed: 1, missing: 5 }); expect(first.errorCode).toBeUndefined(); expect(second).toMatchObject({ state: 'ok', pulled: 0, pushed: 0, missing: 0 }); - expect(linkRow(link.id)).toMatchObject({ failure_count: 0, last_sync_state: 'ok' }); - for (const id of restored) expect(itemRow(id).state).toBe('remote_missing'); + expect(await linkRow(link.id)).toMatchObject({ failure_count: 0, last_sync_state: 'ok' }); + for (const id of restored) expect((await itemRow(id)).state).toBe('remote_missing'); }); it('flags the copy as missing when it vanished upstream by somebody else', async () => { const link = await makeLink({ deletePolicy: 'unlink' }); const { itemId, fileId } = await deletedAndSeen(link); - restore(fileId); + await restore(fileId); provider.list.mockResolvedValueOnce(ok(listing([]))); const run = await service.syncLink(await config.getLink(link.id)); expect(provider.push).not.toHaveBeenCalled(); expect(run.missing).toBe(1); - expect(itemRow(itemId).state).toBe('remote_missing'); + expect((await itemRow(itemId)).state).toBe('remote_missing'); expect((await service.issues(tripId)).map((r) => r.id)).toContain(itemId); }); }); @@ -2074,28 +2062,28 @@ describe('DocSyncService', () => { * delete policy entirely. */ describe('a file purged from TREK trash', () => { - const purge = (fileId: number) => testDb.prepare('DELETE FROM trip_files WHERE id = ?').run(fileId); + const purge = (fileId: number) => deleteRows(orm, TripFiles, { id: fileId }); it('does not download a document again once its deletion was seen', async () => { const link = await makeLink({ deletePolicy: 'unlink' }); - const fileId = makeFile({ name: 'boarding.pdf', deletedAt: '2026-09-18 08:00:00' }); - const itemId = seedItem(link, { remoteId: 'r1', remoteName: 'boarding.pdf', remoteVersion: 'v1', fileId, state: 'synced' }); + const fileId = await makeFile({ name: 'boarding.pdf', deletedAt: '2026-09-18 08:00:00' }); + const itemId = await seedItem(link, { remoteId: 'r1', remoteName: 'boarding.pdf', remoteVersion: 'v1', fileId, state: 'synced' }); provider.list.mockResolvedValue(ok(listing([remoteDoc({ remoteId: 'r1' })]))); await service.syncLink(link); - purge(fileId); + await purge(fileId); await service.syncLink(await config.getLink(link.id)); expect(provider.fetch).not.toHaveBeenCalled(); - expect(fileRows()).toHaveLength(0); - expect(itemRow(itemId).state).toBe('local_deleted'); + expect(await fileRows()).toHaveLength(0); + expect((await itemRow(itemId)).state).toBe('local_deleted'); }); it('applies the delete policy when the purge came before any run saw the deletion', async () => { const link = await makeLink({ deletePolicy: 'trash' }); - const fileId = makeFile({ name: 'boarding.pdf', deletedAt: '2026-09-18 08:00:00' }); - const itemId = seedItem(link, { remoteId: 'r1', remoteName: 'boarding.pdf', remoteVersion: 'v1', fileId, state: 'synced' }); - purge(fileId); + const fileId = await makeFile({ name: 'boarding.pdf', deletedAt: '2026-09-18 08:00:00' }); + const itemId = await seedItem(link, { remoteId: 'r1', remoteName: 'boarding.pdf', remoteVersion: 'v1', fileId, state: 'synced' }); + await purge(fileId); provider.list.mockResolvedValue(ok(listing([remoteDoc({ remoteId: 'r1' })]))); await service.syncLink(link); @@ -2103,8 +2091,8 @@ describe('DocSyncService', () => { expect(provider.fetch).not.toHaveBeenCalled(); expect(provider.trash).toHaveBeenCalledTimes(1); - expect(fileRows()).toHaveLength(0); - expect(itemRow(itemId).state).toBe('local_deleted'); + expect(await fileRows()).toHaveLength(0); + expect((await itemRow(itemId)).state).toBe('local_deleted'); }); it('does not bring back a document purged while its failed update was held back', async () => { @@ -2112,23 +2100,23 @@ describe('DocSyncService', () => { // backoff drops the deletion. Purged in that window, the row read as a // first download that never landed, and "Sync now" fetched it again. const link = await makeLink({ deletePolicy: 'trash' }); - const fileId = makeFile({ name: 'boarding.pdf' }); - const itemId = seedItem(link, { + const fileId = await makeFile({ name: 'boarding.pdf' }); + const itemId = (await seedItem(link, { remoteId: 'r1', remoteName: 'boarding.pdf', remoteVersion: 'v1', fileId, state: 'synced', contentSha256: FILE_SHA, - }); + })); provider.list.mockResolvedValue(ok(listing([remoteDoc({ remoteId: 'r1', remoteVersion: 'v2' })]))); provider.fetch.mockResolvedValueOnce(fail('timeout')); await service.syncLink(link); - expect(itemRow(itemId)).toMatchObject({ state: 'error', file_id: fileId }); + expect(await itemRow(itemId)).toMatchObject({ state: 'error', file_id: fileId }); - purge(fileId); + await purge(fileId); await service.retryShelvedItems(link.id); await service.syncLink(await config.getLink(link.id)); expect(provider.fetch).toHaveBeenCalledTimes(1); - expect(fileRows()).toHaveLength(0); + expect(await fileRows()).toHaveLength(0); expect(provider.trash).toHaveBeenCalledTimes(1); - expect(itemRow(itemId).state).toBe('local_deleted'); + expect((await itemRow(itemId)).state).toBe('local_deleted'); }); /** @@ -2142,19 +2130,19 @@ describe('DocSyncService', () => { */ it('closes a row on record as gone from the store once the TREK copy is purged too', async () => { const link = await makeLink({ deletePolicy: 'trash' }); - const fileId = makeFile({ name: 'boarding.pdf', deletedAt: '2026-09-18 08:00:00' }); - const itemId = seedItem(link, { + const fileId = await makeFile({ name: 'boarding.pdf', deletedAt: '2026-09-18 08:00:00' }); + const itemId = (await seedItem(link, { remoteId: 'r1', remoteName: 'boarding.pdf', remoteVersion: 'v1', fileId, state: 'remote_missing', remoteMissingAt: '2026-09-17 08:00:00', - }); - purge(fileId); + })); + await purge(fileId); provider.list.mockResolvedValue(ok(listing([]))); const res = await service.syncLink(link); expect(provider.trash).not.toHaveBeenCalled(); - expect(itemRow(itemId)).toMatchObject({ state: 'local_deleted', remote_trashed_at: null, file_id: null }); - expect(itemRow(itemId).remote_missing_at).not.toBeNull(); + expect(await itemRow(itemId)).toMatchObject({ state: 'local_deleted', remote_trashed_at: null, file_id: null }); + expect((await itemRow(itemId)).remote_missing_at).not.toBeNull(); expect((await service.issues(tripId)).map((r) => r.id)).not.toContain(itemId); // Closed is not "back at the store": the holdings must not count it there. const [status] = ((await service.status(tripId)) as { links: Array<{ holdings: { atProvider: number; missing: number } }> }).links; @@ -2180,11 +2168,11 @@ describe('DocSyncService', () => { it('takes a rename made at the provider into TREK and does not rename it back', async () => { const link = await makeLink(); - const fileId = makeFile({ name: 'a.pdf' }); - const itemId = seedItem(link, { + const fileId = await makeFile({ name: 'a.pdf' }); + const itemId = (await seedItem(link, { remoteId: '/trek/a.pdf', remoteName: 'a.pdf', remoteVersion: 'va', fileId, state: 'synced', contentSha256: FILE_SHA, remoteSize: 1024, remoteModifiedAt: AT, - }); + })); provider.list.mockResolvedValue(ok(listing([ remoteDoc({ remoteId: '/trek/b.pdf', name: 'b.pdf', remoteVersion: 'vb', contentHash: FILE_SHA }), ]))); @@ -2192,8 +2180,8 @@ describe('DocSyncService', () => { const first = await service.syncLink(link); expect(first).toMatchObject({ state: 'ok', missing: 0, pulled: 0 }); - expect(fileRows().map((f) => [f.id, f.original_name, f.deleted_at])).toEqual([[fileId, 'b.pdf', null]]); - expect(itemRow(itemId)).toMatchObject({ + expect((await fileRows()).map((f) => [f.id, f.original_name, f.deleted_at])).toEqual([[fileId, 'b.pdf', null]]); + expect(await itemRow(itemId)).toMatchObject({ remote_id: '/trek/b.pdf', remote_name: 'b.pdf', remote_version: 'vb', state: 'synced', remote_missing_at: null, }); @@ -2203,30 +2191,30 @@ describe('DocSyncService', () => { expect(provider.rename).not.toHaveBeenCalled(); expect(provider.fetch).not.toHaveBeenCalled(); expect(provider.push).not.toHaveBeenCalled(); - expect(fileRows().map((f) => f.original_name)).toEqual(['b.pdf']); - expect(itemRows()).toHaveLength(1); + expect((await fileRows()).map((f) => f.original_name)).toEqual(['b.pdf']); + expect(await itemRows()).toHaveLength(1); }); it('takes a copy on record as missing off the list in the run that finds it renamed', async () => { const link = await makeLink(); - const fileId = makeFile({ name: 'a.pdf' }); - const itemId = seedItem(link, { + const fileId = await makeFile({ name: 'a.pdf' }); + const itemId = (await seedItem(link, { remoteId: '/trek/a.pdf', remoteName: 'a.pdf', remoteVersion: 'va', fileId, state: 'remote_missing', contentSha256: FILE_SHA, remoteSize: 1024, remoteModifiedAt: AT, remoteMissingAt: '2026-09-18 09:00:00', - }); + })); provider.list.mockResolvedValue(ok(listing([ remoteDoc({ remoteId: '/trek/b.pdf', name: 'b.pdf', remoteVersion: 'vb' }), ]))); await service.syncLink(link); - expect(itemRow(itemId)).toMatchObject({ remote_id: '/trek/b.pdf', state: 'synced', remote_missing_at: null }); - expect(fileRows().map((f) => f.original_name)).toEqual(['b.pdf']); + expect(await itemRow(itemId)).toMatchObject({ remote_id: '/trek/b.pdf', state: 'synced', remote_missing_at: null }); + expect((await fileRows()).map((f) => f.original_name)).toEqual(['b.pdf']); expect(await service.issues(tripId)).toEqual([]); await service.syncLink(await config.getLink(link.id)); - expect(itemRow(itemId).state).toBe('synced'); + expect((await itemRow(itemId)).state).toBe('synced'); expect(provider.rename).not.toHaveBeenCalled(); }); @@ -2239,7 +2227,7 @@ describe('DocSyncService', () => { ]))); await service.syncLink(link); expect(provider.fetch).toHaveBeenCalledTimes(1); - const fileId = Number(fileRows()[0].id); + const fileId = Number((await fileRows())[0].id); provider.list.mockResolvedValue(ok(listing([ remoteDoc({ remoteId: '/trek/2026/b.pdf', name: 'b.pdf', remoteVersion: 'vb' }), @@ -2250,13 +2238,13 @@ describe('DocSyncService', () => { expect(renamed.missing).toBe(0); expect(provider.fetch).toHaveBeenCalledTimes(1); expect(provider.rename).not.toHaveBeenCalled(); - expect(fileRows().map((f) => [f.id, f.original_name, f.deleted_at])).toEqual([[fileId, 'b.pdf', null]]); - expect(itemRows().map((r) => [r.remote_id, r.remote_name, r.state])).toEqual([['/trek/2026/b.pdf', 'b.pdf', 'synced']]); + expect((await fileRows()).map((f) => [f.id, f.original_name, f.deleted_at])).toEqual([[fileId, 'b.pdf', null]]); + expect((await itemRows()).map((r) => [r.remote_id, r.remote_name, r.state])).toEqual([['/trek/2026/b.pdf', 'b.pdf', 'synced']]); }); it('recognises a copy it uploaded itself when it is renamed before the next run', async () => { const link = await makeLink(); - makeFile({ name: 'boarding.pdf' }); + await makeFile({ name: 'boarding.pdf' }); provider.list.mockResolvedValueOnce(ok(listing([]))); provider.push.mockResolvedValueOnce(ok({ remoteId: '/trek/boarding.pdf', remoteVersion: 'v1', remoteModifiedAt: AT, deduplicated: false, @@ -2272,19 +2260,19 @@ describe('DocSyncService', () => { expect(provider.push).toHaveBeenCalledTimes(1); expect(provider.fetch).not.toHaveBeenCalled(); expect(provider.rename).not.toHaveBeenCalled(); - expect(fileRows().map((f) => f.original_name)).toEqual(['flight.pdf']); - expect(itemRows().map((r) => [r.remote_id, r.state])).toEqual([['/trek/flight.pdf', 'synced']]); + expect((await fileRows()).map((f) => f.original_name)).toEqual(['flight.pdf']); + expect((await itemRows()).map((r) => [r.remote_id, r.state])).toEqual([['/trek/flight.pdf', 'synced']]); }); it('finds the copy again after sending a rename made in TREK upstream', async () => { // The adapter cannot report the path its rename produced, so the row // keeps the old one until the next listing shows where the copy went. const link = await makeLink(); - const fileId = makeFile({ name: 'b.pdf' }); - const itemId = seedItem(link, { + const fileId = await makeFile({ name: 'b.pdf' }); + const itemId = (await seedItem(link, { remoteId: '/trek/a.pdf', remoteName: 'a.pdf', remoteVersion: 'va', fileId, state: 'synced', contentSha256: FILE_SHA, remoteSize: 1024, remoteModifiedAt: AT, - }); + })); provider.list.mockResolvedValueOnce(ok(listing([ remoteDoc({ remoteId: '/trek/a.pdf', name: 'a.pdf', remoteVersion: 'va' }), ]))); @@ -2302,8 +2290,8 @@ describe('DocSyncService', () => { expect(provider.rename).toHaveBeenCalledTimes(1); expect(provider.fetch).not.toHaveBeenCalled(); expect(provider.push).not.toHaveBeenCalled(); - expect(fileRows()).toHaveLength(1); - expect(itemRow(itemId)).toMatchObject({ remote_id: '/trek/b.pdf', remote_name: 'b.pdf', state: 'synced' }); + expect(await fileRows()).toHaveLength(1); + expect(await itemRow(itemId)).toMatchObject({ remote_id: '/trek/b.pdf', remote_name: 'b.pdf', state: 'synced' }); }); }); @@ -2347,23 +2335,23 @@ describe('DocSyncService', () => { it('flags a copy deleted at the provider right after TREK uploaded it', async () => { const link = await makeLink(); - const fileId = makeFile({ name: 'boarding.pdf' }); + const fileId = await makeFile({ name: 'boarding.pdf' }); const held = digestScope(); expect(await service.syncLink(link)).toMatchObject({ state: 'ok', pushed: 1 }); - const itemId = Number(itemRows()[0].id); + const itemId = Number((await itemRows())[0].id); held.clear(); const run = await service.syncLink(await config.getLink(link.id)); expect(run).toMatchObject({ state: 'ok', missing: 1 }); - expect(itemRow(itemId)).toMatchObject({ state: 'remote_missing', file_id: fileId }); - expect(itemRow(itemId).remote_missing_at).not.toBeNull(); + expect(await itemRow(itemId)).toMatchObject({ state: 'remote_missing', file_id: fileId }); + expect((await itemRow(itemId)).remote_missing_at).not.toBeNull(); expect((await service.issues(tripId)).map((r) => r.id)).toEqual([itemId]); }); it('lets the mass-delete guard see a scope emptied right after an upload', async () => { const link = await makeLink(); - for (let i = 0; i < 6; i += 1) makeFile({ name: `doc-${i}.pdf`, storageKey: `key-doc-${i}` }); + for (let i = 0; i < 6; i += 1) (await makeFile({ name: `doc-${i}.pdf`, storageKey: `key-doc-${i}` })); const held = digestScope(); expect(await service.syncLink(link)).toMatchObject({ state: 'ok', pushed: 6 }); @@ -2371,46 +2359,46 @@ describe('DocSyncService', () => { const run = await service.syncLink(await config.getLink(link.id)); expect(run).toMatchObject({ state: 'partial', errorCode: 'mass_delete_guard', missing: 6 }); - expect(itemRows().every((r) => r.state === 'synced')).toBe(true); + expect((await itemRows()).every((r) => r.state === 'synced')).toBe(true); }); it('notices a copy TREK binned coming back, so a restore in TREK does not upload it twice', async () => { const link = await makeLink({ deletePolicy: 'trash' }); - const fileId = makeFile({ name: 'boarding.pdf', deletedAt: '2026-09-18 08:00:00' }); - const itemId = seedItem(link, { + const fileId = await makeFile({ name: 'boarding.pdf', deletedAt: '2026-09-18 08:00:00' }); + const itemId = (await seedItem(link, { remoteId: 'r1', remoteName: 'boarding.pdf', remoteVersion: 'v1', fileId, state: 'synced', contentSha256: FILE_SHA, - }); + })); const copy = remoteDoc({ remoteId: 'r1' }); const held = digestScope([copy]); await service.syncLink(link); expect(held.size).toBe(0); - expect(itemRow(itemId).remote_trashed_at).not.toBeNull(); + expect((await itemRow(itemId)).remote_trashed_at).not.toBeNull(); // Out of the provider's recycle bin as it went in, which is the scope the // binning run listed. held.set('r1', copy); await service.syncLink(await config.getLink(link.id)); - expect(itemRow(itemId).remote_trashed_at).toBeNull(); + expect((await itemRow(itemId)).remote_trashed_at).toBeNull(); - testDb.prepare('UPDATE trip_files SET deleted_at = NULL WHERE id = ?').run(fileId); + await updateRows(orm, TripFiles, { id: fileId }, { deleted_at: null }); await service.syncLink(await config.getLink(link.id)); expect(provider.push).not.toHaveBeenCalled(); - expect(itemRow(itemId)).toMatchObject({ state: 'synced', remote_id: 'r1' }); + expect(await itemRow(itemId)).toMatchObject({ state: 'synced', remote_id: 'r1' }); }); it('lists in full after a rename it sent, then goes back to its cursor', async () => { const link = await makeLink(); - const fileId = makeFile({ name: 'new.pdf' }); - seedItem(link, { remoteId: 'r1', remoteName: 'old.pdf', remoteVersion: 'v1', fileId, state: 'synced' }); + const fileId = await makeFile({ name: 'new.pdf' }); + await seedItem(link, { remoteId: 'r1', remoteName: 'old.pdf', remoteVersion: 'v1', fileId, state: 'synced' }); digestScope([remoteDoc({ remoteId: 'r1', name: 'old.pdf' })]); await service.syncLink(link); expect(provider.rename).toHaveBeenCalledTimes(1); - expect(linkRow(link.id).remote_cursor).toBeNull(); + expect((await linkRow(link.id)).remote_cursor).toBeNull(); await service.syncLink(await config.getLink(link.id)); - expect(linkRow(link.id).remote_cursor).toBe('digest:r1@v2'); + expect((await linkRow(link.id)).remote_cursor).toBe('digest:r1@v2'); const quiet = await service.syncLink(await config.getLink(link.id)); expect(provider.list.mock.calls[2][1].cursor).toBe('digest:r1@v2'); @@ -2427,7 +2415,7 @@ describe('DocSyncService', () => { const rest = await service.syncLink(await config.getLink(link.id)); expect(rest).toMatchObject({ state: 'ok', pulled: 5 }); - expect(fileRows()).toHaveLength(MAX_TRANSFERS_PER_RUN + 5); + expect(await fileRows()).toHaveLength(MAX_TRANSFERS_PER_RUN + 5); }); it('fetches a failed download again while nothing moves upstream', async () => { @@ -2436,39 +2424,38 @@ describe('DocSyncService', () => { provider.fetch.mockResolvedValueOnce(fail('timeout')); expect(await service.syncLink(link)).toMatchObject({ state: 'partial', errorCode: 'timeout' }); - testDb.prepare('UPDATE document_sync_items SET next_attempt_at = NULL').run(); + await updateRows(orm, DocumentSyncItems, {}, { next_attempt_at: null }); const retry = await service.syncLink(await config.getLink(link.id)); expect(retry).toMatchObject({ state: 'ok', pulled: 1 }); expect(provider.fetch).toHaveBeenCalledTimes(2); - expect(itemRows()[0]).toMatchObject({ state: 'synced', remote_id: 'r-new' }); + expect((await itemRows())[0]).toMatchObject({ state: 'synced', remote_id: 'r-new' }); }); }); describe('resolveConflict across trips', () => { - function conflictedItem(linkId: number, tripOfItem: number): number { - return Number(testDb.prepare( - `INSERT INTO document_sync_items (link_id, trip_id, trek_doc_uid, remote_id, state) - VALUES (?, ?, 'uid-conflict', 'r-conflict', 'conflict')`, - ).run(linkId, tripOfItem).lastInsertRowid); + function conflictedItem(linkId: number, tripOfItem: number): Promise { + return insertRow(orm, DocumentSyncItems, { + link: linkId, trip: tripOfItem, trek_doc_uid: 'uid-conflict', remote_id: 'r-conflict', state: 'conflict', + }); } it('refuses when the row belongs to another trip', async () => { const link = await makeLink(); - const itemId = conflictedItem(link.id, link.trip_id); + const itemId = await conflictedItem(link.id, link.trip_id); expect(await service.resolveConflict(itemId, 'trek', link.trip_id + 999)).toBe(false); - const after = testDb.prepare('SELECT state FROM document_sync_items WHERE id = ?').get(itemId) as { state: string }; - expect(after.state).toBe('conflict'); + const after = await findRow(orm, DocumentSyncItems, { id: itemId }); + expect(after?.state).toBe('conflict'); }); it('resolves when the row belongs to the trip', async () => { const link = await makeLink(); - const itemId = conflictedItem(link.id, link.trip_id); + const itemId = await conflictedItem(link.id, link.trip_id); expect(await service.resolveConflict(itemId, 'trek', link.trip_id)).toBe(true); - const after = testDb.prepare('SELECT state FROM document_sync_items WHERE id = ?').get(itemId) as { state: string }; - expect(after.state).not.toBe('conflict'); + const after = await findRow(orm, DocumentSyncItems, { id: itemId }); + expect(after?.state).not.toBe('conflict'); }); it('still works for a caller that names no trip, so nothing else breaks', async () => { const link = await makeLink(); - const itemId = conflictedItem(link.id, link.trip_id); + const itemId = await conflictedItem(link.id, link.trip_id); expect(await service.resolveConflict(itemId, 'trek')).toBe(true); }); });