From 0a9f75fb333cfcc58e171e7ec72ea55c9a2b124d Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Fri, 21 Aug 2026 16:06:38 +0545 Subject: [PATCH 1/6] fix(OUT-4084): widen updateChannelMap return type to include undefined updateChannelMap returns undefined when no channel matches the where-clause, but was typed to always return a row. Callers already ignore the result. Co-Authored-By: Claude Opus 4.8 --- src/features/sync/lib/MapFiles.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/sync/lib/MapFiles.service.ts b/src/features/sync/lib/MapFiles.service.ts index 3c268f8..62ccadb 100644 --- a/src/features/sync/lib/MapFiles.service.ts +++ b/src/features/sync/lib/MapFiles.service.ts @@ -438,7 +438,7 @@ export class MapFilesService extends AuthenticatedDropboxService { payload: ChannelSyncUpdatePayload, assemblyChannelId: string, dbxRootPath: string, - ): Promise { + ): Promise { logger.info( 'MapFilesService#updateChannelMap :: Updating channel map', payload, From d0ba854533efc1bba0dc50755081f3ad04166a98 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Fri, 21 Aug 2026 16:06:41 +0545 Subject: [PATCH 2/6] chore(OUT-4084): inline @assembly-js/node-sdk in the unit vitest config Lets unit tests import modules that touch the Assembly SDK, whose dist does a directory import Node's ESM loader can't resolve. Mirrors the integration config. Co-Authored-By: Claude Opus 4.8 --- vitest.config.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/vitest.config.ts b/vitest.config.ts index eac3593..b91f830 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -19,5 +19,9 @@ export default defineConfig({ // against a real Postgres container — keep them out of the fast unit run. exclude: ['**/*.integration.test.ts', '**/node_modules/**'], setupFiles: ['./vitest.setup.ts'], + server: { + // Force @assembly-js/node-sdk through Vite's resolver (Node ESM can't resolve its dist directory import). + deps: { inline: ['@assembly-js/node-sdk'] }, + }, }, }) From cfc31812f2adb50168f05f06669646920f275fdf Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Fri, 21 Aug 2026 16:06:57 +0545 Subject: [PATCH 3/6] test(OUT-4084): unit-test utils error + header helpers Cover normalizeError (per error-type formatting), withErrorHandler and withErrorLogging (error-to-response / logging + rethrow), and dropboxArgHeader (unicode escaping). Co-Authored-By: Claude Opus 4.8 --- src/utils/__tests__/header.test.ts | 19 ++++++++ src/utils/__tests__/normalizeError.test.ts | 38 ++++++++++++++++ src/utils/__tests__/withErrorHandler.test.ts | 46 ++++++++++++++++++++ src/utils/__tests__/withErrorLogger.test.ts | 24 ++++++++++ 4 files changed, 127 insertions(+) create mode 100644 src/utils/__tests__/header.test.ts create mode 100644 src/utils/__tests__/normalizeError.test.ts create mode 100644 src/utils/__tests__/withErrorHandler.test.ts create mode 100644 src/utils/__tests__/withErrorLogger.test.ts diff --git a/src/utils/__tests__/header.test.ts b/src/utils/__tests__/header.test.ts new file mode 100644 index 0000000..18d85af --- /dev/null +++ b/src/utils/__tests__/header.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest' +import { dropboxArgHeader } from '@/utils/header' + +describe('dropboxArgHeader', () => { + it('serializes a plain ascii object to JSON unchanged', () => { + expect(dropboxArgHeader({ path: '/a/b.txt' })).toBe('{"path":"/a/b.txt"}') + }) + + it('escapes accented characters as \\uXXXX', () => { + expect(dropboxArgHeader({ path: '/café' })).toBe('{"path":"/caf\\u00e9"}') + }) + + it('leaves no raw non-ascii character in the output (e.g. emoji)', () => { + const out = dropboxArgHeader({ name: '📁 photos' }) + expect(out).toContain('\\u') // the emoji became escape sequences + // nothing above ascii remains + expect([...out].every((ch) => ch.charCodeAt(0) <= 0x7e)).toBe(true) + }) +}) diff --git a/src/utils/__tests__/normalizeError.test.ts b/src/utils/__tests__/normalizeError.test.ts new file mode 100644 index 0000000..ff79244 --- /dev/null +++ b/src/utils/__tests__/normalizeError.test.ts @@ -0,0 +1,38 @@ +import { DropboxResponseError } from 'dropbox' +import { describe, expect, it } from 'vitest' +import { normalizeError } from '@/utils/normalizeError' + +describe('normalizeError', () => { + it('formats a Copilot API error with status, statusText, body message, and url', () => { + const err = Object.assign(new Error('boom'), { + url: '/v1/files/x', + status: 404, + statusText: 'Not Found', + body: { message: 'nope' }, + }) + expect(normalizeError(err)).toBe('HTTP 404 Not Found — nope (url: /v1/files/x)') + }) + + it('falls back gracefully when the Copilot error has no statusText/url/body message', () => { + const err = Object.assign(new Error(), { url: '', status: 500, statusText: '', body: {} }) + expect(normalizeError(err)).toBe('HTTP 500 — no body message') + }) + + it('formats a DropboxResponseError as "status: "', () => { + const err = new DropboxResponseError( + 409, + {} as never, + { error_summary: 'path/not_found' } as never, + ) + expect(normalizeError(err)).toBe('409: {"error_summary":"path/not_found"}') + }) + + it('uses the message for a plain Error', () => { + expect(normalizeError(new Error('just a message'))).toBe('just a message') + }) + + it('stringifies a non-error value', () => { + expect(normalizeError('a string')).toBe('a string') + expect(normalizeError(42)).toBe('42') + }) +}) diff --git a/src/utils/__tests__/withErrorHandler.test.ts b/src/utils/__tests__/withErrorHandler.test.ts new file mode 100644 index 0000000..516f59b --- /dev/null +++ b/src/utils/__tests__/withErrorHandler.test.ts @@ -0,0 +1,46 @@ +import { NextResponse } from 'next/server' +import { describe, expect, it } from 'vitest' +import { z } from 'zod' +import APIError from '@/errors/APIError' +import { withErrorHandler } from '@/utils/withErrorHandler' + +// req/params are unused by the handler under test. +const run = (handler: () => NextResponse | Promise) => + withErrorHandler(() => Promise.resolve(handler()))({} as never, undefined) + +describe('withErrorHandler', () => { + it('passes a successful response through unchanged', async () => { + const res = await run(() => NextResponse.json({ ok: true })) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ ok: true }) + }) + + it('maps an APIError to its status and message', async () => { + const res = await run(() => { + throw new APIError('nope', 404) + }) + + expect(res.status).toBe(404) + expect(await res.json()).toEqual({ error: 'nope' }) + }) + + it('maps a ZodError to 422', async () => { + const res = await run(() => { + z.number().parse('not a number') + return NextResponse.json({}) + }) + + expect(res.status).toBe(422) + expect(typeof (await res.json()).error).toBe('string') + }) + + it('maps an unexpected Error to 500 with its message', async () => { + const res = await run(() => { + throw new Error('boom') + }) + + expect(res.status).toBe(500) + expect(await res.json()).toEqual({ error: 'boom' }) + }) +}) diff --git a/src/utils/__tests__/withErrorLogger.test.ts b/src/utils/__tests__/withErrorLogger.test.ts new file mode 100644 index 0000000..3164628 --- /dev/null +++ b/src/utils/__tests__/withErrorLogger.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it, vi } from 'vitest' + +// Stub the Trigger SDK logger for this unit test. +vi.mock('@trigger.dev/sdk', () => ({ + ApiError: class ApiError extends Error {}, + logger: { error: vi.fn() }, +})) + +const { withErrorLogging } = await import('@/utils/withErrorLogger') + +describe('withErrorLogging', () => { + it('returns the wrapped function result on success', async () => { + await expect(withErrorLogging({ id: 1 }, () => Promise.resolve('ok'))).resolves.toBe('ok') + }) + + it('rethrows the error after logging it', async () => { + const err = new Error('boom') + await expect( + withErrorLogging({ id: 1 }, () => { + throw err + }), + ).rejects.toBe(err) + }) +}) From 0d9dc6a7c4e569b372c821d8e163e3198299167c Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Fri, 21 Aug 2026 16:06:58 +0545 Subject: [PATCH 4/6] test(OUT-4084): unit-test copilot token, error guard, and hex schema Cover generateToken (AES round-trip + random IV), isCopilotApiError (shape acceptance and rejections incl. a Dropbox error), and HexColorSchema. Co-Authored-By: Claude Opus 4.8 --- .../copilot/__tests__/generateToken.test.ts | 28 +++++++++++ .../__tests__/isCopilotApiError.test.ts | 47 +++++++++++++++++++ src/lib/copilot/__tests__/types.test.ts | 12 +++++ 3 files changed, 87 insertions(+) create mode 100644 src/lib/copilot/__tests__/generateToken.test.ts create mode 100644 src/lib/copilot/__tests__/isCopilotApiError.test.ts create mode 100644 src/lib/copilot/__tests__/types.test.ts diff --git a/src/lib/copilot/__tests__/generateToken.test.ts b/src/lib/copilot/__tests__/generateToken.test.ts new file mode 100644 index 0000000..2af8939 --- /dev/null +++ b/src/lib/copilot/__tests__/generateToken.test.ts @@ -0,0 +1,28 @@ +import * as crypto from 'node:crypto' +import { describe, expect, it } from 'vitest' +import { generateToken } from '@/lib/copilot/generateToken' +import type { Token } from '@/lib/copilot/types' + +// Decrypt with the same key + scheme as production. +const decrypt = (apiKey: string, tokenHex: string) => { + const key = Buffer.from(crypto.createHmac('sha256', apiKey).digest('hex').slice(0, 32), 'hex') + const buf = Buffer.from(tokenHex, 'hex') + const decipher = crypto.createDecipheriv('aes-128-cbc', key, buf.subarray(0, 16)) + return Buffer.concat([decipher.update(buf.subarray(16)), decipher.final()]).toString('utf-8') +} + +describe('generateToken', () => { + const apiKey = 'test-api-key' + const payload = { workspaceId: 'ws-1', internalUserId: 'user-1' } as Token + + it('produces a hex token that decrypts back to the original payload', () => { + const token = generateToken(apiKey, payload) + + expect(token).toMatch(/^[0-9a-f]+$/) + expect(JSON.parse(decrypt(apiKey, token))).toEqual(payload) + }) + + it('uses a random IV, so two encryptions of the same payload differ', () => { + expect(generateToken(apiKey, payload)).not.toBe(generateToken(apiKey, payload)) + }) +}) diff --git a/src/lib/copilot/__tests__/isCopilotApiError.test.ts b/src/lib/copilot/__tests__/isCopilotApiError.test.ts new file mode 100644 index 0000000..7cf00aa --- /dev/null +++ b/src/lib/copilot/__tests__/isCopilotApiError.test.ts @@ -0,0 +1,47 @@ +import { DropboxResponseError } from 'dropbox' +import { describe, expect, it } from 'vitest' +import { isCopilotApiError } from '@/lib/copilot/CopilotAPI' + +// A well-formed Copilot API error. +const copilotError = () => + Object.assign(new Error('boom'), { + url: '/v1/files/x', + status: 404, + statusText: 'Not Found', + body: { message: 'nope' }, + }) + +describe('isCopilotApiError', () => { + it('accepts an Error carrying url/status/statusText/body', () => { + expect(isCopilotApiError(copilotError())).toBe(true) + }) + + it('rejects a plain Error', () => { + expect(isCopilotApiError(new Error('plain'))).toBe(false) + }) + + it('rejects a non-Error object even with the right shape', () => { + const notAnError = { url: '/v1', status: 404, statusText: 'Not Found', body: {} } + expect(isCopilotApiError(notAnError)).toBe(false) + }) + + it('rejects when a required field is missing (status)', () => { + const err = Object.assign(new Error(), { url: '/v1', statusText: 'x', body: {} }) + expect(isCopilotApiError(err)).toBe(false) + }) + + it('rejects when body is null', () => { + const err = Object.assign(new Error(), { + url: '/v1', + status: 500, + statusText: 'x', + body: null, + }) + expect(isCopilotApiError(err)).toBe(false) + }) + + it('rejects a DropboxResponseError (no url/statusText/body — must not match)', () => { + const err = new DropboxResponseError(429, {} as never, {} as never) + expect(isCopilotApiError(err)).toBe(false) + }) +}) diff --git a/src/lib/copilot/__tests__/types.test.ts b/src/lib/copilot/__tests__/types.test.ts new file mode 100644 index 0000000..7a99629 --- /dev/null +++ b/src/lib/copilot/__tests__/types.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest' +import { HexColorSchema } from '@/lib/copilot/types' + +describe('HexColorSchema', () => { + it.each(['#fff', '#FFFFFF', '#Ab12Cd'])('accepts the valid hex color %s', (value) => { + expect(HexColorSchema.safeParse(value).success).toBe(true) + }) + + it.each(['fff', '#ff', '#12345', '#gggggg', ''])('rejects the invalid hex color %s', (value) => { + expect(HexColorSchema.safeParse(value).success).toBe(false) + }) +}) From 73b7f94cee9e8e11a13a4ee05a7f5dfa8fa3b676 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Fri, 21 Aug 2026 16:07:00 +0545 Subject: [PATCH 5/6] test(OUT-4084): unit-test sync/webhook helpers and schemas Cover getCompanySelectorValue, the sync request schemas, the Assembly webhook payload schema, and validateHandleableEvent / parseWebhook. Co-Authored-By: Claude Opus 4.8 --- src/features/sync/__tests__/types.test.ts | 93 +++++++++++++++++++ .../sync/helper/__tests__/sync.helper.test.ts | 41 ++++++++ .../__tests__/webhook.service.unit.test.ts | 65 +++++++++++++ .../assembly/utils/__tests__/types.test.ts | 31 +++++++ 4 files changed, 230 insertions(+) create mode 100644 src/features/sync/__tests__/types.test.ts create mode 100644 src/features/sync/helper/__tests__/sync.helper.test.ts create mode 100644 src/features/webhook/assembly/lib/__tests__/webhook.service.unit.test.ts create mode 100644 src/features/webhook/assembly/utils/__tests__/types.test.ts diff --git a/src/features/sync/__tests__/types.test.ts b/src/features/sync/__tests__/types.test.ts new file mode 100644 index 0000000..d206ef5 --- /dev/null +++ b/src/features/sync/__tests__/types.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest' +import { + FileSyncCreateRequestSchema, + ResyncChannelRequestSchema, + TotalFilesCountRequestSchema, + UpdateConnectionStatusSchema, +} from '@/features/sync/types' + +describe('FileSyncCreateRequestSchema', () => { + it('accepts a fileChannelId and dbxRootPath', () => { + expect( + FileSyncCreateRequestSchema.safeParse({ fileChannelId: 'c', dbxRootPath: '/r' }).success, + ).toBe(true) + }) + + it('rejects a payload missing a field', () => { + expect(FileSyncCreateRequestSchema.safeParse({ fileChannelId: 'c' }).success).toBe(false) + }) +}) + +describe('ResyncChannelRequestSchema', () => { + it('accepts a uuid channelSyncId', () => { + expect( + ResyncChannelRequestSchema.safeParse({ + channelSyncId: '00000000-0000-4000-8000-000000000000', + }).success, + ).toBe(true) + }) + + it('rejects a non-uuid channelSyncId', () => { + expect(ResyncChannelRequestSchema.safeParse({ channelSyncId: 'not-a-uuid' }).success).toBe( + false, + ) + }) +}) + +describe('UpdateConnectionStatusSchema', () => { + it('accepts a boolean status with non-empty ids', () => { + expect( + UpdateConnectionStatusSchema.safeParse({ + status: true, + assemblyChannelId: 'ch', + dbxRootPath: '/r', + }).success, + ).toBe(true) + }) + + it('rejects an empty assemblyChannelId', () => { + expect( + UpdateConnectionStatusSchema.safeParse({ + status: true, + assemblyChannelId: '', + dbxRootPath: '/r', + }).success, + ).toBe(false) + }) + + it('rejects a non-boolean status', () => { + expect( + UpdateConnectionStatusSchema.safeParse({ + status: 'yes', + assemblyChannelId: 'ch', + dbxRootPath: '/r', + }).success, + ).toBe(false) + }) +}) + +describe('TotalFilesCountRequestSchema', () => { + it('accepts required ids, with limit optional', () => { + expect( + TotalFilesCountRequestSchema.safeParse({ assemblyChannelId: 'ch', dbxRootPath: '/r' }) + .success, + ).toBe(true) + expect( + TotalFilesCountRequestSchema.safeParse({ + assemblyChannelId: 'ch', + dbxRootPath: '/r', + limit: '10', + }).success, + ).toBe(true) + }) + + it('rejects an empty limit string when provided', () => { + expect( + TotalFilesCountRequestSchema.safeParse({ + assemblyChannelId: 'ch', + dbxRootPath: '/r', + limit: '', + }).success, + ).toBe(false) + }) +}) diff --git a/src/features/sync/helper/__tests__/sync.helper.test.ts b/src/features/sync/helper/__tests__/sync.helper.test.ts new file mode 100644 index 0000000..fee8f4a --- /dev/null +++ b/src/features/sync/helper/__tests__/sync.helper.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { getCompanySelectorValue } from '@/features/sync/helper/sync.helper' +import type { SelectorClientsCompanies } from '@/features/sync/types' +import type { UserCompanySelectorInputValue } from '@/lib/copilot/types' + +const company = { value: 'comp-1', label: 'Co', type: 'company' } as const +const client = { value: 'cli-1', label: 'Client', type: 'client', companyId: 'comp-1' } as const +const list: SelectorClientsCompanies = { companies: [company], clients: [client] } + +const input = (v: Partial) => + ({ id: '', companyId: '', object: 'company', ...v }) as UserCompanySelectorInputValue + +describe('getCompanySelectorValue', () => { + it('returns an empty array when there is no selected value', () => { + expect( + getCompanySelectorValue(list, undefined as unknown as UserCompanySelectorInputValue), + ).toEqual([]) + }) + + it('finds a company by id', () => { + expect(getCompanySelectorValue(list, input({ id: 'comp-1', object: 'company' }))).toEqual([ + company, + ]) + }) + + it('returns an empty array when the company id is unknown', () => { + expect(getCompanySelectorValue(list, input({ id: 'nope', object: 'company' }))).toEqual([]) + }) + + it('finds a client by matching both id and companyId', () => { + expect( + getCompanySelectorValue(list, input({ id: 'cli-1', companyId: 'comp-1', object: 'client' })), + ).toEqual([client]) + }) + + it('returns an empty array when the client id matches but the companyId does not', () => { + expect( + getCompanySelectorValue(list, input({ id: 'cli-1', companyId: 'other', object: 'client' })), + ).toEqual([]) + }) +}) diff --git a/src/features/webhook/assembly/lib/__tests__/webhook.service.unit.test.ts b/src/features/webhook/assembly/lib/__tests__/webhook.service.unit.test.ts new file mode 100644 index 0000000..a426285 --- /dev/null +++ b/src/features/webhook/assembly/lib/__tests__/webhook.service.unit.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AssemblyWebhookEvent } from '@/features/webhook/assembly/utils/types' +import type User from '@/lib/copilot/models/User.model' + +// Stub the Trigger SDK (both specifiers) so the service imports without a run context. +const { sdk } = vi.hoisted(() => ({ + sdk: { + task: (def: unknown) => def, + schedules: { task: (def: unknown) => def }, + logger: { info: () => undefined, error: () => undefined }, + ApiError: class ApiError extends Error {}, + }, +})) +vi.mock('@trigger.dev/sdk/v3', () => sdk) +vi.mock('@trigger.dev/sdk', () => sdk) + +const { AssemblyWebhookService } = await import('@/features/webhook/assembly/lib/webhook.service') + +const svc = new AssemblyWebhookService( + { portalId: 'p', token: 't' } as unknown as User, + { + refreshToken: 'rt', + accountId: 'acc', + rootNamespaceId: 'ns', + } as never, +) + +const event = (eventType: string, object: string) => + ({ eventType, data: { object } }) as unknown as AssemblyWebhookEvent + +describe('AssemblyWebhookService#validateHandleableEvent', () => { + it('returns the event type for a handleable event on a file/folder', () => { + expect(svc.validateHandleableEvent(event('file.created', 'file'))).toBe('file.created') + expect(svc.validateHandleableEvent(event('folder.deleted', 'folder'))).toBe('folder.deleted') + }) + + it('returns null for an event type it does not handle', () => { + expect(svc.validateHandleableEvent(event('link.created', 'file'))).toBeNull() + }) + + it('returns null for a non-syncable object type (e.g. link)', () => { + expect(svc.validateHandleableEvent(event('file.created', 'link'))).toBeNull() + }) +}) + +describe('AssemblyWebhookService#parseWebhook', () => { + const validBody = { + eventType: 'file.created', + data: { + id: '00000000-0000-4000-8000-000000000000', + channelId: 'ch-1', + name: 'f.txt', + object: 'file', + path: '/f.txt', + }, + } + + it('parses a valid webhook body', () => { + expect(svc.parseWebhook(validBody).eventType).toBe('file.created') + }) + + it('throws on an invalid webhook body', () => { + expect(() => svc.parseWebhook({ nope: true })).toThrow() + }) +}) diff --git a/src/features/webhook/assembly/utils/__tests__/types.test.ts b/src/features/webhook/assembly/utils/__tests__/types.test.ts new file mode 100644 index 0000000..a5d7c6c --- /dev/null +++ b/src/features/webhook/assembly/utils/__tests__/types.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { AssemblyWebhookSchema } from '@/features/webhook/assembly/utils/types' + +const validData = { + id: '00000000-0000-4000-8000-000000000000', + channelId: 'ch-1', + name: 'f.txt', + object: 'file', + path: '/f.txt', +} + +describe('AssemblyWebhookSchema', () => { + it('accepts a well-formed webhook payload', () => { + expect( + AssemblyWebhookSchema.safeParse({ eventType: 'file.created', data: validData }).success, + ).toBe(true) + }) + + it('rejects a payload with no data', () => { + expect(AssemblyWebhookSchema.safeParse({ eventType: 'file.created' }).success).toBe(false) + }) + + it('rejects data whose id is not a uuid', () => { + expect( + AssemblyWebhookSchema.safeParse({ + eventType: 'file.created', + data: { ...validData, id: 'not-a-uuid' }, + }).success, + ).toBe(false) + }) +}) From dc84f7edd5b6f95d7476682e7cdfd9218ae3c149 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Fri, 21 Aug 2026 16:17:54 +0545 Subject: [PATCH 6/6] fix(OUT-4084): surface Dropbox error_summary in withErrorHandler The DropboxResponseError branch sat after the generic Error branch, so it was dead code (DropboxResponseError extends Error with a truthy message) and Dropbox failures surfaced "Response failed with a X code" instead of error_summary. Reorder it above the Error branch, matching normalizeError. Co-Authored-By: Claude Opus 4.8 --- src/utils/__tests__/withErrorHandler.test.ts | 14 ++++++++++++++ src/utils/withErrorHandler.ts | 8 +++++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/utils/__tests__/withErrorHandler.test.ts b/src/utils/__tests__/withErrorHandler.test.ts index 516f59b..0be5bfd 100644 --- a/src/utils/__tests__/withErrorHandler.test.ts +++ b/src/utils/__tests__/withErrorHandler.test.ts @@ -1,3 +1,4 @@ +import { DropboxResponseError } from 'dropbox' import { NextResponse } from 'next/server' import { describe, expect, it } from 'vitest' import { z } from 'zod' @@ -43,4 +44,17 @@ describe('withErrorHandler', () => { expect(res.status).toBe(500) expect(await res.json()).toEqual({ error: 'boom' }) }) + + it('maps a DropboxResponseError to its status and error_summary', async () => { + const res = await run(() => { + throw new DropboxResponseError( + 409, + {} as never, + { error_summary: 'path/not_found/..' } as never, + ) + }) + + expect(res.status).toBe(409) + expect(await res.json()).toEqual({ error: 'path/not_found/..' }) + }) }) diff --git a/src/utils/withErrorHandler.ts b/src/utils/withErrorHandler.ts index 282be7c..45ce31e 100644 --- a/src/utils/withErrorHandler.ts +++ b/src/utils/withErrorHandler.ts @@ -48,14 +48,16 @@ export const withErrorHandler = (handler: RequestHandler): RequestHandler => { } else { logger.error('APIError:', error.error || error.message) } - } else if (error instanceof Error && error.message) { - message = error.message - logger.error('Error:', error) } else if (error instanceof DropboxResponseError) { + // Must precede the generic Error branch: DropboxResponseError extends Error + // with a truthy message, so checking Error first would hide error_summary. message = error.error.error_summary || `DropboxResponseError: ${message}` status = error.status logger.error('DropboxResponseError:', error.error) + } else if (error instanceof Error && error.message) { + message = error.message + logger.error('Error:', error) } else { message = 'Something went wrong' logger.error(error)