diff --git a/src/constants/users.ts b/src/constants/users.ts index f636468e4..9f8b263ae 100644 --- a/src/constants/users.ts +++ b/src/constants/users.ts @@ -1,3 +1,4 @@ export const MAX_FETCH_ASSIGNEE_COUNT = 15_000 export const MAX_LIMIT_CLIENT_COUNT = 5_000 //used to specify max fetching limit while querying for clients in copilot API. +export const MAX_LIMIT_INTERNAL_USER_COUNT = 5_000 diff --git a/src/utils/CopilotAPI.test.ts b/src/utils/CopilotAPI.test.ts new file mode 100644 index 000000000..d34bac28e --- /dev/null +++ b/src/utils/CopilotAPI.test.ts @@ -0,0 +1,82 @@ +import { CopilotAPI } from '@/utils/CopilotAPI' + +const mockListInternalUsers = jest.fn() + +jest.mock('@/config', () => ({ + APP_ID: 'app-id', + assemblyApiDomain: 'https://api.example.test', + copilotAPIKey: 'api-key', +})) + +jest.mock('@/app/api/core/utils/withRetry', () => ({ + withRetry: (fn: (...args: unknown[]) => Promise, args: unknown[]) => fn(...args), +})) + +jest.mock('copilot-node-sdk', () => ({ + copilotApi: jest.fn(() => ({ + listInternalUsers: mockListInternalUsers, + })), +})) + +const makeInternalUser = (index: number) => ({ + id: `00000000-0000-4000-8000-${String(index).padStart(12, '0')}`, + givenName: `Given ${index}`, + familyName: `Family ${index}`, + email: `user-${index}@example.com`, + avatarImageUrl: undefined, + isClientAccessLimited: false, + companyAccessList: null, + fallbackColor: null, + createdAt: '2026-01-01T00:00:00.000Z', +}) + +const makeInternalUsers = ({ count, offset = 0 }: { count: number; offset?: number }) => + Array.from({ length: count }, (_, index) => makeInternalUser(offset + index + 1)) + +describe('CopilotAPI', () => { + beforeEach(() => { + mockListInternalUsers.mockReset() + }) + + describe('_getInternalUsers', () => { + it('uses a single SDK request when the requested limit is within the API page size', async () => { + const users = makeInternalUsers({ count: 2 }) + mockListInternalUsers.mockResolvedValueOnce({ data: users }) + + const response = await new CopilotAPI('token')._getInternalUsers({ limit: 5_000, nextToken: 'cursor' }) + + expect(response.data).toEqual(users) + expect(mockListInternalUsers).toHaveBeenCalledTimes(1) + expect(mockListInternalUsers).toHaveBeenCalledWith({ limit: 5_000, nextToken: 'cursor' }) + }) + + it('splits oversized internal-user requests into bounded pages', async () => { + const firstPage = makeInternalUsers({ count: 5_000 }) + const secondPage = makeInternalUsers({ count: 5_000, offset: 5_000 }) + const thirdPage = makeInternalUsers({ count: 2_000, offset: 10_000 }) + + mockListInternalUsers + .mockResolvedValueOnce({ data: firstPage, nextToken: 'cursor-1' }) + .mockResolvedValueOnce({ data: secondPage, nextToken: 'cursor-2' }) + .mockResolvedValueOnce({ data: thirdPage }) + + const response = await new CopilotAPI('token')._getInternalUsers({ limit: 12_000 }) + + expect(response.data).toHaveLength(12_000) + expect(mockListInternalUsers).toHaveBeenNthCalledWith(1, { limit: 5_000, nextToken: undefined }) + expect(mockListInternalUsers).toHaveBeenNthCalledWith(2, { limit: 5_000, nextToken: 'cursor-1' }) + expect(mockListInternalUsers).toHaveBeenNthCalledWith(3, { limit: 2_000, nextToken: 'cursor-2' }) + }) + + it('starts oversized requests from the provided next token', async () => { + mockListInternalUsers + .mockResolvedValueOnce({ data: makeInternalUsers({ count: 5_000 }), nextToken: 'cursor-2' }) + .mockResolvedValueOnce({ data: makeInternalUsers({ count: 2_000, offset: 5_000 }) }) + + await new CopilotAPI('token')._getInternalUsers({ limit: 7_000, nextToken: 'cursor-1' }) + + expect(mockListInternalUsers).toHaveBeenNthCalledWith(1, { limit: 5_000, nextToken: 'cursor-1' }) + expect(mockListInternalUsers).toHaveBeenNthCalledWith(2, { limit: 2_000, nextToken: 'cursor-2' }) + }) + }) +}) diff --git a/src/utils/CopilotAPI.ts b/src/utils/CopilotAPI.ts index 20b721548..858a7beb1 100644 --- a/src/utils/CopilotAPI.ts +++ b/src/utils/CopilotAPI.ts @@ -2,7 +2,7 @@ import APIError from '@/app/api/core/exceptions/api' import httpStatus from 'http-status' import { withRetry } from '@/app/api/core/utils/withRetry' import { copilotAPIKey as apiKey, APP_ID, assemblyApiDomain } from '@/config' -import { MAX_LIMIT_CLIENT_COUNT } from '@/constants/users' +import { MAX_LIMIT_CLIENT_COUNT, MAX_LIMIT_INTERNAL_USER_COUNT } from '@/constants/users' import { AssemblyMetadata, ClientRequest, @@ -220,7 +220,37 @@ export class CopilotAPI { async _getInternalUsers(args: CopilotListArgs = {}): Promise { console.info('CopilotAPI#_getInternalUsers', this.token) - return InternalUsersResponseSchema.parse(await this.copilot.listInternalUsers(args)) + const maxLimit = MAX_LIMIT_INTERNAL_USER_COUNT + const requestedLimit = args.limit || maxLimit + + if (requestedLimit <= maxLimit) { + return InternalUsersResponseSchema.parse(await this.copilot.listInternalUsers(args)) + } + + const fetchPages = async ({ + users, + nextToken, + }: { + users: InternalUsers[] + nextToken?: string + }): Promise => { + if (users.length >= requestedLimit) return users.slice(0, requestedLimit) + + const remaining = requestedLimit - users.length + const response = await this.copilot.listInternalUsers({ + ...args, + limit: Math.min(maxLimit, remaining), + nextToken, + }) + const parsedResponse = InternalUsersResponseSchema.parse(response) + const nextUsers = [...users, ...parsedResponse.data] + + if (!response?.nextToken) return nextUsers + + return fetchPages({ users: nextUsers, nextToken: response.nextToken }) + } + + return InternalUsersResponseSchema.parse({ data: await fetchPages({ users: [], nextToken: args.nextToken }) }) } async _getInternalUser(id: string): Promise {