diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/generate-call-recording-summaries.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/generate-call-recording-summaries.test.ts index a9dace06e5..c06f40ebe0 100644 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/generate-call-recording-summaries.test.ts +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/generate-call-recording-summaries.test.ts @@ -1,46 +1,24 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { type RoutePayload } from 'twenty-sdk/define'; import { generateCallRecordingSummariesHandler } from 'src/logic-functions/generate-call-recording-summaries'; -const findCallRecordingIdsMissingSummaryMock = vi.hoisted(() => vi.fn()); -const findCallRecordingIdsForCalendarEventsMock = vi.hoisted(() => vi.fn()); -const generateMissingCallRecordingSummariesMock = vi.hoisted(() => vi.fn()); -const isCallRecordingSummaryEnabledMock = vi.hoisted(() => vi.fn()); +const queryMock = vi.hoisted(() => vi.fn()); +const mutationMock = vi.hoisted(() => vi.fn()); +const runAgentMock = vi.hoisted(() => vi.fn()); vi.mock('twenty-client-sdk/core', () => ({ - CoreApiClient: vi.fn(), + CoreApiClient: class { + query = queryMock; + mutation = mutationMock; + }, })); -vi.mock( - 'src/logic-functions/data/find-call-recording-ids-missing-summary.util', - () => ({ - findCallRecordingIdsMissingSummary: findCallRecordingIdsMissingSummaryMock, - }), -); +vi.mock('twenty-sdk/logic-function', () => ({ + runAgent: runAgentMock, +})); -vi.mock( - 'src/logic-functions/data/find-call-recording-ids-for-calendar-events.util', - () => ({ - findCallRecordingIdsForCalendarEvents: - findCallRecordingIdsForCalendarEventsMock, - }), -); - -vi.mock( - 'src/logic-functions/flows/generate-missing-call-recording-summaries.util', - () => ({ - generateMissingCallRecordingSummaries: - generateMissingCallRecordingSummariesMock, - }), -); - -vi.mock( - 'src/logic-functions/utils/is-call-recording-summary-enabled.util', - () => ({ - isCallRecordingSummaryEnabled: isCallRecordingSummaryEnabledMock, - }), -); +const fetchMock = vi.fn(); const buildRoutePayload = ( body: object | null, @@ -56,6 +34,78 @@ const buildRoutePayload = ( userWorkspaceId: null, }) as never; +const TRANSCRIPT = [ + { + participant: { name: 'Alex' }, + words: [{ text: 'Hello' }, { text: 'team' }], + }, +]; + +type CallRecordingsQueryShape = { + callRecordings: { + __args: { + filter: { + id?: { eq: string }; + calendarEventId?: { in: string[] }; + }; + }; + }; +}; + +const buildConnection = (nodes: object[]) => ({ + callRecordings: { + pageInfo: { hasNextPage: false, endCursor: null }, + edges: nodes.map((node) => ({ node })), + }, +}); + +const buildSummarizableCallRecordingNode = ( + id: string, + createdAt = '2026-01-01T00:00:00.000Z', +) => ({ + id, + createdAt, + title: 'Weekly sync', + transcript: TRANSCRIPT, + summary: { markdown: null }, + createdBy: { source: 'APPLICATION', name: 'Call Recorder' }, +}); + +const seedCallRecordingQueries = ({ + sweepNodes = [], + calendarEventNodes = [], + callRecordingsById = {}, +}: { + sweepNodes?: object[]; + calendarEventNodes?: object[]; + callRecordingsById?: Record; +} = {}) => { + queryMock.mockImplementation(async (queryShape: unknown) => { + const filter = (queryShape as CallRecordingsQueryShape).callRecordings + .__args.filter; + + if (filter.id !== undefined) { + const node = callRecordingsById[filter.id.eq]; + + return { + callRecordings: { edges: node === undefined ? [] : [{ node }] }, + }; + } + + if (filter.calendarEventId !== undefined) { + return buildConnection(calendarEventNodes); + } + + return buildConnection(sweepNodes); + }); +}; + +const queriedCallRecordingFilters = (): unknown[] => + queryMock.mock.calls.map( + ([queryShape]) => + (queryShape as CallRecordingsQueryShape).callRecordings.__args.filter, + ); + const BATCH_RESULT = { generatedCallRecordingIds: ['call-recording-1'], failedCallRecordingIds: [], @@ -68,54 +118,92 @@ const BATCH_RESULT = { describe('generateCallRecordingSummariesHandler', () => { beforeEach(() => { vi.clearAllMocks(); - isCallRecordingSummaryEnabledMock.mockReturnValue(true); - findCallRecordingIdsMissingSummaryMock.mockResolvedValue([]); - findCallRecordingIdsForCalendarEventsMock.mockResolvedValue([]); - generateMissingCallRecordingSummariesMock.mockResolvedValue(BATCH_RESULT); + vi.stubGlobal('fetch', fetchMock); + vi.stubEnv('CALL_RECORDER_SUMMARY_ENABLED', 'true'); + vi.stubEnv('CALL_RECORDER_ADDITIONAL_SUMMARY_PROMPT', ''); + vi.stubEnv('TWENTY_FUNCTIONS_URL', 'https://acme.functions.example.com'); + vi.stubEnv('TWENTY_APP_ACCESS_TOKEN', 'app-access-token'); + fetchMock.mockResolvedValue(new Response('{}', { status: 200 })); + mutationMock.mockResolvedValue({}); + runAgentMock.mockResolvedValue({ + success: true, + error: null, + result: { response: '## Overview\nGood call.' }, + }); + seedCallRecordingQueries(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); }); it('returns disabled without touching data when summaries are off', async () => { - isCallRecordingSummaryEnabledMock.mockReturnValue(false); + vi.stubEnv('CALL_RECORDER_SUMMARY_ENABLED', 'false'); const result = await generateCallRecordingSummariesHandler( buildRoutePayload(null), ); expect(result).toEqual({ outcome: 'disabled' }); - expect(findCallRecordingIdsMissingSummaryMock).not.toHaveBeenCalled(); - expect(generateMissingCallRecordingSummariesMock).not.toHaveBeenCalled(); + expect(queryMock).not.toHaveBeenCalled(); + expect(runAgentMock).not.toHaveBeenCalled(); + expect(mutationMock).not.toHaveBeenCalled(); }); it('processes explicit call recording ids without sweeping', async () => { + seedCallRecordingQueries({ + callRecordingsById: { + 'call-recording-1': + buildSummarizableCallRecordingNode('call-recording-1'), + }, + }); + const result = await generateCallRecordingSummariesHandler( buildRoutePayload({ callRecordingIds: ['call-recording-1'] }), ); expect(result).toEqual({ outcome: 'processed', ...BATCH_RESULT }); - expect(generateMissingCallRecordingSummariesMock).toHaveBeenCalledWith( - expect.objectContaining({ callRecordingIds: ['call-recording-1'] }), - ); - expect(findCallRecordingIdsMissingSummaryMock).not.toHaveBeenCalled(); - expect(findCallRecordingIdsForCalendarEventsMock).not.toHaveBeenCalled(); + expect(queriedCallRecordingFilters()).toEqual([ + { id: { eq: 'call-recording-1' } }, + ]); + expect(mutationMock).toHaveBeenCalledWith({ + updateCallRecording: { + __args: { + id: 'call-recording-1', + data: { + summary: { blocknote: null, markdown: '## Overview\nGood call.' }, + }, + }, + id: true, + }, + }); }); it('resolves calendar event ids to their call recordings', async () => { - findCallRecordingIdsForCalendarEventsMock.mockResolvedValue([ - 'call-recording-7', - ]); + seedCallRecordingQueries({ + calendarEventNodes: [{ id: 'call-recording-7' }], + callRecordingsById: { + 'call-recording-7': + buildSummarizableCallRecordingNode('call-recording-7'), + }, + }); await generateCallRecordingSummariesHandler( buildRoutePayload({ calendarEventIds: ['calendar-event-1'] }), ); - expect(findCallRecordingIdsForCalendarEventsMock).toHaveBeenCalledWith( - expect.anything(), - { calendarEventIds: ['calendar-event-1'] }, + expect(queriedCallRecordingFilters()).toEqual([ + { calendarEventId: { in: ['calendar-event-1'] } }, + { id: { eq: 'call-recording-7' } }, + ]); + expect(mutationMock).toHaveBeenCalledWith( + expect.objectContaining({ + updateCallRecording: expect.objectContaining({ + __args: expect.objectContaining({ id: 'call-recording-7' }), + }), + }), ); - expect(generateMissingCallRecordingSummariesMock).toHaveBeenCalledWith( - expect.objectContaining({ callRecordingIds: ['call-recording-7'] }), - ); - expect(findCallRecordingIdsMissingSummaryMock).not.toHaveBeenCalled(); }); it('reports when the selected calendar events have no recordings instead of sweeping', async () => { @@ -126,29 +214,54 @@ describe('generateCallRecordingSummariesHandler', () => { expect(result).toEqual({ outcome: 'no-call-recordings-for-calendar-events', }); - expect(findCallRecordingIdsMissingSummaryMock).not.toHaveBeenCalled(); - expect(generateMissingCallRecordingSummariesMock).not.toHaveBeenCalled(); + expect(queriedCallRecordingFilters()).toEqual([ + { calendarEventId: { in: ['calendar-event-1'] } }, + ]); + expect(runAgentMock).not.toHaveBeenCalled(); + expect(mutationMock).not.toHaveBeenCalled(); }); it('sweeps recordings missing a summary when no ids are given', async () => { - findCallRecordingIdsMissingSummaryMock.mockResolvedValue([ - 'call-recording-1', - 'call-recording-2', - ]); + seedCallRecordingQueries({ + sweepNodes: [ + buildSummarizableCallRecordingNode( + 'call-recording-1', + '2026-01-02T00:00:00.000Z', + ), + buildSummarizableCallRecordingNode( + 'call-recording-2', + '2026-01-01T00:00:00.000Z', + ), + ], + callRecordingsById: { + 'call-recording-1': + buildSummarizableCallRecordingNode('call-recording-1'), + 'call-recording-2': + buildSummarizableCallRecordingNode('call-recording-2'), + }, + }); const result = await generateCallRecordingSummariesHandler( buildRoutePayload(null), ); - expect(findCallRecordingIdsMissingSummaryMock).toHaveBeenCalledWith( - expect.anything(), - ); - expect(generateMissingCallRecordingSummariesMock).toHaveBeenCalledWith( - expect.objectContaining({ - callRecordingIds: ['call-recording-1', 'call-recording-2'], - }), - ); - expect(result).toEqual({ outcome: 'processed', ...BATCH_RESULT }); + expect(queriedCallRecordingFilters()).toEqual([ + { + status: { eq: 'COMPLETED' }, + transcript: { is: 'NOT_NULL' }, + createdBy: { + source: { eq: 'APPLICATION' }, + name: { eq: 'Call Recorder' }, + }, + }, + { id: { eq: 'call-recording-1' } }, + { id: { eq: 'call-recording-2' } }, + ]); + expect(result).toEqual({ + outcome: 'processed', + ...BATCH_RESULT, + generatedCallRecordingIds: ['call-recording-1', 'call-recording-2'], + }); }); it('does not sweep when an empty calendar event selection is sent', async () => { @@ -157,9 +270,9 @@ describe('generateCallRecordingSummariesHandler', () => { ); expect(result).toEqual({ outcome: 'nothing-selected' }); - expect(findCallRecordingIdsMissingSummaryMock).not.toHaveBeenCalled(); - expect(findCallRecordingIdsForCalendarEventsMock).not.toHaveBeenCalled(); - expect(generateMissingCallRecordingSummariesMock).not.toHaveBeenCalled(); + expect(queryMock).not.toHaveBeenCalled(); + expect(runAgentMock).not.toHaveBeenCalled(); + expect(mutationMock).not.toHaveBeenCalled(); }); it('short-circuits an empty sweep without running the batch', async () => { @@ -168,6 +281,10 @@ describe('generateCallRecordingSummariesHandler', () => { ); expect(result).toEqual({ outcome: 'nothing-to-summarize' }); - expect(generateMissingCallRecordingSummariesMock).not.toHaveBeenCalled(); + expect(queriedCallRecordingFilters()).toEqual([ + expect.objectContaining({ status: { eq: 'COMPLETED' } }), + ]); + expect(runAgentMock).not.toHaveBeenCalled(); + expect(mutationMock).not.toHaveBeenCalled(); }); }); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/process-recall-webhook.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/process-recall-webhook.test.ts index b1777294b9..0f1727588b 100644 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/process-recall-webhook.test.ts +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/process-recall-webhook.test.ts @@ -1,18 +1,17 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import processRecallWebhookLogicFunction, { processRecallWebhookHandler, } from 'src/logic-functions/process-recall-webhook'; -const handleRecallWebhookMock = vi.hoisted(() => vi.fn()); -const coreApiClientMock = vi.hoisted(() => vi.fn()); - -vi.mock('src/logic-functions/flows/handle-recall-webhook.util', () => ({ - handleRecallWebhook: handleRecallWebhookMock, -})); +const queryMock = vi.hoisted(() => vi.fn()); +const mutationMock = vi.hoisted(() => vi.fn()); vi.mock('twenty-client-sdk/core', () => ({ - CoreApiClient: coreApiClientMock, + CoreApiClient: class { + query = queryMock; + mutation = mutationMock; + }, })); const buildRecordingDoneWebhookBody = () => ({ @@ -31,9 +30,40 @@ const buildRecordingDoneWebhookBody = () => ({ describe('process-recall-webhook', () => { beforeEach(() => { - handleRecallWebhookMock.mockReset(); - handleRecallWebhookMock.mockResolvedValue({ status: 'updated' }); - coreApiClientMock.mockReset(); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + queryMock.mockReset(); + queryMock.mockResolvedValue({ + callRecordings: { + edges: [ + { + node: { + id: 'call-recording-1', + status: 'PROCESSING', + externalRecordingId: 'recall-recording-1', + transcript: [ + { + participant: { name: 'Ada' }, + words: [ + { text: 'Hello world', start_timestamp: { relative: 0 } }, + ], + }, + ], + audio: [{ fileId: 'file-audio-1' }], + video: [{ fileId: 'file-video-1' }], + }, + }, + ], + }, + }); + mutationMock.mockReset(); + mutationMock.mockResolvedValue({ + updateCallRecording: { id: 'call-recording-1' }, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); }); it('declares no external trigger so it only runs when dispatched by the resolver', () => { @@ -51,12 +81,33 @@ describe('process-recall-webhook', () => { const result = await processRecallWebhookHandler(body); - expect(coreApiClientMock).toHaveBeenCalledTimes(1); - expect(handleRecallWebhookMock).toHaveBeenCalledTimes(1); - expect(handleRecallWebhookMock).toHaveBeenCalledWith({ - client: coreApiClientMock.mock.instances[0], - body, + expect(queryMock).toHaveBeenCalledTimes(1); + expect(queryMock).toHaveBeenCalledWith( + expect.objectContaining({ + callRecordings: expect.objectContaining({ + __args: { filter: { id: { eq: 'call-recording-1' } }, first: 1 }, + }), + }), + ); + expect(mutationMock).toHaveBeenCalledTimes(1); + expect(mutationMock).toHaveBeenCalledWith({ + updateCallRecording: { + __args: { + id: 'call-recording-1', + data: { + externalBotId: 'recall-bot-1', + externalRecordingId: 'recall-recording-1', + status: 'PROCESSING', + }, + }, + id: true, + }, + }); + expect(result).toEqual({ + status: 'updated', + event: 'recording.done', + callRecordingId: 'call-recording-1', + callRecordingStatus: 'PROCESSING', }); - expect(result).toEqual({ status: 'updated' }); }); }); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/recall-webhook.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/recall-webhook.test.ts index 03667dde07..dc63a7f0cd 100644 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/recall-webhook.test.ts +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/recall-webhook.test.ts @@ -1,21 +1,12 @@ import { createHmac } from 'crypto'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { PROCESS_RECALL_WEBHOOK_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/process-recall-webhook-logic-function-universal-identifier'; import recallWebhookLogicFunction, { recallWebhookRouteHandler, } from 'src/logic-functions/recall-webhook'; -const getApplicationVariableValueMock = vi.hoisted(() => vi.fn()); - -vi.mock( - 'src/logic-functions/utils/get-application-variable-value.util', - () => ({ - getApplicationVariableValue: getApplicationVariableValueMock, - }), -); - const SECRET_BYTES = Buffer.from('entry-test-secret'); const SECRET = `whsec_${SECRET_BYTES.toString('base64')}`; const WORKSPACE_ID = '123e4567-e89b-12d3-a456-426614174000'; @@ -65,8 +56,11 @@ const buildRecordingDoneWebhookBody = () => ({ describe('recallWebhookRouteHandler', () => { beforeEach(() => { - getApplicationVariableValueMock.mockReset(); - getApplicationVariableValueMock.mockReturnValue(SECRET); + vi.stubEnv('RECALL_WEBHOOK_SECRET', SECRET); + }); + + afterEach(() => { + vi.unstubAllEnvs(); }); it('declares a server route trigger that forwards the webhook signature headers', () => { @@ -97,7 +91,7 @@ describe('recallWebhookRouteHandler', () => { }); it('throws when the webhook secret is not configured', () => { - getApplicationVariableValueMock.mockReturnValue(undefined); + vi.stubEnv('RECALL_WEBHOOK_SECRET', ''); expect(() => recallWebhookRouteHandler(buildRoutePayload({ rawBody: '{}', body: {} })), diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/reconcile-upcoming-calendar-events.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/reconcile-upcoming-calendar-events.test.ts index 3510a9a56a..34e3a71cc2 100644 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/reconcile-upcoming-calendar-events.test.ts +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/reconcile-upcoming-calendar-events.test.ts @@ -1,31 +1,68 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { type RoutePayload } from 'twenty-sdk/define'; import routeLogicFunction, { reconcileUpcomingCalendarEventsHandler, } from 'src/logic-functions/reconcile-upcoming-calendar-events'; -const fetchUpcomingCalendarEventIdsMock = vi.hoisted(() => vi.fn()); -const reconcileUpcomingCalendarEventBatchesMock = vi.hoisted(() => vi.fn()); +const queryMock = vi.hoisted(() => vi.fn()); +const mutationMock = vi.hoisted(() => vi.fn()); vi.mock('twenty-client-sdk/core', () => ({ - CoreApiClient: vi.fn(), + CoreApiClient: class { + query = queryMock; + mutation = mutationMock; + }, })); -vi.mock( - 'src/logic-functions/data/fetch-upcoming-calendar-event-ids.util', - () => ({ - fetchUpcomingCalendarEventIds: fetchUpcomingCalendarEventIdsMock, - }), -); +const fetchMock = vi.fn(); -vi.mock( - 'src/logic-functions/flows/reconcile-upcoming-calendar-event-batches.util', - () => ({ - reconcileUpcomingCalendarEventBatches: - reconcileUpcomingCalendarEventBatchesMock, - }), -); +type CalendarEventNode = { + id: string; + title: string; + isCanceled: boolean; + startsAt: string; + endsAt: string; +}; + +type RecordsQuery = { + calendarEvents?: { + __args: { + filter: { + id?: { in: string[] }; + startsAt?: { in: string[] }; + isCanceled?: { eq: boolean }; + }; + }; + }; + callRecordings?: { __args: { filter: Record } }; +}; + +const UPCOMING_STARTS_AT = new Date(Date.now() + 60 * 60 * 1000).toISOString(); +const UPCOMING_ENDS_AT = new Date( + Date.now() + 2 * 60 * 60 * 1000, +).toISOString(); + +// Without a conference link the policy deterministically skips each meeting. +const buildUpcomingCalendarEventNode = (id: string): CalendarEventNode => ({ + id, + title: 'Upcoming Sync', + isCanceled: false, + startsAt: UPCOMING_STARTS_AT, + endsAt: UPCOMING_ENDS_AT, +}); + +const buildConnection = (nodes: TNode[]) => ({ + pageInfo: { hasNextPage: false, endCursor: null }, + edges: nodes.map((node) => ({ node })), +}); + +const readSweepQueries = (): unknown[] => + queryMock.mock.calls.filter( + ([query]) => query.calendarEvents?.__args.filter.isCanceled !== undefined, + ); + +let upcomingCalendarEventNodes: CalendarEventNode[]; const buildRoutePayload = ( body: object | null, @@ -45,15 +82,64 @@ const BATCH_RESULT = { reconciledCalendarEventIds: ['calendar-event-1'], failedCalendarEventIds: [], remainingCalendarEventIds: [], - actionCounts: { created: 1, updated: 0, canceled: 0, skipped: 0, failed: 0 }, + actionCounts: { created: 0, updated: 0, canceled: 0, skipped: 1, failed: 0 }, continuationRequested: false, }; describe('reconcileUpcomingCalendarEventsHandler', () => { beforeEach(() => { vi.clearAllMocks(); - fetchUpcomingCalendarEventIdsMock.mockResolvedValue([]); - reconcileUpcomingCalendarEventBatchesMock.mockResolvedValue(BATCH_RESULT); + vi.stubGlobal('fetch', fetchMock); + vi.stubEnv('TWENTY_FUNCTIONS_URL', 'https://acme.functions.example.com'); + vi.stubEnv('TWENTY_APP_ACCESS_TOKEN', 'app-access-token'); + fetchMock.mockResolvedValue(new Response('{}', { status: 200 })); + upcomingCalendarEventNodes = []; + queryMock.mockImplementation(async (query: RecordsQuery) => { + if (query.calendarEvents !== undefined) { + const filter = query.calendarEvents.__args.filter; + + if (filter.id !== undefined) { + const requestedIds = filter.id.in; + + return { + calendarEvents: buildConnection( + upcomingCalendarEventNodes.filter((node) => + requestedIds.includes(node.id), + ), + ), + }; + } + + if (filter.startsAt !== undefined) { + const requestedStartsAtValues = filter.startsAt.in; + + return { + calendarEvents: buildConnection( + upcomingCalendarEventNodes.filter((node) => + requestedStartsAtValues.includes(node.startsAt), + ), + ), + }; + } + + return { + calendarEvents: buildConnection( + upcomingCalendarEventNodes.map(({ id }) => ({ id })), + ), + }; + } + + if (query.callRecordings !== undefined) { + return { callRecordings: buildConnection([]) }; + } + + throw new Error(`Unhandled query: ${JSON.stringify(query)}`); + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); }); it('is configured as an authenticated route with a self-invokable timeout', () => { @@ -71,37 +157,61 @@ describe('reconcileUpcomingCalendarEventsHandler', () => { }); it('processes explicit calendar event ids without sweeping', async () => { + upcomingCalendarEventNodes = [ + buildUpcomingCalendarEventNode('calendar-event-1'), + ]; + const result = await reconcileUpcomingCalendarEventsHandler( buildRoutePayload({ calendarEventIds: ['calendar-event-1'] }), ); expect(result).toEqual({ outcome: 'processed', ...BATCH_RESULT }); - expect(reconcileUpcomingCalendarEventBatchesMock).toHaveBeenCalledWith( - expect.objectContaining({ calendarEventIds: ['calendar-event-1'] }), + expect(queryMock).toHaveBeenCalledWith( + expect.objectContaining({ + calendarEvents: expect.objectContaining({ + __args: expect.objectContaining({ + filter: { id: { in: ['calendar-event-1'] } }, + }), + }), + }), ); - expect(fetchUpcomingCalendarEventIdsMock).not.toHaveBeenCalled(); + expect(readSweepQueries()).toEqual([]); }); it('sweeps upcoming calendar events when no ids are given', async () => { - fetchUpcomingCalendarEventIdsMock.mockResolvedValue([ - 'calendar-event-1', - 'calendar-event-2', - ]); + upcomingCalendarEventNodes = [ + buildUpcomingCalendarEventNode('calendar-event-1'), + buildUpcomingCalendarEventNode('calendar-event-2'), + ]; const result = await reconcileUpcomingCalendarEventsHandler( buildRoutePayload(null), ); - expect(fetchUpcomingCalendarEventIdsMock).toHaveBeenCalledWith( - expect.anything(), - expect.any(Date), - ); - expect(reconcileUpcomingCalendarEventBatchesMock).toHaveBeenCalledWith( + expect(readSweepQueries()).toHaveLength(1); + expect(queryMock).toHaveBeenCalledWith( expect.objectContaining({ - calendarEventIds: ['calendar-event-1', 'calendar-event-2'], + calendarEvents: expect.objectContaining({ + __args: expect.objectContaining({ + filter: { id: { in: ['calendar-event-1', 'calendar-event-2'] } }, + }), + }), }), ); - expect(result).toEqual({ outcome: 'processed', ...BATCH_RESULT }); + expect(result).toEqual({ + outcome: 'processed', + reconciledCalendarEventIds: ['calendar-event-1', 'calendar-event-2'], + failedCalendarEventIds: [], + remainingCalendarEventIds: [], + actionCounts: { + created: 0, + updated: 0, + canceled: 0, + skipped: 2, + failed: 0, + }, + continuationRequested: false, + }); }); it('short-circuits an empty sweep without running batches', async () => { @@ -110,7 +220,7 @@ describe('reconcileUpcomingCalendarEventsHandler', () => { ); expect(result).toEqual({ outcome: 'nothing-to-reconcile' }); - expect(reconcileUpcomingCalendarEventBatchesMock).not.toHaveBeenCalled(); + expect(queryMock).toHaveBeenCalledTimes(1); }); it('does not sweep when an empty calendar event selection is sent', async () => { @@ -119,19 +229,25 @@ describe('reconcileUpcomingCalendarEventsHandler', () => { ); expect(result).toEqual({ outcome: 'nothing-selected' }); - expect(fetchUpcomingCalendarEventIdsMock).not.toHaveBeenCalled(); - expect(reconcileUpcomingCalendarEventBatchesMock).not.toHaveBeenCalled(); + expect(queryMock).not.toHaveBeenCalled(); }); it('passes a deadline that reserves time for the continuation request', async () => { - await reconcileUpcomingCalendarEventsHandler( + upcomingCalendarEventNodes = [ + buildUpcomingCalendarEventNode('calendar-event-1'), + ]; + + const result = await reconcileUpcomingCalendarEventsHandler( buildRoutePayload({ calendarEventIds: ['calendar-event-1'] }), ); - const { deadlineAtMs } = - reconcileUpcomingCalendarEventBatchesMock.mock.calls[0][0]; - - expect(deadlineAtMs).toBeLessThan(Date.now() + 900 * 1000); - expect(deadlineAtMs).toBeGreaterThan(Date.now() + 800 * 1000); + expect(result).toEqual( + expect.objectContaining({ + outcome: 'processed', + remainingCalendarEventIds: [], + continuationRequested: false, + }), + ); + expect(fetchMock).not.toHaveBeenCalled(); }); }); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/start-post-install-backfills.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/start-post-install-backfills.test.ts index 0852d5c327..55fc3cc0dc 100644 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/start-post-install-backfills.test.ts +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/start-post-install-backfills.test.ts @@ -1,35 +1,32 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH } from 'src/constants/generate-call-recording-summaries-route-path'; +import { RECONCILE_UPCOMING_CALENDAR_EVENTS_ROUTE_PATH } from 'src/constants/reconcile-upcoming-calendar-events-route-path'; import postInstallLogicFunction, { startPostInstallBackfillsHandler, } from 'src/logic-functions/start-post-install-backfills'; -const requestCallRecordingSummariesBackfillMock = vi.hoisted(() => vi.fn()); -const requestUpcomingCalendarEventsReconciliationMock = vi.hoisted(() => - vi.fn(), -); +const FUNCTIONS_BASE_URL = 'https://acme.functions.example.com'; -vi.mock( - 'src/logic-functions/data/request-call-recording-summaries-backfill.util', - () => ({ - requestCallRecordingSummariesBackfill: - requestCallRecordingSummariesBackfillMock, - }), -); +const fetchMock = vi.fn(); -vi.mock( - 'src/logic-functions/data/request-upcoming-calendar-events-reconciliation.util', - () => ({ - requestUpcomingCalendarEventsReconciliation: - requestUpcomingCalendarEventsReconciliationMock, - }), -); +const fetchedRoutePaths = (): string[] => + fetchMock.mock.calls.map(([requestUrl]) => + String(requestUrl).replace(FUNCTIONS_BASE_URL, ''), + ); describe('start-post-install-backfills', () => { beforeEach(() => { vi.clearAllMocks(); - requestCallRecordingSummariesBackfillMock.mockResolvedValue(true); - requestUpcomingCalendarEventsReconciliationMock.mockResolvedValue(true); + vi.stubGlobal('fetch', fetchMock); + vi.stubEnv('TWENTY_FUNCTIONS_URL', FUNCTIONS_BASE_URL); + vi.stubEnv('TWENTY_APP_ACCESS_TOKEN', 'app-access-token'); + fetchMock.mockResolvedValue(new Response('{}', { status: 200 })); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); }); it('is configured to run on app version upgrades', () => { @@ -51,10 +48,9 @@ describe('start-post-install-backfills', () => { calendarEventSweepOutcome: 'sweep-requested', summaryBackfillOutcome: 'skipped-initial-install', }); - expect( - requestUpcomingCalendarEventsReconciliationMock, - ).toHaveBeenCalledTimes(1); - expect(requestCallRecordingSummariesBackfillMock).not.toHaveBeenCalled(); + expect(fetchedRoutePaths()).toEqual([ + RECONCILE_UPCOMING_CALENDAR_EVENTS_ROUTE_PATH, + ]); }); it('backfills summaries and skips the sweep on an upgrade', async () => { @@ -67,25 +63,26 @@ describe('start-post-install-backfills', () => { calendarEventSweepOutcome: 'skipped-upgrade', summaryBackfillOutcome: 'backfill-requested', }); - expect(requestCallRecordingSummariesBackfillMock).toHaveBeenCalledTimes(1); - expect( - requestUpcomingCalendarEventsReconciliationMock, - ).not.toHaveBeenCalled(); + expect(fetchedRoutePaths()).toEqual([ + GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH, + ]); }); it('throws when the fresh-install sweep kickoff fails', async () => { - requestUpcomingCalendarEventsReconciliationMock.mockResolvedValue(false); + fetchMock.mockRejectedValue(new Error('Network failed')); await expect( startPostInstallBackfillsHandler({ newVersion: '1.0.7' }), ).rejects.toThrow( 'Failed to start post-install backfills: upcoming calendar event sweep', ); - expect(requestCallRecordingSummariesBackfillMock).not.toHaveBeenCalled(); + expect(fetchedRoutePaths()).not.toContain( + GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH, + ); }); it('throws when the upgrade summary backfill kickoff fails', async () => { - requestCallRecordingSummariesBackfillMock.mockResolvedValue(false); + fetchMock.mockRejectedValue(new Error('Network failed')); await expect( startPostInstallBackfillsHandler({ @@ -95,8 +92,8 @@ describe('start-post-install-backfills', () => { ).rejects.toThrow( 'Failed to start post-install backfills: call recording summary backfill', ); - expect( - requestUpcomingCalendarEventsReconciliationMock, - ).not.toHaveBeenCalled(); + expect(fetchedRoutePaths()).not.toContain( + RECONCILE_UPCOMING_CALENDAR_EVENTS_ROUTE_PATH, + ); }); }); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/summarize-call-recording.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/summarize-call-recording.test.ts index d8ac91be61..39bfdc15bb 100644 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/summarize-call-recording.test.ts +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/summarize-call-recording.test.ts @@ -1,19 +1,28 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { summarizeCallRecordingHandler } from 'src/logic-functions/summarize-call-recording'; -const generateCallRecordingSummaryMock = vi.hoisted(() => vi.fn()); +const queryMock = vi.hoisted(() => vi.fn()); +const mutationMock = vi.hoisted(() => vi.fn()); +const runAgentMock = vi.hoisted(() => vi.fn()); vi.mock('twenty-client-sdk/core', () => ({ - CoreApiClient: class {}, + CoreApiClient: class { + query = queryMock; + mutation = mutationMock; + }, })); -vi.mock( - 'src/logic-functions/flows/generate-call-recording-summary.util', - () => ({ - generateCallRecordingSummary: generateCallRecordingSummaryMock, - }), -); +vi.mock('twenty-sdk/logic-function', () => ({ + runAgent: runAgentMock, +})); + +const TRANSCRIPT = [ + { + participant: { name: 'Alex' }, + words: [{ text: 'Hello' }, { text: 'team' }], + }, +]; const FAKE_OBJECT_METADATA = { id: 'object-metadata-id', @@ -74,9 +83,33 @@ const buildEvent = ({ describe('summarize-call-recording logic function', () => { beforeEach(() => { vi.clearAllMocks(); - generateCallRecordingSummaryMock.mockResolvedValue({ - outcome: 'generated', + vi.stubEnv('CALL_RECORDER_SUMMARY_ENABLED', 'true'); + vi.stubEnv('CALL_RECORDER_ADDITIONAL_SUMMARY_PROMPT', ''); + queryMock.mockResolvedValue({ + callRecordings: { + edges: [ + { + node: { + id: 'call-recording-1', + title: 'Weekly sync', + transcript: TRANSCRIPT, + summary: { markdown: null }, + createdBy: { source: 'APPLICATION', name: 'Call Recorder' }, + }, + }, + ], + }, }); + mutationMock.mockResolvedValue({}); + runAgentMock.mockResolvedValue({ + success: true, + error: null, + result: { response: '## Overview\nGood call.' }, + }); + }); + + afterEach(() => { + vi.unstubAllEnvs(); }); it('generates a summary when the transcript field changed', async () => { @@ -87,17 +120,21 @@ describe('summarize-call-recording logic function', () => { }), ); - expect(generateCallRecordingSummaryMock).toHaveBeenCalledWith( - expect.anything(), - { - callRecordingId: 'call-recording-1', - requireCreatedByCallRecorder: true, - }, - ); expect(result).toEqual({ callRecordingId: 'call-recording-1', outcome: 'generated', }); + expect(mutationMock).toHaveBeenCalledWith({ + updateCallRecording: { + __args: { + id: 'call-recording-1', + data: { + summary: { blocknote: null, markdown: '## Overview\nGood call.' }, + }, + }, + id: true, + }, + }); }); it('skips summary-only updates to avoid re-entrancy', async () => { @@ -108,7 +145,9 @@ describe('summarize-call-recording logic function', () => { }), ); - expect(generateCallRecordingSummaryMock).not.toHaveBeenCalled(); + expect(queryMock).not.toHaveBeenCalled(); + expect(runAgentMock).not.toHaveBeenCalled(); + expect(mutationMock).not.toHaveBeenCalled(); expect(result).toEqual({ skipped: true, reason: 'transcript unchanged' }); }); @@ -120,7 +159,9 @@ describe('summarize-call-recording logic function', () => { }), ); - expect(generateCallRecordingSummaryMock).not.toHaveBeenCalled(); + expect(queryMock).not.toHaveBeenCalled(); + expect(runAgentMock).not.toHaveBeenCalled(); + expect(mutationMock).not.toHaveBeenCalled(); expect(result).toEqual({ skipped: true, reason: 'not a call recording update', diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/sweep-upcoming-calendar-events.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/sweep-upcoming-calendar-events.test.ts index 234ece8c2f..9e31761f01 100644 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/sweep-upcoming-calendar-events.test.ts +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/sweep-upcoming-calendar-events.test.ts @@ -1,44 +1,117 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import sweepLogicFunction, { sweepUpcomingCalendarEventsHandler, } from 'src/logic-functions/sweep-upcoming-calendar-events'; -const fetchUpcomingCalendarEventIdsMock = vi.hoisted(() => vi.fn()); -const reconcileUpcomingCalendarEventBatchesMock = vi.hoisted(() => vi.fn()); +const queryMock = vi.hoisted(() => vi.fn()); +const mutationMock = vi.hoisted(() => vi.fn()); vi.mock('twenty-client-sdk/core', () => ({ - CoreApiClient: vi.fn(), + CoreApiClient: class { + query = queryMock; + mutation = mutationMock; + }, })); -vi.mock( - 'src/logic-functions/data/fetch-upcoming-calendar-event-ids.util', - () => ({ - fetchUpcomingCalendarEventIds: fetchUpcomingCalendarEventIdsMock, - }), -); +const fetchMock = vi.fn(); -vi.mock( - 'src/logic-functions/flows/reconcile-upcoming-calendar-event-batches.util', - () => ({ - reconcileUpcomingCalendarEventBatches: - reconcileUpcomingCalendarEventBatchesMock, - }), -); - -const BATCH_RESULT = { - reconciledCalendarEventIds: ['calendar-event-1'], - failedCalendarEventIds: [], - remainingCalendarEventIds: [], - actionCounts: { created: 1, updated: 0, canceled: 0, skipped: 0, failed: 0 }, - continuationRequested: false, +type CalendarEventNode = { + id: string; + title: string; + isCanceled: boolean; + startsAt: string; + endsAt: string; }; +type RecordsQuery = { + calendarEvents?: { + __args: { + filter: { + id?: { in: string[] }; + startsAt?: { in: string[] }; + isCanceled?: { eq: boolean }; + }; + }; + }; + callRecordings?: { __args: { filter: Record } }; +}; + +const UPCOMING_STARTS_AT = new Date(Date.now() + 60 * 60 * 1000).toISOString(); +const UPCOMING_ENDS_AT = new Date( + Date.now() + 2 * 60 * 60 * 1000, +).toISOString(); + +// Without a conference link the policy deterministically skips each meeting. +const buildUpcomingCalendarEventNode = (id: string): CalendarEventNode => ({ + id, + title: 'Upcoming Sync', + isCanceled: false, + startsAt: UPCOMING_STARTS_AT, + endsAt: UPCOMING_ENDS_AT, +}); + +const buildConnection = (nodes: TNode[]) => ({ + pageInfo: { hasNextPage: false, endCursor: null }, + edges: nodes.map((node) => ({ node })), +}); + +let upcomingCalendarEventNodes: CalendarEventNode[]; + describe('sweepUpcomingCalendarEventsHandler', () => { beforeEach(() => { vi.clearAllMocks(); - fetchUpcomingCalendarEventIdsMock.mockResolvedValue([]); - reconcileUpcomingCalendarEventBatchesMock.mockResolvedValue(BATCH_RESULT); + vi.stubGlobal('fetch', fetchMock); + vi.stubEnv('TWENTY_FUNCTIONS_URL', 'https://acme.functions.example.com'); + vi.stubEnv('TWENTY_APP_ACCESS_TOKEN', 'app-access-token'); + fetchMock.mockResolvedValue(new Response('{}', { status: 200 })); + upcomingCalendarEventNodes = []; + queryMock.mockImplementation(async (query: RecordsQuery) => { + if (query.calendarEvents !== undefined) { + const filter = query.calendarEvents.__args.filter; + + if (filter.id !== undefined) { + const requestedIds = filter.id.in; + + return { + calendarEvents: buildConnection( + upcomingCalendarEventNodes.filter((node) => + requestedIds.includes(node.id), + ), + ), + }; + } + + if (filter.startsAt !== undefined) { + const requestedStartsAtValues = filter.startsAt.in; + + return { + calendarEvents: buildConnection( + upcomingCalendarEventNodes.filter((node) => + requestedStartsAtValues.includes(node.startsAt), + ), + ), + }; + } + + return { + calendarEvents: buildConnection( + upcomingCalendarEventNodes.map(({ id }) => ({ id })), + ), + }; + } + + if (query.callRecordings !== undefined) { + return { callRecordings: buildConnection([]) }; + } + + throw new Error(`Unhandled query: ${JSON.stringify(query)}`); + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); }); it('is configured as a daily cron with a self-invokable timeout', () => { @@ -52,41 +125,68 @@ describe('sweepUpcomingCalendarEventsHandler', () => { }); it('reconciles every upcoming calendar event within the horizon', async () => { - fetchUpcomingCalendarEventIdsMock.mockResolvedValue([ - 'calendar-event-1', - 'calendar-event-2', - ]); + upcomingCalendarEventNodes = [ + buildUpcomingCalendarEventNode('calendar-event-1'), + buildUpcomingCalendarEventNode('calendar-event-2'), + ]; const result = await sweepUpcomingCalendarEventsHandler(); - expect(fetchUpcomingCalendarEventIdsMock).toHaveBeenCalledWith( - expect.anything(), - expect.any(Date), - ); - expect(reconcileUpcomingCalendarEventBatchesMock).toHaveBeenCalledWith( + expect(queryMock).toHaveBeenCalledWith( expect.objectContaining({ - calendarEventIds: ['calendar-event-1', 'calendar-event-2'], + calendarEvents: expect.objectContaining({ + __args: expect.objectContaining({ + filter: expect.objectContaining({ isCanceled: { eq: false } }), + }), + }), }), ); - expect(result).toEqual({ outcome: 'processed', ...BATCH_RESULT }); + expect(queryMock).toHaveBeenCalledWith( + expect.objectContaining({ + calendarEvents: expect.objectContaining({ + __args: expect.objectContaining({ + filter: { id: { in: ['calendar-event-1', 'calendar-event-2'] } }, + }), + }), + }), + ); + expect(result).toEqual({ + outcome: 'processed', + reconciledCalendarEventIds: ['calendar-event-1', 'calendar-event-2'], + failedCalendarEventIds: [], + remainingCalendarEventIds: [], + actionCounts: { + created: 0, + updated: 0, + canceled: 0, + skipped: 2, + failed: 0, + }, + continuationRequested: false, + }); }); it('short-circuits without running batches when nothing is upcoming', async () => { const result = await sweepUpcomingCalendarEventsHandler(); expect(result).toEqual({ outcome: 'nothing-to-reconcile' }); - expect(reconcileUpcomingCalendarEventBatchesMock).not.toHaveBeenCalled(); + expect(queryMock).toHaveBeenCalledTimes(1); }); it('passes a deadline that reserves time for the continuation request', async () => { - fetchUpcomingCalendarEventIdsMock.mockResolvedValue(['calendar-event-1']); + upcomingCalendarEventNodes = [ + buildUpcomingCalendarEventNode('calendar-event-1'), + ]; - await sweepUpcomingCalendarEventsHandler(); + const result = await sweepUpcomingCalendarEventsHandler(); - const { deadlineAtMs } = - reconcileUpcomingCalendarEventBatchesMock.mock.calls[0][0]; - - expect(deadlineAtMs).toBeLessThan(Date.now() + 900 * 1000); - expect(deadlineAtMs).toBeGreaterThan(Date.now() + 800 * 1000); + expect(result).toEqual( + expect.objectContaining({ + outcome: 'processed', + remainingCalendarEventIds: [], + continuationRequested: false, + }), + ); + expect(fetchMock).not.toHaveBeenCalled(); }); }); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/post-to-own-route.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/post-to-own-route.test.ts index c8e93de959..824117bd9d 100644 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/post-to-own-route.test.ts +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/post-to-own-route.test.ts @@ -1,29 +1,21 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { postToOwnRoute } from 'src/logic-functions/data/post-to-own-route.util'; -const postMock = vi.hoisted(() => vi.fn()); -const restApiClientMock = vi.hoisted(() => vi.fn()); -const resolveOwnRouteBaseUrlMock = vi.hoisted(() => vi.fn()); - -vi.mock('twenty-client-sdk/rest', () => ({ - RestApiClient: restApiClientMock, -})); - -vi.mock('src/logic-functions/data/resolve-own-route-base-url.util', () => ({ - resolveOwnRouteBaseUrl: resolveOwnRouteBaseUrlMock, -})); +const fetchMock = vi.fn(); describe('postToOwnRoute', () => { beforeEach(() => { vi.clearAllMocks(); - restApiClientMock.mockImplementation(function RestApiClient() { - return { post: postMock }; - }); - postMock.mockResolvedValue({}); - resolveOwnRouteBaseUrlMock.mockReturnValue( - 'https://acme.functions.example.com', - ); + vi.stubGlobal('fetch', fetchMock); + vi.stubEnv('TWENTY_FUNCTIONS_URL', 'https://acme.functions.example.com'); + vi.stubEnv('TWENTY_APP_ACCESS_TOKEN', 'app-access-token'); + fetchMock.mockResolvedValue(new Response('{}', { status: 200 })); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); }); it('posts to the functions origin when resolved', async () => { @@ -33,20 +25,20 @@ describe('postToOwnRoute', () => { }); expect(result).toBe(true); - expect(restApiClientMock).toHaveBeenCalledWith({ - baseUrl: 'https://acme.functions.example.com', - }); - expect(postMock).toHaveBeenCalledWith( - '/call-recorder/some-route', - { key: 'value' }, - { signal: expect.any(AbortSignal) }, + expect(fetchMock).toHaveBeenCalledTimes(1); + const [requestUrl, requestInit] = fetchMock.mock.calls[0]; + expect(requestUrl).toBe( + 'https://acme.functions.example.com/call-recorder/some-route', ); + expect(requestInit.method).toBe('POST'); + expect(requestInit.body).toBe(JSON.stringify({ key: 'value' })); + expect(requestInit.signal).toBeInstanceOf(AbortSignal); }); it('treats timeout as a successfully flushed request', async () => { const timeoutError = new Error('Timed out'); timeoutError.name = 'TimeoutError'; - postMock.mockRejectedValue(timeoutError); + fetchMock.mockRejectedValue(timeoutError); await expect( postToOwnRoute({ path: '/call-recorder/some-route', body: {} }), @@ -54,7 +46,7 @@ describe('postToOwnRoute', () => { }); it('returns false when the request fails before flushing', async () => { - postMock.mockRejectedValue(new Error('Network failed')); + fetchMock.mockRejectedValue(new Error('Network failed')); await expect( postToOwnRoute({ path: '/call-recorder/some-route', body: {} }), @@ -62,14 +54,11 @@ describe('postToOwnRoute', () => { }); it('returns false when the route base url cannot be resolved', async () => { - resolveOwnRouteBaseUrlMock.mockImplementation(() => { - throw new Error('Unable to resolve target'); - }); + vi.stubEnv('TWENTY_FUNCTIONS_URL', ''); await expect( postToOwnRoute({ path: '/call-recorder/some-route', body: {} }), ).resolves.toBe(false); - expect(restApiClientMock).not.toHaveBeenCalled(); - expect(postMock).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); }); }); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/request-call-recording-summaries-backfill.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/request-call-recording-summaries-backfill.test.ts index b096af371d..9f12afc567 100644 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/request-call-recording-summaries-backfill.test.ts +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/request-call-recording-summaries-backfill.test.ts @@ -1,32 +1,39 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH } from 'src/constants/generate-call-recording-summaries-route-path'; import { requestCallRecordingSummariesBackfill } from 'src/logic-functions/data/request-call-recording-summaries-backfill.util'; -const postToOwnRouteMock = vi.hoisted(() => vi.fn()); - -vi.mock('src/logic-functions/data/post-to-own-route.util', () => ({ - postToOwnRoute: postToOwnRouteMock, -})); +const fetchMock = vi.fn(); describe('requestCallRecordingSummariesBackfill', () => { beforeEach(() => { vi.clearAllMocks(); - postToOwnRouteMock.mockResolvedValue(true); + vi.stubGlobal('fetch', fetchMock); + vi.stubEnv('TWENTY_FUNCTIONS_URL', 'https://acme.functions.example.com'); + vi.stubEnv('TWENTY_APP_ACCESS_TOKEN', 'app-access-token'); + fetchMock.mockResolvedValue(new Response('{}', { status: 200 })); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); }); it('posts an empty body to the summary generation route', async () => { const result = await requestCallRecordingSummariesBackfill(); expect(result).toBe(true); - expect(postToOwnRouteMock).toHaveBeenCalledWith({ - path: GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH, - body: {}, - }); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [requestUrl, requestInit] = fetchMock.mock.calls[0]; + expect(requestUrl).toBe( + `https://acme.functions.example.com${GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH}`, + ); + expect(requestInit.method).toBe('POST'); + expect(requestInit.body).toBe(JSON.stringify({})); }); it('reports a kickoff that failed to fire', async () => { - postToOwnRouteMock.mockResolvedValue(false); + fetchMock.mockRejectedValue(new Error('Network failed')); await expect(requestCallRecordingSummariesBackfill()).resolves.toBe(false); }); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/request-upcoming-calendar-events-reconciliation.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/request-upcoming-calendar-events-reconciliation.test.ts index 78da918dfe..04823969b8 100644 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/request-upcoming-calendar-events-reconciliation.test.ts +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/request-upcoming-calendar-events-reconciliation.test.ts @@ -1,28 +1,35 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { RECONCILE_UPCOMING_CALENDAR_EVENTS_ROUTE_PATH } from 'src/constants/reconcile-upcoming-calendar-events-route-path'; import { requestUpcomingCalendarEventsReconciliation } from 'src/logic-functions/data/request-upcoming-calendar-events-reconciliation.util'; -const postToOwnRouteMock = vi.hoisted(() => vi.fn()); - -vi.mock('src/logic-functions/data/post-to-own-route.util', () => ({ - postToOwnRoute: postToOwnRouteMock, -})); +const fetchMock = vi.fn(); describe('requestUpcomingCalendarEventsReconciliation', () => { beforeEach(() => { vi.clearAllMocks(); - postToOwnRouteMock.mockResolvedValue(true); + vi.stubGlobal('fetch', fetchMock); + vi.stubEnv('TWENTY_FUNCTIONS_URL', 'https://acme.functions.example.com'); + vi.stubEnv('TWENTY_APP_ACCESS_TOKEN', 'app-access-token'); + fetchMock.mockResolvedValue(new Response('{}', { status: 200 })); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); }); it('posts an empty body to start a full sweep', async () => { const result = await requestUpcomingCalendarEventsReconciliation(); expect(result).toBe(true); - expect(postToOwnRouteMock).toHaveBeenCalledWith({ - path: RECONCILE_UPCOMING_CALENDAR_EVENTS_ROUTE_PATH, - body: {}, - }); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [requestUrl, requestInit] = fetchMock.mock.calls[0]; + expect(requestUrl).toBe( + `https://acme.functions.example.com${RECONCILE_UPCOMING_CALENDAR_EVENTS_ROUTE_PATH}`, + ); + expect(requestInit.method).toBe('POST'); + expect(requestInit.body).toBe(JSON.stringify({})); }); it('posts the remaining calendar event ids to continue a sweep', async () => { @@ -30,14 +37,19 @@ describe('requestUpcomingCalendarEventsReconciliation', () => { calendarEventIds: ['calendar-event-1', 'calendar-event-2'], }); - expect(postToOwnRouteMock).toHaveBeenCalledWith({ - path: RECONCILE_UPCOMING_CALENDAR_EVENTS_ROUTE_PATH, - body: { calendarEventIds: ['calendar-event-1', 'calendar-event-2'] }, - }); + const [requestUrl, requestInit] = fetchMock.mock.calls[0]; + expect(requestUrl).toBe( + `https://acme.functions.example.com${RECONCILE_UPCOMING_CALENDAR_EVENTS_ROUTE_PATH}`, + ); + expect(requestInit.body).toBe( + JSON.stringify({ + calendarEventIds: ['calendar-event-1', 'calendar-event-2'], + }), + ); }); it('reports a kickoff that failed to fire', async () => { - postToOwnRouteMock.mockResolvedValue(false); + fetchMock.mockRejectedValue(new Error('Network failed')); await expect(requestUpcomingCalendarEventsReconciliation()).resolves.toBe( false, diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/build-recall-bot-automatic-video-output.util.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/build-recall-bot-automatic-video-output.util.test.ts index 1c99c77799..5fdb32923f 100644 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/build-recall-bot-automatic-video-output.util.test.ts +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/build-recall-bot-automatic-video-output.util.test.ts @@ -1,84 +1,142 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { buildRecallBotAutomaticVideoOutput } from 'src/logic-functions/domain/build-recall-bot-automatic-video-output.util'; -const isEnabledMock = vi.hoisted(() => vi.fn()); -const getBackgroundMock = vi.hoisted(() => vi.fn()); -const getWorkspaceLogoMock = vi.hoisted(() => vi.fn()); -const buildBotImageMock = vi.hoisted(() => vi.fn()); +const sharpMock = vi.hoisted(() => vi.fn()); +const metadataQueryMock = vi.hoisted(() => vi.fn()); -vi.mock( - 'src/logic-functions/constants/is-workspace-logo-bot-image-enabled', - () => ({ isWorkspaceLogoBotImageEnabled: isEnabledMock }), -); +vi.mock('sharp', () => ({ default: sharpMock })); -vi.mock('src/logic-functions/constants/get-bot-image-background', () => ({ - getBotImageBackground: getBackgroundMock, +vi.mock('twenty-client-sdk/metadata', () => ({ + MetadataApiClient: class { + query = metadataQueryMock; + }, })); -vi.mock('src/logic-functions/data/get-workspace-logo.util', () => ({ - getWorkspaceLogo: getWorkspaceLogoMock, -})); +const RECORDING_JPEG_BASE64 = Buffer.from('RECORDING_JPEG').toString('base64'); +const PLAIN_JPEG_BASE64 = Buffer.from('PLAIN_JPEG').toString('base64'); -vi.mock('src/logic-functions/domain/build-bot-image.util', () => ({ - buildBotImage: buildBotImageMock, -})); +const fetchMock = vi.fn(); + +let badgeBuildFails = false; +let jpegComposeFails = false; + +type FakeSharpPipeline = { + resize: () => FakeSharpPipeline; + png: () => FakeSharpPipeline; + jpeg: () => FakeSharpPipeline; + composite: (composites: unknown[]) => FakeSharpPipeline; + toBuffer: () => Promise; +}; + +const buildSharpPipeline = ( + produce: (composites: unknown[]) => Buffer, +): FakeSharpPipeline => { + let appliedComposites: unknown[] = []; + const pipeline: FakeSharpPipeline = { + resize: () => pipeline, + png: () => pipeline, + jpeg: () => pipeline, + composite: (composites) => { + appliedComposites = composites; + return pipeline; + }, + toBuffer: async () => produce(appliedComposites), + }; + + return pipeline; +}; describe('buildRecallBotAutomaticVideoOutput', () => { beforeEach(() => { vi.clearAllMocks(); - isEnabledMock.mockReturnValue(true); - getBackgroundMock.mockReturnValue('#ffffff'); - getWorkspaceLogoMock.mockResolvedValue(Buffer.from('logo')); - buildBotImageMock.mockImplementation(({ withRecordingStatusBadge }) => - Promise.resolve( - withRecordingStatusBadge ? 'RECORDING_JPEG' : 'PLAIN_JPEG', - ), + vi.stubGlobal('fetch', fetchMock); + vi.stubEnv('CALL_RECORDER_USE_WORKSPACE_LOGO', 'true'); + vi.stubEnv('CALL_RECORDER_BOT_IMAGE_BACKGROUND', '#ffffff'); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + badgeBuildFails = false; + jpegComposeFails = false; + metadataQueryMock.mockResolvedValue({ + currentWorkspace: { + logo: 'https://files.example.com/workspace-logo.png', + }, + }); + fetchMock.mockResolvedValue( + new Response(Buffer.from('logo'), { status: 200 }), ); + sharpMock.mockImplementation((input?: unknown) => { + if (Buffer.isBuffer(input) && input.toString('utf8').startsWith(' { + if (badgeBuildFails) { + throw new Error('badge rendering failed'); + } + + return Buffer.from('BADGE_PNG'); + }); + } + + if (Buffer.isBuffer(input)) { + return buildSharpPipeline(() => Buffer.from('LOGO_PNG')); + } + + return buildSharpPipeline((composites) => { + if (jpegComposeFails) { + throw new Error('jpeg composition failed'); + } + + return Buffer.from( + composites.length > 1 ? 'RECORDING_JPEG' : 'PLAIN_JPEG', + ); + }); + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + vi.restoreAllMocks(); }); it('badges the recording state and leaves the not-recording state plain', async () => { const result = await buildRecallBotAutomaticVideoOutput(); expect(result).toEqual({ - in_call_recording: { kind: 'jpeg', b64_data: 'RECORDING_JPEG' }, - in_call_not_recording: { kind: 'jpeg', b64_data: 'PLAIN_JPEG' }, + in_call_recording: { kind: 'jpeg', b64_data: RECORDING_JPEG_BASE64 }, + in_call_not_recording: { kind: 'jpeg', b64_data: PLAIN_JPEG_BASE64 }, }); }); it('falls back to the plain image when the badged variant fails', async () => { - buildBotImageMock.mockImplementation(({ withRecordingStatusBadge }) => - Promise.resolve(withRecordingStatusBadge ? undefined : 'PLAIN_JPEG'), - ); + badgeBuildFails = true; const result = await buildRecallBotAutomaticVideoOutput(); expect(result).toEqual({ - in_call_recording: { kind: 'jpeg', b64_data: 'PLAIN_JPEG' }, - in_call_not_recording: { kind: 'jpeg', b64_data: 'PLAIN_JPEG' }, + in_call_recording: { kind: 'jpeg', b64_data: PLAIN_JPEG_BASE64 }, + in_call_not_recording: { kind: 'jpeg', b64_data: PLAIN_JPEG_BASE64 }, }); }); it('returns undefined when the feature is disabled', async () => { - isEnabledMock.mockReturnValue(false); + vi.stubEnv('CALL_RECORDER_USE_WORKSPACE_LOGO', 'false'); const result = await buildRecallBotAutomaticVideoOutput(); expect(result).toBeUndefined(); - expect(getWorkspaceLogoMock).not.toHaveBeenCalled(); + expect(metadataQueryMock).not.toHaveBeenCalled(); }); it('returns undefined when no workspace logo is set', async () => { - getWorkspaceLogoMock.mockResolvedValue(undefined); + metadataQueryMock.mockResolvedValue({ currentWorkspace: { logo: null } }); const result = await buildRecallBotAutomaticVideoOutput(); expect(result).toBeUndefined(); - expect(buildBotImageMock).not.toHaveBeenCalled(); + expect(sharpMock).not.toHaveBeenCalled(); }); it('returns undefined when the image cannot be built', async () => { - buildBotImageMock.mockResolvedValue(undefined); + jpegComposeFails = true; const result = await buildRecallBotAutomaticVideoOutput(); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/complete-and-charge-call-recording.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/complete-and-charge-call-recording.test.ts index b44f4604c9..32529c07bd 100644 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/complete-and-charge-call-recording.test.ts +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/complete-and-charge-call-recording.test.ts @@ -1,61 +1,69 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -const completeCallRecordingImportMock = vi.hoisted(() => vi.fn()); -const chargeCompletedCallRecordingMock = vi.hoisted(() => vi.fn()); +const chargeCreditsMock = vi.hoisted(() => vi.fn()); -vi.mock( - 'src/logic-functions/data/complete-call-recording-import.util', - () => ({ - completeCallRecordingImport: completeCallRecordingImportMock, - }), -); - -vi.mock( - 'src/logic-functions/flows/charge-completed-call-recording.util', - () => ({ - chargeCompletedCallRecording: chargeCompletedCallRecordingMock, - }), -); +vi.mock('twenty-sdk/billing', () => ({ + chargeCredits: chargeCreditsMock, +})); import { completeAndChargeCallRecording } from 'src/logic-functions/flows/complete-and-charge-call-recording.util'; describe('completeAndChargeCallRecording', () => { beforeEach(() => { vi.clearAllMocks(); + chargeCreditsMock.mockResolvedValue(undefined); }); it('charges exactly once when this path wins the completion claim', async () => { - completeCallRecordingImportMock.mockResolvedValue(true); + const mutationMock = vi.fn(async () => ({ + updateCallRecordings: [{ id: 'call-recording-1' }], + })); - const claimed = await completeAndChargeCallRecording({} as never, { - id: 'call-recording-1', - startedAt: '2026-06-10T12:00:00.000Z', - endedAt: '2026-06-10T13:00:00.000Z', - }); + const claimed = await completeAndChargeCallRecording( + { mutation: mutationMock } as never, + { + id: 'call-recording-1', + startedAt: '2026-06-10T12:00:00.000Z', + endedAt: '2026-06-10T13:00:00.000Z', + }, + ); expect(claimed).toBe(true); - expect(completeCallRecordingImportMock).toHaveBeenCalledWith( - {}, - { id: 'call-recording-1' }, - ); - expect(chargeCompletedCallRecordingMock).toHaveBeenCalledTimes(1); - expect(chargeCompletedCallRecordingMock).toHaveBeenCalledWith({ - callRecordingId: 'call-recording-1', - startedAt: '2026-06-10T12:00:00.000Z', - endedAt: '2026-06-10T13:00:00.000Z', + expect(mutationMock).toHaveBeenCalledTimes(1); + expect(mutationMock).toHaveBeenCalledWith({ + updateCallRecordings: { + __args: { + filter: { + id: { eq: 'call-recording-1' }, + status: { in: ['SCHEDULED', 'JOINING', 'RECORDING', 'PROCESSING'] }, + }, + data: { status: 'COMPLETED' }, + }, + id: true, + }, + }); + expect(chargeCreditsMock).toHaveBeenCalledTimes(1); + expect(chargeCreditsMock).toHaveBeenCalledWith({ + creditsUsedMicro: 1_000_000, + quantity: 60, + operationType: 'CALL_RECORDING', + resourceContext: 'recall', }); }); it('does not charge when another path already completed the recording', async () => { - completeCallRecordingImportMock.mockResolvedValue(false); + const mutationMock = vi.fn(async () => ({ updateCallRecordings: [] })); - const claimed = await completeAndChargeCallRecording({} as never, { - id: 'call-recording-1', - startedAt: '2026-06-10T12:00:00.000Z', - endedAt: '2026-06-10T13:00:00.000Z', - }); + const claimed = await completeAndChargeCallRecording( + { mutation: mutationMock } as never, + { + id: 'call-recording-1', + startedAt: '2026-06-10T12:00:00.000Z', + endedAt: '2026-06-10T13:00:00.000Z', + }, + ); expect(claimed).toBe(false); - expect(chargeCompletedCallRecordingMock).not.toHaveBeenCalled(); + expect(chargeCreditsMock).not.toHaveBeenCalled(); }); }); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/converge-diverged-call-recordings.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/converge-diverged-call-recordings.test.ts index 80019607a8..608f576550 100644 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/converge-diverged-call-recordings.test.ts +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/converge-diverged-call-recordings.test.ts @@ -1,47 +1,176 @@ +import { type ClientRequest, type IncomingMessage } from 'node:http'; +import { PassThrough, Readable } from 'node:stream'; + import { type CoreApiClient } from 'twenty-client-sdk/core'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { convergeDivergedCallRecordings } from 'src/logic-functions/flows/converge-diverged-call-recordings.util'; -const getRecallBotMock = vi.hoisted(() => vi.fn()); -const listRecallTranscriptsMock = vi.hoisted(() => vi.fn()); -const createAsyncRecallTranscriptMock = vi.hoisted(() => vi.fn()); -const downloadTranscriptMock = vi.hoisted(() => vi.fn()); -const importCallRecordingMediaMock = vi.hoisted(() => vi.fn()); -const chargeCompletedCallRecordingMock = vi.hoisted(() => vi.fn()); +const chargeCreditsMock = vi.hoisted(() => vi.fn()); +const metadataMutationMock = vi.hoisted(() => vi.fn()); +const requestOverHttpsMock = vi.hoisted(() => vi.fn()); -vi.mock('src/logic-functions/recall-api/get-recall-bot.util', () => ({ - getRecallBot: getRecallBotMock, +vi.mock('twenty-sdk/billing', () => ({ + chargeCredits: chargeCreditsMock, })); -vi.mock('src/logic-functions/recall-api/list-recall-transcripts.util', () => ({ - listRecallTranscripts: listRecallTranscriptsMock, +vi.mock('twenty-client-sdk/metadata', () => ({ + MetadataApiClient: class { + mutation = metadataMutationMock; + }, })); -vi.mock( - 'src/logic-functions/recall-api/create-async-recall-transcript.util', - () => ({ - createAsyncRecallTranscript: createAsyncRecallTranscriptMock, - }), -); +vi.mock('node:https', async () => { + const actualHttps = + await vi.importActual('node:https'); -vi.mock('src/logic-functions/flows/download-transcript.util', () => ({ - downloadTranscript: downloadTranscriptMock, -})); - -vi.mock('src/logic-functions/flows/import-call-recording-media.util', () => ({ - importCallRecordingMedia: importCallRecordingMediaMock, -})); - -vi.mock( - 'src/logic-functions/flows/charge-completed-call-recording.util', - () => ({ - chargeCompletedCallRecording: chargeCompletedCallRecordingMock, - }), -); + return { ...actualHttps, request: requestOverHttpsMock }; +}); const NOW = new Date('2026-06-10T12:00:00.000Z'); +const RECALL_BASE_URL = 'https://us-west-2.recall.ai/api/v1'; +const RECALL_BOT_URL = `${RECALL_BASE_URL}/bot/recall-bot-1/`; +const RECALL_TRANSCRIPT_LIST_URL = `${RECALL_BASE_URL}/transcript/?recording_id=recall-recording-1`; +const RECALL_CREATE_TRANSCRIPT_URL = `${RECALL_BASE_URL}/recording/recall-recording-1/create_transcript/`; +const RECALL_TRANSCRIPT_DETAILS_URL = `${RECALL_BASE_URL}/transcript/recall-transcript-1/`; +const RECALL_RECORDING_URL = `${RECALL_BASE_URL}/recording/recall-recording-1/`; +const TRANSCRIPT_DOWNLOAD_URL = 'https://media.example.com/transcript.json'; +const VIDEO_DOWNLOAD_URL = 'https://media.example.com/video.mp4'; +const AUDIO_DOWNLOAD_URL = 'https://media.example.com/audio.mp3'; + +const RECORDING_WITH_MEDIA = { + id: 'recall-recording-1', + media_shortcuts: { + video_mixed: { download_url: VIDEO_DOWNLOAD_URL }, + audio_mixed: { download_url: AUDIO_DOWNLOAD_URL }, + }, +}; + +// 2026-06-09T13:02:00.000Z -> 2026-06-09T14:00:00.000Z at 1_000_000 micro-credits per hour. +const CHARGE_FOR_58_RECORDED_MINUTES = { + creditsUsedMicro: 966_667, + quantity: 58, + operationType: 'CALL_RECORDING', + resourceContext: 'recall', +}; + +const fetchMock = vi.fn(); +const fetchResponsesByRequest = new Map unknown>(); + +const setFetchResponse = ( + method: string, + url: string, + respond: () => unknown, +) => { + fetchResponsesByRequest.set(`${method} ${url}`, respond); +}; + +const setFetchJsonResponse = ( + method: string, + url: string, + body: unknown, + status = 200, +) => { + setFetchResponse( + method, + url, + () => new Response(JSON.stringify(body), { status }), + ); +}; + +const setRecallBotResponse = (bot: Record) => { + setFetchJsonResponse('GET', RECALL_BOT_URL, bot); +}; + +const fetchedRequests = (): string[] => + fetchMock.mock.calls.map( + ([requestUrl, requestInit]) => + `${requestInit?.method ?? 'GET'} ${requestUrl}`, + ); + +const buildMediaDownloadResponse = (contentLengthBytes: number) => ({ + ok: true, + status: 200, + headers: { + get: (name: string) => + name.toLowerCase() === 'content-length' + ? String(contentLengthBytes) + : null, + }, + body: new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(contentLengthBytes)); + controller.close(); + }, + }), +}); + +const buildOversizedMediaDownloadResponse = () => ({ + ok: true, + status: 200, + headers: { + get: (name: string) => + name.toLowerCase() === 'content-length' + ? String(500 * 1024 * 1024 + 1) + : null, + }, + body: { cancel: async () => {} }, +}); + +type DirectUploadMutationRequest = + | { createFileUpload: { __args: { filename: string } } } + | { completeFileUpload: { __args: { fileId: string } } }; + +const FINAL_FILE_ID_BY_FILENAME: Record = { + 'video.mp4': 'file-video-1', + 'audio.mp3': 'file-audio-1', +}; + +const stubDirectUpload = () => { + metadataMutationMock.mockReset(); + metadataMutationMock.mockImplementation( + async (mutationRequest: DirectUploadMutationRequest) => { + if ('createFileUpload' in mutationRequest) { + const { filename } = mutationRequest.createFileUpload.__args; + + return { + createFileUpload: { + fileId: filename, + uploadUrl: `https://storage.example.com/${filename}`, + contentType: 'application/octet-stream', + }, + }; + } + + return { + completeFileUpload: { + id: FINAL_FILE_ID_BY_FILENAME[ + mutationRequest.completeFileUpload.__args.fileId + ], + }, + }; + }, + ); +}; + +const stubUploadRequests = () => { + requestOverHttpsMock.mockReset(); + requestOverHttpsMock.mockImplementation(() => { + const uploadRequest = new PassThrough(); + + uploadRequest.resume(); + uploadRequest.on('finish', () => { + const uploadResponse = Readable.from([]) as IncomingMessage; + + uploadResponse.statusCode = 200; + uploadRequest.emit('response', uploadResponse); + }); + + return uploadRequest as unknown as ClientRequest; + }); +}; + type CallRecordingNode = Record; class FakeCoreApiClient { @@ -102,42 +231,69 @@ const buildStuckRecordingNode = ( describe('convergeDivergedCallRecordings', () => { beforeEach(() => { vi.spyOn(console, 'warn').mockImplementation(() => {}); - getRecallBotMock.mockReset(); - listRecallTranscriptsMock.mockReset(); - listRecallTranscriptsMock.mockResolvedValue({ - ok: true, - transcripts: [], + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.stubGlobal('fetch', fetchMock); + vi.stubEnv('RECALL_API_KEY', 'recall-api-key'); + vi.stubEnv('RECALL_REGION', 'us-west-2'); + fetchMock.mockReset(); + fetchMock.mockImplementation( + async (requestUrl: string, requestInit?: { method?: string }) => { + const respond = fetchResponsesByRequest.get( + `${requestInit?.method ?? 'GET'} ${requestUrl}`, + ); + + if (respond === undefined) { + throw new Error( + `Unhandled fetch in test: ${requestInit?.method ?? 'GET'} ${requestUrl}`, + ); + } + + return respond(); + }, + ); + fetchResponsesByRequest.clear(); + setFetchJsonResponse('GET', RECALL_TRANSCRIPT_LIST_URL, { + next: null, + results: [], }); - createAsyncRecallTranscriptMock.mockReset(); - createAsyncRecallTranscriptMock.mockResolvedValue({ - ok: true, - transcriptId: 'recall-transcript-1', + setFetchJsonResponse( + 'POST', + RECALL_CREATE_TRANSCRIPT_URL, + { id: 'recall-transcript-1' }, + 201, + ); + setFetchJsonResponse('GET', RECALL_TRANSCRIPT_DETAILS_URL, { + status: { code: 'processing' }, }); - downloadTranscriptMock.mockReset(); - downloadTranscriptMock.mockResolvedValue({ outcome: 'pending' }); - importCallRecordingMediaMock.mockReset(); - importCallRecordingMediaMock.mockResolvedValue({}); - chargeCompletedCallRecordingMock.mockReset(); - chargeCompletedCallRecordingMock.mockResolvedValue(undefined); + setFetchJsonResponse('GET', RECALL_RECORDING_URL, { + id: 'recall-recording-1', + }); + stubDirectUpload(); + stubUploadRequests(); + chargeCreditsMock.mockReset(); + chargeCreditsMock.mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + vi.restoreAllMocks(); }); it('heals a stuck RECORDING record from the Recall bot state', async () => { - getRecallBotMock.mockResolvedValue({ - ok: true, - bot: { - statusChanges: [ - { code: 'in_call_recording', createdAt: '2026-06-09T13:02:30.000Z' }, - { code: 'call_ended', createdAt: '2026-06-09T14:00:30.000Z' }, - { code: 'done', createdAt: '2026-06-09T14:05:00.000Z' }, - ], - recordings: [ - { - id: 'recall-recording-1', - startedAt: '2026-06-09T13:02:00.000Z', - completedAt: '2026-06-09T14:00:00.000Z', - }, - ], - }, + setRecallBotResponse({ + status_changes: [ + { code: 'in_call_recording', created_at: '2026-06-09T13:02:30.000Z' }, + { code: 'call_ended', created_at: '2026-06-09T14:00:30.000Z' }, + { code: 'done', created_at: '2026-06-09T14:05:00.000Z' }, + ], + recordings: [ + { + id: 'recall-recording-1', + started_at: '2026-06-09T13:02:00.000Z', + completed_at: '2026-06-09T14:00:00.000Z', + }, + ], }); const client = buildClient([buildStuckRecordingNode()]); @@ -146,21 +302,14 @@ describe('convergeDivergedCallRecordings', () => { now: NOW, }); - expect(getRecallBotMock).toHaveBeenCalledWith({ - externalBotId: 'recall-bot-1', - }); - expect(importCallRecordingMediaMock).toHaveBeenCalledWith({ - callRecordingId: 'call-recording-1', - externalRecordingId: 'recall-recording-1', - hasAudio: false, - hasVideo: false, - }); - expect(listRecallTranscriptsMock).toHaveBeenCalledWith({ - externalRecordingId: 'recall-recording-1', - }); - expect(createAsyncRecallTranscriptMock).toHaveBeenCalledWith({ - externalRecordingId: 'recall-recording-1', + const [botRequestUrl, botRequestInit] = fetchMock.mock.calls[0]; + expect(botRequestUrl).toBe(RECALL_BOT_URL); + expect(botRequestInit.headers).toMatchObject({ + Authorization: 'Token recall-api-key', }); + expect(fetchedRequests()).toContain(`GET ${RECALL_RECORDING_URL}`); + expect(fetchedRequests()).toContain(`GET ${RECALL_TRANSCRIPT_LIST_URL}`); + expect(fetchedRequests()).toContain(`POST ${RECALL_CREATE_TRANSCRIPT_URL}`); expect(client.mutations).toEqual([ expect.objectContaining({ id: 'call-recording-1', @@ -172,7 +321,7 @@ describe('convergeDivergedCallRecordings', () => { }), }), ]); - expect(chargeCompletedCallRecordingMock).not.toHaveBeenCalled(); + expect(chargeCreditsMock).not.toHaveBeenCalled(); expect(result).toEqual({ candidateCount: 1, updatedCallRecordingIds: ['call-recording-1'], @@ -184,14 +333,11 @@ describe('convergeDivergedCallRecordings', () => { }); it('marks FAILED when Recall is done but has no recording artifact path', async () => { - getRecallBotMock.mockResolvedValue({ - ok: true, - bot: { - statusChanges: [ - { code: 'done', createdAt: '2026-06-09T14:05:00.000Z' }, - ], - recordings: [], - }, + setRecallBotResponse({ + status_changes: [ + { code: 'done', created_at: '2026-06-09T14:05:00.000Z' }, + ], + recordings: [], }); const client = buildClient([buildStuckRecordingNode()]); @@ -200,8 +346,10 @@ describe('convergeDivergedCallRecordings', () => { now: NOW, }); - expect(listRecallTranscriptsMock).not.toHaveBeenCalled(); - expect(importCallRecordingMediaMock).not.toHaveBeenCalled(); + expect(fetchedRequests()).not.toContain( + `GET ${RECALL_TRANSCRIPT_LIST_URL}`, + ); + expect(fetchedRequests()).not.toContain(`GET ${RECALL_RECORDING_URL}`); expect(client.mutations).toEqual([ { id: 'call-recording-1', @@ -215,25 +363,25 @@ describe('convergeDivergedCallRecordings', () => { }); it('completes and charges when convergence lands the last artifact', async () => { - getRecallBotMock.mockResolvedValue({ - ok: true, - bot: { - statusChanges: [ - { code: 'done', createdAt: '2026-06-09T14:05:00.000Z' }, - ], - recordings: [ - { - id: 'recall-recording-1', - startedAt: '2026-06-09T13:02:00.000Z', - completedAt: '2026-06-09T14:00:00.000Z', - }, - ], - }, - }); - importCallRecordingMediaMock.mockResolvedValue({ - audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }], - video: [{ fileId: 'file-video-1', label: 'video.mp4' }], + setRecallBotResponse({ + status_changes: [ + { code: 'done', created_at: '2026-06-09T14:05:00.000Z' }, + ], + recordings: [ + { + id: 'recall-recording-1', + started_at: '2026-06-09T13:02:00.000Z', + completed_at: '2026-06-09T14:00:00.000Z', + }, + ], }); + setFetchJsonResponse('GET', RECALL_RECORDING_URL, RECORDING_WITH_MEDIA); + setFetchResponse('GET', VIDEO_DOWNLOAD_URL, () => + buildMediaDownloadResponse(8), + ); + setFetchResponse('GET', AUDIO_DOWNLOAD_URL, () => + buildMediaDownloadResponse(8), + ); const client = buildClient([ buildStuckRecordingNode({ status: 'PROCESSING', @@ -249,8 +397,12 @@ describe('convergeDivergedCallRecordings', () => { now: NOW, }); - expect(createAsyncRecallTranscriptMock).not.toHaveBeenCalled(); - expect(listRecallTranscriptsMock).not.toHaveBeenCalled(); + expect(fetchedRequests()).not.toContain( + `POST ${RECALL_CREATE_TRANSCRIPT_URL}`, + ); + expect(fetchedRequests()).not.toContain( + `GET ${RECALL_TRANSCRIPT_LIST_URL}`, + ); expect(client.mutations).toEqual([ { id: 'call-recording-1', @@ -264,11 +416,9 @@ describe('convergeDivergedCallRecordings', () => { data: { status: 'COMPLETED' }, }, ]); - expect(chargeCompletedCallRecordingMock).toHaveBeenCalledWith({ - callRecordingId: 'call-recording-1', - startedAt: '2026-06-09T13:02:00.000Z', - endedAt: '2026-06-09T14:00:00.000Z', - }); + expect(chargeCreditsMock).toHaveBeenCalledWith( + CHARGE_FOR_58_RECORDED_MINUTES, + ); expect(result).toEqual({ candidateCount: 1, updatedCallRecordingIds: ['call-recording-1'], @@ -280,25 +430,25 @@ describe('convergeDivergedCallRecordings', () => { }); it('completes and charges when the missing video is marked too large', async () => { - getRecallBotMock.mockResolvedValue({ - ok: true, - bot: { - statusChanges: [ - { code: 'done', createdAt: '2026-06-09T14:05:00.000Z' }, - ], - recordings: [ - { - id: 'recall-recording-1', - startedAt: '2026-06-09T13:02:00.000Z', - completedAt: '2026-06-09T14:00:00.000Z', - }, - ], - }, - }); - importCallRecordingMediaMock.mockResolvedValue({ - audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }], - callRecorderFailureReason: 'video_file_too_large', + setRecallBotResponse({ + status_changes: [ + { code: 'done', created_at: '2026-06-09T14:05:00.000Z' }, + ], + recordings: [ + { + id: 'recall-recording-1', + started_at: '2026-06-09T13:02:00.000Z', + completed_at: '2026-06-09T14:00:00.000Z', + }, + ], }); + setFetchJsonResponse('GET', RECALL_RECORDING_URL, RECORDING_WITH_MEDIA); + setFetchResponse('GET', VIDEO_DOWNLOAD_URL, () => + buildOversizedMediaDownloadResponse(), + ); + setFetchResponse('GET', AUDIO_DOWNLOAD_URL, () => + buildMediaDownloadResponse(8), + ); const client = buildClient([ buildStuckRecordingNode({ status: 'PROCESSING', @@ -327,28 +477,29 @@ describe('convergeDivergedCallRecordings', () => { data: { status: 'COMPLETED' }, }, ]); - expect(chargeCompletedCallRecordingMock).toHaveBeenCalledWith({ - callRecordingId: 'call-recording-1', - startedAt: '2026-06-09T13:02:00.000Z', - endedAt: '2026-06-09T14:00:00.000Z', - }); + expect(chargeCreditsMock).toHaveBeenCalledWith( + CHARGE_FOR_58_RECORDED_MINUTES, + ); expect(result.updatedCallRecordingIds).toEqual(['call-recording-1']); }); it('completes from a persisted size marker once the transcript lands', async () => { - getRecallBotMock.mockResolvedValue({ - ok: true, - bot: { - statusChanges: [ - { code: 'done', createdAt: '2026-06-09T14:05:00.000Z' }, - ], - recordings: [ - { - id: 'recall-recording-1', - startedAt: '2026-06-09T13:02:00.000Z', - completedAt: '2026-06-09T14:00:00.000Z', - }, - ], + setRecallBotResponse({ + status_changes: [ + { code: 'done', created_at: '2026-06-09T14:05:00.000Z' }, + ], + recordings: [ + { + id: 'recall-recording-1', + started_at: '2026-06-09T13:02:00.000Z', + completed_at: '2026-06-09T14:00:00.000Z', + }, + ], + }); + setFetchJsonResponse('GET', RECALL_RECORDING_URL, { + id: 'recall-recording-1', + media_shortcuts: { + audio_mixed: { download_url: AUDIO_DOWNLOAD_URL }, }, }); const client = buildClient([ @@ -368,46 +519,40 @@ describe('convergeDivergedCallRecordings', () => { now: NOW, }); - expect(importCallRecordingMediaMock).toHaveBeenCalledWith({ - callRecordingId: 'call-recording-1', - externalRecordingId: 'recall-recording-1', - hasAudio: true, - hasVideo: false, - }); + expect(fetchedRequests()).toContain(`GET ${RECALL_RECORDING_URL}`); + expect(fetchedRequests()).not.toContain(`GET ${AUDIO_DOWNLOAD_URL}`); expect(client.mutations).toEqual([ { id: 'call-recording-1', data: { status: 'COMPLETED' }, }, ]); - expect(chargeCompletedCallRecordingMock).toHaveBeenCalledWith({ - callRecordingId: 'call-recording-1', - startedAt: '2026-06-09T13:02:00.000Z', - endedAt: '2026-06-09T14:00:00.000Z', - }); + expect(chargeCreditsMock).toHaveBeenCalledWith( + CHARGE_FOR_58_RECORDED_MINUTES, + ); expect(result.updatedCallRecordingIds).toEqual(['call-recording-1']); }); it('keeps the real failure reason over the size marker when the bot failed', async () => { - getRecallBotMock.mockResolvedValue({ - ok: true, - bot: { - statusChanges: [ - { code: 'fatal', createdAt: '2026-06-09T14:05:00.000Z' }, - ], - recordings: [ - { - id: 'recall-recording-1', - startedAt: '2026-06-09T13:02:00.000Z', - completedAt: '2026-06-09T14:00:00.000Z', - }, - ], - }, - }); - importCallRecordingMediaMock.mockResolvedValue({ - audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }], - callRecorderFailureReason: 'video_file_too_large', + setRecallBotResponse({ + status_changes: [ + { code: 'fatal', created_at: '2026-06-09T14:05:00.000Z' }, + ], + recordings: [ + { + id: 'recall-recording-1', + started_at: '2026-06-09T13:02:00.000Z', + completed_at: '2026-06-09T14:00:00.000Z', + }, + ], }); + setFetchJsonResponse('GET', RECALL_RECORDING_URL, RECORDING_WITH_MEDIA); + setFetchResponse('GET', VIDEO_DOWNLOAD_URL, () => + buildOversizedMediaDownloadResponse(), + ); + setFetchResponse('GET', AUDIO_DOWNLOAD_URL, () => + buildMediaDownloadResponse(8), + ); const client = buildClient([ buildStuckRecordingNode({ status: 'PROCESSING', @@ -433,7 +578,7 @@ describe('convergeDivergedCallRecordings', () => { }, }, ]); - expect(chargeCompletedCallRecordingMock).not.toHaveBeenCalled(); + expect(chargeCreditsMock).not.toHaveBeenCalled(); }); it('skips records whose meeting has not started yet', async () => { @@ -451,7 +596,7 @@ describe('convergeDivergedCallRecordings', () => { now: NOW, }); - expect(getRecallBotMock).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); expect(client.mutations).toEqual([]); expect(result.skippedNotStartedCallRecordingIds).toEqual([ 'call-recording-1', @@ -459,20 +604,17 @@ describe('convergeDivergedCallRecordings', () => { }); it('converges a meeting that ended early while its scheduled end is still in the future', async () => { - getRecallBotMock.mockResolvedValue({ - ok: true, - bot: { - statusChanges: [ - { code: 'done', createdAt: '2026-06-10T11:30:00.000Z' }, - ], - recordings: [ - { - id: 'recall-recording-1', - startedAt: '2026-06-10T11:05:00.000Z', - completedAt: '2026-06-10T11:25:00.000Z', - }, - ], - }, + setRecallBotResponse({ + status_changes: [ + { code: 'done', created_at: '2026-06-10T11:30:00.000Z' }, + ], + recordings: [ + { + id: 'recall-recording-1', + started_at: '2026-06-10T11:05:00.000Z', + completed_at: '2026-06-10T11:25:00.000Z', + }, + ], }); const client = buildClient([ buildStuckRecordingNode({ @@ -488,19 +630,13 @@ describe('convergeDivergedCallRecordings', () => { now: NOW, }); - expect(getRecallBotMock).toHaveBeenCalledWith({ - externalBotId: 'recall-bot-1', - }); + expect(fetchedRequests()).toContain(`GET ${RECALL_BOT_URL}`); expect(result.updatedCallRecordingIds).toEqual(['call-recording-1']); expect(result.skippedNotStartedCallRecordingIds).toEqual([]); }); it('marks FAILED without clearing the bot id when Recall returns 404', async () => { - getRecallBotMock.mockResolvedValue({ - ok: false, - status: 404, - errorMessage: 'Recall API responded with HTTP 404', - }); + setFetchJsonResponse('GET', RECALL_BOT_URL, { detail: 'Not found.' }, 404); const client = buildClient([buildStuckRecordingNode()]); const result = await convergeDivergedCallRecordings({ @@ -522,11 +658,7 @@ describe('convergeDivergedCallRecordings', () => { }); it('does not downgrade a COMPLETED record when its bot 404s', async () => { - getRecallBotMock.mockResolvedValue({ - ok: false, - status: 404, - errorMessage: 'Recall API responded with HTTP 404', - }); + setFetchJsonResponse('GET', RECALL_BOT_URL, { detail: 'Not found.' }, 404); const client = buildClient([ buildStuckRecordingNode({ status: 'COMPLETED', @@ -556,21 +688,17 @@ describe('convergeDivergedCallRecordings', () => { now: NOW, }); - expect(getRecallBotMock).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); expect(client.mutations).toEqual([]); expect(result.unconvergeableCallRecordingIds).toEqual(['call-recording-1']); expect(console.warn).toHaveBeenCalled(); }); it('converges candidates created long before a recently ended meeting', async () => { - getRecallBotMock.mockResolvedValue({ - ok: true, - bot: { - statusChanges: [ - { code: 'in_call_recording', createdAt: '2026-06-09T13:02:00.000Z' }, - ], - recordings: [], - }, + setRecallBotResponse({ + status_changes: [ + { code: 'in_call_recording', created_at: '2026-06-09T13:02:00.000Z' }, + ], }); const client = buildClient([ buildStuckRecordingNode({ @@ -584,23 +712,18 @@ describe('convergeDivergedCallRecordings', () => { now: NOW, }); - expect(getRecallBotMock).toHaveBeenCalledWith({ - externalBotId: 'recall-bot-1', - }); + expect(fetchedRequests()).toContain(`GET ${RECALL_BOT_URL}`); expect(result.unconvergeableCallRecordingIds).toEqual([]); }); it('applies the downgrade guard to pulled statuses while still filling timestamps', async () => { - getRecallBotMock.mockResolvedValue({ - ok: true, - bot: { - statusChanges: [ - { code: 'in_call_recording', createdAt: '2026-06-09T13:02:00.000Z' }, - ], - recordings: [ - { id: 'recall-recording-1', startedAt: '2026-06-09T13:02:00.000Z' }, - ], - }, + setRecallBotResponse({ + status_changes: [ + { code: 'in_call_recording', created_at: '2026-06-09T13:02:00.000Z' }, + ], + recordings: [ + { id: 'recall-recording-1', started_at: '2026-06-09T13:02:00.000Z' }, + ], }); const client = buildClient([ buildStuckRecordingNode({ status: 'PROCESSING' }), @@ -623,20 +746,17 @@ describe('convergeDivergedCallRecordings', () => { }); it('requests a transcript for a COMPLETED candidate that has none', async () => { - getRecallBotMock.mockResolvedValue({ - ok: true, - bot: { - statusChanges: [ - { code: 'done', createdAt: '2026-06-09T14:05:00.000Z' }, - ], - recordings: [ - { - id: 'recall-recording-1', - startedAt: '2026-06-09T13:02:00.000Z', - completedAt: '2026-06-09T14:00:00.000Z', - }, - ], - }, + setRecallBotResponse({ + status_changes: [ + { code: 'done', created_at: '2026-06-09T14:05:00.000Z' }, + ], + recordings: [ + { + id: 'recall-recording-1', + started_at: '2026-06-09T13:02:00.000Z', + completed_at: '2026-06-09T14:00:00.000Z', + }, + ], }); const client = buildClient([ buildStuckRecordingNode({ @@ -651,10 +771,11 @@ describe('convergeDivergedCallRecordings', () => { now: NOW, }); - expect(createAsyncRecallTranscriptMock).toHaveBeenCalledTimes(1); - expect(createAsyncRecallTranscriptMock).toHaveBeenCalledWith({ - externalRecordingId: 'recall-recording-1', - }); + expect( + fetchedRequests().filter( + (request) => request === `POST ${RECALL_CREATE_TRANSCRIPT_URL}`, + ), + ).toHaveLength(1); expect(client.mutations).toEqual([ { id: 'call-recording-1', @@ -674,31 +795,22 @@ describe('convergeDivergedCallRecordings', () => { }); it('does not create a duplicate transcript when Recall already has one processing', async () => { - getRecallBotMock.mockResolvedValue({ - ok: true, - bot: { - statusChanges: [ - { code: 'done', createdAt: '2026-06-09T14:05:00.000Z' }, - ], - recordings: [ - { - id: 'recall-recording-1', - startedAt: '2026-06-09T13:02:00.000Z', - completedAt: '2026-06-09T14:00:00.000Z', - }, - ], - }, - }); - listRecallTranscriptsMock.mockResolvedValue({ - ok: true, - transcripts: [ + setRecallBotResponse({ + status_changes: [ + { code: 'done', created_at: '2026-06-09T14:05:00.000Z' }, + ], + recordings: [ { - id: 'recall-transcript-1', - statusCode: 'processing', - statusSubCode: undefined, + id: 'recall-recording-1', + started_at: '2026-06-09T13:02:00.000Z', + completed_at: '2026-06-09T14:00:00.000Z', }, ], }); + setFetchJsonResponse('GET', RECALL_TRANSCRIPT_LIST_URL, { + next: null, + results: [{ id: 'recall-transcript-1', status: { code: 'processing' } }], + }); const client = buildClient([buildStuckRecordingNode()]); const result = await convergeDivergedCallRecordings({ @@ -706,8 +818,12 @@ describe('convergeDivergedCallRecordings', () => { now: NOW, }); - expect(createAsyncRecallTranscriptMock).not.toHaveBeenCalled(); - expect(downloadTranscriptMock).not.toHaveBeenCalled(); + expect(fetchedRequests()).not.toContain( + `POST ${RECALL_CREATE_TRANSCRIPT_URL}`, + ); + expect(fetchedRequests()).not.toContain( + `GET ${RECALL_TRANSCRIPT_DETAILS_URL}`, + ); expect(client.mutations).toEqual([ { id: 'call-recording-1', @@ -730,35 +846,27 @@ describe('convergeDivergedCallRecordings', () => { }, ]; - getRecallBotMock.mockResolvedValue({ - ok: true, - bot: { - statusChanges: [ - { code: 'done', createdAt: '2026-06-09T14:05:00.000Z' }, - ], - recordings: [ - { - id: 'recall-recording-1', - startedAt: '2026-06-09T13:02:00.000Z', - completedAt: '2026-06-09T14:00:00.000Z', - }, - ], - }, - }); - listRecallTranscriptsMock.mockResolvedValue({ - ok: true, - transcripts: [ + setRecallBotResponse({ + status_changes: [ + { code: 'done', created_at: '2026-06-09T14:05:00.000Z' }, + ], + recordings: [ { - id: 'recall-transcript-1', - statusCode: 'done', - statusSubCode: undefined, + id: 'recall-recording-1', + started_at: '2026-06-09T13:02:00.000Z', + completed_at: '2026-06-09T14:00:00.000Z', }, ], }); - downloadTranscriptMock.mockResolvedValue({ - outcome: 'filled', - content: transcriptContent, + setFetchJsonResponse('GET', RECALL_TRANSCRIPT_LIST_URL, { + next: null, + results: [{ id: 'recall-transcript-1', status: { code: 'done' } }], }); + setFetchJsonResponse('GET', RECALL_TRANSCRIPT_DETAILS_URL, { + data: { download_url: TRANSCRIPT_DOWNLOAD_URL }, + status: { code: 'done' }, + }); + setFetchJsonResponse('GET', TRANSCRIPT_DOWNLOAD_URL, transcriptContent); const client = buildClient([ buildStuckRecordingNode({ status: 'PROCESSING', @@ -780,10 +888,10 @@ describe('convergeDivergedCallRecordings', () => { now: NOW, }); - expect(createAsyncRecallTranscriptMock).not.toHaveBeenCalled(); - expect(downloadTranscriptMock).toHaveBeenCalledWith({ - transcriptId: 'recall-transcript-1', - }); + expect(fetchedRequests()).not.toContain( + `POST ${RECALL_CREATE_TRANSCRIPT_URL}`, + ); + expect(fetchedRequests()).toContain(`GET ${RECALL_TRANSCRIPT_DETAILS_URL}`); expect(client.mutations).toEqual([ { id: 'call-recording-1', @@ -794,37 +902,31 @@ describe('convergeDivergedCallRecordings', () => { data: { status: 'COMPLETED' }, }, ]); - expect(chargeCompletedCallRecordingMock).toHaveBeenCalledWith({ - callRecordingId: 'call-recording-1', - startedAt: '2026-06-09T13:02:00.000Z', - endedAt: '2026-06-09T14:00:00.000Z', - }); + expect(chargeCreditsMock).toHaveBeenCalledWith( + CHARGE_FOR_58_RECORDED_MINUTES, + ); expect(result.requestedTranscriptCallRecordingIds).toEqual([]); }); it('marks the call recording failed when Recall has a failed transcript artifact', async () => { - getRecallBotMock.mockResolvedValue({ - ok: true, - bot: { - statusChanges: [ - { code: 'done', createdAt: '2026-06-09T14:05:00.000Z' }, - ], - recordings: [ - { - id: 'recall-recording-1', - startedAt: '2026-06-09T13:02:00.000Z', - completedAt: '2026-06-09T14:00:00.000Z', - }, - ], - }, + setRecallBotResponse({ + status_changes: [ + { code: 'done', created_at: '2026-06-09T14:05:00.000Z' }, + ], + recordings: [ + { + id: 'recall-recording-1', + started_at: '2026-06-09T13:02:00.000Z', + completed_at: '2026-06-09T14:00:00.000Z', + }, + ], }); - listRecallTranscriptsMock.mockResolvedValue({ - ok: true, - transcripts: [ + setFetchJsonResponse('GET', RECALL_TRANSCRIPT_LIST_URL, { + next: null, + results: [ { id: 'recall-transcript-1', - statusCode: 'failed', - statusSubCode: 'audio_missing', + status: { code: 'failed', sub_code: 'audio_missing' }, }, ], }); @@ -842,8 +944,12 @@ describe('convergeDivergedCallRecordings', () => { now: NOW, }); - expect(createAsyncRecallTranscriptMock).not.toHaveBeenCalled(); - expect(downloadTranscriptMock).not.toHaveBeenCalled(); + expect(fetchedRequests()).not.toContain( + `POST ${RECALL_CREATE_TRANSCRIPT_URL}`, + ); + expect(fetchedRequests()).not.toContain( + `GET ${RECALL_TRANSCRIPT_DETAILS_URL}`, + ); expect(client.mutations).toEqual([ { id: 'call-recording-1', @@ -862,14 +968,10 @@ describe('convergeDivergedCallRecordings', () => { }); it('does not mutate a record the bot state agrees with', async () => { - getRecallBotMock.mockResolvedValue({ - ok: true, - bot: { - statusChanges: [ - { code: 'in_call_recording', createdAt: '2026-06-09T13:02:00.000Z' }, - ], - recordings: [], - }, + setRecallBotResponse({ + status_changes: [ + { code: 'in_call_recording', created_at: '2026-06-09T13:02:00.000Z' }, + ], }); const client = buildClient([ buildStuckRecordingNode({ startedAt: '2026-06-09T13:02:00.000Z' }), diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/download-transcript.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/download-transcript.test.ts index bc1b7f0ee3..304bba522d 100644 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/download-transcript.test.ts +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/download-transcript.test.ts @@ -1,40 +1,53 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { downloadTranscript } from 'src/logic-functions/flows/download-transcript.util'; -const retrieveRecallTranscriptMock = vi.hoisted(() => vi.fn()); +const TRANSCRIPT_DOWNLOAD_URL = + 'https://recall-transcripts.example.com/transcript-1'; +const RECALL_TRANSCRIPT_URL = + 'https://us-west-2.recall.ai/api/v1/transcript/recall-transcript-1/'; -vi.mock( - 'src/logic-functions/recall-api/retrieve-recall-transcript.util', - () => ({ - retrieveRecallTranscript: retrieveRecallTranscriptMock, - }), -); +const buildRecallTranscriptResponse = () => + new Response( + JSON.stringify({ + data: { download_url: TRANSCRIPT_DOWNLOAD_URL }, + status: { code: 'done' }, + }), + { status: 200 }, + ); describe('downloadTranscript', () => { const fetchMock = vi.fn(); beforeEach(() => { vi.spyOn(console, 'warn').mockImplementation(() => {}); - retrieveRecallTranscriptMock.mockReset(); fetchMock.mockReset(); vi.stubGlobal('fetch', fetchMock); + vi.stubEnv('RECALL_API_KEY', 'recall-api-key'); + vi.stubEnv('RECALL_REGION', 'us-west-2'); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + vi.restoreAllMocks(); }); it('downloads transcript content with a timeout', async () => { const transcriptContent = [{ participant: { id: 1 }, words: [] }]; - retrieveRecallTranscriptMock.mockResolvedValue({ - ok: true, - transcript: { - downloadUrl: 'https://recall-transcripts.example.com/transcript-1', - statusCode: 'done', - statusSubCode: undefined, - }, - }); - fetchMock.mockResolvedValue({ - ok: true, - json: async () => transcriptContent, + fetchMock.mockImplementation((url: string) => { + if (url === RECALL_TRANSCRIPT_URL) { + return Promise.resolve(buildRecallTranscriptResponse()); + } + + if (url === TRANSCRIPT_DOWNLOAD_URL) { + return Promise.resolve( + new Response(JSON.stringify(transcriptContent), { status: 200 }), + ); + } + + throw new Error(`Unhandled fetch url in test: ${url}`); }); const result = await downloadTranscript({ @@ -43,7 +56,14 @@ describe('downloadTranscript', () => { expect(result).toEqual({ outcome: 'filled', content: transcriptContent }); expect(fetchMock).toHaveBeenCalledWith( - 'https://recall-transcripts.example.com/transcript-1', + RECALL_TRANSCRIPT_URL, + expect.objectContaining({ + method: 'GET', + headers: { Authorization: 'Token recall-api-key' }, + }), + ); + expect(fetchMock).toHaveBeenCalledWith( + TRANSCRIPT_DOWNLOAD_URL, expect.objectContaining({ signal: expect.any(AbortSignal), }), @@ -51,15 +71,17 @@ describe('downloadTranscript', () => { }); it('logs raw download failures but returns a generic error', async () => { - retrieveRecallTranscriptMock.mockResolvedValue({ - ok: true, - transcript: { - downloadUrl: 'https://recall-transcripts.example.com/transcript-1', - statusCode: 'done', - statusSubCode: undefined, - }, + fetchMock.mockImplementation((url: string) => { + if (url === RECALL_TRANSCRIPT_URL) { + return Promise.resolve(buildRecallTranscriptResponse()); + } + + if (url === TRANSCRIPT_DOWNLOAD_URL) { + return Promise.reject(new Error('socket leaked detail')); + } + + throw new Error(`Unhandled fetch url in test: ${url}`); }); - fetchMock.mockRejectedValue(new Error('socket leaked detail')); await expect( downloadTranscript({ transcriptId: 'recall-transcript-1' }), diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/generate-call-recording-summary.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/generate-call-recording-summary.test.ts index b80d5014ec..9035267373 100644 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/generate-call-recording-summary.test.ts +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/generate-call-recording-summary.test.ts @@ -1,44 +1,14 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { CoreApiClient } from 'twenty-client-sdk/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { type CoreApiClient } from 'twenty-client-sdk/core'; import { generateCallRecordingSummary } from 'src/logic-functions/flows/generate-call-recording-summary.util'; const runAgentMock = vi.hoisted(() => vi.fn()); -const findCallRecordingForSummaryMock = vi.hoisted(() => vi.fn()); -const updateCallRecordingMock = vi.hoisted(() => vi.fn()); -const getCallRecorderAdditionalSummaryPromptMock = vi.hoisted(() => vi.fn()); -const isCallRecordingSummaryEnabledMock = vi.hoisted(() => vi.fn()); vi.mock('twenty-sdk/logic-function', () => ({ runAgent: runAgentMock, })); -vi.mock( - 'src/logic-functions/data/find-call-recording-for-summary.util', - () => ({ - findCallRecordingForSummary: findCallRecordingForSummaryMock, - }), -); - -vi.mock('src/logic-functions/data/update-call-recording.util', () => ({ - updateCallRecording: updateCallRecordingMock, -})); - -vi.mock( - 'src/logic-functions/utils/get-call-recorder-additional-summary-prompt.util', - () => ({ - getCallRecorderAdditionalSummaryPrompt: - getCallRecorderAdditionalSummaryPromptMock, - }), -); - -vi.mock( - 'src/logic-functions/utils/is-call-recording-summary-enabled.util', - () => ({ - isCallRecordingSummaryEnabled: isCallRecordingSummaryEnabledMock, - }), -); - const TRANSCRIPT = [ { participant: { name: 'Alex' }, @@ -46,27 +16,31 @@ const TRANSCRIPT = [ }, ]; -const CLIENT: CoreApiClient = Object.assign( - Object.create(CoreApiClient.prototype), - { - mutation: vi.fn(), - query: vi.fn(), - }, -); +const queryMock = vi.fn(); +const mutationMock = vi.fn(); + +const CLIENT = { + query: queryMock, + mutation: mutationMock, +} as unknown as CoreApiClient; + +const seedCallRecording = (node: object) => { + queryMock.mockResolvedValue({ callRecordings: { edges: [{ node }] } }); +}; describe('generateCallRecordingSummary', () => { beforeEach(() => { vi.clearAllMocks(); - getCallRecorderAdditionalSummaryPromptMock.mockReturnValue(undefined); - isCallRecordingSummaryEnabledMock.mockReturnValue(true); - findCallRecordingForSummaryMock.mockResolvedValue({ + vi.stubEnv('CALL_RECORDER_SUMMARY_ENABLED', 'true'); + vi.stubEnv('CALL_RECORDER_ADDITIONAL_SUMMARY_PROMPT', ''); + seedCallRecording({ id: 'call-recording-1', title: 'Weekly sync', transcript: TRANSCRIPT, - summaryMarkdown: undefined, + summary: { markdown: null }, createdBy: { source: 'APPLICATION', name: 'Call Recorder' }, }); - updateCallRecordingMock.mockResolvedValue(undefined); + mutationMock.mockResolvedValue({}); runAgentMock.mockResolvedValue({ success: true, error: null, @@ -74,24 +48,28 @@ describe('generateCallRecordingSummary', () => { }); }); + afterEach(() => { + vi.unstubAllEnvs(); + }); + it('skips when summaries are disabled', async () => { - isCallRecordingSummaryEnabledMock.mockReturnValue(false); + vi.stubEnv('CALL_RECORDER_SUMMARY_ENABLED', 'false'); const result = await generateCallRecordingSummary(CLIENT, { callRecordingId: 'call-recording-1', }); expect(result).toEqual({ outcome: 'disabled' }); - expect(findCallRecordingForSummaryMock).not.toHaveBeenCalled(); + expect(queryMock).not.toHaveBeenCalled(); expect(runAgentMock).not.toHaveBeenCalled(); }); it('skips when there is no real transcript', async () => { - findCallRecordingForSummaryMock.mockResolvedValue({ + seedCallRecording({ id: 'call-recording-1', - title: undefined, + title: null, transcript: { status: 'PENDING' }, - summaryMarkdown: undefined, + summary: { markdown: null }, }); const result = await generateCallRecordingSummary(CLIENT, { @@ -103,11 +81,11 @@ describe('generateCallRecordingSummary', () => { }); it('skips when a summary already exists', async () => { - findCallRecordingForSummaryMock.mockResolvedValue({ + seedCallRecording({ id: 'call-recording-1', - title: undefined, + title: null, transcript: TRANSCRIPT, - summaryMarkdown: '## Overview\nAlready here.', + summary: { markdown: '## Overview\nAlready here.' }, }); const result = await generateCallRecordingSummary(CLIENT, { @@ -119,11 +97,11 @@ describe('generateCallRecordingSummary', () => { }); it('skips recordings another actor created when the app-created gate is on', async () => { - findCallRecordingForSummaryMock.mockResolvedValue({ + seedCallRecording({ id: 'call-recording-1', - title: undefined, + title: null, transcript: TRANSCRIPT, - summaryMarkdown: undefined, + summary: { markdown: null }, createdBy: { source: 'MANUAL', name: 'Alex' }, }); @@ -144,15 +122,15 @@ describe('generateCallRecordingSummary', () => { expect(result).toEqual({ outcome: 'generated' }); expect(runAgentMock).toHaveBeenCalledTimes(1); - expect(updateCallRecordingMock).toHaveBeenCalledTimes(1); + expect(mutationMock).toHaveBeenCalledTimes(1); }); it('generates for recordings another actor created when explicitly requested', async () => { - findCallRecordingForSummaryMock.mockResolvedValue({ + seedCallRecording({ id: 'call-recording-1', - title: undefined, + title: null, transcript: TRANSCRIPT, - summaryMarkdown: undefined, + summary: { markdown: null }, createdBy: { source: 'MANUAL', name: 'Alex' }, }); @@ -174,18 +152,21 @@ describe('generateCallRecordingSummary', () => { prompt: expect.stringContaining('Alex: Hello team'), }), ); - expect(updateCallRecordingMock).toHaveBeenCalledWith(CLIENT, { - id: 'call-recording-1', - data: { - summary: { blocknote: null, markdown: '## Overview\nGood call.' }, + expect(mutationMock).toHaveBeenCalledWith({ + updateCallRecording: { + __args: { + id: 'call-recording-1', + data: { + summary: { blocknote: null, markdown: '## Overview\nGood call.' }, + }, + }, + id: true, }, }); }); it('appends the workspace admin instructions to the agent prompt', async () => { - getCallRecorderAdditionalSummaryPromptMock.mockReturnValue( - 'Write terse notes.', - ); + vi.stubEnv('CALL_RECORDER_ADDITIONAL_SUMMARY_PROMPT', 'Write terse notes.'); await generateCallRecordingSummary(CLIENT, { callRecordingId: 'call-recording-1', @@ -212,9 +193,16 @@ describe('generateCallRecordingSummary', () => { }); expect(result).toEqual({ outcome: 'generated' }); - expect(updateCallRecordingMock).toHaveBeenCalledWith(CLIENT, { - id: 'call-recording-1', - data: { summary: { blocknote: null, markdown: 'No summary available.' } }, + expect(mutationMock).toHaveBeenCalledWith({ + updateCallRecording: { + __args: { + id: 'call-recording-1', + data: { + summary: { blocknote: null, markdown: 'No summary available.' }, + }, + }, + id: true, + }, }); }); @@ -230,7 +218,7 @@ describe('generateCallRecordingSummary', () => { }); expect(result).toEqual({ outcome: 'empty-summary' }); - expect(updateCallRecordingMock).not.toHaveBeenCalled(); + expect(mutationMock).not.toHaveBeenCalled(); }); it('propagates agent errors without writing a summary', async () => { @@ -242,13 +230,11 @@ describe('generateCallRecordingSummary', () => { }), ).rejects.toThrow('Agent execution failed'); - expect(updateCallRecordingMock).not.toHaveBeenCalled(); + expect(mutationMock).not.toHaveBeenCalled(); }); it('propagates summary write errors', async () => { - updateCallRecordingMock.mockRejectedValue( - new Error('Summary write failed'), - ); + mutationMock.mockRejectedValue(new Error('Summary write failed')); await expect( generateCallRecordingSummary(CLIENT, { @@ -256,6 +242,6 @@ describe('generateCallRecordingSummary', () => { }), ).rejects.toThrow('Summary write failed'); - expect(updateCallRecordingMock).toHaveBeenCalledTimes(1); + expect(mutationMock).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/generate-missing-call-recording-summaries.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/generate-missing-call-recording-summaries.test.ts index f3ed3f37e4..34bdfa53f9 100644 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/generate-missing-call-recording-summaries.test.ts +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/generate-missing-call-recording-summaries.test.ts @@ -1,32 +1,56 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { CoreApiClient } from 'twenty-client-sdk/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { type CoreApiClient } from 'twenty-client-sdk/core'; +import { GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH } from 'src/constants/generate-call-recording-summaries-route-path'; import { generateMissingCallRecordingSummaries } from 'src/logic-functions/flows/generate-missing-call-recording-summaries.util'; -const generateCallRecordingSummaryMock = vi.hoisted(() => vi.fn()); -const requestContinuationMock = vi.hoisted(() => vi.fn()); +const runAgentMock = vi.hoisted(() => vi.fn()); -vi.mock( - 'src/logic-functions/flows/generate-call-recording-summary.util', - () => ({ - generateCallRecordingSummary: generateCallRecordingSummaryMock, - }), -); +vi.mock('twenty-sdk/logic-function', () => ({ + runAgent: runAgentMock, +})); -vi.mock( - 'src/logic-functions/data/request-call-recording-summaries-continuation.util', - () => ({ - requestCallRecordingSummariesContinuation: requestContinuationMock, - }), -); +const FUNCTIONS_BASE_URL = 'https://acme.functions.example.com'; -const CLIENT: CoreApiClient = Object.assign( - Object.create(CoreApiClient.prototype), +const TRANSCRIPT = [ { - mutation: vi.fn(), - query: vi.fn(), + participant: { name: 'Alex' }, + words: [{ text: 'Hello' }, { text: 'team' }], }, -); +]; + +const fetchMock = vi.fn(); +const queryMock = vi.fn(); +const mutationMock = vi.fn(); + +const CLIENT = { + query: queryMock, + mutation: mutationMock, +} as unknown as CoreApiClient; + +const buildCallRecordingNode = (id: string, overrides: object = {}) => ({ + id, + title: 'Weekly sync', + transcript: TRANSCRIPT, + summary: { markdown: null }, + createdBy: { source: 'APPLICATION', name: 'Call Recorder' }, + ...overrides, +}); + +const seedCallRecordingQueries = (nodesById: Record) => { + queryMock.mockImplementation(async (queryShape: unknown) => { + const callRecordingId = ( + queryShape as { + callRecordings: { __args: { filter: { id: { eq: string } } } }; + } + ).callRecordings.__args.filter.id.eq; + const node = nodesById[callRecordingId]; + + return { + callRecordings: { edges: node === undefined ? [] : [{ node }] }, + }; + }); +}; // Each processed item advances the clock by ITEM_MS across the three // getNowMs reads of one loop iteration. @@ -47,10 +71,29 @@ const buildClock = (itemMs: number) => { describe('generateMissingCallRecordingSummaries', () => { beforeEach(() => { vi.clearAllMocks(); - generateCallRecordingSummaryMock.mockResolvedValue({ - outcome: 'generated', + vi.stubGlobal('fetch', fetchMock); + vi.stubEnv('TWENTY_FUNCTIONS_URL', FUNCTIONS_BASE_URL); + vi.stubEnv('TWENTY_APP_ACCESS_TOKEN', 'app-access-token'); + vi.stubEnv('CALL_RECORDER_SUMMARY_ENABLED', 'true'); + vi.stubEnv('CALL_RECORDER_ADDITIONAL_SUMMARY_PROMPT', ''); + fetchMock.mockResolvedValue(new Response('{}', { status: 200 })); + mutationMock.mockResolvedValue({}); + runAgentMock.mockResolvedValue({ + success: true, + error: null, + result: { response: '## Overview\nGood call.' }, }); - requestContinuationMock.mockResolvedValue(true); + seedCallRecordingQueries({ + 'call-recording-1': buildCallRecordingNode('call-recording-1'), + 'call-recording-2': buildCallRecordingNode('call-recording-2'), + 'call-recording-3': buildCallRecordingNode('call-recording-3'), + 'call-recording-4': buildCallRecordingNode('call-recording-4'), + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); }); it('processes every id and skips the continuation when the budget allows', async () => { @@ -69,7 +112,7 @@ describe('generateMissingCallRecordingSummaries', () => { remainingCallRecordingIds: [], continuationRequested: false, }); - expect(requestContinuationMock).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); }); it('always processes at least one id even when the deadline already passed', async () => { @@ -83,9 +126,15 @@ describe('generateMissingCallRecordingSummaries', () => { expect(result.generatedCallRecordingIds).toEqual(['call-recording-1']); expect(result.remainingCallRecordingIds).toEqual(['call-recording-2']); expect(result.continuationRequested).toBe(true); - expect(requestContinuationMock).toHaveBeenCalledWith({ - callRecordingIds: ['call-recording-2'], - }); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [requestUrl, requestInit] = fetchMock.mock.calls[0]; + expect(requestUrl).toBe( + `${FUNCTIONS_BASE_URL}${GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH}`, + ); + expect(requestInit.method).toBe('POST'); + expect(requestInit.body).toBe( + JSON.stringify({ callRecordingIds: ['call-recording-2'] }), + ); }); it('stops when the next item would overrun the deadline and hands off the rest', async () => { @@ -111,16 +160,31 @@ describe('generateMissingCallRecordingSummaries', () => { 'call-recording-3', 'call-recording-4', ]); - expect(requestContinuationMock).toHaveBeenCalledWith({ - callRecordingIds: ['call-recording-3', 'call-recording-4'], - }); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [requestUrl, requestInit] = fetchMock.mock.calls[0]; + expect(requestUrl).toBe( + `${FUNCTIONS_BASE_URL}${GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH}`, + ); + expect(requestInit.body).toBe( + JSON.stringify({ + callRecordingIds: ['call-recording-3', 'call-recording-4'], + }), + ); }); it('separates empty summaries from thrown generation errors', async () => { - generateCallRecordingSummaryMock - .mockResolvedValueOnce({ outcome: 'empty-summary' }) + runAgentMock + .mockResolvedValueOnce({ + success: false, + error: 'no more available credits', + result: null, + }) .mockRejectedValueOnce(new Error('agent exploded')) - .mockResolvedValueOnce({ outcome: 'generated' }); + .mockResolvedValueOnce({ + success: true, + error: null, + result: { response: '## Overview\nGood call.' }, + }); const result = await generateMissingCallRecordingSummaries({ client: CLIENT, @@ -144,9 +208,14 @@ describe('generateMissingCallRecordingSummaries', () => { }); it('records skip outcomes without treating them as failures', async () => { - generateCallRecordingSummaryMock - .mockResolvedValueOnce({ outcome: 'already-summarized' }) - .mockResolvedValueOnce({ outcome: 'no-transcript' }); + seedCallRecordingQueries({ + 'call-recording-1': buildCallRecordingNode('call-recording-1', { + summary: { markdown: '## Overview\nAlready here.' }, + }), + 'call-recording-2': buildCallRecordingNode('call-recording-2', { + transcript: { status: 'PENDING' }, + }), + }); const result = await generateMissingCallRecordingSummaries({ client: CLIENT, @@ -164,9 +233,13 @@ describe('generateMissingCallRecordingSummaries', () => { }); it('stops spending immediately when summaries get disabled mid-run', async () => { - generateCallRecordingSummaryMock - .mockResolvedValueOnce({ outcome: 'generated' }) - .mockResolvedValueOnce({ outcome: 'disabled' }); + // Persisting the first summary flips the workspace toggle off, so the + // second id observes the disabled state. + mutationMock.mockImplementationOnce(async () => { + vi.stubEnv('CALL_RECORDER_SUMMARY_ENABLED', 'false'); + + return {}; + }); const result = await generateMissingCallRecordingSummaries({ client: CLIENT, @@ -182,7 +255,8 @@ describe('generateMissingCallRecordingSummaries', () => { expect(result.generatedCallRecordingIds).toEqual(['call-recording-1']); expect(result.remainingCallRecordingIds).toEqual(['call-recording-3']); expect(result.continuationRequested).toBe(false); - expect(requestContinuationMock).not.toHaveBeenCalled(); - expect(generateCallRecordingSummaryMock).toHaveBeenCalledTimes(2); + expect(fetchMock).not.toHaveBeenCalled(); + expect(queryMock).toHaveBeenCalledTimes(1); + expect(runAgentMock).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/handle-recall-webhook.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/handle-recall-webhook.test.ts index f53bee1cca..5767470a3d 100644 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/handle-recall-webhook.test.ts +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/handle-recall-webhook.test.ts @@ -1,5 +1,8 @@ +import { type ClientRequest, type IncomingMessage } from 'node:http'; +import { PassThrough, Readable } from 'node:stream'; + import { type CoreApiClient } from 'twenty-client-sdk/core'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { handleRecallWebhook } from 'src/logic-functions/flows/handle-recall-webhook.util'; @@ -21,45 +24,175 @@ const buildRecordingDoneWebhookBody = () => ({ }, }); -const getRecallBotMock = vi.hoisted(() => vi.fn()); -const listRecallTranscriptsMock = vi.hoisted(() => vi.fn()); -const createAsyncRecallTranscriptMock = vi.hoisted(() => vi.fn()); -const retrieveRecallTranscriptMock = vi.hoisted(() => vi.fn()); -const importCallRecordingMediaMock = vi.hoisted(() => vi.fn()); -const chargeCompletedCallRecordingMock = vi.hoisted(() => vi.fn()); +const metadataMutationMock = vi.hoisted(() => vi.fn()); +const chargeCreditsMock = vi.hoisted(() => vi.fn()); +const requestOverHttpsMock = vi.hoisted(() => vi.fn()); -vi.mock('src/logic-functions/recall-api/get-recall-bot.util', () => ({ - getRecallBot: getRecallBotMock, +vi.mock('twenty-client-sdk/metadata', () => ({ + MetadataApiClient: class { + mutation = metadataMutationMock; + }, })); -vi.mock('src/logic-functions/recall-api/list-recall-transcripts.util', () => ({ - listRecallTranscripts: listRecallTranscriptsMock, +vi.mock('twenty-sdk/billing', () => ({ + chargeCredits: chargeCreditsMock, })); -vi.mock( - 'src/logic-functions/recall-api/create-async-recall-transcript.util', - () => ({ - createAsyncRecallTranscript: createAsyncRecallTranscriptMock, - }), -); +vi.mock('node:https', async () => { + const actualHttps = + await vi.importActual('node:https'); -vi.mock( - 'src/logic-functions/recall-api/retrieve-recall-transcript.util', - () => ({ - retrieveRecallTranscript: retrieveRecallTranscriptMock, - }), -); + return { ...actualHttps, request: requestOverHttpsMock }; +}); -vi.mock('src/logic-functions/flows/import-call-recording-media.util', () => ({ - importCallRecordingMedia: importCallRecordingMediaMock, -})); +const RECALL_API_BASE_URL = 'https://us-west-2.recall.ai/api/v1'; +const VIDEO_DOWNLOAD_URL = 'https://recall-media.example.com/video.mp4'; +const AUDIO_DOWNLOAD_URL = 'https://recall-media.example.com/audio.mp3'; +const TOO_LARGE_MEDIA_CONTENT_LENGTH_BYTES = 500 * 1024 * 1024 + 1; -vi.mock( - 'src/logic-functions/flows/charge-completed-call-recording.util', - () => ({ - chargeCompletedCallRecording: chargeCompletedCallRecordingMock, - }), -); +const fetchMock = vi.fn(); + +let fetchRoutes: Record Response>; + +const jsonResponse = (body: unknown, status = 200): Response => + new Response(JSON.stringify(body), { status }); + +const mediaDownloadResponse = (contentLengthBytes: number): Response => + new Response(new Uint8Array(8), { + status: 200, + headers: { 'content-length': String(contentLengthBytes) }, + }); + +const setFetchRoute = ( + method: 'GET' | 'POST', + url: string, + buildResponse: () => Response, +) => { + fetchRoutes[`${method} ${url}`] = buildResponse; +}; + +// Unrouted Recall API calls fail like the old per-util "disabled in test" defaults. +const defaultRecallApiResponse = ( + method: string, + url: string, +): Response | undefined => { + if (method === 'POST' && url.endsWith('/create_transcript/')) { + return jsonResponse({ detail: 'transcript request disabled in test' }, 400); + } + + if (method !== 'GET') { + return undefined; + } + + if (url.startsWith(`${RECALL_API_BASE_URL}/transcript/?recording_id=`)) { + return jsonResponse({ results: [], next: null }); + } + + if (url.startsWith(`${RECALL_API_BASE_URL}/transcript/`)) { + return jsonResponse( + { detail: 'transcript retrieval disabled in test' }, + 400, + ); + } + + if (url.startsWith(`${RECALL_API_BASE_URL}/bot/`)) { + return jsonResponse({ detail: 'bot fetch disabled in test' }, 404); + } + + if (url.startsWith(`${RECALL_API_BASE_URL}/recording/`)) { + return jsonResponse({ detail: 'media import disabled in test' }, 404); + } + + return undefined; +}; + +const fetchedUrls = (): string[] => + fetchMock.mock.calls.map(([requestUrl]) => String(requestUrl)); + +const stubRecallRecordingMedia = ({ + externalRecordingId, + videoContentLengthBytes, + audioContentLengthBytes, +}: { + externalRecordingId: string; + videoContentLengthBytes?: number; + audioContentLengthBytes?: number; +}) => { + setFetchRoute( + 'GET', + `${RECALL_API_BASE_URL}/recording/${externalRecordingId}/`, + () => + jsonResponse({ + id: externalRecordingId, + media_shortcuts: { + ...(videoContentLengthBytes === undefined + ? {} + : { video_mixed: { download_url: VIDEO_DOWNLOAD_URL } }), + ...(audioContentLengthBytes === undefined + ? {} + : { audio_mixed: { download_url: AUDIO_DOWNLOAD_URL } }), + }, + }), + ); + + if (videoContentLengthBytes !== undefined) { + setFetchRoute('GET', VIDEO_DOWNLOAD_URL, () => + mediaDownloadResponse(videoContentLengthBytes), + ); + } + + if (audioContentLengthBytes !== undefined) { + setFetchRoute('GET', AUDIO_DOWNLOAD_URL, () => + mediaDownloadResponse(audioContentLengthBytes), + ); + } +}; + +type MediaUploadMutationRequest = + | { createFileUpload: { __args: { filename: string } } } + | { completeFileUpload: { __args: { fileId: string } } }; + +const FINAL_FILE_ID_BY_UPLOAD_FILE_ID: Record = { + 'upload-video.mp4': 'file-video-1', + 'upload-audio.mp3': 'file-audio-1', +}; + +const stubMediaUploadTargets = () => { + metadataMutationMock.mockImplementation( + (mutation: MediaUploadMutationRequest) => { + if ('createFileUpload' in mutation) { + const { filename } = mutation.createFileUpload.__args; + + return Promise.resolve({ + createFileUpload: { + fileId: `upload-${filename}`, + uploadUrl: `https://storage.example.com/${filename}`, + contentType: 'application/octet-stream', + }, + }); + } + + const { fileId } = mutation.completeFileUpload.__args; + + return Promise.resolve({ + completeFileUpload: { id: FINAL_FILE_ID_BY_UPLOAD_FILE_ID[fileId] }, + }); + }, + ); +}; + +const buildUploadRequest = (): ClientRequest => { + const uploadRequest = new PassThrough(); + + uploadRequest.on('finish', () => { + const uploadResponse = Readable.from([]) as IncomingMessage; + + uploadResponse.statusCode = 200; + uploadRequest.emit('response', uploadResponse); + }); + + return uploadRequest as unknown as ClientRequest; +}; type CallRecordingNode = { id: string; @@ -145,33 +278,43 @@ class FakeCoreApiClient { describe('handleRecallWebhook', () => { beforeEach(() => { vi.spyOn(console, 'warn').mockImplementation(() => {}); - getRecallBotMock.mockReset(); - getRecallBotMock.mockResolvedValue({ - ok: false, - status: null, - errorMessage: 'bot fetch disabled in test', - }); - listRecallTranscriptsMock.mockReset(); - listRecallTranscriptsMock.mockResolvedValue({ - ok: true, - transcripts: [], - }); - createAsyncRecallTranscriptMock.mockReset(); - createAsyncRecallTranscriptMock.mockResolvedValue({ - ok: false, - status: null, - errorMessage: 'transcript request disabled in test', - }); - retrieveRecallTranscriptMock.mockReset(); - retrieveRecallTranscriptMock.mockResolvedValue({ - ok: false, - status: null, - errorMessage: 'transcript retrieval disabled in test', - }); - importCallRecordingMediaMock.mockReset(); - importCallRecordingMediaMock.mockResolvedValue({}); - chargeCompletedCallRecordingMock.mockReset(); - chargeCompletedCallRecordingMock.mockResolvedValue(undefined); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.stubEnv('RECALL_API_KEY', 'recall-api-key'); + vi.stubEnv('RECALL_REGION', 'us-west-2'); + fetchRoutes = {}; + fetchMock.mockReset(); + fetchMock.mockImplementation( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + const method = init?.method ?? 'GET'; + const route = fetchRoutes[`${method} ${url}`]; + + if (route !== undefined) { + return route(); + } + + const defaultResponse = defaultRecallApiResponse(method, url); + + if (defaultResponse === undefined) { + throw new Error(`Unhandled fetch in test: ${method} ${url}`); + } + + return defaultResponse; + }, + ); + vi.stubGlobal('fetch', fetchMock); + metadataMutationMock.mockReset(); + stubMediaUploadTargets(); + chargeCreditsMock.mockReset(); + chargeCreditsMock.mockResolvedValue(undefined); + requestOverHttpsMock.mockReset(); + requestOverHttpsMock.mockImplementation(() => buildUploadRequest()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + vi.restoreAllMocks(); }); it('updates a call recording from bot metadata on status change events', async () => { @@ -747,10 +890,11 @@ describe('handleRecallWebhook', () => { }); it('requests a transcript once when the recording first completes', async () => { - createAsyncRecallTranscriptMock.mockResolvedValue({ - ok: true, - transcriptId: 'recall-transcript-1', - }); + setFetchRoute( + 'POST', + `${RECALL_API_BASE_URL}/recording/recall-recording-1/create_transcript/`, + () => jsonResponse({ id: 'recall-transcript-1' }), + ); const client = new FakeCoreApiClient([ { id: 'call-recording-1', @@ -765,10 +909,24 @@ describe('handleRecallWebhook', () => { body: buildRecordingDoneWebhookBody(), }); - expect(createAsyncRecallTranscriptMock).toHaveBeenCalledTimes(1); - expect(createAsyncRecallTranscriptMock).toHaveBeenCalledWith({ - externalRecordingId: 'recall-recording-1', - }); + expect( + fetchedUrls().filter((requestUrl) => + requestUrl.endsWith('/create_transcript/'), + ), + ).toHaveLength(1); + expect(fetchMock).toHaveBeenCalledWith( + `${RECALL_API_BASE_URL}/recording/recall-recording-1/create_transcript/`, + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: 'Token recall-api-key', + }), + body: JSON.stringify({ + provider: { recallai_async: { language_code: 'auto' } }, + diarization: { use_separate_streams_when_available: true }, + }), + }), + ); expect(client.mutations).toEqual([ { id: 'call-recording-1', @@ -806,13 +964,17 @@ describe('handleRecallWebhook', () => { body: buildRecordingDoneWebhookBody(), }); - expect(createAsyncRecallTranscriptMock).not.toHaveBeenCalled(); - expect(listRecallTranscriptsMock).toHaveBeenCalledWith({ - externalRecordingId: 'recall-recording-1', - }); - expect(retrieveRecallTranscriptMock).toHaveBeenCalledWith({ - transcriptId: 'recall-transcript-1', - }); + expect( + fetchedUrls().filter((requestUrl) => + requestUrl.endsWith('/create_transcript/'), + ), + ).toEqual([]); + expect(fetchedUrls()).toContain( + `${RECALL_API_BASE_URL}/transcript/?recording_id=recall-recording-1`, + ); + expect(fetchedUrls()).toContain( + `${RECALL_API_BASE_URL}/transcript/recall-transcript-1/`, + ); expect(client.mutations).toEqual([ { id: 'call-recording-1', @@ -826,19 +988,14 @@ describe('handleRecallWebhook', () => { }); it('resolves the recording id from the bot when the payload and record lack one', async () => { - getRecallBotMock.mockResolvedValue({ - ok: true, - bot: { - id: 'recall-bot-1', - metadata: {}, - statusChanges: [], - recordings: [{ id: 'recall-recording-9' }], - }, - }); - createAsyncRecallTranscriptMock.mockResolvedValue({ - ok: true, - transcriptId: 'recall-transcript-9', - }); + setFetchRoute('GET', `${RECALL_API_BASE_URL}/bot/recall-bot-1/`, () => + jsonResponse({ recordings: [{ id: 'recall-recording-9' }] }), + ); + setFetchRoute( + 'POST', + `${RECALL_API_BASE_URL}/recording/recall-recording-9/create_transcript/`, + () => jsonResponse({ id: 'recall-transcript-9' }), + ); const client = new FakeCoreApiClient([ { id: 'call-recording-1', @@ -867,12 +1024,14 @@ describe('handleRecallWebhook', () => { }, }); - expect(getRecallBotMock).toHaveBeenCalledWith({ - externalBotId: 'recall-bot-1', - }); - expect(createAsyncRecallTranscriptMock).toHaveBeenCalledWith({ - externalRecordingId: 'recall-recording-9', - }); + expect(fetchMock).toHaveBeenCalledWith( + `${RECALL_API_BASE_URL}/bot/recall-bot-1/`, + expect.objectContaining({ method: 'GET' }), + ); + expect(fetchMock).toHaveBeenCalledWith( + `${RECALL_API_BASE_URL}/recording/recall-recording-9/create_transcript/`, + expect.objectContaining({ method: 'POST' }), + ); expect(client.mutations).toEqual([ expect.objectContaining({ id: 'call-recording-1', @@ -886,13 +1045,13 @@ describe('handleRecallWebhook', () => { }); it('imports media on recording.done and completes once all artifacts are present', async () => { - getRecallBotMock.mockResolvedValue({ - ok: true, - bot: { id: 'recall-bot-1', metadata: {}, statusChanges: [], recordings: [] }, - }); - importCallRecordingMediaMock.mockResolvedValue({ - audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }], - video: [{ fileId: 'file-video-1', label: 'video.mp4' }], + setFetchRoute('GET', `${RECALL_API_BASE_URL}/bot/recall-bot-1/`, () => + jsonResponse({ id: 'recall-bot-1' }), + ); + stubRecallRecordingMedia({ + externalRecordingId: 'recall-recording-1', + videoContentLengthBytes: 8, + audioContentLengthBytes: 8, }); const client = new FakeCoreApiClient([ { @@ -911,12 +1070,11 @@ describe('handleRecallWebhook', () => { body: buildRecordingDoneWebhookBody(), }); - expect(importCallRecordingMediaMock).toHaveBeenCalledWith({ - callRecordingId: 'call-recording-1', - externalRecordingId: 'recall-recording-1', - hasAudio: false, - hasVideo: false, - }); + expect(fetchedUrls()).toContain( + `${RECALL_API_BASE_URL}/recording/recall-recording-1/`, + ); + expect(fetchedUrls()).toContain(VIDEO_DOWNLOAD_URL); + expect(fetchedUrls()).toContain(AUDIO_DOWNLOAD_URL); expect(client.mutations).toEqual([ { id: 'call-recording-1', @@ -932,21 +1090,22 @@ describe('handleRecallWebhook', () => { data: { status: 'COMPLETED' }, }, ]); - expect(chargeCompletedCallRecordingMock).toHaveBeenCalledWith({ - callRecordingId: 'call-recording-1', - startedAt: '2026-01-01T13:02:00.000Z', - endedAt: '2026-01-01T14:05:00.000Z', + expect(chargeCreditsMock).toHaveBeenCalledWith({ + creditsUsedMicro: 1_050_000, + quantity: 63, + operationType: 'CALL_RECORDING', + resourceContext: 'recall', }); }); it('completes and keeps the size marker when a media file is too large', async () => { - getRecallBotMock.mockResolvedValue({ - ok: true, - bot: { id: 'recall-bot-1', metadata: {}, statusChanges: [], recordings: [] }, - }); - importCallRecordingMediaMock.mockResolvedValue({ - audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }], - callRecorderFailureReason: 'video_file_too_large', + setFetchRoute('GET', `${RECALL_API_BASE_URL}/bot/recall-bot-1/`, () => + jsonResponse({ id: 'recall-bot-1' }), + ); + stubRecallRecordingMedia({ + externalRecordingId: 'recall-recording-1', + videoContentLengthBytes: TOO_LARGE_MEDIA_CONTENT_LENGTH_BYTES, + audioContentLengthBytes: 8, }); const client = new FakeCoreApiClient([ { @@ -980,10 +1139,11 @@ describe('handleRecallWebhook', () => { data: { status: 'COMPLETED' }, }, ]); - expect(chargeCompletedCallRecordingMock).toHaveBeenCalledWith({ - callRecordingId: 'call-recording-1', - startedAt: '2026-01-01T13:02:00.000Z', - endedAt: '2026-01-01T14:05:00.000Z', + expect(chargeCreditsMock).toHaveBeenCalledWith({ + creditsUsedMicro: 1_050_000, + quantity: 63, + operationType: 'CALL_RECORDING', + resourceContext: 'recall', }); expect(result).toEqual({ status: 'updated', @@ -994,13 +1154,13 @@ describe('handleRecallWebhook', () => { }); it('keeps the real failure reason over the size marker on recording.failed', async () => { - getRecallBotMock.mockResolvedValue({ - ok: true, - bot: { id: 'recall-bot-1', metadata: {}, statusChanges: [], recordings: [] }, - }); - importCallRecordingMediaMock.mockResolvedValue({ - audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }], - callRecorderFailureReason: 'video_file_too_large', + setFetchRoute('GET', `${RECALL_API_BASE_URL}/bot/recall-bot-1/`, () => + jsonResponse({ id: 'recall-bot-1' }), + ); + stubRecallRecordingMedia({ + externalRecordingId: 'recall-recording-1', + videoContentLengthBytes: TOO_LARGE_MEDIA_CONTENT_LENGTH_BYTES, + audioContentLengthBytes: 8, }); const client = new FakeCoreApiClient([ { @@ -1034,7 +1194,7 @@ describe('handleRecallWebhook', () => { }, }, ]); - expect(chargeCompletedCallRecordingMock).not.toHaveBeenCalled(); + expect(chargeCreditsMock).not.toHaveBeenCalled(); expect(result).toEqual({ status: 'updated', event: 'recording.failed', @@ -1044,17 +1204,18 @@ describe('handleRecallWebhook', () => { }); it('stays PROCESSING on recording.done while artifacts are missing', async () => { - getRecallBotMock.mockResolvedValue({ - ok: true, - bot: { id: 'recall-bot-1', metadata: {}, statusChanges: [], recordings: [] }, - }); - importCallRecordingMediaMock.mockResolvedValue({ - audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }], - }); - createAsyncRecallTranscriptMock.mockResolvedValue({ - ok: true, - transcriptId: 'recall-transcript-1', + setFetchRoute('GET', `${RECALL_API_BASE_URL}/bot/recall-bot-1/`, () => + jsonResponse({ id: 'recall-bot-1' }), + ); + stubRecallRecordingMedia({ + externalRecordingId: 'recall-recording-1', + audioContentLengthBytes: 8, }); + setFetchRoute( + 'POST', + `${RECALL_API_BASE_URL}/recording/recall-recording-1/create_transcript/`, + () => jsonResponse({ id: 'recall-transcript-1' }), + ); const client = new FakeCoreApiClient([ { id: 'call-recording-1', @@ -1071,9 +1232,10 @@ describe('handleRecallWebhook', () => { body: buildRecordingDoneWebhookBody(), }); - expect(createAsyncRecallTranscriptMock).toHaveBeenCalledWith({ - externalRecordingId: 'recall-recording-1', - }); + expect(fetchMock).toHaveBeenCalledWith( + `${RECALL_API_BASE_URL}/recording/recall-recording-1/create_transcript/`, + expect.objectContaining({ method: 'POST' }), + ); expect(client.mutations).toEqual([ expect.objectContaining({ id: 'call-recording-1', @@ -1085,14 +1247,13 @@ describe('handleRecallWebhook', () => { }), }), ]); - expect(chargeCompletedCallRecordingMock).not.toHaveBeenCalled(); + expect(chargeCreditsMock).not.toHaveBeenCalled(); }); it('marks FAILED on recording.done when no recording artifact path exists', async () => { - getRecallBotMock.mockResolvedValue({ - ok: true, - bot: { id: 'recall-bot-1', metadata: {}, statusChanges: [], recordings: [] }, - }); + setFetchRoute('GET', `${RECALL_API_BASE_URL}/bot/recall-bot-1/`, () => + jsonResponse({ id: 'recall-bot-1', recordings: [] }), + ); const client = new FakeCoreApiClient([ { id: 'call-recording-1', @@ -1135,7 +1296,7 @@ describe('handleRecallWebhook', () => { }, }, ]); - expect(chargeCompletedCallRecordingMock).not.toHaveBeenCalled(); + expect(chargeCreditsMock).not.toHaveBeenCalled(); }); it('completes and charges on transcript.done when media is already imported', async () => { @@ -1146,20 +1307,21 @@ describe('handleRecallWebhook', () => { }, ]; - retrieveRecallTranscriptMock.mockResolvedValue({ - ok: true, - transcript: { - downloadUrl: 'https://recall-transcripts.example.com/transcript-1', - statusCode: 'done', - statusSubCode: null, - }, - }); - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: async () => transcriptContent, - }), + setFetchRoute( + 'GET', + `${RECALL_API_BASE_URL}/transcript/recall-transcript-1/`, + () => + jsonResponse({ + data: { + download_url: 'https://recall-transcripts.example.com/transcript-1', + }, + status: { code: 'done', sub_code: null }, + }), + ); + setFetchRoute( + 'GET', + 'https://recall-transcripts.example.com/transcript-1', + () => jsonResponse(transcriptContent), ); const client = new FakeCoreApiClient([ @@ -1215,13 +1377,12 @@ describe('handleRecallWebhook', () => { data: { status: 'COMPLETED' }, }, ]); - expect(chargeCompletedCallRecordingMock).toHaveBeenCalledWith({ - callRecordingId: 'call-recording-1', - startedAt: '2026-01-01T13:02:00.000Z', - endedAt: '2026-01-01T14:05:00.000Z', + expect(chargeCreditsMock).toHaveBeenCalledWith({ + creditsUsedMicro: 1_050_000, + quantity: 63, + operationType: 'CALL_RECORDING', + resourceContext: 'recall', }); - - vi.unstubAllGlobals(); }); it('fills the transcript from the download URL on transcript.done', async () => { @@ -1232,20 +1393,21 @@ describe('handleRecallWebhook', () => { }, ]; - retrieveRecallTranscriptMock.mockResolvedValue({ - ok: true, - transcript: { - downloadUrl: 'https://recall-transcripts.example.com/transcript-1', - statusCode: 'done', - statusSubCode: null, - }, - }); - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: async () => transcriptContent, - }), + setFetchRoute( + 'GET', + `${RECALL_API_BASE_URL}/transcript/recall-transcript-1/`, + () => + jsonResponse({ + data: { + download_url: 'https://recall-transcripts.example.com/transcript-1', + }, + status: { code: 'done', sub_code: null }, + }), + ); + setFetchRoute( + 'GET', + 'https://recall-transcripts.example.com/transcript-1', + () => jsonResponse(transcriptContent), ); const client = new FakeCoreApiClient([ @@ -1289,9 +1451,10 @@ describe('handleRecallWebhook', () => { callRecordingId: 'call-recording-1', transcriptOutcome: 'FILLED', }); - expect(retrieveRecallTranscriptMock).toHaveBeenCalledWith({ - transcriptId: 'recall-transcript-1', - }); + expect(fetchMock).toHaveBeenCalledWith( + `${RECALL_API_BASE_URL}/transcript/recall-transcript-1/`, + expect.objectContaining({ method: 'GET' }), + ); expect(client.mutations).toEqual([ { id: 'call-recording-1', @@ -1301,9 +1464,7 @@ describe('handleRecallWebhook', () => { }, }, ]); - expect(chargeCompletedCallRecordingMock).not.toHaveBeenCalled(); - - vi.unstubAllGlobals(); + expect(chargeCreditsMock).not.toHaveBeenCalled(); }); it('writes a FAILED marker on transcript.failed', async () => { diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/import-call-recording-media.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/import-call-recording-media.test.ts index 6c2e154192..4dd99142f8 100644 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/import-call-recording-media.test.ts +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/import-call-recording-media.test.ts @@ -1,11 +1,13 @@ +import { type ClientRequest, type IncomingMessage } from 'node:http'; +import { PassThrough, Readable } from 'node:stream'; + import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { CALL_RECORDING_VIDEO_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/call-recording-video-field-universal-identifier'; import { importCallRecordingMedia } from 'src/logic-functions/flows/import-call-recording-media.util'; const mutationMock = vi.hoisted(() => vi.fn()); -const getRecallRecordingMock = vi.hoisted(() => vi.fn()); -const putMediaDownloadBodyToUploadTargetMock = vi.hoisted(() => vi.fn()); +const requestOverHttpsMock = vi.hoisted(() => vi.fn()); vi.mock('twenty-client-sdk/metadata', () => ({ MetadataApiClient: class { @@ -13,19 +15,17 @@ vi.mock('twenty-client-sdk/metadata', () => ({ }, })); -vi.mock('src/logic-functions/recall-api/get-recall-recording.util', () => ({ - getRecallRecording: getRecallRecordingMock, -})); +vi.mock('node:https', async () => { + const actualHttps = + await vi.importActual('node:https'); -vi.mock( - 'src/logic-functions/flows/put-media-download-body-to-upload-target.util', - () => ({ - putMediaDownloadBodyToUploadTarget: putMediaDownloadBodyToUploadTargetMock, - }), -); + return { ...actualHttps, request: requestOverHttpsMock }; +}); const VIDEO_URL = 'https://media.example.com/video.mp4'; const AUDIO_URL = 'https://media.example.com/audio.mp3'; +const RECALL_RECORDING_URL = + 'https://us-west-2.recall.ai/api/v1/recording/recall-recording-1/'; const RECORDING_WITH_MEDIA = { id: 'recall-recording-1', @@ -35,6 +35,8 @@ const RECORDING_WITH_MEDIA = { }, }; +let buildRecallRecordingResponse: () => Response; + const uploadUrlForFilename = (filename: string) => `https://storage.example.com/${filename}`; @@ -88,6 +90,10 @@ const stubFetch = ({ throw new Error('Upload requests should go through the upload bridge'); } + if (url === RECALL_RECORDING_URL) { + return Promise.resolve(buildRecallRecordingResponse()); + } + const downloadResponse = downloadsByUrl[url]; if (downloadResponse === undefined) { @@ -139,23 +145,50 @@ const stubDirectUpload = ({ }); }; -const getUploadBridgeCall = (fileName: string) => - putMediaDownloadBodyToUploadTargetMock.mock.calls.find( - ([uploadInput]) => uploadInput.fileName === fileName, +const buildUploadResponse = (): IncomingMessage => { + const uploadResponse = Readable.from([]) as IncomingMessage; + + uploadResponse.statusCode = 200; + + return uploadResponse; +}; + +const uploadedBytesByUrl = new Map(); + +const stubUploadRequests = () => { + uploadedBytesByUrl.clear(); + requestOverHttpsMock.mockReset(); + requestOverHttpsMock.mockImplementation((uploadUrl: URL) => { + const uploadRequest = new PassThrough(); + const uploadedBytes: number[] = []; + + uploadedBytesByUrl.set(uploadUrl.href, uploadedBytes); + uploadRequest.on('data', (chunk: Buffer) => { + uploadedBytes.push(...chunk); + }); + uploadRequest.on('finish', () => { + uploadRequest.emit('response', buildUploadResponse()); + }); + + return uploadRequest as unknown as ClientRequest; + }); +}; + +const getUploadRequestCall = (fileName: string) => + requestOverHttpsMock.mock.calls.find( + ([uploadUrl]) => uploadUrl.href === uploadUrlForFilename(fileName), ); describe('importCallRecordingMedia', () => { beforeEach(() => { vi.spyOn(console, 'warn').mockImplementation(() => {}); vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.stubEnv('RECALL_API_KEY', 'recall-api-key'); + vi.stubEnv('RECALL_REGION', 'us-west-2'); mutationMock.mockReset(); - getRecallRecordingMock.mockReset(); - putMediaDownloadBodyToUploadTargetMock.mockReset(); - putMediaDownloadBodyToUploadTargetMock.mockResolvedValue(undefined); - getRecallRecordingMock.mockResolvedValue({ - ok: true, - recording: RECORDING_WITH_MEDIA, - }); + stubUploadRequests(); + buildRecallRecordingResponse = () => + new Response(JSON.stringify(RECORDING_WITH_MEDIA), { status: 200 }); stubDirectUpload({ finalFileIdByFilename: { 'video.mp4': 'file-video-1', @@ -172,6 +205,9 @@ describe('importCallRecordingMedia', () => { afterEach(() => { vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + vi.useRealTimers(); + vi.restoreAllMocks(); }); it('streams and uploads every missing artifact', async () => { @@ -186,24 +222,32 @@ describe('importCallRecordingMedia', () => { video: [{ fileId: 'file-video-1', label: 'video.mp4' }], audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }], }); - expect(getUploadBridgeCall('video.mp4')).toMatchObject([ - expect.objectContaining({ - fileName: 'video.mp4', - sizeBytes: 8, - uploadTarget: expect.objectContaining({ - uploadUrl: uploadUrlForFilename('video.mp4'), - }), - }), - ]); - expect(getUploadBridgeCall('audio.mp3')).toMatchObject([ - expect.objectContaining({ - fileName: 'audio.mp3', - sizeBytes: 8, - uploadTarget: expect.objectContaining({ - uploadUrl: uploadUrlForFilename('audio.mp3'), - }), - }), - ]); + const [videoUploadUrl, videoUploadOptions] = + getUploadRequestCall('video.mp4') ?? []; + expect(videoUploadUrl?.href).toBe(uploadUrlForFilename('video.mp4')); + expect(videoUploadOptions).toMatchObject({ + method: 'PUT', + headers: { + 'Content-Length': 8, + 'Content-Type': 'application/octet-stream', + }, + }); + expect( + uploadedBytesByUrl.get(uploadUrlForFilename('video.mp4')), + ).toHaveLength(8); + const [audioUploadUrl, audioUploadOptions] = + getUploadRequestCall('audio.mp3') ?? []; + expect(audioUploadUrl?.href).toBe(uploadUrlForFilename('audio.mp3')); + expect(audioUploadOptions).toMatchObject({ + method: 'PUT', + headers: { + 'Content-Length': 8, + 'Content-Type': 'application/octet-stream', + }, + }); + expect( + uploadedBytesByUrl.get(uploadUrlForFilename('audio.mp3')), + ).toHaveLength(8); }); it('declares the presigned upload with the download size, folder and field identifier', async () => { @@ -247,7 +291,7 @@ describe('importCallRecordingMedia', () => { expect(updateFields).toEqual({ audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }], }); - expect(getUploadBridgeCall('video.mp4')).toBeUndefined(); + expect(getUploadRequestCall('video.mp4')).toBeUndefined(); }); it('does not fetch the recording when both artifacts are present', async () => { @@ -259,7 +303,7 @@ describe('importCallRecordingMedia', () => { }); expect(updateFields).toEqual({}); - expect(getRecallRecordingMock).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); expect(mutationMock).not.toHaveBeenCalled(); }); @@ -296,7 +340,7 @@ describe('importCallRecordingMedia', () => { audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }], }); expect(cancelMock).toHaveBeenCalledTimes(1); - expect(getUploadBridgeCall('video.mp4')).toBeUndefined(); + expect(getUploadRequestCall('video.mp4')).toBeUndefined(); expect(console.warn).toHaveBeenCalledWith( expect.stringContaining('upload target exploded'), ); @@ -324,7 +368,7 @@ describe('importCallRecordingMedia', () => { expect(updateFields).toEqual({ audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }], }); - expect(getUploadBridgeCall('video.mp4')).toBeUndefined(); + expect(getUploadRequestCall('video.mp4')).toBeUndefined(); expect(console.warn).toHaveBeenCalledWith( expect.stringContaining('content-length'), ); @@ -334,10 +378,10 @@ describe('importCallRecordingMedia', () => { }); it('returns nothing when the recording exposes no media urls', async () => { - getRecallRecordingMock.mockResolvedValue({ - ok: true, - recording: { id: 'recall-recording-1' }, - }); + buildRecallRecordingResponse = () => + new Response(JSON.stringify({ id: 'recall-recording-1' }), { + status: 200, + }); const updateFields = await importCallRecordingMedia({ callRecordingId: 'call-recording-1', @@ -351,18 +395,20 @@ describe('importCallRecordingMedia', () => { }); it('warns and returns nothing when the recording fetch fails', async () => { - getRecallRecordingMock.mockResolvedValue({ - ok: false, - status: 500, - errorMessage: 'recording boom', - }); + buildRecallRecordingResponse = () => + new Response(JSON.stringify({ error: 'recording boom' }), { + status: 500, + }); + vi.useFakeTimers(); - const updateFields = await importCallRecordingMedia({ + const updateFieldsPromise = importCallRecordingMedia({ callRecordingId: 'call-recording-1', externalRecordingId: 'recall-recording-1', hasAudio: false, hasVideo: false, }); + await vi.runAllTimersAsync(); + const updateFields = await updateFieldsPromise; expect(updateFields).toEqual({}); expect(mutationMock).not.toHaveBeenCalled(); @@ -395,7 +441,7 @@ describe('importCallRecordingMedia', () => { audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }], callRecorderFailureReason: 'video_file_too_large', }); - expect(getUploadBridgeCall('video.mp4')).toBeUndefined(); + expect(getUploadRequestCall('video.mp4')).toBeUndefined(); expect(cancelMock).toHaveBeenCalled(); expect(console.warn).toHaveBeenCalledWith( expect.stringContaining('artifact-too-large'), diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/reconcile-call-recorder.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/reconcile-call-recorder.test.ts index e5524b6734..e35273f770 100644 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/reconcile-call-recorder.test.ts +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/reconcile-call-recorder.test.ts @@ -1,35 +1,43 @@ import { type CoreApiClient } from 'twenty-client-sdk/core'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { computeCallRecordingIdForMeeting } from 'src/logic-functions/domain/compute-call-recording-id-for-meeting.util'; import { reconcileCallRecorderForCalendarEventIds } from 'src/logic-functions/flows/reconcile-call-recorder.util'; -const scheduleRecallBotMock = vi.hoisted(() => vi.fn()); -const rescheduleRecallBotMock = vi.hoisted(() => vi.fn()); -const cancelRecallBotMock = vi.hoisted(() => vi.fn()); -const getCurrentWorkspaceIdMock = vi.hoisted(() => vi.fn()); - -vi.mock('src/logic-functions/data/get-current-workspace-id.util', () => ({ - getCurrentWorkspaceId: getCurrentWorkspaceIdMock, -})); - -vi.mock('src/logic-functions/recall-api/schedule-recall-bot.util', () => ({ - scheduleRecallBot: scheduleRecallBotMock, -})); - -vi.mock('src/logic-functions/recall-api/reschedule-recall-bot.util', () => ({ - rescheduleRecallBot: rescheduleRecallBotMock, -})); - -vi.mock('src/logic-functions/recall-api/cancel-recall-bot.util', () => ({ - cancelRecallBot: cancelRecallBotMock, -})); +const fetchMock = vi.fn(); const NOW = new Date('2026-01-01T12:00:00.000Z'); const WORKSPACE_ID = '123e4567-e89b-12d3-a456-426614174000'; const FUTURE_STARTS_AT = '2026-01-01T13:00:00.000Z'; const FUTURE_RECALL_BOT_JOIN_AT = '2026-01-01T12:59:00.000Z'; const FUTURE_ENDS_AT = '2026-01-01T14:00:00.000Z'; +const RECALL_API_BASE_URL = 'https://us-west-2.recall.ai/api/v1'; + +const buildAccessToken = (payload: Record): string => + [ + Buffer.from(JSON.stringify({ alg: 'none' })).toString('base64url'), + Buffer.from(JSON.stringify(payload)).toString('base64url'), + 'signature', + ].join('.'); + +type RecallFetchCall = [ + requestUrl: string, + requestInit: { + method: string; + headers: Record; + body?: string; + }, +]; + +const recallFetchCalls = (method: string): RecallFetchCall[] => + (fetchMock.mock.calls as RecallFetchCall[]).filter( + ([, requestInit]) => requestInit.method === method, + ); + +const recallBotCreateCalls = (): RecallFetchCall[] => recallFetchCalls('POST'); +const recallBotUpdateCalls = (): RecallFetchCall[] => recallFetchCalls('PATCH'); +const recallBotDeleteCalls = (): RecallFetchCall[] => + recallFetchCalls('DELETE'); const buildCustomerSyncCallRecordingId = ( startsAt: string = FUTURE_STARTS_AT, @@ -213,23 +221,37 @@ describe('reconcileCallRecorderForCalendarEventIds', () => { beforeEach(() => { vi.spyOn(console, 'warn').mockImplementation(() => {}); vi.spyOn(console, 'error').mockImplementation(() => {}); - getCurrentWorkspaceIdMock.mockReset(); - getCurrentWorkspaceIdMock.mockReturnValue(WORKSPACE_ID); - scheduleRecallBotMock.mockReset(); - scheduleRecallBotMock.mockResolvedValue({ - ok: true, - externalBotId: 'recall-bot-1', - }); - rescheduleRecallBotMock.mockReset(); - rescheduleRecallBotMock.mockResolvedValue({ - ok: true, - externalBotId: 'recall-bot-1', - }); - cancelRecallBotMock.mockReset(); - cancelRecallBotMock.mockResolvedValue({ - ok: true, - externalBotId: null, - }); + vi.stubGlobal('fetch', fetchMock); + vi.stubEnv( + 'TWENTY_APP_ACCESS_TOKEN', + buildAccessToken({ workspaceId: WORKSPACE_ID }), + ); + vi.stubEnv('RECALL_API_KEY', 'recall-api-key'); + vi.stubEnv('RECALL_REGION', 'us-west-2'); + vi.stubEnv('CALL_RECORDER_USE_WORKSPACE_LOGO', 'false'); + fetchMock.mockReset(); + fetchMock.mockImplementation( + async (requestUrl: string, requestInit: RequestInit) => { + if (requestInit.method === 'POST' || requestInit.method === 'PATCH') { + return new Response(JSON.stringify({ id: 'recall-bot-1' }), { + status: 200, + }); + } + + if (requestInit.method === 'DELETE') { + return new Response(null, { status: 204 }); + } + + throw new Error(`Unhandled fetch: ${requestInit.method} ${requestUrl}`); + }, + ); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + vi.restoreAllMocks(); }); it('creates a scheduled call recording when the policy requests a bot', async () => { @@ -259,14 +281,22 @@ describe('reconcileCallRecorderForCalendarEventIds', () => { externalBotId: 'recall-bot-1', }, ]); - expect(scheduleRecallBotMock).toHaveBeenCalledWith({ - meetingUrl: 'https://meet.example.com/customer-sync', - joinAt: FUTURE_RECALL_BOT_JOIN_AT, - metadata: { - twentyWorkspaceId: WORKSPACE_ID, - twentyCallRecordingId: buildCustomerSyncCallRecordingId(), - }, - }); + expect(recallBotCreateCalls()).toHaveLength(1); + const [createBotUrl, createBotInit] = recallBotCreateCalls()[0]; + expect(createBotUrl).toBe(`${RECALL_API_BASE_URL}/bot/`); + expect(createBotInit.headers).toEqual( + expect.objectContaining({ Authorization: 'Token recall-api-key' }), + ); + expect(JSON.parse(createBotInit.body ?? '')).toEqual( + expect.objectContaining({ + meeting_url: 'https://meet.example.com/customer-sync', + join_at: FUTURE_RECALL_BOT_JOIN_AT, + metadata: { + twentyWorkspaceId: WORKSPACE_ID, + twentyCallRecordingId: buildCustomerSyncCallRecordingId(), + }, + }), + ); }); it('creates a scheduled call recording with a fallback title when the calendar title is unavailable', async () => { @@ -312,7 +342,7 @@ describe('reconcileCallRecorderForCalendarEventIds', () => { callRecordingId: buildCustomerSyncCallRecordingId(), }), ]); - expect(scheduleRecallBotMock).toHaveBeenCalledTimes(1); + expect(recallBotCreateCalls()).toHaveLength(1); }); it('creates a recording for an in-progress meeting that has not ended', async () => { @@ -340,7 +370,7 @@ describe('reconcileCallRecorderForCalendarEventIds', () => { ), }), ]); - expect(scheduleRecallBotMock).toHaveBeenCalledTimes(1); + expect(recallBotCreateCalls()).toHaveLength(1); }); it('updates an existing in-progress recording', async () => { @@ -432,15 +462,19 @@ describe('reconcileCallRecorderForCalendarEventIds', () => { externalBotId: 'recall-bot-1', }), ]); - expect(rescheduleRecallBotMock).toHaveBeenCalledWith({ - externalBotId: 'recall-bot-1', - meetingUrl: 'https://meet.example.com/customer-sync', - joinAt: FUTURE_RECALL_BOT_JOIN_AT, - metadata: { - twentyWorkspaceId: WORKSPACE_ID, - twentyCallRecordingId: buildCustomerSyncCallRecordingId(), - }, - }); + expect(recallBotUpdateCalls()).toHaveLength(1); + const [updateBotUrl, updateBotInit] = recallBotUpdateCalls()[0]; + expect(updateBotUrl).toBe(`${RECALL_API_BASE_URL}/bot/recall-bot-1/`); + expect(JSON.parse(updateBotInit.body ?? '')).toEqual( + expect.objectContaining({ + meeting_url: 'https://meet.example.com/customer-sync', + join_at: FUTURE_RECALL_BOT_JOIN_AT, + metadata: { + twentyWorkspaceId: WORKSPACE_ID, + twentyCallRecordingId: buildCustomerSyncCallRecordingId(), + }, + }), + ); }); it('replaces a stale visible title with the fallback title when the calendar title becomes unavailable', async () => { @@ -521,17 +555,18 @@ describe('reconcileCallRecorderForCalendarEventIds', () => { externalBotId: null, }), ]); - expect(cancelRecallBotMock).toHaveBeenCalledWith({ - externalBotId: 'recall-bot-1', - }); + expect(recallBotDeleteCalls().map(([requestUrl]) => requestUrl)).toEqual([ + `${RECALL_API_BASE_URL}/bot/recall-bot-1/`, + ]); }); it('persists the cancel intent and leaves the bot for the planned stale-state cron when the Recall cancel fails', async () => { - cancelRecallBotMock.mockResolvedValue({ - ok: false, - status: 500, - errorMessage: 'Recall API responded with HTTP 500', - }); + vi.useFakeTimers(); + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ detail: 'server error' }), { + status: 500, + }), + ); const client = buildFakeCoreApiClient({ calendarEvents: [ @@ -553,12 +588,16 @@ describe('reconcileCallRecorderForCalendarEventIds', () => { ], }); - const result = await reconcileCallRecorderForCalendarEventIds({ + const resultPromise = reconcileCallRecorderForCalendarEventIds({ client: client as unknown as CoreApiClient, calendarEventIds: ['calendar-event-1'], now: NOW, }); + await vi.runAllTimersAsync(); + + const result = await resultPromise; + expect(result).toEqual([ expect.objectContaining({ action: 'CANCELED', @@ -640,7 +679,7 @@ describe('reconcileCallRecorderForCalendarEventIds', () => { }), ]); expect(client.callRecordings).toHaveLength(1); - expect(scheduleRecallBotMock).toHaveBeenCalledTimes(1); + expect(recallBotCreateCalls()).toHaveLength(1); }); it('does not create a duplicate when a non-policy-managed open recording already exists', async () => { @@ -673,7 +712,7 @@ describe('reconcileCallRecorderForCalendarEventIds', () => { ]); expect(client.callRecordings).toHaveLength(1); expect(client.mutations).toEqual([]); - expect(scheduleRecallBotMock).not.toHaveBeenCalled(); + expect(recallBotCreateCalls()).toHaveLength(0); }); it('cancels the scheduled request when the calendar event is deleted', async () => { @@ -719,9 +758,9 @@ describe('reconcileCallRecorderForCalendarEventIds', () => { externalBotId: null, }), ]); - expect(cancelRecallBotMock).toHaveBeenCalledWith({ - externalBotId: 'recall-bot-1', - }); + expect(recallBotDeleteCalls().map(([requestUrl]) => requestUrl)).toEqual([ + `${RECALL_API_BASE_URL}/bot/recall-bot-1/`, + ]); }); it('cancels the old occurrence and creates a fresh recording when the meeting moves to a new time', async () => { @@ -772,11 +811,12 @@ describe('reconcileCallRecorderForCalendarEventIds', () => { callRecordingId: buildCustomerSyncCallRecordingId(NEW_STARTS_AT), }), ]); - expect(cancelRecallBotMock).toHaveBeenCalledExactlyOnceWith({ - externalBotId: 'recall-bot-old', - }); - expect(scheduleRecallBotMock).toHaveBeenCalledExactlyOnceWith( - expect.objectContaining({ joinAt: NEW_RECALL_BOT_JOIN_AT }), + expect(recallBotDeleteCalls().map(([requestUrl]) => requestUrl)).toEqual([ + `${RECALL_API_BASE_URL}/bot/recall-bot-old/`, + ]); + expect(recallBotCreateCalls()).toHaveLength(1); + expect(JSON.parse(recallBotCreateCalls()[0][1].body ?? '')).toEqual( + expect.objectContaining({ join_at: NEW_RECALL_BOT_JOIN_AT }), ); expect(client.callRecordings).toEqual([ expect.objectContaining({ @@ -793,9 +833,21 @@ describe('reconcileCallRecorderForCalendarEventIds', () => { }); it('reconciles the remaining meetings when one meeting fails', async () => { - cancelRecallBotMock.mockRejectedValue(new Error('recall exploded')); + // The cancel intent persists, then clearing the canceled bot id blows up mid-meeting. + class CancelCleanupFailureFakeCoreApiClient extends FakeCoreApiClient { + override async mutation(mutation: any): Promise { + if ( + mutation.updateCallRecording !== undefined && + mutation.updateCallRecording.__args.data.externalBotId === null + ) { + throw new Error('recall exploded'); + } - const client = buildFakeCoreApiClient({ + return super.mutation(mutation); + } + } + + const client = new CancelCleanupFailureFakeCoreApiClient({ calendarEvents: [ buildCalendarEvent({ callRecorderPreference: 'OFF', @@ -892,11 +944,17 @@ describe('reconcileCallRecorderForCalendarEventIds', () => { }); it('clears the stale bot id for the stale-state cron to re-create when the existing Recall bot no longer exists', async () => { - rescheduleRecallBotMock.mockResolvedValue({ - ok: false, - status: 404, - errorMessage: 'Recall API responded with HTTP 404', - }); + fetchMock.mockImplementation( + async (requestUrl: string, requestInit: RequestInit) => { + if (requestInit.method === 'PATCH') { + return new Response(JSON.stringify({ detail: 'Not found.' }), { + status: 404, + }); + } + + throw new Error(`Unhandled fetch: ${requestInit.method} ${requestUrl}`); + }, + ); const client = buildFakeCoreApiClient({ calendarEvents: [buildCalendarEvent()], @@ -926,11 +984,11 @@ describe('reconcileCallRecorderForCalendarEventIds', () => { callRecordingId: buildCustomerSyncCallRecordingId(), }), ]); - expect(rescheduleRecallBotMock).toHaveBeenCalledWith( - expect.objectContaining({ externalBotId: 'recall-bot-stale' }), - ); + expect(recallBotUpdateCalls().map(([requestUrl]) => requestUrl)).toEqual([ + `${RECALL_API_BASE_URL}/bot/recall-bot-stale/`, + ]); // The event path no longer re-creates the bot; the stale id is cleared and the cron schedules a bot for the pending row. - expect(scheduleRecallBotMock).not.toHaveBeenCalled(); + expect(recallBotCreateCalls()).toHaveLength(0); expect(client.callRecordings).toEqual([ expect.objectContaining({ id: buildCustomerSyncCallRecordingId(), @@ -982,10 +1040,10 @@ describe('reconcileCallRecorderForCalendarEventIds', () => { }), ]); expect(client.callRecordings).toHaveLength(1); - expect(scheduleRecallBotMock).not.toHaveBeenCalled(); - expect(rescheduleRecallBotMock).toHaveBeenCalledWith( - expect.objectContaining({ externalBotId: 'sibling-bot' }), - ); + expect(recallBotCreateCalls()).toHaveLength(0); + expect(recallBotUpdateCalls().map(([requestUrl]) => requestUrl)).toEqual([ + `${RECALL_API_BASE_URL}/bot/sibling-bot/`, + ]); }); it('fails the meeting when the create conflicts without a readable recording', async () => { @@ -1015,7 +1073,7 @@ describe('reconcileCallRecorderForCalendarEventIds', () => { errorMessage: 'Duplicate id on a soft-deleted record', }), ]); - expect(scheduleRecallBotMock).not.toHaveBeenCalled(); + expect(recallBotCreateCalls()).toHaveLength(0); }); it('schedules exactly one bot when concurrent reconciles race for the same meeting', async () => { @@ -1034,7 +1092,7 @@ describe('reconcileCallRecorderForCalendarEventIds', () => { ); expect(client.callRecordings).toHaveLength(1); - expect(scheduleRecallBotMock).toHaveBeenCalledTimes(1); + expect(recallBotCreateCalls()).toHaveLength(1); expect(client.callRecordings[0].externalBotId).toBe('recall-bot-1'); }); @@ -1066,6 +1124,6 @@ describe('reconcileCallRecorderForCalendarEventIds', () => { }); expect(result).toEqual([expect.objectContaining({ action: 'CREATED' })]); - expect(scheduleRecallBotMock).not.toHaveBeenCalled(); + expect(recallBotCreateCalls()).toHaveLength(0); }); }); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/reconcile-upcoming-calendar-event-batches.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/reconcile-upcoming-calendar-event-batches.test.ts index baabaad78e..d3d67de106 100644 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/reconcile-upcoming-calendar-event-batches.test.ts +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/reconcile-upcoming-calendar-event-batches.test.ts @@ -1,48 +1,198 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { CoreApiClient } from 'twenty-client-sdk/core'; +import { RECONCILE_UPCOMING_CALENDAR_EVENTS_ROUTE_PATH } from 'src/constants/reconcile-upcoming-calendar-events-route-path'; import { reconcileUpcomingCalendarEventBatches } from 'src/logic-functions/flows/reconcile-upcoming-calendar-event-batches.util'; -const reconcileCallRecorderForCalendarEventIdsMock = vi.hoisted(() => vi.fn()); -const requestUpcomingCalendarEventsReconciliationMock = vi.hoisted(() => - vi.fn(), -); - -vi.mock('src/logic-functions/flows/reconcile-call-recorder.util', () => ({ - reconcileCallRecorderForCalendarEventIds: - reconcileCallRecorderForCalendarEventIdsMock, -})); - -vi.mock( - 'src/logic-functions/data/request-upcoming-calendar-events-reconciliation.util', - () => ({ - requestUpcomingCalendarEventsReconciliation: - requestUpcomingCalendarEventsReconciliationMock, - }), -); +const queryMock = vi.fn(); +const mutationMock = vi.fn(); +const fetchMock = vi.fn(); const CLIENT: CoreApiClient = Object.assign( Object.create(CoreApiClient.prototype), { - mutation: vi.fn(), - query: vi.fn(), + mutation: mutationMock, + query: queryMock, }, ); +const MEETING_URL = 'https://meet.example.com/abc'; +const MEETING_STARTS_AT = new Date(Date.now() + 60 * 60 * 1000).toISOString(); +const MEETING_ENDS_AT = new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString(); + +type CalendarEventNode = { + id: string; + title: string; + isCanceled: boolean; + startsAt: string; + endsAt: string; + conferenceLink: { primaryLinkUrl: string } | null; +}; + +type CallRecordingNode = { + id: string; + status: string; + recordingRequestStatus: string | null; + calendarEventId?: string; +}; + +type RecordsQuery = { + calendarEvents?: { + __args: { + filter: { + id?: { in: string[] }; + startsAt?: { in: string[] }; + }; + }; + }; + callRecordings?: { + __args: { + filter: { + id?: { in: string[] }; + calendarEventId?: { in: string[] }; + }; + }; + }; +}; + +type RecordsMutation = { + createCallRecording?: { + __args: { data: { id: string; calendarEventId: string } }; + }; + updateCallRecording?: { __args: { id: string } }; +}; + +const buildConnection = (nodes: TNode[]) => ({ + pageInfo: { hasNextPage: false, endCursor: null }, + edges: nodes.map((node) => ({ node })), +}); + +const buildCalendarEventNode = ( + id: string, + overrides: Partial = {}, +): CalendarEventNode => ({ + id, + title: 'Customer Sync', + isCanceled: false, + startsAt: MEETING_STARTS_AT, + endsAt: MEETING_ENDS_AT, + conferenceLink: { primaryLinkUrl: MEETING_URL }, + ...overrides, +}); + +const seedClientQueries = ({ + calendarEventNodesById = {}, + callRecordingNodesByCalendarEventId = {}, + hasExistingPolicyManagedCallRecordings = false, +}: { + calendarEventNodesById?: Record; + callRecordingNodesByCalendarEventId?: Record; + hasExistingPolicyManagedCallRecordings?: boolean; +} = {}): void => { + queryMock.mockImplementation(async (query: RecordsQuery) => { + if (query.calendarEvents !== undefined) { + const filter = query.calendarEvents.__args.filter; + + if (filter.id !== undefined) { + return { + calendarEvents: buildConnection( + filter.id.in.map( + (calendarEventId) => + calendarEventNodesById[calendarEventId] ?? + buildCalendarEventNode(calendarEventId), + ), + ), + }; + } + + return { calendarEvents: buildConnection([]) }; + } + + if (query.callRecordings !== undefined) { + const filter = query.callRecordings.__args.filter; + + if (filter.calendarEventId !== undefined) { + return { + callRecordings: buildConnection( + filter.calendarEventId.in.flatMap( + (calendarEventId) => + callRecordingNodesByCalendarEventId[calendarEventId] ?? [], + ), + ), + }; + } + + return { + callRecordings: buildConnection( + hasExistingPolicyManagedCallRecordings + ? (filter.id?.in ?? []).map((callRecordingId) => ({ + id: callRecordingId, + status: 'SCHEDULED', + recordingRequestStatus: 'REQUESTED', + })) + : [], + ), + }; + } + + throw new Error(`Unhandled query: ${JSON.stringify(query)}`); + }); +}; + +const seedClientMutations = ({ + failingCreateCalendarEventId, +}: { + failingCreateCalendarEventId?: string; +} = {}): void => { + mutationMock.mockImplementation(async (mutation: RecordsMutation) => { + if (mutation.createCallRecording !== undefined) { + const data = mutation.createCallRecording.__args.data; + + if (data.calendarEventId === failingCreateCalendarEventId) { + throw new Error('createCallRecording rejected'); + } + + return { createCallRecording: { id: data.id } }; + } + + if (mutation.updateCallRecording !== undefined) { + return { + updateCallRecording: { id: mutation.updateCallRecording.__args.id }, + }; + } + + throw new Error(`Unhandled mutation: ${JSON.stringify(mutation)}`); + }); +}; + +const readBatchCalendarEventIdFilters = (): string[][] => + queryMock.mock.calls + .map(([query]) => query.calendarEvents?.__args.filter.id?.in) + .filter( + (requestedIds): requestedIds is string[] => + requestedIds !== undefined && requestedIds.length > 1, + ); + const buildCalendarEventIds = (count: number): string[] => Array.from({ length: count }, (_, index) => `calendar-event-${index + 1}`); describe('reconcileUpcomingCalendarEventBatches', () => { beforeEach(() => { vi.clearAllMocks(); - reconcileCallRecorderForCalendarEventIdsMock.mockResolvedValue([ - { - action: 'CREATED', - realMeetingKey: 'link:meet.example.com/abc:2026-07-05T10:00:00.000Z', - callRecordingId: 'call-recording-1', - }, - ]); - requestUpcomingCalendarEventsReconciliationMock.mockResolvedValue(true); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.stubGlobal('fetch', fetchMock); + vi.stubEnv('TWENTY_FUNCTIONS_URL', 'https://acme.functions.example.com'); + vi.stubEnv('TWENTY_APP_ACCESS_TOKEN', 'app-access-token'); + fetchMock.mockResolvedValue(new Response('{}', { status: 200 })); + seedClientQueries(); + seedClientMutations(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + vi.restoreAllMocks(); }); it('reconciles every batch when the deadline is far away', async () => { @@ -54,21 +204,10 @@ describe('reconcileUpcomingCalendarEventBatches', () => { deadlineAtMs: Date.now() + 60_000, }); - expect(reconcileCallRecorderForCalendarEventIdsMock).toHaveBeenCalledTimes( - 2, - ); - expect( - reconcileCallRecorderForCalendarEventIdsMock, - ).toHaveBeenNthCalledWith(1, { - client: CLIENT, - calendarEventIds: calendarEventIds.slice(0, 25), - }); - expect( - reconcileCallRecorderForCalendarEventIdsMock, - ).toHaveBeenNthCalledWith(2, { - client: CLIENT, - calendarEventIds: calendarEventIds.slice(25), - }); + expect(readBatchCalendarEventIdFilters()).toEqual([ + [...calendarEventIds.slice(0, 25)].sort(), + [...calendarEventIds.slice(25)].sort(), + ]); expect(result.reconciledCalendarEventIds).toEqual(calendarEventIds); expect(result.remainingCalendarEventIds).toEqual([]); expect(result.actionCounts).toEqual({ @@ -79,9 +218,7 @@ describe('reconcileUpcomingCalendarEventBatches', () => { failed: 0, }); expect(result.continuationRequested).toBe(false); - expect( - requestUpcomingCalendarEventsReconciliationMock, - ).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); }); it('stops at the deadline and requests a continuation with the remaining ids', async () => { @@ -101,9 +238,9 @@ describe('reconcileUpcomingCalendarEventBatches', () => { getNowMs, }); - expect(reconcileCallRecorderForCalendarEventIdsMock).toHaveBeenCalledTimes( - 1, - ); + expect(readBatchCalendarEventIdFilters()).toEqual([ + [...calendarEventIds.slice(0, 25)].sort(), + ]); expect(result.reconciledCalendarEventIds).toEqual( calendarEventIds.slice(0, 25), ); @@ -111,25 +248,22 @@ describe('reconcileUpcomingCalendarEventBatches', () => { calendarEventIds.slice(25), ); expect(result.continuationRequested).toBe(true); - expect( - requestUpcomingCalendarEventsReconciliationMock, - ).toHaveBeenCalledWith({ - calendarEventIds: calendarEventIds.slice(25), - }); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [requestUrl, requestInit] = fetchMock.mock.calls[0]; + expect(requestUrl).toBe( + `https://acme.functions.example.com${RECONCILE_UPCOMING_CALENDAR_EVENTS_ROUTE_PATH}`, + ); + expect(requestInit.method).toBe('POST'); + expect(requestInit.body).toBe( + JSON.stringify({ calendarEventIds: calendarEventIds.slice(25) }), + ); }); it('records a failed batch and keeps processing the next one', async () => { const calendarEventIds = buildCalendarEventIds(30); - reconcileCallRecorderForCalendarEventIdsMock - .mockRejectedValueOnce(new Error('core api unavailable')) - .mockResolvedValueOnce([ - { - action: 'UPDATED', - realMeetingKey: 'link:meet.example.com/xyz:2026-07-06T10:00:00.000Z', - callRecordingId: 'call-recording-2', - }, - ]); + seedClientQueries({ hasExistingPolicyManagedCallRecordings: true }); + queryMock.mockRejectedValueOnce(new Error('core api unavailable')); const result = await reconcileUpcomingCalendarEventBatches({ client: CLIENT, @@ -153,24 +287,45 @@ describe('reconcileUpcomingCalendarEventBatches', () => { }); it('tallies every reconciliation action kind', async () => { - reconcileCallRecorderForCalendarEventIdsMock.mockResolvedValue([ - { - action: 'CREATED', - realMeetingKey: 'meeting-1', - callRecordingId: 'call-recording-1', + seedClientQueries({ + calendarEventNodesById: { + 'calendar-event-1': buildCalendarEventNode('calendar-event-1', { + conferenceLink: { + primaryLinkUrl: 'https://meet.example.com/created', + }, + }), + 'calendar-event-2': buildCalendarEventNode('calendar-event-2', { + isCanceled: true, + conferenceLink: { + primaryLinkUrl: 'https://meet.example.com/canceled', + }, + }), + 'calendar-event-3': buildCalendarEventNode('calendar-event-3', { + isCanceled: true, + conferenceLink: { + primaryLinkUrl: 'https://meet.example.com/skipped', + }, + }), + 'calendar-event-4': buildCalendarEventNode('calendar-event-4', { + conferenceLink: { + primaryLinkUrl: 'https://meet.example.com/failed', + }, + }), }, - { - action: 'CANCELED', - realMeetingKey: 'meeting-2', - callRecordingId: 'call-recording-2', + callRecordingNodesByCalendarEventId: { + 'calendar-event-2': [ + { + id: 'call-recording-2', + status: 'SCHEDULED', + recordingRequestStatus: 'REQUESTED', + calendarEventId: 'calendar-event-2', + }, + ], }, - { action: 'SKIPPED', realMeetingKey: 'meeting-3', callRecordingId: null }, - { - action: 'FAILED', - realMeetingKey: 'meeting-4', - errorMessage: 'recall rejected the bot', - }, - ]); + }); + seedClientMutations({ + failingCreateCalendarEventId: 'calendar-event-4', + }); const result = await reconcileUpcomingCalendarEventBatches({ client: CLIENT, diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/schedule-recall-bots-for-pending-call-recordings.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/schedule-recall-bots-for-pending-call-recordings.test.ts index edbd5332e3..e9ad60da27 100644 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/schedule-recall-bots-for-pending-call-recordings.test.ts +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/schedule-recall-bots-for-pending-call-recordings.test.ts @@ -1,25 +1,24 @@ import { type CoreApiClient } from 'twenty-client-sdk/core'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { scheduleRecallBotsForPendingCallRecordings } from 'src/logic-functions/flows/schedule-recall-bots-for-pending-call-recordings.util'; -const scheduleRecallBotMock = vi.hoisted(() => vi.fn()); -const getCurrentWorkspaceIdMock = vi.hoisted(() => vi.fn()); - -vi.mock('src/logic-functions/data/get-current-workspace-id.util', () => ({ - getCurrentWorkspaceId: getCurrentWorkspaceIdMock, -})); - -vi.mock('src/logic-functions/recall-api/schedule-recall-bot.util', () => ({ - scheduleRecallBot: scheduleRecallBotMock, -})); - const NOW = new Date('2026-01-01T12:00:00.000Z'); const WORKSPACE_ID = '123e4567-e89b-12d3-a456-426614174000'; const UPCOMING_STARTS_AT = '2026-01-01T13:00:00.000Z'; const UPCOMING_ENDS_AT = '2026-01-01T14:00:00.000Z'; const PAST_STARTS_AT = '2026-01-01T10:00:00.000Z'; const PAST_ENDS_AT = '2026-01-01T11:00:00.000Z'; +const RECALL_CREATE_BOT_URL = 'https://us-west-2.recall.ai/api/v1/bot/'; + +const buildAccessToken = (payload: Record): string => + [ + Buffer.from(JSON.stringify({ alg: 'none' })).toString('base64url'), + Buffer.from(JSON.stringify(payload)).toString('base64url'), + 'signature', + ].join('.'); + +const fetchMock = vi.fn(); type CallRecordingNode = { id: string; @@ -133,13 +132,26 @@ const buildCalendarEvent = ( describe('scheduleRecallBotsForPendingCallRecordings', () => { beforeEach(() => { vi.spyOn(console, 'warn').mockImplementation(() => {}); - getCurrentWorkspaceIdMock.mockReset(); - getCurrentWorkspaceIdMock.mockReturnValue(WORKSPACE_ID); - scheduleRecallBotMock.mockReset(); - scheduleRecallBotMock.mockResolvedValue({ - ok: true, - externalBotId: 'recall-bot-1', - }); + vi.stubGlobal('fetch', fetchMock); + vi.stubEnv('RECALL_API_KEY', 'recall-api-key'); + vi.stubEnv('RECALL_REGION', 'us-west-2'); + vi.stubEnv('CALL_RECORDER_USE_WORKSPACE_LOGO', 'false'); + vi.stubEnv( + 'TWENTY_APP_ACCESS_TOKEN', + buildAccessToken({ workspaceId: WORKSPACE_ID }), + ); + fetchMock.mockReset(); + fetchMock.mockImplementation( + async () => + new Response(JSON.stringify({ id: 'recall-bot-1' }), { status: 201 }), + ); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + vi.useRealTimers(); + vi.restoreAllMocks(); }); it('schedules a bot and writes the id for an upcoming pending recording', async () => { @@ -154,35 +166,50 @@ describe('scheduleRecallBotsForPendingCallRecordings', () => { }); expect(result.scheduledCallRecordingIds).toEqual(['call-recording-1']); - expect(scheduleRecallBotMock).toHaveBeenCalledTimes(1); - expect(scheduleRecallBotMock).toHaveBeenCalledWith( - expect.objectContaining({ - metadata: expect.objectContaining({ - twentyWorkspaceId: WORKSPACE_ID, - }), - }), - ); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [requestUrl, requestInit] = fetchMock.mock.calls[0]; + expect(requestUrl).toBe(RECALL_CREATE_BOT_URL); + expect(requestInit.method).toBe('POST'); + expect(requestInit.headers).toMatchObject({ + Authorization: 'Token recall-api-key', + }); + expect(JSON.parse(requestInit.body)).toMatchObject({ + meeting_url: 'https://meet.example.com/customer-sync', + join_at: '2026-01-01T12:59:00.000Z', + metadata: { + twentyWorkspaceId: WORKSPACE_ID, + twentyCallRecordingId: 'call-recording-1', + }, + }); expect(client.callRecordings[0].externalBotId).toBe('recall-bot-1'); }); it('does not report a recording as scheduled when Recall scheduling fails', async () => { - scheduleRecallBotMock.mockResolvedValue({ - ok: false, - status: 500, - errorMessage: 'Recall API responded with HTTP 500', - }); + fetchMock.mockImplementation( + async () => + new Response(JSON.stringify({ error: 'boom' }), { status: 500 }), + ); const client = new FakeCoreApiClient({ callRecordings: [buildPendingCallRecording()], calendarEvents: [buildCalendarEvent()], }); - const result = await scheduleRecallBotsForPendingCallRecordings({ + vi.useFakeTimers(); + const resultPromise = scheduleRecallBotsForPendingCallRecordings({ client: client as unknown as CoreApiClient, now: NOW, }); + await vi.runAllTimersAsync(); + const result = await resultPromise; expect(result.scheduledCallRecordingIds).toEqual([]); - expect(scheduleRecallBotMock).toHaveBeenCalledTimes(1); + // One scheduling attempt, retried to exhaustion on the wire. + expect(fetchMock).toHaveBeenCalledTimes(3); + expect( + fetchMock.mock.calls.every( + ([requestUrl]) => requestUrl === RECALL_CREATE_BOT_URL, + ), + ).toBe(true); expect(client.callRecordings[0].externalBotId).toBeNull(); }); @@ -203,7 +230,7 @@ describe('scheduleRecallBotsForPendingCallRecordings', () => { }); expect(result.scheduledCallRecordingIds).toEqual([]); - expect(scheduleRecallBotMock).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); }); it('does nothing when every scheduled recording already has a bot', async () => { @@ -220,6 +247,6 @@ describe('scheduleRecallBotsForPendingCallRecordings', () => { }); expect(result.scheduledCallRecordingIds).toEqual([]); - expect(scheduleRecallBotMock).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); }); });