diff --git a/test/flows/channel-map-resync-guards.integration.test.ts b/test/flows/channel-map-resync-guards.integration.test.ts index a87d45b..5b3db86 100644 --- a/test/flows/channel-map-resync-guards.integration.test.ts +++ b/test/flows/channel-map-resync-guards.integration.test.ts @@ -168,3 +168,31 @@ describe('ResyncService#resyncFailedFilesForChannel guards', () => { expect((await channelById(channel.id)).resyncingAt).toBeNull() }) }) + +describe('MapFilesService#updateChannelMap', () => { + it('only updates the channel when the root path matches exactly', async () => { + const connection = await dropboxConnectionSeeder.create({ accountId: 'acc' }) + const svc = makeMapService(connection.portalId) + const channel = await channelSeeder.create({ + portalId: connection.portalId, + dbxRootPath: '/root', + }) + + // Matching tuple (portal + account + channel + root path) → updates. + const updated = await svc.updateChannelMap( + { dbxRootId: 'id:new' }, + channel.assemblyChannelId, + '/root', + ) + expect(updated?.dbxRootId).toBe('id:new') + + // A stale root path no longer matches, so nothing is updated. + const noMatch = await svc.updateChannelMap( + { dbxRootId: 'id:stale' }, + channel.assemblyChannelId, + '/moved-root', + ) + expect(noMatch).toBeUndefined() + expect((await channelById(channel.id)).dbxRootId).toBe('id:new') // unchanged by the stale call + }) +}) diff --git a/test/flows/dropbox-to-assembly.initial-sync.integration.test.ts b/test/flows/dropbox-to-assembly.initial-sync.integration.test.ts index b7c5eab..f34681c 100644 --- a/test/flows/dropbox-to-assembly.initial-sync.integration.test.ts +++ b/test/flows/dropbox-to-assembly.initial-sync.integration.test.ts @@ -1,8 +1,11 @@ import { eq } from 'drizzle-orm' +import { HttpResponse, http } from 'msw' import { describe, expect, it } from 'vitest' import db from '@/db' +import { ObjectType, PendingActionTarget } from '@/db/constants' import { channelSync } from '@/db/schema/channelSync.schema' import { fileFolderSync } from '@/db/schema/fileFolderSync.schema' +import { SyncService } from '@/features/sync/lib/Sync.service' import User from '@/lib/copilot/models/User.model' import type { Token } from '@/lib/copilot/types' import { initiateDropboxToAssemblySync } from '@/trigger/processFileSync' @@ -13,7 +16,7 @@ import { paginateDropboxListFolder, server, } from '../msw' -import { channelSeeder, dropboxConnectionSeeder } from '../seeders' +import { channelSeeder, dropboxConnectionSeeder, fileSyncSeeder, pendingCreate } from '../seeders' const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i @@ -121,3 +124,62 @@ describe('initial sync: Dropbox -> Assembly', () => { expect(ch.syncedFilesCount).toBe(3) }) }) + +// The leaf create stamps assemblyFileId onto the row BEFORE uploading the bytes, so a +// concurrent Assembly "file.created" echo dedupes against the row instead of re-creating. +describe('completePendingAssemblyCreate ordering', () => { + it('saves the Assembly file id before uploading the file', async () => { + const connection = await dropboxConnectionSeeder.create({ + accountId: 'acc-stamp', + rootNamespaceId: 'ns', + refreshToken: 'rt', + }) + const channel = await channelSeeder.create({ + portalId: connection.portalId, + dbxRootPath: '/root', + }) + const user = new User('test-token', { workspaceId: connection.portalId } as Token) + const svc = new SyncService(user, { + refreshToken: 'rt', + accountId: 'acc-stamp', + rootNamespaceId: 'ns', + }) + const row = await fileSyncSeeder.create({ + ...pendingCreate(PendingActionTarget.ASSEMBLY), + channelSyncId: channel.id, + itemPath: '/f.txt', + dbxFileId: 'dbx:f', + object: ObjectType.FILE, + }) + const entry = dropboxEntryFactory.build({ + id: 'dbx:f', + name: 'f.txt', + path_display: '/root/f.txt', + content_hash: 'h', + }) + + const uploadUrl = 'https://upload.example/put' + mockCopilotCreateFile({ uploadUrl }) + mockDropboxDownload({ '/root/f.txt': 'bytes' }) + // Capture the row's assemblyFileId at the moment the upload PUT fires. + const idAtUpload: (string | null)[] = [] + server.use( + http.put(uploadUrl, async () => { + const [r] = await db.select().from(fileFolderSync).where(eq(fileFolderSync.id, row.id)) + idAtUpload.push(r.assemblyFileId) + return new HttpResponse(null, { status: 200 }) + }), + ) + + await svc.completePendingAssemblyCreate({ + pendingRowId: row.id, + assemblyChannelId: channel.assemblyChannelId, + channelSyncId: channel.id, + entry, + assemblyCreatePath: '/f.txt', + }) + + expect(idAtUpload).toHaveLength(1) + expect(idAtUpload[0]).toBeTruthy() // id was already stamped before the upload ran + }) +}) diff --git a/test/flows/dropbox-webhook-debounce.integration.test.ts b/test/flows/dropbox-webhook-debounce.integration.test.ts index a2e5c49..6b86149 100644 --- a/test/flows/dropbox-webhook-debounce.integration.test.ts +++ b/test/flows/dropbox-webhook-debounce.integration.test.ts @@ -1,8 +1,9 @@ import { eq } from 'drizzle-orm' -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import db from '@/db' import { dropboxConnections } from '@/db/schema/dropboxConnections.schema' import { DropboxWebhook } from '@/features/webhook/dropbox/lib/webhook.service' +import { processDropboxChanges } from '@/trigger/processFileSync' import { dropboxConnectionSeeder } from '../seeders' import { minutesAgo } from '../time' @@ -16,6 +17,8 @@ const readConnection = async () => { return row } +afterEach(() => vi.restoreAllMocks()) + // Account-level webhook debounce (handleDropboxEvents): within the 5-min window we defer // (mark pending, no trigger); older than the window we trigger a sync. describe('webhook debounce', () => { @@ -47,4 +50,42 @@ describe('webhook debounce', () => { expect(row.pendingWebhook).toBe(false) expect(row.lastWebhookSyncedAt).not.toBeNull() }) + + it('does nothing when the account has no active connection', async () => { + // status:false → handleDropboxEvents' own status=true lookup misses → account skipped. + await dropboxConnectionSeeder.create({ + accountId: ACCOUNT, + status: false, + pendingWebhook: false, + lastWebhookSyncStartedAt: minutesAgo(6), + }) + // Spy the trigger so we prove handleDropboxEvents itself never reached the sync branch, + // independent of the redundant connection re-check inside fetchDropBoxChanges. + const triggerSpy = vi + .spyOn(processDropboxChanges, 'trigger') + .mockResolvedValue(undefined as never) + + await new DropboxWebhook().handleDropboxEvents([ACCOUNT]) + + expect(triggerSpy).not.toHaveBeenCalled() + const row = await readConnection() + expect(row.pendingWebhook).toBe(false) // untouched + expect(row.lastWebhookSyncedAt).toBeNull() // no sync ran + }) + + it('skips when a webhook is already pending, even past the debounce window', async () => { + // pendingWebhook already set + last sync older than the window: the pending guard wins, + // so it neither defers again nor triggers a sync (the cron will pick it up). + await dropboxConnectionSeeder.create({ + accountId: ACCOUNT, + pendingWebhook: true, + lastWebhookSyncStartedAt: minutesAgo(6), + }) + + await new DropboxWebhook().handleDropboxEvents([ACCOUNT]) + + const row = await readConnection() + expect(row.pendingWebhook).toBe(true) // unchanged + expect(row.lastWebhookSyncedAt).toBeNull() // no sync triggered + }) }) diff --git a/test/flows/dropbox-webhook-internals.integration.test.ts b/test/flows/dropbox-webhook-internals.integration.test.ts index 006ed80..a1821b8 100644 --- a/test/flows/dropbox-webhook-internals.integration.test.ts +++ b/test/flows/dropbox-webhook-internals.integration.test.ts @@ -4,6 +4,7 @@ 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 { dropboxConnections } from '@/db/schema/dropboxConnections.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' @@ -183,3 +184,23 @@ describe('getDropboxChanges', () => { expect(paths).not.toContain('/other/b.txt') }) }) + +// fetchDropBoxChanges bails out before doing any work if the connection can't be used. +describe('fetchDropBoxChanges guards', () => { + it('returns early when the account has no active connection', async () => { + // No connection seeded — a stray Dropbox/Copilot call would trip onUnhandledRequest:'error'. + await expect(new DropboxWebhook().fetchDropBoxChanges('ghost-account')).resolves.toBeUndefined() + }) + + it('returns early when the connection has no refresh token', async () => { + await dropboxConnectionSeeder.create({ accountId: 'acc-no-rt', refreshToken: null }) + + await new DropboxWebhook().fetchDropBoxChanges('acc-no-rt') + + const [row] = await db + .select() + .from(dropboxConnections) + .where(eq(dropboxConnections.accountId, 'acc-no-rt')) + expect(row.lastWebhookSyncedAt).toBeNull() // bailed before the end-of-sync stamp + }) +}) diff --git a/test/flows/sync-error-shape.integration.test.ts b/test/flows/sync-error-shape.integration.test.ts index cd4861b..3a58460 100644 --- a/test/flows/sync-error-shape.integration.test.ts +++ b/test/flows/sync-error-shape.integration.test.ts @@ -2,6 +2,7 @@ import { eq } from 'drizzle-orm' import { describe, expect, it } from 'vitest' import db from '@/db' import { ObjectType, type ObjectTypeValue } from '@/db/constants' +import { channelSync } from '@/db/schema/channelSync.schema' import { fileFolderSync } from '@/db/schema/fileFolderSync.schema' import { SyncService } from '@/features/sync/lib/Sync.service' import type { DropboxFileListFolderSingleEntry } from '@/features/sync/types' @@ -13,6 +14,7 @@ import { copilotNotFound, type DropboxMeta, dropboxFileMetadata, + dropboxFolderMetadata, dropboxPathLookupNotFound, dropboxRpcError, mockAssemblyFileDownload, @@ -118,6 +120,49 @@ describe('Dropbox create-vs-update: dead-end branches throw', () => { 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') }) + + it('returns the existing folder id without uploading when the folder already exists', async () => { + const svc = makeService('portal-x') + const file = copilotFileFactory.build({ path: 'existing-folder' }) + // A folder already lives at the path → return its id, no move/upload. + mockDropboxGetMetadata({ + '/root/existing-folder': dropboxFolderMetadata({ + path_display: '/root/existing-folder', + id: 'dbx:existing-folder', + }), + }) + // No move/upload mocks: a stray call would trip onUnhandledRequest:'error'. + + const result = await svc.createAndUploadFileInDropbox('/root', ObjectType.FOLDER, file) + + expect(result).toEqual({ dbxFileId: 'dbx:existing-folder' }) + }) +}) + +// handleChannelMap validates the Dropbox root path before recording its id. +describe('handleChannelMap', () => { + type ChannelMapSvc = { handleChannelMap(channelId: string, rootPath: string): Promise } + + it('saves the Dropbox root folder id when the root path is a folder', async () => { + const { channel, svc } = await seed() + mockDropboxGetMetadata({ + '/root': dropboxFolderMetadata({ path_display: '/root', id: 'id:root' }), + }) + + await (svc as unknown as ChannelMapSvc).handleChannelMap(channel.assemblyChannelId, '/root') + + const [after] = await db.select().from(channelSync).where(eq(channelSync.id, channel.id)) + expect(after.dbxRootId).toBe('id:root') + }) + + it('rejects a root path that is not a folder with a 400', async () => { + const svc = makeService('portal-x') + mockDropboxGetMetadata({ '/root': dropboxFileMetadata({ path_display: '/root' }) }) + + await expect( + (svc as unknown as ChannelMapSvc).handleChannelMap('ch-x', '/root'), + ).rejects.toMatchObject({ status: 400 }) + }) }) // deleteDropboxFileQuietly swallows a "already gone" 409, rethrows anything else.