Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions src/features/sync/lib/__tests__/Sync.folderRecovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
})
})
16 changes: 16 additions & 0 deletions src/features/sync/lib/__tests__/Sync.resyncLeaf.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }),
)
})
})
185 changes: 185 additions & 0 deletions test/flows/dropbox-webhook-internals.integration.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
Loading
Loading