Skip to content
Draft
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
44 changes: 44 additions & 0 deletions src/app/api/label-mapping/label-mapping.service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
const mockLabelFindFirst = jest.fn()
const mockLabelDelete = jest.fn()

jest.mock('@/lib/db', () => ({
__esModule: true,
default: {
getInstance: () => ({
label: { findFirst: mockLabelFindFirst, delete: mockLabelDelete },
}),
},
}))

jest.mock('@/utils/CopilotAPI', () => ({ CopilotAPI: jest.fn() }))

import { LabelMappingService } from '@api/label-mapping/label-mapping.service'
import User from '@api/core/models/User.model'
import { UserRole } from '@api/core/types/user'

const user = {
workspaceId: 'ws-1',
role: UserRole.IU,
internalUserId: 'iu-1',
token: 'token',
} as unknown as User

describe('LabelMappingService#deleteLabel', () => {
beforeEach(() => jest.clearAllMocks())

it('deletes the matching label row', async () => {
mockLabelFindFirst.mockResolvedValue({ id: 'label-1' })

await new LabelMappingService(user).deleteLabel('ASS10-009')

expect(mockLabelDelete).toHaveBeenCalledWith({ where: { id: 'label-1' } })
})

it('no-ops when the label row is already gone', async () => {
mockLabelFindFirst.mockResolvedValue(null)

await new LabelMappingService(user).deleteLabel('ASS10-009')

expect(mockLabelDelete).not.toHaveBeenCalled()
})
})
3 changes: 2 additions & 1 deletion src/app/api/label-mapping/label-mapping.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,9 +175,10 @@ export class LabelMappingService extends BaseService {
label,
},
})
if (!currentLabel) return
await this.db.label.delete({
where: {
id: currentLabel?.id,
id: currentLabel.id,
},
})
}
Expand Down
74 changes: 74 additions & 0 deletions src/utils/CopilotAPI.getClients.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { MAX_LIMIT_CLIENT_COUNT } from '@/constants/users'
import { ClientResponse } from '@/types/common'
import { CopilotAPI } from '@/utils/CopilotAPI'

const mockListClients = jest.fn()

jest.mock('copilot-node-sdk', () => ({
copilotApi: () => ({
listClients: mockListClients,
}),
}))

jest.mock('@/app/api/core/utils/withRetry', () => ({
withRetry: (fn: (...args: unknown[]) => unknown, args: unknown[]) => fn(...args),
RETRY_404_ENABLED: false,
}))

const invalidStartingKeyError = new Error(
'Failed to list clients: ValidationException: The provided starting key is invalid',
)

const buildClient = (id: string): ClientResponse => ({
id,
givenName: 'Test',
familyName: 'Client',
email: `${id}@example.com`,
companyId: '00000000-0000-4000-8000-000000000001',
status: 'active',
avatarImageUrl: null,
createdAt: '2026-01-01T00:00:00.000Z',
})

describe('CopilotAPI#getClients pagination', () => {
beforeEach(() => {
jest.clearAllMocks()
})

it('retries without a stale nextToken for single-page requests', async () => {
mockListClients
.mockRejectedValueOnce(invalidStartingKeyError)
.mockResolvedValueOnce({ data: [buildClient('client-1')] })

const copilot = new CopilotAPI('token')
const response = await copilot.getClients({ limit: 100, nextToken: 'stale-token' })

expect(response.data).toEqual([buildClient('client-1')])
expect(mockListClients).toHaveBeenCalledTimes(2)
expect(mockListClients.mock.calls[0][0]).toEqual({ limit: 100, nextToken: 'stale-token' })
expect(mockListClients.mock.calls[1][0]).toEqual({ limit: 100 })
})

it('retries batched pagination from the first page when a later page cursor is invalid', async () => {
const firstPageClients = Array.from({ length: MAX_LIMIT_CLIENT_COUNT }, (_, index) =>
buildClient(`client-${index}`),
)
const secondPageClients = [buildClient('client-next-page')]

mockListClients
.mockResolvedValueOnce({ data: firstPageClients, nextToken: 'page-2-token' })
.mockRejectedValueOnce(invalidStartingKeyError)
.mockResolvedValueOnce({ data: firstPageClients, nextToken: 'page-2-token' })
.mockResolvedValueOnce({ data: secondPageClients })

const copilot = new CopilotAPI('token')
const response = await copilot.getClients({ limit: MAX_LIMIT_CLIENT_COUNT + 1 })

expect(response.data).toHaveLength(MAX_LIMIT_CLIENT_COUNT + 1)
expect(mockListClients).toHaveBeenCalledTimes(4)
expect(mockListClients.mock.calls[2][0]).toEqual({
limit: MAX_LIMIT_CLIENT_COUNT,
nextToken: undefined,
})
})
})
63 changes: 46 additions & 17 deletions src/utils/CopilotAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import { DISPATCHABLE_EVENT } from '@/types/webhook'
import Bottleneck from 'bottleneck'
import type { CopilotAPI as SDK } from 'copilot-node-sdk'
import { copilotApi } from 'copilot-node-sdk'
import { isInvalidPaginationCursorError } from '@/utils/copilotError'
import { cache } from 'react'
import { z } from 'zod'

