Migrate call-recorder tests off own-code vi.mock (#22902)

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22902?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
nitin
2026-07-15 20:26:26 +05:30
committed by GitHub
parent 36a14478ae
commit cdb2590355
21 changed files with 2431 additions and 1310 deletions
@@ -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<string, object>;
} = {}) => {
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();
});
});
@@ -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' });
});
});
@@ -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: {} })),
@@ -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<string, unknown> } };
};
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 = <TNode>(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();
});
});
@@ -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,
);
});
});
@@ -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',
@@ -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<string, unknown> } };
};
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 = <TNode>(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();
});
});
@@ -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();
});
});
@@ -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);
});
@@ -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,
@@ -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<Buffer>;
};
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('<svg')) {
return buildSharpPipeline(() => {
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();
@@ -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();
});
});
@@ -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<typeof import('node:https')>('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<string, () => 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<string, unknown>) => {
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<Uint8Array>({
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<string, string> = {
'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<string, unknown>;
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' }),
@@ -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' }),
@@ -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);
});
});
@@ -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<string, object>) => {
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);
});
});
@@ -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<typeof import('node:https')>('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<string, () => 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<string, string> = {
'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 () => {
@@ -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<typeof import('node:https')>('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<string, number[]>();
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'),
@@ -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, unknown>): 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<string, string>;
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<any> {
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);
});
});
@@ -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 = <TNode>(nodes: TNode[]) => ({
pageInfo: { hasNextPage: false, endCursor: null },
edges: nodes.map((node) => ({ node })),
});
const buildCalendarEventNode = (
id: string,
overrides: Partial<CalendarEventNode> = {},
): 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<string, CalendarEventNode>;
callRecordingNodesByCalendarEventId?: Record<string, CallRecordingNode[]>;
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,
@@ -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, unknown>): 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();
});
});