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
28 changes: 28 additions & 0 deletions test/flows/channel-map-resync-guards.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
})
})
64 changes: 63 additions & 1 deletion test/flows/dropbox-to-assembly.initial-sync.integration.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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

Expand Down Expand Up @@ -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
})
})
43 changes: 42 additions & 1 deletion test/flows/dropbox-webhook-debounce.integration.test.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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', () => {
Expand Down Expand Up @@ -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
})
})
21 changes: 21 additions & 0 deletions test/flows/dropbox-webhook-internals.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
})
})
45 changes: 45 additions & 0 deletions test/flows/sync-error-shape.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -13,6 +14,7 @@ import {
copilotNotFound,
type DropboxMeta,
dropboxFileMetadata,
dropboxFolderMetadata,
dropboxPathLookupNotFound,
dropboxRpcError,
mockAssemblyFileDownload,
Expand Down Expand Up @@ -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<void> }

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.
Expand Down
Loading