Expand Down Expand Up @@ -161,28 +162,56 @@ export class CopilotAPI {
console.info('CopilotAPI#_getClients', this.token)
const maxLimit = MAX_LIMIT_CLIENT_COUNT
const requestedLimit = args.limit || maxLimit
let clients: ClientResponse[] = []
let nextToken: string | undefined = undefined

const listClientsOnce = async (listArgs: CopilotListArgs & { companyId?: string }) => {
return ClientsResponseSchema.parse(await this.copilot.listClients(listArgs))
}

const listClientsWithCursorRetry = async (listArgs: CopilotListArgs & { companyId?: string }) => {
try {
return await listClientsOnce(listArgs)
} catch (error) {
if (!isInvalidPaginationCursorError(error) || !listArgs.nextToken) throw error

const { nextToken: _staleToken, ...listArgsWithoutToken } = listArgs
return await listClientsOnce(listArgsWithoutToken)
}
}

if (requestedLimit <= maxLimit) {
return ClientsResponseSchema.parse(await this.copilot.listClients(args))
return listClientsWithCursorRetry(args)
}

//fetching client data in batches of MAX_LIMIT_CLIENT_COUNT instead of fetching it as a whole.
do {
const remaining = requestedLimit - clients.length
const fetchLimit = Math.min(maxLimit, remaining)
const response = await this.copilot.listClients({
...args,
limit: fetchLimit,
nextToken,
})
const parsedData = ClientsResponseSchema.parse(response)?.data ?? []
clients.push(...parsedData)
nextToken = response?.nextToken || undefined
} while (nextToken && clients.length < requestedLimit)
const fetchAllPages = async (): Promise<ClientResponse[]> => {
const clients: ClientResponse[] = []
let nextToken: string | undefined = undefined
const { nextToken: _callerToken, ...baseArgs } = args

do {
const remaining = requestedLimit - clients.length
const fetchLimit = Math.min(maxLimit, remaining)
const response = await this.copilot.listClients({
...baseArgs,
limit: fetchLimit,
nextToken,
})
const parsedData = ClientsResponseSchema.parse(response)?.data ?? []
clients.push(...parsedData)
nextToken = response?.nextToken || undefined
} while (nextToken && clients.length < requestedLimit)

return clients
}

return ClientsResponseSchema.parse({ data: clients })
try {
const clients = await fetchAllPages()
return ClientsResponseSchema.parse({ data: clients })
} catch (error) {
if (!isInvalidPaginationCursorError(error)) throw error

const clients = await fetchAllPages()
return ClientsResponseSchema.parse({ data: clients })
}
}

async _updateClient(id: string, requestBody: ClientRequest): Promise<ClientResponse> {
Expand Down
15 changes: 15 additions & 0 deletions src/utils/copilotError.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { isInvalidPaginationCursorError } from '@/utils/copilotError'

describe('isInvalidPaginationCursorError', () => {
it('matches DynamoDB invalid starting key errors', () => {
expect(
isInvalidPaginationCursorError(
new Error('Failed to list clients: ValidationException: The provided starting key is invalid'),
),
).toBe(true)
})

it('does not match unrelated errors', () => {
expect(isInvalidPaginationCursorError(new Error('Unauthorized'))).toBe(false)
})
})
5 changes: 5 additions & 0 deletions src/utils/copilotError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,8 @@ export const isMessagableError = (e: unknown): e is MessagableError => {
typeof e === 'object' && e !== null && 'message' in e && (!('body' in e) || typeof (e as any).body?.message === 'string')
)
}

export const isInvalidPaginationCursorError = (error: unknown): boolean => {
const message = error instanceof Error ? error.message : String(error)
return /starting key is invalid/i.test(message)
}
Loading