diff --git a/src/features/sync/lib/__tests__/Sync.folderRecovery.test.ts b/src/features/sync/lib/__tests__/Sync.folderRecovery.test.ts index ba68c38..d4053f0 100644 --- a/src/features/sync/lib/__tests__/Sync.folderRecovery.test.ts +++ b/src/features/sync/lib/__tests__/Sync.folderRecovery.test.ts @@ -130,3 +130,111 @@ describe('createFolderInAssembly :: recovers an unmapped existing folder', () => expect(updateFileMap).toHaveBeenCalledWith({ dbxFileId: 'dbx:folder' }, expect.anything()) }) }) + +const folderParams = { + assemblyChannelId: 'ch-1', + itemPath: '/John_s Cafe', + assemblyCreatePath: '/John_s Cafe', + lastItem: true, + tempFileType: 'folder', + channelSyncId: 'cs-1', + entry, + basePath: '/John_s Cafe', +} + +describe('createFolderInAssembly :: when the folder already exists in Assembly', () => { + it('updates the already-mapped row instead of searching Assembly', async () => { + // Pre-check misses, create hits "Folder already exists", re-lookup now finds the row. + vi.spyOn(service.mapFilesService, 'getDbxMappedFileFromPath') + .mockResolvedValueOnce(undefined as never) + .mockResolvedValueOnce({ id: 'row-x' } as never) + createFileMock.mockRejectedValue(folderExistsError) + const updateFileMap = vi + .spyOn(service.mapFilesService, 'updateFileMap') + .mockResolvedValue({} as never) + + await (service as unknown as FolderSvc).createFolderInAssembly(folderParams) + + expect(updateFileMap).toHaveBeenCalledWith({ dbxFileId: 'dbx:folder' }, expect.anything()) + expect(listFilesMock).not.toHaveBeenCalled() // no recovery paging + }) + + it('rethrows an error that is not "Folder already exists"', async () => { + vi.spyOn(service.mapFilesService, 'getDbxMappedFileFromPath').mockResolvedValue( + undefined as never, + ) + createFileMock.mockRejectedValue(new Error('network down')) + + await expect( + (service as unknown as FolderSvc).createFolderInAssembly(folderParams), + ).rejects.toThrow('network down') + }) +}) + +describe('recoverUnmappedAssemblyFolder :: finding the folder in Assembly', () => { + beforeEach(() => { + vi.spyOn(service.mapFilesService, 'getDbxMappedFileFromPath').mockResolvedValue( + undefined as never, + ) + createFileMock.mockRejectedValue(folderExistsError) + }) + + it('finds the folder on a later page of results', async () => { + listFilesMock + .mockResolvedValueOnce({ + data: [{ id: 'asm:other', object: 'folder', path: 'Other' }], + nextToken: 't1', + }) + .mockResolvedValueOnce({ + data: [{ id: 'asm:folder', object: 'folder', path: 'John_s Cafe' }], + nextToken: undefined, + }) + const insertFileMap = vi + .spyOn(service.mapFilesService, 'insertFileMap') + .mockResolvedValue({ id: 'row-1' } as never) + vi.spyOn(service.mapFilesService, 'updateChannelMapSyncedFilesCount').mockResolvedValue( + undefined as never, + ) + + await (service as unknown as FolderSvc).createFolderInAssembly(folderParams) + + expect(listFilesMock).toHaveBeenCalledTimes(2) // paged past the first page + expect(listFilesMock.mock.calls[1][1]).toBe('t1') // followed the nextToken + expect(insertFileMap).toHaveBeenCalledWith( + expect.objectContaining({ assemblyFileId: 'asm:folder' }), + ) + }) + + it('does nothing when the folder is not found on any page', async () => { + listFilesMock.mockResolvedValueOnce({ + data: [{ id: 'asm:other', object: 'folder', path: 'Other' }], + nextToken: undefined, + }) + const insertFileMap = vi.spyOn(service.mapFilesService, 'insertFileMap') + + await expect( + (service as unknown as FolderSvc).createFolderInAssembly(folderParams), + ).resolves.toBeUndefined() + + expect(insertFileMap).not.toHaveBeenCalled() + }) +}) + +describe('handleFolderCreatedCase :: stamping the folder id', () => { + it('does not update the row when this is not the folder entry itself', async () => { + // Folder already mapped → create is skipped and handleFolderCreatedCase is a no-op + // because lastItem is false. + vi.spyOn(service.mapFilesService, 'getDbxMappedFileFromPath').mockResolvedValue({ + id: 'row-x', + } as never) + const updateFileMap = vi.spyOn(service.mapFilesService, 'updateFileMap') + + await (service as unknown as FolderSvc).createFolderInAssembly({ + ...folderParams, + lastItem: false, + }) + + expect(updateFileMap).not.toHaveBeenCalled() + expect(createFileMock).not.toHaveBeenCalled() // already mapped → no create attempt + }) +}) diff --git a/src/features/sync/lib/__tests__/Sync.resyncLeaf.test.ts b/src/features/sync/lib/__tests__/Sync.resyncLeaf.test.ts index aff8323..b6423c7 100644 --- a/src/features/sync/lib/__tests__/Sync.resyncLeaf.test.ts +++ b/src/features/sync/lib/__tests__/Sync.resyncLeaf.test.ts @@ -163,4 +163,20 @@ describe('resyncLeafOnContentChange (via createLeafFileInAssembly path conflict) expect(removeSpy).toHaveBeenCalledTimes(1) expect(completeSpy).not.toHaveBeenCalled() }) + + it('recreates the file at the existing Assembly path on the row', async () => { + insertSpy + .mockResolvedValueOnce(null) // path conflict + .mockResolvedValueOnce(row({ id: 'row-2' })) // recreate insert + getPathSpy.mockResolvedValueOnce( + row({ id: 'row-1', assemblyFileId: 'a1', contentHash: 'old', assemblyPath: '/John_s Cafe' }), + ) + + await leaf.createLeafFileInAssembly({ ...params, entry: { ...baseEntry, content_hash: 'new' } }) + + // The recreate carries the existing row's assemblyPath as the override. + expect(completeSpy).toHaveBeenCalledWith( + expect.objectContaining({ pendingRowId: 'row-2', assemblyPathOverride: '/John_s Cafe' }), + ) + }) }) diff --git a/test/flows/dropbox-webhook-internals.integration.test.ts b/test/flows/dropbox-webhook-internals.integration.test.ts new file mode 100644 index 0000000..006ed80 --- /dev/null +++ b/test/flows/dropbox-webhook-internals.integration.test.ts @@ -0,0 +1,185 @@ +import { eq } from 'drizzle-orm' +import { DropboxResponseError } from 'dropbox' +import { afterEach, describe, expect, it, vi } from 'vitest' +import db from '@/db' +import { ObjectType } from '@/db/constants' +import { channelSync } from '@/db/schema/channelSync.schema' +import { MapFilesService } from '@/features/sync/lib/MapFiles.service' +import { DropboxWebhook } from '@/features/webhook/dropbox/lib/webhook.service' +import { getDropboxChanges } from '@/features/webhook/dropbox/utils/getDropboxChanges' +import User from '@/lib/copilot/models/User.model' +import type { Token } from '@/lib/copilot/types' +import { DropboxClient } from '@/lib/dropbox/DropboxClient' +import { dropboxDeletedFactory, dropboxEntryFactory } from '../factories' +import { mockDropboxLatestCursor, paginateDropboxListFolder, server } from '../msw' +import { channelSeeder, dropboxConnectionSeeder, fileSyncSeeder, synced } from '../seeders' + +const CONNECTION_TOKEN = { refreshToken: 'rt', accountId: 'acc', rootNamespaceId: 'ns' } + +async function seed() { + const connection = await dropboxConnectionSeeder.create({ + accountId: 'acc', + rootNamespaceId: 'ns', + refreshToken: 'rt', + }) + const user = new User('test-token', { workspaceId: connection.portalId } as Token) + const mapFilesService = new MapFilesService(user, CONNECTION_TOKEN) + const dbxClient = new DropboxClient('rt', 'ns').getDropboxClient() + return { connection, user, mapFilesService, dbxClient } +} + +const channelById = async (id: string) => { + const [row] = await db.select().from(channelSync).where(eq(channelSync.id, id)) + return row +} + +afterEach(() => vi.restoreAllMocks()) + +// handleDbxRootPathMove decides whether the delta cycle can proceed, and recovers +// the root path when Dropbox reports it moved. The metadata call is wrapped in a +// long-backoff withRetry, so we inject the outcome at the getDropboxFileMetadata seam. +describe('handleDbxRootPathMove', () => { + it('recovers the root path when the folder was moved, then skips the cycle', async () => { + const { connection, mapFilesService, dbxClient } = await seed() + const channel = await channelSeeder.create({ + portalId: connection.portalId, + dbxRootPath: '/root', + dbxRootId: 'id:root', + dbxCursor: 'cursor:old', + }) + const webhook = new DropboxWebhook() + const metaSpy = vi.spyOn(webhook, 'getDropboxFileMetadata') + // 1st (by current path) → 409 gone; 2nd (by stored dbxRootId) → the new location. + metaSpy.mockRejectedValueOnce( + new DropboxResponseError(409, {} as never, { error_summary: 'path/not_found/..' } as never), + ) + metaSpy.mockResolvedValueOnce({ result: { path_display: '/moved-root' } } as never) + mockDropboxLatestCursor('cursor:new') + + const proceed = await webhook.handleDbxRootPathMove(channel, mapFilesService, dbxClient) + + expect(proceed).toBe(false) // skip this cycle after recovery + const after = await channelById(channel.id) + expect(after.dbxRootPath).toBe('/moved-root') + expect(after.dbxCursor).toBe('cursor:new') + }) + + it('rethrows a non-409 error and leaves the channel map untouched', async () => { + const { connection, mapFilesService, dbxClient } = await seed() + const channel = await channelSeeder.create({ + portalId: connection.portalId, + dbxRootPath: '/root', + dbxRootId: 'id:root', + dbxCursor: 'cursor:old', + }) + const webhook = new DropboxWebhook() + vi.spyOn(webhook, 'getDropboxFileMetadata').mockRejectedValueOnce(new Error('network down')) + + await expect( + webhook.handleDbxRootPathMove(channel, mapFilesService, dbxClient), + ).rejects.toThrow('network down') + + const after = await channelById(channel.id) + expect(after.dbxRootPath).toBe('/root') // unchanged + expect(after.dbxCursor).toBe('cursor:old') + }) +}) + +// getDropboxChanges resolves deleted entries to their mapped dbxFileId, drops the +// unresolvable ones, validates the payload, and scopes results to the root path. +describe('getDropboxChanges', () => { + async function seedChannel() { + const { connection, mapFilesService, dbxClient } = await seed() + const channel = await channelSeeder.create({ + portalId: connection.portalId, + dbxRootPath: '/root', + }) + return { channel, mapFilesService, dbxClient } + } + + it('drops a deleted entry that has no mapped row', async () => { + const { channel, mapFilesService, dbxClient } = await seedChannel() + server.use( + ...paginateDropboxListFolder([ + dropboxDeletedFactory.build({ + id: 'x', + name: 'ghost.txt', + path_display: '/root/ghost.txt', + }), + ]), + ) + + const result = await getDropboxChanges( + 'cursor:0', + '/root', + dbxClient, + mapFilesService, + channel.id, + ) + + expect(result.entries).toEqual([]) + }) + + it('drops a deleted entry whose mapped row has no dbxFileId', async () => { + const { channel, mapFilesService, dbxClient } = await seedChannel() + await fileSyncSeeder.create({ + ...synced(), // assemblyFileId set, but no dbxFileId + channelSyncId: channel.id, + itemPath: '/orphan.txt', + object: ObjectType.FILE, + }) + server.use( + ...paginateDropboxListFolder([ + dropboxDeletedFactory.build({ + id: 'y', + name: 'orphan.txt', + path_display: '/root/orphan.txt', + }), + ]), + ) + + const result = await getDropboxChanges( + 'cursor:0', + '/root', + dbxClient, + mapFilesService, + channel.id, + ) + + expect(result.entries).toEqual([]) + }) + + it('throws when an entry fails schema validation', async () => { + const { channel, mapFilesService, dbxClient } = await seedChannel() + // Missing id + name → DropboxFileListFolderResultEntriesSchema.safeParse fails. + server.use(...paginateDropboxListFolder([{ '.tag': 'file', path_display: '/root/bad.txt' }])) + + await expect( + getDropboxChanges('cursor:0', '/root', dbxClient, mapFilesService, channel.id), + ).rejects.toThrow('Invalid Dropbox entries format') + }) + + it('keeps only entries under the root path and ignores the rest', async () => { + const { channel, mapFilesService, dbxClient } = await seedChannel() + server.use( + ...paginateDropboxListFolder([ + dropboxEntryFactory.build({ id: 'a', name: 'a.txt', path_display: '/root/a.txt' }), + dropboxEntryFactory.build({ id: 'b', name: 'b.txt', path_display: '/other/b.txt' }), + dropboxEntryFactory.build({ id: 'c', name: 'c.txt', path_display: '/ROOT/c.txt' }), + ]), + ) + + const result = await getDropboxChanges( + 'cursor:0', + '/root', + dbxClient, + mapFilesService, + channel.id, + ) + + const paths = result.entries.map((e) => e.path_display) + expect(paths).toContain('/root/a.txt') + expect(paths).toContain('/ROOT/c.txt') // case-insensitive prefix + expect(paths).not.toContain('/other/b.txt') + }) +}) diff --git a/test/flows/resync-sweep.integration.test.ts b/test/flows/resync-sweep.integration.test.ts index f5f1fc4..565ade7 100644 --- a/test/flows/resync-sweep.integration.test.ts +++ b/test/flows/resync-sweep.integration.test.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'node:crypto' import { eq } from 'drizzle-orm' +import { HttpResponse } from 'msw' import { afterEach, describe, expect, it, vi } from 'vitest' import db from '@/db' import { ObjectType, PendingActionTarget } from '@/db/constants' @@ -13,7 +14,10 @@ import { resyncFailedFilesAndMasterSync } from '@/trigger/processFileSync' import { copilotDownloadableFactory, copilotFileFactory } from '../factories' import { copilotError, + copilotNotFound, dropboxFileMetadata, + dropboxPathLookupNotFound, + dropboxRpcError, mockAssemblyFileDownload, mockCopilot, mockCopilotCreateFile, @@ -22,6 +26,7 @@ import { mockDropboxDeleteFile, mockDropboxDownload, mockDropboxGetMetadata, + mockDropboxRpc, mockDropboxUpload, } from '../msw' import { @@ -406,4 +411,423 @@ describe('resync sweep', () => { expect(after.pendingActionLastError).toContain('unrecognised') expect(after.deletedAt).toBeNull() // never dispatched to a real handler }) + + // --- retryDeleteInAssembly guard + error branches --- + describe('retryDeleteInAssembly branches', () => { + it('soft-deletes a row that has no assemblyFileId (nothing to delete in Assembly)', async () => { + const { connection, channel } = await seed() + const row = await fileSyncSeeder.create({ + ...pendingDelete(PendingActionTarget.ASSEMBLY), + channelSyncId: channel.id, + itemPath: '/no-asm.txt', + dbxFileId: 'dbx:no-asm', + object: ObjectType.FILE, + pendingActionLastAttemptAt: minutesAgo(6), + }) + + await retryFailedSyncsForPortal(connection.portalId, [row]) + + const after = await rowById(row.id) + expect(after.deletedAt).not.toBeNull() + expect(after.pendingAction).toBeNull() + }) + + it('treats a Copilot 404 as already-gone and soft-deletes the row', async () => { + const { connection, channel } = await seed() + const row = await fileSyncSeeder.create({ + ...synced(), + ...pendingDelete(PendingActionTarget.ASSEMBLY), + channelSyncId: channel.id, + itemPath: '/gone.txt', + dbxFileId: 'dbx:gone', + object: ObjectType.FILE, + pendingActionLastAttemptAt: minutesAgo(6), + }) + mockCopilotDeleteFile({ error: copilotNotFound }) + + await retryFailedSyncsForPortal(connection.portalId, [row]) + + const after = await rowById(row.id) + expect(after.deletedAt).not.toBeNull() + expect(after.pendingAction).toBeNull() + }) + + it('marks the row failed on any other Copilot error', async () => { + const { connection, channel } = await seed() + const row = await fileSyncSeeder.create({ + ...synced(), + ...pendingDelete(PendingActionTarget.ASSEMBLY), + channelSyncId: channel.id, + itemPath: '/boom.txt', + dbxFileId: 'dbx:boom', + object: ObjectType.FILE, + pendingActionLastAttemptAt: minutesAgo(6), + }) + mockCopilotDeleteFile({ + error: () => copilotError({ status: 400, body: { message: 'boom' } }), + }) + + await retryFailedSyncsForPortal(connection.portalId, [row]) + + const after = await rowById(row.id) + expect(after.deletedAt).toBeNull() + expect(after.pendingActionLastError).not.toBeNull() + }) + }) + + // --- retryDeleteInDropbox guard + error branches --- + describe('retryDeleteInDropbox branches', () => { + const baseRow = (channelId: string) => ({ + ...pendingDelete(PendingActionTarget.DROPBOX), + channelSyncId: channelId, + itemPath: '/gone.txt', + dbxFileId: 'dbx:gone', + object: ObjectType.FILE, + pendingActionLastAttemptAt: minutesAgo(6), + }) + + it('soft-deletes a row that has no itemPath', async () => { + const { connection, channel } = await seed() + const row = await fileSyncSeeder.create(baseRow(channel.id)) + + await retryFailedSyncsForPortal(connection.portalId, [{ ...row, itemPath: null }]) + + const after = await rowById(row.id) + expect(after.deletedAt).not.toBeNull() + }) + + it('soft-deletes a row whose channelSync is missing', async () => { + const { connection, channel } = await seed() + const row = await fileSyncSeeder.create(baseRow(channel.id)) + + await retryFailedSyncsForPortal(connection.portalId, [ + { ...row, channelSyncId: randomUUID() }, + ]) + + const after = await rowById(row.id) + expect(after.deletedAt).not.toBeNull() + }) + + it('treats a Dropbox "not found" as already-gone and soft-deletes', async () => { + const { connection, channel } = await seed() + const row = await fileSyncSeeder.create(baseRow(channel.id)) + mockDropboxDeleteFile({ error: dropboxPathLookupNotFound }) + + await retryFailedSyncsForPortal(connection.portalId, [row]) + + const after = await rowById(row.id) + expect(after.deletedAt).not.toBeNull() + }) + + it('marks the row failed on any other Dropbox error', async () => { + const { connection, channel } = await seed() + const row = await fileSyncSeeder.create(baseRow(channel.id)) + mockDropboxDeleteFile({ + error: () => + dropboxRpcError({ + status: 409, + errorSummary: 'path/conflict/..', + error: { '.tag': 'path', path: { '.tag': 'conflict' } }, + }), + }) + + await retryFailedSyncsForPortal(connection.portalId, [row]) + + const after = await rowById(row.id) + expect(after.deletedAt).toBeNull() + expect(after.pendingActionLastError).not.toBeNull() + }) + }) + + // --- retryCreateInDropbox guard + error branches --- + describe('retryCreateInDropbox branches', () => { + const baseRow = (channelId: string) => ({ + ...synced(), + ...pendingCreate(PendingActionTarget.DROPBOX), + channelSyncId: channelId, + itemPath: '/doc.txt', + object: ObjectType.FILE, + pendingActionLastAttemptAt: minutesAgo(6), + }) + + it('marks the row failed when it has no assemblyFileId', async () => { + const { connection, channel } = await seed() + const row = await fileSyncSeeder.create({ + ...pendingCreate(PendingActionTarget.DROPBOX), + channelSyncId: channel.id, + itemPath: '/doc.txt', + object: ObjectType.FILE, + pendingActionLastAttemptAt: minutesAgo(6), + }) + + await retryFailedSyncsForPortal(connection.portalId, [row]) + + const after = await rowById(row.id) + expect(after.pendingActionLastError).toContain('missing assemblyFileId') + expect(after.deletedAt).toBeNull() + }) + + it('marks the row failed when it has no itemPath', async () => { + const { connection, channel } = await seed() + const row = await fileSyncSeeder.create(baseRow(channel.id)) + + await retryFailedSyncsForPortal(connection.portalId, [{ ...row, itemPath: null }]) + + const after = await rowById(row.id) + expect(after.pendingActionLastError).toContain('missing itemPath') + }) + + it('marks the row failed when its channelSync is missing', async () => { + const { connection, channel } = await seed() + const row = await fileSyncSeeder.create(baseRow(channel.id)) + + await retryFailedSyncsForPortal(connection.portalId, [ + { ...row, channelSyncId: randomUUID() }, + ]) + + const after = await rowById(row.id) + expect(after.pendingActionLastError).toContain('channelSync missing') + }) + + it('soft-deletes when the Assembly file no longer exists', async () => { + const { connection, channel } = await seed() + const row = await fileSyncSeeder.create(baseRow(channel.id)) + mockCopilotRetrieveFile({}) // unknown id → 404 + + await retryFailedSyncsForPortal(connection.portalId, [row]) + + const after = await rowById(row.id) + expect(after.deletedAt).not.toBeNull() + }) + + it('marks the row failed when the Assembly file is still uploading', async () => { + const { connection, channel } = await seed() + const row = await fileSyncSeeder.create(baseRow(channel.id)) + const pendingFile = copilotFileFactory.build({ + id: row.assemblyFileId as string, + status: 'pending', + }) + mockCopilotRetrieveFile({ [pendingFile.id]: pendingFile }) + + await retryFailedSyncsForPortal(connection.portalId, [row]) + + const after = await rowById(row.id) + expect(after.pendingActionLastError).toContain('still pending') + expect(after.deletedAt).toBeNull() + }) + }) + + // --- retryCreateInAssembly + reconcile branches --- + describe('retryCreateInAssembly branches', () => { + const baseRow = (channelId: string) => ({ + ...synced(), + ...pendingCreate(PendingActionTarget.ASSEMBLY), + channelSyncId: channelId, + itemPath: '/doc.txt', + dbxFileId: 'dbx:doc', + object: ObjectType.FILE, + pendingActionLastAttemptAt: minutesAgo(6), + }) + + // Mocks for a successful Dropbox→Assembly recreate of /root/doc.txt. + const mockRecreate = (dbxId = 'dbx:doc', path = '/root/doc.txt') => { + mockDropboxGetMetadata({ [dbxId]: dropboxFileMetadata({ path_display: path, id: dbxId }) }) + mockCopilotCreateFile() + mockDropboxDownload({ [path]: 'bytes' }) + } + + it('marks the row failed when it has no dbxFileId', async () => { + const { connection, channel } = await seed() + const row = await fileSyncSeeder.create({ + ...synced(), + ...pendingCreate(PendingActionTarget.ASSEMBLY), + channelSyncId: channel.id, + itemPath: '/doc.txt', + object: ObjectType.FILE, + pendingActionLastAttemptAt: minutesAgo(6), + }) + + await retryFailedSyncsForPortal(connection.portalId, [row]) + + const after = await rowById(row.id) + expect(after.pendingActionLastError).toContain('missing dbxFileId') + }) + + it('marks the row failed when it has no itemPath', async () => { + const { connection, channel } = await seed() + const row = await fileSyncSeeder.create(baseRow(channel.id)) + + await retryFailedSyncsForPortal(connection.portalId, [{ ...row, itemPath: null }]) + + const after = await rowById(row.id) + expect(after.pendingActionLastError).toContain('missing itemPath') + }) + + it('marks the row failed when its channelSync is missing', async () => { + const { connection, channel } = await seed() + const row = await fileSyncSeeder.create(baseRow(channel.id)) + + await retryFailedSyncsForPortal(connection.portalId, [ + { ...row, channelSyncId: randomUUID() }, + ]) + + const after = await rowById(row.id) + expect(after.pendingActionLastError).toContain('channelSync missing') + }) + + it('soft-deletes when the Dropbox source no longer exists', async () => { + const { connection, channel } = await seed() + const row = await fileSyncSeeder.create(baseRow(channel.id)) + mockDropboxGetMetadata({}) // dbxFileId path → 409 not_found → null + + await retryFailedSyncsForPortal(connection.portalId, [row]) + + const after = await rowById(row.id) + expect(after.deletedAt).not.toBeNull() + }) + + it('soft-deletes when the Dropbox source is already deleted', async () => { + const { connection, channel } = await seed() + const row = await fileSyncSeeder.create(baseRow(channel.id)) + mockDropboxGetMetadata({ + 'dbx:doc': { + '.tag': 'deleted', + id: 'dbx:doc', + name: 'doc.txt', + path_display: '/root/doc.txt', + path_lower: '/root/doc.txt', + }, + }) + + await retryFailedSyncsForPortal(connection.portalId, [row]) + + const after = await rowById(row.id) + expect(after.deletedAt).not.toBeNull() + }) + + it('marks the row failed (kept for retry) on an unexpected Dropbox error', async () => { + const { connection, channel } = await seed() + const row = await fileSyncSeeder.create(baseRow(channel.id)) + mockDropboxRpc('/2/files/get_metadata', () => + dropboxRpcError({ status: 500, errorSummary: 'internal_error/..', error: {} }), + ) + + await retryFailedSyncsForPortal(connection.portalId, [row]) + + const after = await rowById(row.id) + expect(after.deletedAt).toBeNull() + expect(after.pendingActionLastError).not.toBeNull() + }) + + it('recreates the file when the row has no Assembly file id yet', async () => { + const { connection, channel } = await seed() + const row = await fileSyncSeeder.create({ + ...pendingCreate(PendingActionTarget.ASSEMBLY), + channelSyncId: channel.id, + itemPath: '/doc.txt', + dbxFileId: 'dbx:doc', + object: ObjectType.FILE, + pendingActionLastAttemptAt: minutesAgo(6), + }) + mockRecreate() + + await retryFailedSyncsForPortal(connection.portalId, [row]) + + const after = await rowById(row.id) + expect(after.assemblyFileId).toBeTruthy() + expect(after.pendingAction).toBeNull() + expect(after.deletedAt).toBeNull() + }) + + it('recreates the file when the stamped Assembly file no longer exists', async () => { + const { connection, channel } = await seed() + const oldId = randomUUID() + const row = await fileSyncSeeder.create({ + ...pendingCreate(PendingActionTarget.ASSEMBLY), + channelSyncId: channel.id, + itemPath: '/doc.txt', + dbxFileId: 'dbx:doc', + assemblyFileId: oldId, + object: ObjectType.FILE, + pendingActionLastAttemptAt: minutesAgo(6), + }) + mockCopilotRetrieveFile({}) // oldId → 404 → recreate + mockRecreate() + + await retryFailedSyncsForPortal(connection.portalId, [row]) + + const after = await rowById(row.id) + expect(after.assemblyFileId).toBeTruthy() + expect(after.assemblyFileId).not.toBe(oldId) + expect(after.pendingAction).toBeNull() + }) + + it('marks the row failed on an unexpected error while checking the Assembly file', async () => { + const { connection, channel } = await seed() + const oldId = randomUUID() + const row = await fileSyncSeeder.create({ + ...pendingCreate(PendingActionTarget.ASSEMBLY), + channelSyncId: channel.id, + itemPath: '/doc.txt', + dbxFileId: 'dbx:doc', + assemblyFileId: oldId, + object: ObjectType.FILE, + pendingActionLastAttemptAt: minutesAgo(6), + }) + mockDropboxGetMetadata({ + 'dbx:doc': dropboxFileMetadata({ path_display: '/root/doc.txt', id: 'dbx:doc' }), + }) + mockCopilot( + '/v1/files/:id', + () => copilotError({ status: 400, body: { message: 'boom' } }), + 'get', + ) + + await retryFailedSyncsForPortal(connection.portalId, [row]) + + const after = await rowById(row.id) + expect(after.deletedAt).toBeNull() + expect(after.assemblyFileId).toBe(oldId) // untouched + expect(after.pendingActionLastError).not.toBeNull() + }) + + it('clears the stored Assembly file id before deleting the stale file', async () => { + const { connection, channel } = await seed() + const oldId = randomUUID() + const stalePending = copilotFileFactory.build({ id: oldId, status: 'pending' }) + const row = await fileSyncSeeder.create({ + ...pendingCreate(PendingActionTarget.ASSEMBLY), + channelSyncId: channel.id, + itemPath: '/doc.txt', + dbxFileId: 'dbx:doc', + assemblyFileId: oldId, + object: ObjectType.FILE, + pendingActionLastAttemptAt: minutesAgo(6), + createdAt: hoursAgo(13), // abandoned → delete stale + recreate + }) + mockDropboxGetMetadata({ + 'dbx:doc': dropboxFileMetadata({ path_display: '/root/doc.txt', id: 'dbx:doc' }), + }) + mockCopilotRetrieveFile({ [oldId]: stalePending }) + // Capture the row's assemblyFileId at the moment deleteFile is called. + const idAtDelete: (string | null)[] = [] + mockCopilot( + '/v1/files/:id', + async () => { + const [r] = await db.select().from(fileFolderSync).where(eq(fileFolderSync.id, row.id)) + idAtDelete.push(r.assemblyFileId) + return HttpResponse.json({}) + }, + 'delete', + ) + mockRecreate() + + await retryFailedSyncsForPortal(connection.portalId, [row]) + + expect(idAtDelete).toEqual([null]) // nulled before the delete ran + const after = await rowById(row.id) + expect(after.assemblyFileId).toBeTruthy() + expect(after.assemblyFileId).not.toBe(oldId) + }) + }) }) diff --git a/test/flows/sync-error-shape.integration.test.ts b/test/flows/sync-error-shape.integration.test.ts index 8d6ee0f..cd4861b 100644 --- a/test/flows/sync-error-shape.integration.test.ts +++ b/test/flows/sync-error-shape.integration.test.ts @@ -7,16 +7,20 @@ 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 { copilotDownloadableFactory, copilotFileFactory } from '../factories' import { copilotError, copilotNotFound, type DropboxMeta, + dropboxFileMetadata, dropboxPathLookupNotFound, dropboxRpcError, + mockAssemblyFileDownload, mockCopilotDeleteFile, mockDropboxDeleteFile, mockDropboxGetMetadata, + mockDropboxMove, + mockDropboxUpload, } from '../msw' import { channelSeeder, dropboxConnectionSeeder, fileSyncSeeder, synced } from '../seeders' @@ -90,6 +94,30 @@ describe('Dropbox create-vs-update: dead-end branches throw', () => { }), ).rejects.toThrow('returned undefined') }) + + it('renames the existing file and uploads the new one when a file already exists', async () => { + const svc = makeService('portal-x') + const file = copilotDownloadableFactory.build({ path: 'dup.txt' }) + // A file already lives at /root/dup.txt → rename-then-reupload branch. + mockDropboxGetMetadata({ + '/root/dup.txt': dropboxFileMetadata({ path_display: '/root/dup.txt' }), + }) + const moves: { from: string; to: string }[] = [] + mockDropboxMove((from, to) => { + moves.push({ from, to }) + return dropboxFileMetadata({ path_display: to }) + }) + mockDropboxUpload() + mockAssemblyFileDownload() + + const result = await svc.createAndUploadFileInDropbox('/root', ObjectType.FILE, file) + + // Existing file renamed to a timestamped name, new content uploaded at the original path. + expect(moves).toHaveLength(1) + expect(moves[0].from).toBe('/root/dup.txt') + expect(moves[0].to).toMatch(/\/root\/dup \(\d{2}-\d{2}-\d{4} \d{2}:\d{2}:\d{2}\)\.txt$/) + expect(result?.dbxFileId).toBe('id:dbx:/root/dup.txt') + }) }) // deleteDropboxFileQuietly swallows a "already gone" 409, rethrows anything else.