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
40 changes: 40 additions & 0 deletions test/flows/assembly-webhook-controller.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
40 changes: 40 additions & 0 deletions test/flows/resync-sweep.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
})
})
199 changes: 199 additions & 0 deletions test/flows/sync-error-shape.integration.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
23 changes: 17 additions & 6 deletions test/msw/write.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }
Expand All @@ -167,14 +174,18 @@ export function mockCopilotRetrieveFile(byId: Record<string, CopilotFileRetrieve
}

// Copilot file delete (DELETE /v1/files/{id}) — used by the delete + content-change leaves.
// Returns { deletedIds } capturing the ids sent, so tests can verify WHICH file was
// deleted and how many times (the DB row's soft-delete alone can't see the outbound id).
export function mockCopilotDeleteFile(): { deletedIds: string[] } {
// Returns { deletedIds } capturing the ids sent (recorded even when `error` is set), so tests
// can verify WHICH file was targeted and how many times (the DB row alone can't see the outbound id).
// Pass `error` to make every call fail (e.g. copilotNotFound for the quiet-delete swallow path).
export function mockCopilotDeleteFile(opts: { error?: () => 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',
Expand Down
Loading