diff --git a/test/flows/assembly-webhook-controller.integration.test.ts b/test/flows/assembly-webhook-controller.integration.test.ts index 37fd99c..66af83d 100644 --- a/test/flows/assembly-webhook-controller.integration.test.ts +++ b/test/flows/assembly-webhook-controller.integration.test.ts @@ -230,4 +230,44 @@ describe('Assembly webhook: deciding whether to sync to Dropbox', () => { expect(res.status).toBe(404) }) + + it('waits 800ms before processing to avoid a webhook ping-pong', async () => { + const { channel } = await seedActive() + const sleepSpy = mockSleepInstant() + const data = copilotFileFactory.build({ channelId: channel.assemblyChannelId, path: 'x.txt' }) + + // A non-handleable event returns early, but the 800ms guard runs first for every request. + await post('link.created', data) + + expect(sleepSpy).toHaveBeenCalledWith(800) + expect(sleepSpy).not.toHaveBeenCalledWith(5000) + }) + + it('skips a create for a file that was already soft-deleted (lookup ignores deletedAt)', async () => { + const { channel } = await seedActive() + mockSleepInstant() + // A tombstoned row still matches the assemblyFileId lookup, so the create is treated + // as existing and skipped — this is what prevents resyncing a soft-deleted file. + const row = await fileSyncSeeder.create({ + ...synced(), + ...tombstone(), + channelSyncId: channel.id, + itemPath: '/back.txt', + dbxFileId: 'dbx:back', + object: ObjectType.FILE, + }) + const data = copilotFileFactory.build({ + id: row.assemblyFileId as string, + channelId: channel.assemblyChannelId, + path: 'back.txt', + }) + + // No Dropbox mocks: a stray create would trip onUnhandledRequest:'error'. + const res = await post('file.created', data) + + expect(res.status).toBe(200) + const rows = await rowsFor(channel.id) + expect(rows).toHaveLength(1) // only the tombstone; no new row created + expect(rows[0].id).toBe(row.id) + }) }) diff --git a/test/flows/resync-sweep.integration.test.ts b/test/flows/resync-sweep.integration.test.ts index 31b1b2b..f5f1fc4 100644 --- a/test/flows/resync-sweep.integration.test.ts +++ b/test/flows/resync-sweep.integration.test.ts @@ -366,4 +366,44 @@ describe('resync sweep', () => { const [ch] = await db.select().from(channelSync).where(eq(channelSync.id, channel.id)) expect(ch.resyncingAt).toBeNull() // orchestrator's finally cleared the in-progress flag }) + + it('does nothing when the portal has no Dropbox connection', async () => { + const { channel } = await seed() + const row = await fileSyncSeeder.create({ + ...pendingDelete(PendingActionTarget.DROPBOX), + channelSyncId: channel.id, + itemPath: '/orphan.txt', + dbxFileId: 'dbx:orphan', + object: ObjectType.FILE, + pendingActionLastAttemptAt: minutesAgo(6), + }) + + // A portal id with no connection → early return before any row is processed. + await retryFailedSyncsForPortal(randomUUID(), [row]) + + const after = await rowById(row.id) + expect(after.deletedAt).toBeNull() + expect(after.pendingAction).toBe('delete') // untouched + expect(after.pendingActionLastError).toBeNull() + }) + + it('marks a row failed when its action/target combo is unrecognised', async () => { + const { connection, channel } = await seed() + const row = await fileSyncSeeder.create({ + ...pendingDelete(PendingActionTarget.ASSEMBLY), + channelSyncId: channel.id, + itemPath: '/weird.txt', + dbxFileId: 'dbx:weird', + object: ObjectType.FILE, + pendingActionLastAttemptAt: minutesAgo(6), + }) + // Hand the dispatcher a row whose target is out of range; the DB row keeps a valid combo. + const mangled = { ...row, pendingActionTarget: 'BOGUS' } as unknown as typeof row + + await retryFailedSyncsForPortal(connection.portalId, [mangled]) + + const after = await rowById(row.id) + expect(after.pendingActionLastError).toContain('unrecognised') + expect(after.deletedAt).toBeNull() // never dispatched to a real handler + }) }) diff --git a/test/flows/sync-error-shape.integration.test.ts b/test/flows/sync-error-shape.integration.test.ts new file mode 100644 index 0000000..8d6ee0f --- /dev/null +++ b/test/flows/sync-error-shape.integration.test.ts @@ -0,0 +1,199 @@ +import { eq } from 'drizzle-orm' +import { describe, expect, it } from 'vitest' +import db from '@/db' +import { ObjectType, type ObjectTypeValue } from '@/db/constants' +import { fileFolderSync } from '@/db/schema/fileFolderSync.schema' +import { SyncService } from '@/features/sync/lib/Sync.service' +import type { DropboxFileListFolderSingleEntry } from '@/features/sync/types' +import User from '@/lib/copilot/models/User.model' +import type { CopilotFileRetrieve, Token } from '@/lib/copilot/types' +import { copilotFileFactory } from '../factories' +import { + copilotError, + copilotNotFound, + type DropboxMeta, + dropboxPathLookupNotFound, + dropboxRpcError, + mockCopilotDeleteFile, + mockDropboxDeleteFile, + mockDropboxGetMetadata, +} from '../msw' +import { channelSeeder, dropboxConnectionSeeder, fileSyncSeeder, synced } from '../seeders' + +const CONNECTION_TOKEN = { refreshToken: 'rt', accountId: 'acc', rootNamespaceId: 'ns' } + +function makeService(portalId: string) { + const user = new User('test-token', { workspaceId: portalId } as Token) + return new SyncService(user, CONNECTION_TOKEN) +} + +async function seed() { + const connection = await dropboxConnectionSeeder.create({ + accountId: 'acc', + rootNamespaceId: 'ns', + refreshToken: 'rt', + }) + const channel = await channelSeeder.create({ + portalId: connection.portalId, + dbxRootPath: '/root', + }) + return { connection, channel, svc: makeService(connection.portalId) } +} + +const rowById = async (id: string) => { + const [row] = await db.select().from(fileFolderSync).where(eq(fileFolderSync.id, id)) + return row +} + +// createAndUploadFileInDropbox returns undefined on the dead-end branches; the caller +// (completePendingDropboxCreate) turns that into a throw. +describe('Dropbox create-vs-update: dead-end branches throw', () => { + it('throws when the existing Dropbox item has an unexpected tag (not file or folder)', async () => { + const svc = makeService('portal-x') + const file = copilotFileFactory.build({ path: 'weird.txt' }) as CopilotFileRetrieve & { + object: ObjectTypeValue + } + // filesGetMetadata resolves with a deleted-tag item → neither folder nor file branch. + const deletedMeta: DropboxMeta = { + '.tag': 'deleted', + id: 'id:x', + name: 'weird.txt', + path_display: '/root/weird.txt', + path_lower: '/root/weird.txt', + } + mockDropboxGetMetadata({ '/root/weird.txt': deletedMeta }) + + await expect( + svc.completePendingDropboxCreate({ + pendingRowId: 'row-x', + channelSyncId: 'chan-x', + dbxRootPath: '/root', + file, + }), + ).rejects.toThrow('returned undefined') + }) + + it('throws when the file type is out of range (not file or folder)', async () => { + const svc = makeService('portal-x') + const file = copilotFileFactory.build({ + path: 'odd.txt', + object: 'link', // out-of-range object → falls through both create branches + }) as CopilotFileRetrieve & { object: ObjectTypeValue } + mockDropboxGetMetadata({}) // not_found → item does not exist yet + + await expect( + svc.completePendingDropboxCreate({ + pendingRowId: 'row-x', + channelSyncId: 'chan-x', + dbxRootPath: '/root', + file, + }), + ).rejects.toThrow('returned undefined') + }) +}) + +// deleteDropboxFileQuietly swallows a "already gone" 409, rethrows anything else. +describe('deleteDropboxFileQuietly error handling (via removeFileFromDropbox)', () => { + async function seedDropboxRow() { + const { connection, channel, svc } = await seed() + const row = await fileSyncSeeder.create({ + ...synced(), + channelSyncId: channel.id, + itemPath: '/gone.txt', + dbxFileId: 'dbx:gone', + object: ObjectType.FILE, + contentHash: 'h', + }) + const file = copilotFileFactory.build({ + id: row.assemblyFileId as string, + channelId: channel.assemblyChannelId, + path: 'gone.txt', + }) as CopilotFileRetrieve & { object: ObjectTypeValue } + const payload = { + file, + opts: { + channelSyncId: channel.id, + dbxRootPath: '/root', + assemblyChannelId: channel.assemblyChannelId, + // opts.user/connectionToken are unused by removeFileFromDropbox; present to satisfy the type. + user: new User('test-token', { workspaceId: connection.portalId } as Token), + connectionToken: CONNECTION_TOKEN, + }, + } + return { svc, row, payload } + } + + it('swallows a 409 path_lookup/not_found and still soft-deletes the row', async () => { + const { svc, row, payload } = await seedDropboxRow() + mockDropboxDeleteFile({ error: dropboxPathLookupNotFound }) + + await expect(svc.removeFileFromDropbox(payload)).resolves.toBeUndefined() + + const after = await rowById(row.id) + expect(after.deletedAt).not.toBeNull() + expect(after.pendingAction).toBeNull() + }) + + it('rethrows a different 409 and marks the row failed', async () => { + const { svc, row, payload } = await seedDropboxRow() + mockDropboxDeleteFile({ + error: () => + dropboxRpcError({ + status: 409, + errorSummary: 'path/conflict/..', + error: { '.tag': 'path', path: { '.tag': 'conflict' } }, + }), + }) + + await expect(svc.removeFileFromDropbox(payload)).rejects.toThrow() + + const after = await rowById(row.id) + expect(after.deletedAt).toBeNull() + expect(after.pendingActionLastError).not.toBeNull() + }) +}) + +// deleteAssemblyFileQuietly swallows a Copilot 404, rethrows anything else. +describe('deleteAssemblyFileQuietly error handling (via removeFileFromAssembly)', () => { + async function seedAssemblyRow() { + const { channel, svc } = await seed() + const row = await fileSyncSeeder.create({ + ...synced(), + channelSyncId: channel.id, + itemPath: '/gone.txt', + dbxFileId: 'dbx:gone', + object: ObjectType.FILE, + contentHash: 'h', + }) + const entry: DropboxFileListFolderSingleEntry = { + '.tag': 'file', + id: 'dbx:gone', + name: 'gone.txt', + path_display: '/root/gone.txt', + content_hash: 'h', + } + return { svc, channel, row, entry } + } + + it('swallows a Copilot 404 and still soft-deletes the row', async () => { + const { svc, channel, row, entry } = await seedAssemblyRow() + mockCopilotDeleteFile({ error: copilotNotFound }) + + await expect(svc.removeFileFromAssembly(channel.id, '/root', entry)).resolves.toBeUndefined() + + const after = await rowById(row.id) + expect(after.deletedAt).not.toBeNull() + expect(after.pendingAction).toBeNull() + }) + + it('rethrows a non-404 Copilot error and marks the row failed', async () => { + const { svc, channel, row, entry } = await seedAssemblyRow() + mockCopilotDeleteFile({ error: () => copilotError({ status: 400, body: { message: 'boom' } }) }) + + await expect(svc.removeFileFromAssembly(channel.id, '/root', entry)).rejects.toThrow() + + const after = await rowById(row.id) + expect(after.deletedAt).toBeNull() + expect(after.pendingActionLastError).not.toBeNull() + }) +}) diff --git a/test/msw/write.ts b/test/msw/write.ts index 3177100..a91f185 100644 --- a/test/msw/write.ts +++ b/test/msw/write.ts @@ -8,7 +8,9 @@ import { mockCopilot, mockDropboxContent, mockDropboxRpc } from './overrides' import { server } from './server' export interface DropboxMeta { - '.tag': 'file' | 'folder' + // 'deleted' lets a fixture stand in for a DeletedMetadataReference (get_metadata's + // unexpected-tag branch); file/folder-only fields stay optional for that case. + '.tag': 'file' | 'folder' | 'deleted' id: string name: string path_display: string @@ -142,13 +144,18 @@ export function mockDropboxLatestCursor(cursor = 'cursor:latest'): void { mockDropboxRpc('/2/files/list_folder/get_latest_cursor', () => HttpResponse.json({ cursor })) } -// Dropbox delete_v2. Records the deleted paths so a test can check which path was deleted. +// Dropbox delete_v2. Records the attempted delete paths (recorded even when `error` is set) +// so a test can check which path was targeted. // Per the Dropbox API spec, delete_v2 returns the item's file/folder metadata (not a "deleted" tag). -export function mockDropboxDeleteFile(): { deletedPaths: string[] } { +// Pass `error` to make every call fail (e.g. dropboxPathLookupNotFound for the quiet-delete swallow path). +export function mockDropboxDeleteFile(opts: { error?: () => Response } = {}): { + deletedPaths: string[] +} { const deletedPaths: string[] = [] mockDropboxRpc('/2/files/delete_v2', async ({ request }) => { const { path } = (await request.json()) as { path: string } deletedPaths.push(path) + if (opts.error) return opts.error() return HttpResponse.json({ metadata: dropboxFileMetadata({ path_display: path }) }) }) return { deletedPaths } @@ -167,14 +174,18 @@ export function mockCopilotRetrieveFile(byId: Record Response } = {}): { + deletedIds: string[] +} { const deletedIds: string[] = [] mockCopilot( '/v1/files/:id', ({ params }) => { deletedIds.push(params.id as string) + if (opts.error) return opts.error() return HttpResponse.json({}) }, 'delete',