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:
+192
-75
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+68
-17
@@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
+7
-13
@@ -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: {} })),
|
||||
|
||||
+159
-43
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+32
-35
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+61
-20
@@ -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',
|
||||
|
||||
+145
-45
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+22
-33
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+19
-12
@@ -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);
|
||||
});
|
||||
|
||||
+28
-16
@@ -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,
|
||||
|
||||
+94
-36
@@ -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();
|
||||
|
||||
|
||||
+45
-37
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+433
-331
@@ -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' }),
|
||||
|
||||
+51
-29
@@ -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' }),
|
||||
|
||||
+61
-75
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+116
-42
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+349
-188
@@ -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 () => {
|
||||
|
||||
+100
-54
@@ -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'),
|
||||
|
||||
+152
-94
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+234
-79
@@ -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,
|
||||
|
||||
+63
-36
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user