From 9e5ff742740a09033ef1eb6e32a0df36452f3e27 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Tue, 18 Aug 2026 21:49:10 +0545 Subject: [PATCH 1/4] test(OUT-4068): cover resync-helper guard + error branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the untested guard/error legs of the resync retry handlers (cases 132-137), driven via retryFailedSyncsForPortal: - retryDeleteInAssembly / retryDeleteInDropbox: missing-field + already-gone (Copilot 404 / Dropbox 409 path_lookup_not_found) → markDeleted; other → markFailure. - retryCreateInDropbox: missing assemblyFileId/itemPath/channelSync → markFailure; retrieve 404 → markDeleted; still-pending → markFailure. - retryCreateInAssembly + reconcileExistingAssemblyFile: missing fields → markFailure; Dropbox not_found / deleted-tag → markDeleted; non-not_found metadata error → markFailure; recreate on null/404 assemblyFileId; non-404 reconcile error → markFailure; abandoned path nulls assemblyFileId before deleting the stale file (ordering). Co-Authored-By: Claude Opus 4.8 --- test/flows/resync-sweep.integration.test.ts | 424 ++++++++++++++++++++ 1 file changed, 424 insertions(+) diff --git a/test/flows/resync-sweep.integration.test.ts b/test/flows/resync-sweep.integration.test.ts index f5f1fc4..7ebc5f0 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 (case 132) --- + 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 a non-404 Copilot error (rethrow)', 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 (case 133) --- + 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 409 path_lookup/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 (rethrow)', 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 (case 134) --- + 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 is 404 on retrieve', 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 (retry later) when the Assembly file is still pending', 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 (cases 135, 136, 137) --- + 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 is gone (metadata not_found)', 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 a deleted tombstone', 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 (retried, not deleted) on a non-not_found Dropbox metadata 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 early-stamped assemblyFileId', 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 early-stamped Assembly file is 404', 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 a non-404 error while reconciling 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('nulls the assemblyFileId BEFORE deleting the stale file (abandoned reconcile ordering)', 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) + }) + }) }) From fab0407b8f4fcf8cce31ac7eec40cbe70d51df7a Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Tue, 18 Aug 2026 21:49:27 +0545 Subject: [PATCH 2/4] test(OUT-4068): cover root-path-move recovery + delta resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - handleDbxRootPathMove: 409 path-gone → re-fetch by dbxRootId, refresh cursor, update the channel map, skip the cycle; non-409 error rethrows, map untouched. (Metadata is injected at the getDropboxFileMetadata seam to skip withRetry's backoff.) - getDropboxChanges: deleted entry with no mapped row / no dbxFileId → dropped; malformed entry → throws; entries scoped to the root path (case-insensitive). Co-Authored-By: Claude Opus 4.8 --- ...pbox-webhook-internals.integration.test.ts | 185 ++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 test/flows/dropbox-webhook-internals.integration.test.ts 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..a9be9a5 --- /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 a moved root path: re-fetches by id, refreshes the cursor, updates the map, 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 (case-insensitive), dropping 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') + }) +}) From 946c8f8dcbd021f907b7479bec2df79332ea3461 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Tue, 18 Aug 2026 21:49:28 +0545 Subject: [PATCH 3/4] test(OUT-4068): cover Dropbox rename + folder-recovery residuals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - createAndUploadFileInDropbox: existing file at the path → rename via filesMoveV2 to a timestamped name, then re-upload the new content. - createFolderInAssembly "Folder already exists" catch: row found on re-lookup → stamp only (no recovery); non-matching error → rethrow. - recoverUnmappedAssemblyFolder: match on a later listFiles page (nextToken paging); never-found → log + give up (no insert, no throw). - handleFolderCreatedCase: no-op when not the last folder item. - resyncLeafOnContentChange: recreate carries assemblyPathOverride = existing.assemblyPath. Co-Authored-By: Claude Opus 4.8 --- .../lib/__tests__/Sync.folderRecovery.test.ts | 108 ++++++++++++++++++ .../lib/__tests__/Sync.resyncLeaf.test.ts | 16 +++ .../sync-error-shape.integration.test.ts | 30 ++++- 3 files changed, 153 insertions(+), 1 deletion(-) diff --git a/src/features/sync/lib/__tests__/Sync.folderRecovery.test.ts b/src/features/sync/lib/__tests__/Sync.folderRecovery.test.ts index ba68c38..87ddbe3 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 :: catch-branch discriminations (case 84)', () => { + it('stamps the existing row (no recovery) when the re-lookup finds a row', 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 :: paging + give-up (case 85)', () => { + beforeEach(() => { + vi.spyOn(service.mapFilesService, 'getDbxMappedFileFromPath').mockResolvedValue( + undefined as never, + ) + createFileMock.mockRejectedValue(folderExistsError) + }) + + it('finds the folder on a later listFiles page (nextToken pagination)', 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('gives up silently (no insert, no throw) when the folder is never found', 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 :: no-op guard (case 86)', () => { + it('does not stamp dbxFileId when the entry is not the last folder item', 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..d2ae174 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 at the existing Assembly path so a legacy invalid name still lands', 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/sync-error-shape.integration.test.ts b/test/flows/sync-error-shape.integration.test.ts index 8d6ee0f..97790df 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 Dropbox file then re-uploads when a file already exists at the path', 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. From 3aad8446f3f0e027d110bf870196fc5afbfdfacd Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Thu, 20 Aug 2026 10:58:42 +0545 Subject: [PATCH 4/4] test(OUT-4068): clarify test names, drop internal catalog references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite the L3b test/describe titles in plain language and remove the "(case NN)" references to the local scoping catalog (not committed, so meaningless to readers). Labels only — no behavior change. Co-Authored-By: Claude Opus 4.8 --- .../lib/__tests__/Sync.folderRecovery.test.ts | 14 ++++---- .../lib/__tests__/Sync.resyncLeaf.test.ts | 2 +- ...pbox-webhook-internals.integration.test.ts | 4 +-- test/flows/resync-sweep.integration.test.ts | 32 +++++++++---------- .../sync-error-shape.integration.test.ts | 2 +- 5 files changed, 27 insertions(+), 27 deletions(-) diff --git a/src/features/sync/lib/__tests__/Sync.folderRecovery.test.ts b/src/features/sync/lib/__tests__/Sync.folderRecovery.test.ts index 87ddbe3..d4053f0 100644 --- a/src/features/sync/lib/__tests__/Sync.folderRecovery.test.ts +++ b/src/features/sync/lib/__tests__/Sync.folderRecovery.test.ts @@ -142,8 +142,8 @@ const folderParams = { basePath: '/John_s Cafe', } -describe('createFolderInAssembly :: catch-branch discriminations (case 84)', () => { - it('stamps the existing row (no recovery) when the re-lookup finds a row', async () => { +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) @@ -171,7 +171,7 @@ describe('createFolderInAssembly :: catch-branch discriminations (case 84)', () }) }) -describe('recoverUnmappedAssemblyFolder :: paging + give-up (case 85)', () => { +describe('recoverUnmappedAssemblyFolder :: finding the folder in Assembly', () => { beforeEach(() => { vi.spyOn(service.mapFilesService, 'getDbxMappedFileFromPath').mockResolvedValue( undefined as never, @@ -179,7 +179,7 @@ describe('recoverUnmappedAssemblyFolder :: paging + give-up (case 85)', () => { createFileMock.mockRejectedValue(folderExistsError) }) - it('finds the folder on a later listFiles page (nextToken pagination)', async () => { + it('finds the folder on a later page of results', async () => { listFilesMock .mockResolvedValueOnce({ data: [{ id: 'asm:other', object: 'folder', path: 'Other' }], @@ -205,7 +205,7 @@ describe('recoverUnmappedAssemblyFolder :: paging + give-up (case 85)', () => { ) }) - it('gives up silently (no insert, no throw) when the folder is never found', async () => { + 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, @@ -220,8 +220,8 @@ describe('recoverUnmappedAssemblyFolder :: paging + give-up (case 85)', () => { }) }) -describe('handleFolderCreatedCase :: no-op guard (case 86)', () => { - it('does not stamp dbxFileId when the entry is not the last folder item', async () => { +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({ diff --git a/src/features/sync/lib/__tests__/Sync.resyncLeaf.test.ts b/src/features/sync/lib/__tests__/Sync.resyncLeaf.test.ts index d2ae174..b6423c7 100644 --- a/src/features/sync/lib/__tests__/Sync.resyncLeaf.test.ts +++ b/src/features/sync/lib/__tests__/Sync.resyncLeaf.test.ts @@ -164,7 +164,7 @@ describe('resyncLeafOnContentChange (via createLeafFileInAssembly path conflict) expect(completeSpy).not.toHaveBeenCalled() }) - it('recreates at the existing Assembly path so a legacy invalid name still lands', async () => { + 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 diff --git a/test/flows/dropbox-webhook-internals.integration.test.ts b/test/flows/dropbox-webhook-internals.integration.test.ts index a9be9a5..006ed80 100644 --- a/test/flows/dropbox-webhook-internals.integration.test.ts +++ b/test/flows/dropbox-webhook-internals.integration.test.ts @@ -39,7 +39,7 @@ afterEach(() => vi.restoreAllMocks()) // 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 a moved root path: re-fetches by id, refreshes the cursor, updates the map, skips the cycle', async () => { + 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, @@ -159,7 +159,7 @@ describe('getDropboxChanges', () => { ).rejects.toThrow('Invalid Dropbox entries format') }) - it('keeps only entries under the root path (case-insensitive), dropping the rest', async () => { + it('keeps only entries under the root path and ignores the rest', async () => { const { channel, mapFilesService, dbxClient } = await seedChannel() server.use( ...paginateDropboxListFolder([ diff --git a/test/flows/resync-sweep.integration.test.ts b/test/flows/resync-sweep.integration.test.ts index 7ebc5f0..565ade7 100644 --- a/test/flows/resync-sweep.integration.test.ts +++ b/test/flows/resync-sweep.integration.test.ts @@ -412,7 +412,7 @@ describe('resync sweep', () => { expect(after.deletedAt).toBeNull() // never dispatched to a real handler }) - // --- retryDeleteInAssembly guard + error branches (case 132) --- + // --- 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() @@ -452,7 +452,7 @@ describe('resync sweep', () => { expect(after.pendingAction).toBeNull() }) - it('marks the row failed on a non-404 Copilot error (rethrow)', async () => { + it('marks the row failed on any other Copilot error', async () => { const { connection, channel } = await seed() const row = await fileSyncSeeder.create({ ...synced(), @@ -475,7 +475,7 @@ describe('resync sweep', () => { }) }) - // --- retryDeleteInDropbox guard + error branches (case 133) --- + // --- retryDeleteInDropbox guard + error branches --- describe('retryDeleteInDropbox branches', () => { const baseRow = (channelId: string) => ({ ...pendingDelete(PendingActionTarget.DROPBOX), @@ -508,7 +508,7 @@ describe('resync sweep', () => { expect(after.deletedAt).not.toBeNull() }) - it('treats a 409 path_lookup/not_found as already-gone and soft-deletes', async () => { + 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 }) @@ -519,7 +519,7 @@ describe('resync sweep', () => { expect(after.deletedAt).not.toBeNull() }) - it('marks the row failed on any other Dropbox error (rethrow)', async () => { + 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({ @@ -539,7 +539,7 @@ describe('resync sweep', () => { }) }) - // --- retryCreateInDropbox guard + error branches (case 134) --- + // --- retryCreateInDropbox guard + error branches --- describe('retryCreateInDropbox branches', () => { const baseRow = (channelId: string) => ({ ...synced(), @@ -589,7 +589,7 @@ describe('resync sweep', () => { expect(after.pendingActionLastError).toContain('channelSync missing') }) - it('soft-deletes when the Assembly file is 404 on retrieve', async () => { + 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 @@ -600,7 +600,7 @@ describe('resync sweep', () => { expect(after.deletedAt).not.toBeNull() }) - it('marks the row failed (retry later) when the Assembly file is still pending', async () => { + 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({ @@ -617,7 +617,7 @@ describe('resync sweep', () => { }) }) - // --- retryCreateInAssembly + reconcile branches (cases 135, 136, 137) --- + // --- retryCreateInAssembly + reconcile branches --- describe('retryCreateInAssembly branches', () => { const baseRow = (channelId: string) => ({ ...synced(), @@ -675,7 +675,7 @@ describe('resync sweep', () => { expect(after.pendingActionLastError).toContain('channelSync missing') }) - it('soft-deletes when the Dropbox source is gone (metadata not_found)', async () => { + 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 @@ -686,7 +686,7 @@ describe('resync sweep', () => { expect(after.deletedAt).not.toBeNull() }) - it('soft-deletes when the Dropbox source is a deleted tombstone', async () => { + 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({ @@ -705,7 +705,7 @@ describe('resync sweep', () => { expect(after.deletedAt).not.toBeNull() }) - it('marks the row failed (retried, not deleted) on a non-not_found Dropbox metadata error', async () => { + 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', () => @@ -719,7 +719,7 @@ describe('resync sweep', () => { expect(after.pendingActionLastError).not.toBeNull() }) - it('recreates the file when the row has no early-stamped assemblyFileId', async () => { + 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), @@ -739,7 +739,7 @@ describe('resync sweep', () => { expect(after.deletedAt).toBeNull() }) - it('recreates the file when the early-stamped Assembly file is 404', async () => { + 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({ @@ -762,7 +762,7 @@ describe('resync sweep', () => { expect(after.pendingAction).toBeNull() }) - it('marks the row failed on a non-404 error while reconciling the Assembly file', async () => { + 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({ @@ -791,7 +791,7 @@ describe('resync sweep', () => { expect(after.pendingActionLastError).not.toBeNull() }) - it('nulls the assemblyFileId BEFORE deleting the stale file (abandoned reconcile ordering)', async () => { + 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' }) diff --git a/test/flows/sync-error-shape.integration.test.ts b/test/flows/sync-error-shape.integration.test.ts index 97790df..cd4861b 100644 --- a/test/flows/sync-error-shape.integration.test.ts +++ b/test/flows/sync-error-shape.integration.test.ts @@ -95,7 +95,7 @@ describe('Dropbox create-vs-update: dead-end branches throw', () => { ).rejects.toThrow('returned undefined') }) - it('renames the existing Dropbox file then re-uploads when a file already exists at the path', async () => { + 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.