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
93 changes: 93 additions & 0 deletions src/features/sync/__tests__/types.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
41 changes: 41 additions & 0 deletions src/features/sync/helper/__tests__/sync.helper.test.ts
Original file line number Diff line number Diff line change
@@ -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<UserCompanySelectorInputValue>) =>
({ 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([])
})
})
2 changes: 1 addition & 1 deletion src/features/sync/lib/MapFiles.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -438,7 +438,7 @@ export class MapFilesService extends AuthenticatedDropboxService {
payload: ChannelSyncUpdatePayload,
assemblyChannelId: string,
dbxRootPath: string,
): Promise<ChannelSyncSelectType> {
): Promise<ChannelSyncSelectType | undefined> {
logger.info(
'MapFilesService#updateChannelMap :: Updating channel map',
payload,
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
})
})
31 changes: 31 additions & 0 deletions src/features/webhook/assembly/utils/__tests__/types.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
28 changes: 28 additions & 0 deletions src/lib/copilot/__tests__/generateToken.test.ts
Original file line number Diff line number Diff line change
@@ -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))
})
})
47 changes: 47 additions & 0 deletions src/lib/copilot/__tests__/isCopilotApiError.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
12 changes: 12 additions & 0 deletions src/lib/copilot/__tests__/types.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
19 changes: 19 additions & 0 deletions src/utils/__tests__/header.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
38 changes: 38 additions & 0 deletions src/utils/__tests__/normalizeError.test.ts
Original file line number Diff line number Diff line change
@@ -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: <serialized error>"', () => {
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')
})
})
Loading
Loading