From cdb2590355c98f19c0ffc77f428385c15c174c04 Mon Sep 17 00:00:00 2001
From: nitin <142569587+ehconitin@users.noreply.github.com>
Date: Wed, 15 Jul 2026 20:26:26 +0530
Subject: [PATCH] Migrate call-recorder tests off own-code vi.mock (#22902)
---
.../generate-call-recording-summaries.test.ts | 267 ++++--
.../__tests__/process-recall-webhook.test.ts | 85 +-
.../__tests__/recall-webhook.test.ts | 20 +-
...reconcile-upcoming-calendar-events.test.ts | 202 ++++-
.../start-post-install-backfills.test.ts | 67 +-
.../summarize-call-recording.test.ts | 81 +-
.../sweep-upcoming-calendar-events.test.ts | 190 +++--
.../data/__tests__/post-to-own-route.test.ts | 55 +-
...-call-recording-summaries-backfill.test.ts | 31 +-
...ing-calendar-events-reconciliation.test.ts | 44 +-
...ll-bot-automatic-video-output.util.test.ts | 130 ++-
...complete-and-charge-call-recording.test.ts | 82 +-
.../converge-diverged-call-recordings.test.ts | 764 ++++++++++--------
.../__tests__/download-transcript.test.ts | 80 +-
.../generate-call-recording-summary.test.ts | 136 ++--
...e-missing-call-recording-summaries.test.ts | 158 +++-
.../__tests__/handle-recall-webhook.test.ts | 537 +++++++-----
.../import-call-recording-media.test.ts | 154 ++--
.../__tests__/reconcile-call-recorder.test.ts | 246 +++---
...le-upcoming-calendar-event-batches.test.ts | 313 +++++--
...l-bots-for-pending-call-recordings.test.ts | 99 ++-
21 files changed, 2431 insertions(+), 1310 deletions(-)
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/generate-call-recording-summaries.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/generate-call-recording-summaries.test.ts
index a9dace06e5..c06f40ebe0 100644
--- a/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/generate-call-recording-summaries.test.ts
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/generate-call-recording-summaries.test.ts
@@ -1,46 +1,24 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { type RoutePayload } from 'twenty-sdk/define';
import { generateCallRecordingSummariesHandler } from 'src/logic-functions/generate-call-recording-summaries';
-const findCallRecordingIdsMissingSummaryMock = vi.hoisted(() => vi.fn());
-const findCallRecordingIdsForCalendarEventsMock = vi.hoisted(() => vi.fn());
-const generateMissingCallRecordingSummariesMock = vi.hoisted(() => vi.fn());
-const isCallRecordingSummaryEnabledMock = vi.hoisted(() => vi.fn());
+const queryMock = vi.hoisted(() => vi.fn());
+const mutationMock = vi.hoisted(() => vi.fn());
+const runAgentMock = vi.hoisted(() => vi.fn());
vi.mock('twenty-client-sdk/core', () => ({
- CoreApiClient: vi.fn(),
+ CoreApiClient: class {
+ query = queryMock;
+ mutation = mutationMock;
+ },
}));
-vi.mock(
- 'src/logic-functions/data/find-call-recording-ids-missing-summary.util',
- () => ({
- findCallRecordingIdsMissingSummary: findCallRecordingIdsMissingSummaryMock,
- }),
-);
+vi.mock('twenty-sdk/logic-function', () => ({
+ runAgent: runAgentMock,
+}));
-vi.mock(
- 'src/logic-functions/data/find-call-recording-ids-for-calendar-events.util',
- () => ({
- findCallRecordingIdsForCalendarEvents:
- findCallRecordingIdsForCalendarEventsMock,
- }),
-);
-
-vi.mock(
- 'src/logic-functions/flows/generate-missing-call-recording-summaries.util',
- () => ({
- generateMissingCallRecordingSummaries:
- generateMissingCallRecordingSummariesMock,
- }),
-);
-
-vi.mock(
- 'src/logic-functions/utils/is-call-recording-summary-enabled.util',
- () => ({
- isCallRecordingSummaryEnabled: isCallRecordingSummaryEnabledMock,
- }),
-);
+const fetchMock = vi.fn();
const buildRoutePayload = (
body: object | null,
@@ -56,6 +34,78 @@ const buildRoutePayload = (
userWorkspaceId: null,
}) as never;
+const TRANSCRIPT = [
+ {
+ participant: { name: 'Alex' },
+ words: [{ text: 'Hello' }, { text: 'team' }],
+ },
+];
+
+type CallRecordingsQueryShape = {
+ callRecordings: {
+ __args: {
+ filter: {
+ id?: { eq: string };
+ calendarEventId?: { in: string[] };
+ };
+ };
+ };
+};
+
+const buildConnection = (nodes: object[]) => ({
+ callRecordings: {
+ pageInfo: { hasNextPage: false, endCursor: null },
+ edges: nodes.map((node) => ({ node })),
+ },
+});
+
+const buildSummarizableCallRecordingNode = (
+ id: string,
+ createdAt = '2026-01-01T00:00:00.000Z',
+) => ({
+ id,
+ createdAt,
+ title: 'Weekly sync',
+ transcript: TRANSCRIPT,
+ summary: { markdown: null },
+ createdBy: { source: 'APPLICATION', name: 'Call Recorder' },
+});
+
+const seedCallRecordingQueries = ({
+ sweepNodes = [],
+ calendarEventNodes = [],
+ callRecordingsById = {},
+}: {
+ sweepNodes?: object[];
+ calendarEventNodes?: object[];
+ callRecordingsById?: Record;
+} = {}) => {
+ queryMock.mockImplementation(async (queryShape: unknown) => {
+ const filter = (queryShape as CallRecordingsQueryShape).callRecordings
+ .__args.filter;
+
+ if (filter.id !== undefined) {
+ const node = callRecordingsById[filter.id.eq];
+
+ return {
+ callRecordings: { edges: node === undefined ? [] : [{ node }] },
+ };
+ }
+
+ if (filter.calendarEventId !== undefined) {
+ return buildConnection(calendarEventNodes);
+ }
+
+ return buildConnection(sweepNodes);
+ });
+};
+
+const queriedCallRecordingFilters = (): unknown[] =>
+ queryMock.mock.calls.map(
+ ([queryShape]) =>
+ (queryShape as CallRecordingsQueryShape).callRecordings.__args.filter,
+ );
+
const BATCH_RESULT = {
generatedCallRecordingIds: ['call-recording-1'],
failedCallRecordingIds: [],
@@ -68,54 +118,92 @@ const BATCH_RESULT = {
describe('generateCallRecordingSummariesHandler', () => {
beforeEach(() => {
vi.clearAllMocks();
- isCallRecordingSummaryEnabledMock.mockReturnValue(true);
- findCallRecordingIdsMissingSummaryMock.mockResolvedValue([]);
- findCallRecordingIdsForCalendarEventsMock.mockResolvedValue([]);
- generateMissingCallRecordingSummariesMock.mockResolvedValue(BATCH_RESULT);
+ vi.stubGlobal('fetch', fetchMock);
+ vi.stubEnv('CALL_RECORDER_SUMMARY_ENABLED', 'true');
+ vi.stubEnv('CALL_RECORDER_ADDITIONAL_SUMMARY_PROMPT', '');
+ vi.stubEnv('TWENTY_FUNCTIONS_URL', 'https://acme.functions.example.com');
+ vi.stubEnv('TWENTY_APP_ACCESS_TOKEN', 'app-access-token');
+ fetchMock.mockResolvedValue(new Response('{}', { status: 200 }));
+ mutationMock.mockResolvedValue({});
+ runAgentMock.mockResolvedValue({
+ success: true,
+ error: null,
+ result: { response: '## Overview\nGood call.' },
+ });
+ seedCallRecordingQueries();
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.unstubAllEnvs();
});
it('returns disabled without touching data when summaries are off', async () => {
- isCallRecordingSummaryEnabledMock.mockReturnValue(false);
+ vi.stubEnv('CALL_RECORDER_SUMMARY_ENABLED', 'false');
const result = await generateCallRecordingSummariesHandler(
buildRoutePayload(null),
);
expect(result).toEqual({ outcome: 'disabled' });
- expect(findCallRecordingIdsMissingSummaryMock).not.toHaveBeenCalled();
- expect(generateMissingCallRecordingSummariesMock).not.toHaveBeenCalled();
+ expect(queryMock).not.toHaveBeenCalled();
+ expect(runAgentMock).not.toHaveBeenCalled();
+ expect(mutationMock).not.toHaveBeenCalled();
});
it('processes explicit call recording ids without sweeping', async () => {
+ seedCallRecordingQueries({
+ callRecordingsById: {
+ 'call-recording-1':
+ buildSummarizableCallRecordingNode('call-recording-1'),
+ },
+ });
+
const result = await generateCallRecordingSummariesHandler(
buildRoutePayload({ callRecordingIds: ['call-recording-1'] }),
);
expect(result).toEqual({ outcome: 'processed', ...BATCH_RESULT });
- expect(generateMissingCallRecordingSummariesMock).toHaveBeenCalledWith(
- expect.objectContaining({ callRecordingIds: ['call-recording-1'] }),
- );
- expect(findCallRecordingIdsMissingSummaryMock).not.toHaveBeenCalled();
- expect(findCallRecordingIdsForCalendarEventsMock).not.toHaveBeenCalled();
+ expect(queriedCallRecordingFilters()).toEqual([
+ { id: { eq: 'call-recording-1' } },
+ ]);
+ expect(mutationMock).toHaveBeenCalledWith({
+ updateCallRecording: {
+ __args: {
+ id: 'call-recording-1',
+ data: {
+ summary: { blocknote: null, markdown: '## Overview\nGood call.' },
+ },
+ },
+ id: true,
+ },
+ });
});
it('resolves calendar event ids to their call recordings', async () => {
- findCallRecordingIdsForCalendarEventsMock.mockResolvedValue([
- 'call-recording-7',
- ]);
+ seedCallRecordingQueries({
+ calendarEventNodes: [{ id: 'call-recording-7' }],
+ callRecordingsById: {
+ 'call-recording-7':
+ buildSummarizableCallRecordingNode('call-recording-7'),
+ },
+ });
await generateCallRecordingSummariesHandler(
buildRoutePayload({ calendarEventIds: ['calendar-event-1'] }),
);
- expect(findCallRecordingIdsForCalendarEventsMock).toHaveBeenCalledWith(
- expect.anything(),
- { calendarEventIds: ['calendar-event-1'] },
+ expect(queriedCallRecordingFilters()).toEqual([
+ { calendarEventId: { in: ['calendar-event-1'] } },
+ { id: { eq: 'call-recording-7' } },
+ ]);
+ expect(mutationMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ updateCallRecording: expect.objectContaining({
+ __args: expect.objectContaining({ id: 'call-recording-7' }),
+ }),
+ }),
);
- expect(generateMissingCallRecordingSummariesMock).toHaveBeenCalledWith(
- expect.objectContaining({ callRecordingIds: ['call-recording-7'] }),
- );
- expect(findCallRecordingIdsMissingSummaryMock).not.toHaveBeenCalled();
});
it('reports when the selected calendar events have no recordings instead of sweeping', async () => {
@@ -126,29 +214,54 @@ describe('generateCallRecordingSummariesHandler', () => {
expect(result).toEqual({
outcome: 'no-call-recordings-for-calendar-events',
});
- expect(findCallRecordingIdsMissingSummaryMock).not.toHaveBeenCalled();
- expect(generateMissingCallRecordingSummariesMock).not.toHaveBeenCalled();
+ expect(queriedCallRecordingFilters()).toEqual([
+ { calendarEventId: { in: ['calendar-event-1'] } },
+ ]);
+ expect(runAgentMock).not.toHaveBeenCalled();
+ expect(mutationMock).not.toHaveBeenCalled();
});
it('sweeps recordings missing a summary when no ids are given', async () => {
- findCallRecordingIdsMissingSummaryMock.mockResolvedValue([
- 'call-recording-1',
- 'call-recording-2',
- ]);
+ seedCallRecordingQueries({
+ sweepNodes: [
+ buildSummarizableCallRecordingNode(
+ 'call-recording-1',
+ '2026-01-02T00:00:00.000Z',
+ ),
+ buildSummarizableCallRecordingNode(
+ 'call-recording-2',
+ '2026-01-01T00:00:00.000Z',
+ ),
+ ],
+ callRecordingsById: {
+ 'call-recording-1':
+ buildSummarizableCallRecordingNode('call-recording-1'),
+ 'call-recording-2':
+ buildSummarizableCallRecordingNode('call-recording-2'),
+ },
+ });
const result = await generateCallRecordingSummariesHandler(
buildRoutePayload(null),
);
- expect(findCallRecordingIdsMissingSummaryMock).toHaveBeenCalledWith(
- expect.anything(),
- );
- expect(generateMissingCallRecordingSummariesMock).toHaveBeenCalledWith(
- expect.objectContaining({
- callRecordingIds: ['call-recording-1', 'call-recording-2'],
- }),
- );
- expect(result).toEqual({ outcome: 'processed', ...BATCH_RESULT });
+ expect(queriedCallRecordingFilters()).toEqual([
+ {
+ status: { eq: 'COMPLETED' },
+ transcript: { is: 'NOT_NULL' },
+ createdBy: {
+ source: { eq: 'APPLICATION' },
+ name: { eq: 'Call Recorder' },
+ },
+ },
+ { id: { eq: 'call-recording-1' } },
+ { id: { eq: 'call-recording-2' } },
+ ]);
+ expect(result).toEqual({
+ outcome: 'processed',
+ ...BATCH_RESULT,
+ generatedCallRecordingIds: ['call-recording-1', 'call-recording-2'],
+ });
});
it('does not sweep when an empty calendar event selection is sent', async () => {
@@ -157,9 +270,9 @@ describe('generateCallRecordingSummariesHandler', () => {
);
expect(result).toEqual({ outcome: 'nothing-selected' });
- expect(findCallRecordingIdsMissingSummaryMock).not.toHaveBeenCalled();
- expect(findCallRecordingIdsForCalendarEventsMock).not.toHaveBeenCalled();
- expect(generateMissingCallRecordingSummariesMock).not.toHaveBeenCalled();
+ expect(queryMock).not.toHaveBeenCalled();
+ expect(runAgentMock).not.toHaveBeenCalled();
+ expect(mutationMock).not.toHaveBeenCalled();
});
it('short-circuits an empty sweep without running the batch', async () => {
@@ -168,6 +281,10 @@ describe('generateCallRecordingSummariesHandler', () => {
);
expect(result).toEqual({ outcome: 'nothing-to-summarize' });
- expect(generateMissingCallRecordingSummariesMock).not.toHaveBeenCalled();
+ expect(queriedCallRecordingFilters()).toEqual([
+ expect.objectContaining({ status: { eq: 'COMPLETED' } }),
+ ]);
+ expect(runAgentMock).not.toHaveBeenCalled();
+ expect(mutationMock).not.toHaveBeenCalled();
});
});
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/process-recall-webhook.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/process-recall-webhook.test.ts
index b1777294b9..0f1727588b 100644
--- a/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/process-recall-webhook.test.ts
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/process-recall-webhook.test.ts
@@ -1,18 +1,17 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import processRecallWebhookLogicFunction, {
processRecallWebhookHandler,
} from 'src/logic-functions/process-recall-webhook';
-const handleRecallWebhookMock = vi.hoisted(() => vi.fn());
-const coreApiClientMock = vi.hoisted(() => vi.fn());
-
-vi.mock('src/logic-functions/flows/handle-recall-webhook.util', () => ({
- handleRecallWebhook: handleRecallWebhookMock,
-}));
+const queryMock = vi.hoisted(() => vi.fn());
+const mutationMock = vi.hoisted(() => vi.fn());
vi.mock('twenty-client-sdk/core', () => ({
- CoreApiClient: coreApiClientMock,
+ CoreApiClient: class {
+ query = queryMock;
+ mutation = mutationMock;
+ },
}));
const buildRecordingDoneWebhookBody = () => ({
@@ -31,9 +30,40 @@ const buildRecordingDoneWebhookBody = () => ({
describe('process-recall-webhook', () => {
beforeEach(() => {
- handleRecallWebhookMock.mockReset();
- handleRecallWebhookMock.mockResolvedValue({ status: 'updated' });
- coreApiClientMock.mockReset();
+ vi.spyOn(console, 'log').mockImplementation(() => {});
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
+ queryMock.mockReset();
+ queryMock.mockResolvedValue({
+ callRecordings: {
+ edges: [
+ {
+ node: {
+ id: 'call-recording-1',
+ status: 'PROCESSING',
+ externalRecordingId: 'recall-recording-1',
+ transcript: [
+ {
+ participant: { name: 'Ada' },
+ words: [
+ { text: 'Hello world', start_timestamp: { relative: 0 } },
+ ],
+ },
+ ],
+ audio: [{ fileId: 'file-audio-1' }],
+ video: [{ fileId: 'file-video-1' }],
+ },
+ },
+ ],
+ },
+ });
+ mutationMock.mockReset();
+ mutationMock.mockResolvedValue({
+ updateCallRecording: { id: 'call-recording-1' },
+ });
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
});
it('declares no external trigger so it only runs when dispatched by the resolver', () => {
@@ -51,12 +81,33 @@ describe('process-recall-webhook', () => {
const result = await processRecallWebhookHandler(body);
- expect(coreApiClientMock).toHaveBeenCalledTimes(1);
- expect(handleRecallWebhookMock).toHaveBeenCalledTimes(1);
- expect(handleRecallWebhookMock).toHaveBeenCalledWith({
- client: coreApiClientMock.mock.instances[0],
- body,
+ expect(queryMock).toHaveBeenCalledTimes(1);
+ expect(queryMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ callRecordings: expect.objectContaining({
+ __args: { filter: { id: { eq: 'call-recording-1' } }, first: 1 },
+ }),
+ }),
+ );
+ expect(mutationMock).toHaveBeenCalledTimes(1);
+ expect(mutationMock).toHaveBeenCalledWith({
+ updateCallRecording: {
+ __args: {
+ id: 'call-recording-1',
+ data: {
+ externalBotId: 'recall-bot-1',
+ externalRecordingId: 'recall-recording-1',
+ status: 'PROCESSING',
+ },
+ },
+ id: true,
+ },
+ });
+ expect(result).toEqual({
+ status: 'updated',
+ event: 'recording.done',
+ callRecordingId: 'call-recording-1',
+ callRecordingStatus: 'PROCESSING',
});
- expect(result).toEqual({ status: 'updated' });
});
});
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/recall-webhook.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/recall-webhook.test.ts
index 03667dde07..dc63a7f0cd 100644
--- a/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/recall-webhook.test.ts
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/recall-webhook.test.ts
@@ -1,21 +1,12 @@
import { createHmac } from 'crypto';
-import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { PROCESS_RECALL_WEBHOOK_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/process-recall-webhook-logic-function-universal-identifier';
import recallWebhookLogicFunction, {
recallWebhookRouteHandler,
} from 'src/logic-functions/recall-webhook';
-const getApplicationVariableValueMock = vi.hoisted(() => vi.fn());
-
-vi.mock(
- 'src/logic-functions/utils/get-application-variable-value.util',
- () => ({
- getApplicationVariableValue: getApplicationVariableValueMock,
- }),
-);
-
const SECRET_BYTES = Buffer.from('entry-test-secret');
const SECRET = `whsec_${SECRET_BYTES.toString('base64')}`;
const WORKSPACE_ID = '123e4567-e89b-12d3-a456-426614174000';
@@ -65,8 +56,11 @@ const buildRecordingDoneWebhookBody = () => ({
describe('recallWebhookRouteHandler', () => {
beforeEach(() => {
- getApplicationVariableValueMock.mockReset();
- getApplicationVariableValueMock.mockReturnValue(SECRET);
+ vi.stubEnv('RECALL_WEBHOOK_SECRET', SECRET);
+ });
+
+ afterEach(() => {
+ vi.unstubAllEnvs();
});
it('declares a server route trigger that forwards the webhook signature headers', () => {
@@ -97,7 +91,7 @@ describe('recallWebhookRouteHandler', () => {
});
it('throws when the webhook secret is not configured', () => {
- getApplicationVariableValueMock.mockReturnValue(undefined);
+ vi.stubEnv('RECALL_WEBHOOK_SECRET', '');
expect(() =>
recallWebhookRouteHandler(buildRoutePayload({ rawBody: '{}', body: {} })),
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/reconcile-upcoming-calendar-events.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/reconcile-upcoming-calendar-events.test.ts
index 3510a9a56a..34e3a71cc2 100644
--- a/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/reconcile-upcoming-calendar-events.test.ts
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/reconcile-upcoming-calendar-events.test.ts
@@ -1,31 +1,68 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { type RoutePayload } from 'twenty-sdk/define';
import routeLogicFunction, {
reconcileUpcomingCalendarEventsHandler,
} from 'src/logic-functions/reconcile-upcoming-calendar-events';
-const fetchUpcomingCalendarEventIdsMock = vi.hoisted(() => vi.fn());
-const reconcileUpcomingCalendarEventBatchesMock = vi.hoisted(() => vi.fn());
+const queryMock = vi.hoisted(() => vi.fn());
+const mutationMock = vi.hoisted(() => vi.fn());
vi.mock('twenty-client-sdk/core', () => ({
- CoreApiClient: vi.fn(),
+ CoreApiClient: class {
+ query = queryMock;
+ mutation = mutationMock;
+ },
}));
-vi.mock(
- 'src/logic-functions/data/fetch-upcoming-calendar-event-ids.util',
- () => ({
- fetchUpcomingCalendarEventIds: fetchUpcomingCalendarEventIdsMock,
- }),
-);
+const fetchMock = vi.fn();
-vi.mock(
- 'src/logic-functions/flows/reconcile-upcoming-calendar-event-batches.util',
- () => ({
- reconcileUpcomingCalendarEventBatches:
- reconcileUpcomingCalendarEventBatchesMock,
- }),
-);
+type CalendarEventNode = {
+ id: string;
+ title: string;
+ isCanceled: boolean;
+ startsAt: string;
+ endsAt: string;
+};
+
+type RecordsQuery = {
+ calendarEvents?: {
+ __args: {
+ filter: {
+ id?: { in: string[] };
+ startsAt?: { in: string[] };
+ isCanceled?: { eq: boolean };
+ };
+ };
+ };
+ callRecordings?: { __args: { filter: Record } };
+};
+
+const UPCOMING_STARTS_AT = new Date(Date.now() + 60 * 60 * 1000).toISOString();
+const UPCOMING_ENDS_AT = new Date(
+ Date.now() + 2 * 60 * 60 * 1000,
+).toISOString();
+
+// Without a conference link the policy deterministically skips each meeting.
+const buildUpcomingCalendarEventNode = (id: string): CalendarEventNode => ({
+ id,
+ title: 'Upcoming Sync',
+ isCanceled: false,
+ startsAt: UPCOMING_STARTS_AT,
+ endsAt: UPCOMING_ENDS_AT,
+});
+
+const buildConnection = (nodes: TNode[]) => ({
+ pageInfo: { hasNextPage: false, endCursor: null },
+ edges: nodes.map((node) => ({ node })),
+});
+
+const readSweepQueries = (): unknown[] =>
+ queryMock.mock.calls.filter(
+ ([query]) => query.calendarEvents?.__args.filter.isCanceled !== undefined,
+ );
+
+let upcomingCalendarEventNodes: CalendarEventNode[];
const buildRoutePayload = (
body: object | null,
@@ -45,15 +82,64 @@ const BATCH_RESULT = {
reconciledCalendarEventIds: ['calendar-event-1'],
failedCalendarEventIds: [],
remainingCalendarEventIds: [],
- actionCounts: { created: 1, updated: 0, canceled: 0, skipped: 0, failed: 0 },
+ actionCounts: { created: 0, updated: 0, canceled: 0, skipped: 1, failed: 0 },
continuationRequested: false,
};
describe('reconcileUpcomingCalendarEventsHandler', () => {
beforeEach(() => {
vi.clearAllMocks();
- fetchUpcomingCalendarEventIdsMock.mockResolvedValue([]);
- reconcileUpcomingCalendarEventBatchesMock.mockResolvedValue(BATCH_RESULT);
+ vi.stubGlobal('fetch', fetchMock);
+ vi.stubEnv('TWENTY_FUNCTIONS_URL', 'https://acme.functions.example.com');
+ vi.stubEnv('TWENTY_APP_ACCESS_TOKEN', 'app-access-token');
+ fetchMock.mockResolvedValue(new Response('{}', { status: 200 }));
+ upcomingCalendarEventNodes = [];
+ queryMock.mockImplementation(async (query: RecordsQuery) => {
+ if (query.calendarEvents !== undefined) {
+ const filter = query.calendarEvents.__args.filter;
+
+ if (filter.id !== undefined) {
+ const requestedIds = filter.id.in;
+
+ return {
+ calendarEvents: buildConnection(
+ upcomingCalendarEventNodes.filter((node) =>
+ requestedIds.includes(node.id),
+ ),
+ ),
+ };
+ }
+
+ if (filter.startsAt !== undefined) {
+ const requestedStartsAtValues = filter.startsAt.in;
+
+ return {
+ calendarEvents: buildConnection(
+ upcomingCalendarEventNodes.filter((node) =>
+ requestedStartsAtValues.includes(node.startsAt),
+ ),
+ ),
+ };
+ }
+
+ return {
+ calendarEvents: buildConnection(
+ upcomingCalendarEventNodes.map(({ id }) => ({ id })),
+ ),
+ };
+ }
+
+ if (query.callRecordings !== undefined) {
+ return { callRecordings: buildConnection([]) };
+ }
+
+ throw new Error(`Unhandled query: ${JSON.stringify(query)}`);
+ });
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.unstubAllEnvs();
});
it('is configured as an authenticated route with a self-invokable timeout', () => {
@@ -71,37 +157,61 @@ describe('reconcileUpcomingCalendarEventsHandler', () => {
});
it('processes explicit calendar event ids without sweeping', async () => {
+ upcomingCalendarEventNodes = [
+ buildUpcomingCalendarEventNode('calendar-event-1'),
+ ];
+
const result = await reconcileUpcomingCalendarEventsHandler(
buildRoutePayload({ calendarEventIds: ['calendar-event-1'] }),
);
expect(result).toEqual({ outcome: 'processed', ...BATCH_RESULT });
- expect(reconcileUpcomingCalendarEventBatchesMock).toHaveBeenCalledWith(
- expect.objectContaining({ calendarEventIds: ['calendar-event-1'] }),
+ expect(queryMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ calendarEvents: expect.objectContaining({
+ __args: expect.objectContaining({
+ filter: { id: { in: ['calendar-event-1'] } },
+ }),
+ }),
+ }),
);
- expect(fetchUpcomingCalendarEventIdsMock).not.toHaveBeenCalled();
+ expect(readSweepQueries()).toEqual([]);
});
it('sweeps upcoming calendar events when no ids are given', async () => {
- fetchUpcomingCalendarEventIdsMock.mockResolvedValue([
- 'calendar-event-1',
- 'calendar-event-2',
- ]);
+ upcomingCalendarEventNodes = [
+ buildUpcomingCalendarEventNode('calendar-event-1'),
+ buildUpcomingCalendarEventNode('calendar-event-2'),
+ ];
const result = await reconcileUpcomingCalendarEventsHandler(
buildRoutePayload(null),
);
- expect(fetchUpcomingCalendarEventIdsMock).toHaveBeenCalledWith(
- expect.anything(),
- expect.any(Date),
- );
- expect(reconcileUpcomingCalendarEventBatchesMock).toHaveBeenCalledWith(
+ expect(readSweepQueries()).toHaveLength(1);
+ expect(queryMock).toHaveBeenCalledWith(
expect.objectContaining({
- calendarEventIds: ['calendar-event-1', 'calendar-event-2'],
+ calendarEvents: expect.objectContaining({
+ __args: expect.objectContaining({
+ filter: { id: { in: ['calendar-event-1', 'calendar-event-2'] } },
+ }),
+ }),
}),
);
- expect(result).toEqual({ outcome: 'processed', ...BATCH_RESULT });
+ expect(result).toEqual({
+ outcome: 'processed',
+ reconciledCalendarEventIds: ['calendar-event-1', 'calendar-event-2'],
+ failedCalendarEventIds: [],
+ remainingCalendarEventIds: [],
+ actionCounts: {
+ created: 0,
+ updated: 0,
+ canceled: 0,
+ skipped: 2,
+ failed: 0,
+ },
+ continuationRequested: false,
+ });
});
it('short-circuits an empty sweep without running batches', async () => {
@@ -110,7 +220,7 @@ describe('reconcileUpcomingCalendarEventsHandler', () => {
);
expect(result).toEqual({ outcome: 'nothing-to-reconcile' });
- expect(reconcileUpcomingCalendarEventBatchesMock).not.toHaveBeenCalled();
+ expect(queryMock).toHaveBeenCalledTimes(1);
});
it('does not sweep when an empty calendar event selection is sent', async () => {
@@ -119,19 +229,25 @@ describe('reconcileUpcomingCalendarEventsHandler', () => {
);
expect(result).toEqual({ outcome: 'nothing-selected' });
- expect(fetchUpcomingCalendarEventIdsMock).not.toHaveBeenCalled();
- expect(reconcileUpcomingCalendarEventBatchesMock).not.toHaveBeenCalled();
+ expect(queryMock).not.toHaveBeenCalled();
});
it('passes a deadline that reserves time for the continuation request', async () => {
- await reconcileUpcomingCalendarEventsHandler(
+ upcomingCalendarEventNodes = [
+ buildUpcomingCalendarEventNode('calendar-event-1'),
+ ];
+
+ const result = await reconcileUpcomingCalendarEventsHandler(
buildRoutePayload({ calendarEventIds: ['calendar-event-1'] }),
);
- const { deadlineAtMs } =
- reconcileUpcomingCalendarEventBatchesMock.mock.calls[0][0];
-
- expect(deadlineAtMs).toBeLessThan(Date.now() + 900 * 1000);
- expect(deadlineAtMs).toBeGreaterThan(Date.now() + 800 * 1000);
+ expect(result).toEqual(
+ expect.objectContaining({
+ outcome: 'processed',
+ remainingCalendarEventIds: [],
+ continuationRequested: false,
+ }),
+ );
+ expect(fetchMock).not.toHaveBeenCalled();
});
});
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/start-post-install-backfills.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/start-post-install-backfills.test.ts
index 0852d5c327..55fc3cc0dc 100644
--- a/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/start-post-install-backfills.test.ts
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/start-post-install-backfills.test.ts
@@ -1,35 +1,32 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH } from 'src/constants/generate-call-recording-summaries-route-path';
+import { RECONCILE_UPCOMING_CALENDAR_EVENTS_ROUTE_PATH } from 'src/constants/reconcile-upcoming-calendar-events-route-path';
import postInstallLogicFunction, {
startPostInstallBackfillsHandler,
} from 'src/logic-functions/start-post-install-backfills';
-const requestCallRecordingSummariesBackfillMock = vi.hoisted(() => vi.fn());
-const requestUpcomingCalendarEventsReconciliationMock = vi.hoisted(() =>
- vi.fn(),
-);
+const FUNCTIONS_BASE_URL = 'https://acme.functions.example.com';
-vi.mock(
- 'src/logic-functions/data/request-call-recording-summaries-backfill.util',
- () => ({
- requestCallRecordingSummariesBackfill:
- requestCallRecordingSummariesBackfillMock,
- }),
-);
+const fetchMock = vi.fn();
-vi.mock(
- 'src/logic-functions/data/request-upcoming-calendar-events-reconciliation.util',
- () => ({
- requestUpcomingCalendarEventsReconciliation:
- requestUpcomingCalendarEventsReconciliationMock,
- }),
-);
+const fetchedRoutePaths = (): string[] =>
+ fetchMock.mock.calls.map(([requestUrl]) =>
+ String(requestUrl).replace(FUNCTIONS_BASE_URL, ''),
+ );
describe('start-post-install-backfills', () => {
beforeEach(() => {
vi.clearAllMocks();
- requestCallRecordingSummariesBackfillMock.mockResolvedValue(true);
- requestUpcomingCalendarEventsReconciliationMock.mockResolvedValue(true);
+ vi.stubGlobal('fetch', fetchMock);
+ vi.stubEnv('TWENTY_FUNCTIONS_URL', FUNCTIONS_BASE_URL);
+ vi.stubEnv('TWENTY_APP_ACCESS_TOKEN', 'app-access-token');
+ fetchMock.mockResolvedValue(new Response('{}', { status: 200 }));
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.unstubAllEnvs();
});
it('is configured to run on app version upgrades', () => {
@@ -51,10 +48,9 @@ describe('start-post-install-backfills', () => {
calendarEventSweepOutcome: 'sweep-requested',
summaryBackfillOutcome: 'skipped-initial-install',
});
- expect(
- requestUpcomingCalendarEventsReconciliationMock,
- ).toHaveBeenCalledTimes(1);
- expect(requestCallRecordingSummariesBackfillMock).not.toHaveBeenCalled();
+ expect(fetchedRoutePaths()).toEqual([
+ RECONCILE_UPCOMING_CALENDAR_EVENTS_ROUTE_PATH,
+ ]);
});
it('backfills summaries and skips the sweep on an upgrade', async () => {
@@ -67,25 +63,26 @@ describe('start-post-install-backfills', () => {
calendarEventSweepOutcome: 'skipped-upgrade',
summaryBackfillOutcome: 'backfill-requested',
});
- expect(requestCallRecordingSummariesBackfillMock).toHaveBeenCalledTimes(1);
- expect(
- requestUpcomingCalendarEventsReconciliationMock,
- ).not.toHaveBeenCalled();
+ expect(fetchedRoutePaths()).toEqual([
+ GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH,
+ ]);
});
it('throws when the fresh-install sweep kickoff fails', async () => {
- requestUpcomingCalendarEventsReconciliationMock.mockResolvedValue(false);
+ fetchMock.mockRejectedValue(new Error('Network failed'));
await expect(
startPostInstallBackfillsHandler({ newVersion: '1.0.7' }),
).rejects.toThrow(
'Failed to start post-install backfills: upcoming calendar event sweep',
);
- expect(requestCallRecordingSummariesBackfillMock).not.toHaveBeenCalled();
+ expect(fetchedRoutePaths()).not.toContain(
+ GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH,
+ );
});
it('throws when the upgrade summary backfill kickoff fails', async () => {
- requestCallRecordingSummariesBackfillMock.mockResolvedValue(false);
+ fetchMock.mockRejectedValue(new Error('Network failed'));
await expect(
startPostInstallBackfillsHandler({
@@ -95,8 +92,8 @@ describe('start-post-install-backfills', () => {
).rejects.toThrow(
'Failed to start post-install backfills: call recording summary backfill',
);
- expect(
- requestUpcomingCalendarEventsReconciliationMock,
- ).not.toHaveBeenCalled();
+ expect(fetchedRoutePaths()).not.toContain(
+ RECONCILE_UPCOMING_CALENDAR_EVENTS_ROUTE_PATH,
+ );
});
});
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/summarize-call-recording.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/summarize-call-recording.test.ts
index d8ac91be61..39bfdc15bb 100644
--- a/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/summarize-call-recording.test.ts
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/summarize-call-recording.test.ts
@@ -1,19 +1,28 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { summarizeCallRecordingHandler } from 'src/logic-functions/summarize-call-recording';
-const generateCallRecordingSummaryMock = vi.hoisted(() => vi.fn());
+const queryMock = vi.hoisted(() => vi.fn());
+const mutationMock = vi.hoisted(() => vi.fn());
+const runAgentMock = vi.hoisted(() => vi.fn());
vi.mock('twenty-client-sdk/core', () => ({
- CoreApiClient: class {},
+ CoreApiClient: class {
+ query = queryMock;
+ mutation = mutationMock;
+ },
}));
-vi.mock(
- 'src/logic-functions/flows/generate-call-recording-summary.util',
- () => ({
- generateCallRecordingSummary: generateCallRecordingSummaryMock,
- }),
-);
+vi.mock('twenty-sdk/logic-function', () => ({
+ runAgent: runAgentMock,
+}));
+
+const TRANSCRIPT = [
+ {
+ participant: { name: 'Alex' },
+ words: [{ text: 'Hello' }, { text: 'team' }],
+ },
+];
const FAKE_OBJECT_METADATA = {
id: 'object-metadata-id',
@@ -74,9 +83,33 @@ const buildEvent = ({
describe('summarize-call-recording logic function', () => {
beforeEach(() => {
vi.clearAllMocks();
- generateCallRecordingSummaryMock.mockResolvedValue({
- outcome: 'generated',
+ vi.stubEnv('CALL_RECORDER_SUMMARY_ENABLED', 'true');
+ vi.stubEnv('CALL_RECORDER_ADDITIONAL_SUMMARY_PROMPT', '');
+ queryMock.mockResolvedValue({
+ callRecordings: {
+ edges: [
+ {
+ node: {
+ id: 'call-recording-1',
+ title: 'Weekly sync',
+ transcript: TRANSCRIPT,
+ summary: { markdown: null },
+ createdBy: { source: 'APPLICATION', name: 'Call Recorder' },
+ },
+ },
+ ],
+ },
});
+ mutationMock.mockResolvedValue({});
+ runAgentMock.mockResolvedValue({
+ success: true,
+ error: null,
+ result: { response: '## Overview\nGood call.' },
+ });
+ });
+
+ afterEach(() => {
+ vi.unstubAllEnvs();
});
it('generates a summary when the transcript field changed', async () => {
@@ -87,17 +120,21 @@ describe('summarize-call-recording logic function', () => {
}),
);
- expect(generateCallRecordingSummaryMock).toHaveBeenCalledWith(
- expect.anything(),
- {
- callRecordingId: 'call-recording-1',
- requireCreatedByCallRecorder: true,
- },
- );
expect(result).toEqual({
callRecordingId: 'call-recording-1',
outcome: 'generated',
});
+ expect(mutationMock).toHaveBeenCalledWith({
+ updateCallRecording: {
+ __args: {
+ id: 'call-recording-1',
+ data: {
+ summary: { blocknote: null, markdown: '## Overview\nGood call.' },
+ },
+ },
+ id: true,
+ },
+ });
});
it('skips summary-only updates to avoid re-entrancy', async () => {
@@ -108,7 +145,9 @@ describe('summarize-call-recording logic function', () => {
}),
);
- expect(generateCallRecordingSummaryMock).not.toHaveBeenCalled();
+ expect(queryMock).not.toHaveBeenCalled();
+ expect(runAgentMock).not.toHaveBeenCalled();
+ expect(mutationMock).not.toHaveBeenCalled();
expect(result).toEqual({ skipped: true, reason: 'transcript unchanged' });
});
@@ -120,7 +159,9 @@ describe('summarize-call-recording logic function', () => {
}),
);
- expect(generateCallRecordingSummaryMock).not.toHaveBeenCalled();
+ expect(queryMock).not.toHaveBeenCalled();
+ expect(runAgentMock).not.toHaveBeenCalled();
+ expect(mutationMock).not.toHaveBeenCalled();
expect(result).toEqual({
skipped: true,
reason: 'not a call recording update',
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/sweep-upcoming-calendar-events.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/sweep-upcoming-calendar-events.test.ts
index 234ece8c2f..9e31761f01 100644
--- a/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/sweep-upcoming-calendar-events.test.ts
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/sweep-upcoming-calendar-events.test.ts
@@ -1,44 +1,117 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import sweepLogicFunction, {
sweepUpcomingCalendarEventsHandler,
} from 'src/logic-functions/sweep-upcoming-calendar-events';
-const fetchUpcomingCalendarEventIdsMock = vi.hoisted(() => vi.fn());
-const reconcileUpcomingCalendarEventBatchesMock = vi.hoisted(() => vi.fn());
+const queryMock = vi.hoisted(() => vi.fn());
+const mutationMock = vi.hoisted(() => vi.fn());
vi.mock('twenty-client-sdk/core', () => ({
- CoreApiClient: vi.fn(),
+ CoreApiClient: class {
+ query = queryMock;
+ mutation = mutationMock;
+ },
}));
-vi.mock(
- 'src/logic-functions/data/fetch-upcoming-calendar-event-ids.util',
- () => ({
- fetchUpcomingCalendarEventIds: fetchUpcomingCalendarEventIdsMock,
- }),
-);
+const fetchMock = vi.fn();
-vi.mock(
- 'src/logic-functions/flows/reconcile-upcoming-calendar-event-batches.util',
- () => ({
- reconcileUpcomingCalendarEventBatches:
- reconcileUpcomingCalendarEventBatchesMock,
- }),
-);
-
-const BATCH_RESULT = {
- reconciledCalendarEventIds: ['calendar-event-1'],
- failedCalendarEventIds: [],
- remainingCalendarEventIds: [],
- actionCounts: { created: 1, updated: 0, canceled: 0, skipped: 0, failed: 0 },
- continuationRequested: false,
+type CalendarEventNode = {
+ id: string;
+ title: string;
+ isCanceled: boolean;
+ startsAt: string;
+ endsAt: string;
};
+type RecordsQuery = {
+ calendarEvents?: {
+ __args: {
+ filter: {
+ id?: { in: string[] };
+ startsAt?: { in: string[] };
+ isCanceled?: { eq: boolean };
+ };
+ };
+ };
+ callRecordings?: { __args: { filter: Record } };
+};
+
+const UPCOMING_STARTS_AT = new Date(Date.now() + 60 * 60 * 1000).toISOString();
+const UPCOMING_ENDS_AT = new Date(
+ Date.now() + 2 * 60 * 60 * 1000,
+).toISOString();
+
+// Without a conference link the policy deterministically skips each meeting.
+const buildUpcomingCalendarEventNode = (id: string): CalendarEventNode => ({
+ id,
+ title: 'Upcoming Sync',
+ isCanceled: false,
+ startsAt: UPCOMING_STARTS_AT,
+ endsAt: UPCOMING_ENDS_AT,
+});
+
+const buildConnection = (nodes: TNode[]) => ({
+ pageInfo: { hasNextPage: false, endCursor: null },
+ edges: nodes.map((node) => ({ node })),
+});
+
+let upcomingCalendarEventNodes: CalendarEventNode[];
+
describe('sweepUpcomingCalendarEventsHandler', () => {
beforeEach(() => {
vi.clearAllMocks();
- fetchUpcomingCalendarEventIdsMock.mockResolvedValue([]);
- reconcileUpcomingCalendarEventBatchesMock.mockResolvedValue(BATCH_RESULT);
+ vi.stubGlobal('fetch', fetchMock);
+ vi.stubEnv('TWENTY_FUNCTIONS_URL', 'https://acme.functions.example.com');
+ vi.stubEnv('TWENTY_APP_ACCESS_TOKEN', 'app-access-token');
+ fetchMock.mockResolvedValue(new Response('{}', { status: 200 }));
+ upcomingCalendarEventNodes = [];
+ queryMock.mockImplementation(async (query: RecordsQuery) => {
+ if (query.calendarEvents !== undefined) {
+ const filter = query.calendarEvents.__args.filter;
+
+ if (filter.id !== undefined) {
+ const requestedIds = filter.id.in;
+
+ return {
+ calendarEvents: buildConnection(
+ upcomingCalendarEventNodes.filter((node) =>
+ requestedIds.includes(node.id),
+ ),
+ ),
+ };
+ }
+
+ if (filter.startsAt !== undefined) {
+ const requestedStartsAtValues = filter.startsAt.in;
+
+ return {
+ calendarEvents: buildConnection(
+ upcomingCalendarEventNodes.filter((node) =>
+ requestedStartsAtValues.includes(node.startsAt),
+ ),
+ ),
+ };
+ }
+
+ return {
+ calendarEvents: buildConnection(
+ upcomingCalendarEventNodes.map(({ id }) => ({ id })),
+ ),
+ };
+ }
+
+ if (query.callRecordings !== undefined) {
+ return { callRecordings: buildConnection([]) };
+ }
+
+ throw new Error(`Unhandled query: ${JSON.stringify(query)}`);
+ });
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.unstubAllEnvs();
});
it('is configured as a daily cron with a self-invokable timeout', () => {
@@ -52,41 +125,68 @@ describe('sweepUpcomingCalendarEventsHandler', () => {
});
it('reconciles every upcoming calendar event within the horizon', async () => {
- fetchUpcomingCalendarEventIdsMock.mockResolvedValue([
- 'calendar-event-1',
- 'calendar-event-2',
- ]);
+ upcomingCalendarEventNodes = [
+ buildUpcomingCalendarEventNode('calendar-event-1'),
+ buildUpcomingCalendarEventNode('calendar-event-2'),
+ ];
const result = await sweepUpcomingCalendarEventsHandler();
- expect(fetchUpcomingCalendarEventIdsMock).toHaveBeenCalledWith(
- expect.anything(),
- expect.any(Date),
- );
- expect(reconcileUpcomingCalendarEventBatchesMock).toHaveBeenCalledWith(
+ expect(queryMock).toHaveBeenCalledWith(
expect.objectContaining({
- calendarEventIds: ['calendar-event-1', 'calendar-event-2'],
+ calendarEvents: expect.objectContaining({
+ __args: expect.objectContaining({
+ filter: expect.objectContaining({ isCanceled: { eq: false } }),
+ }),
+ }),
}),
);
- expect(result).toEqual({ outcome: 'processed', ...BATCH_RESULT });
+ expect(queryMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ calendarEvents: expect.objectContaining({
+ __args: expect.objectContaining({
+ filter: { id: { in: ['calendar-event-1', 'calendar-event-2'] } },
+ }),
+ }),
+ }),
+ );
+ expect(result).toEqual({
+ outcome: 'processed',
+ reconciledCalendarEventIds: ['calendar-event-1', 'calendar-event-2'],
+ failedCalendarEventIds: [],
+ remainingCalendarEventIds: [],
+ actionCounts: {
+ created: 0,
+ updated: 0,
+ canceled: 0,
+ skipped: 2,
+ failed: 0,
+ },
+ continuationRequested: false,
+ });
});
it('short-circuits without running batches when nothing is upcoming', async () => {
const result = await sweepUpcomingCalendarEventsHandler();
expect(result).toEqual({ outcome: 'nothing-to-reconcile' });
- expect(reconcileUpcomingCalendarEventBatchesMock).not.toHaveBeenCalled();
+ expect(queryMock).toHaveBeenCalledTimes(1);
});
it('passes a deadline that reserves time for the continuation request', async () => {
- fetchUpcomingCalendarEventIdsMock.mockResolvedValue(['calendar-event-1']);
+ upcomingCalendarEventNodes = [
+ buildUpcomingCalendarEventNode('calendar-event-1'),
+ ];
- await sweepUpcomingCalendarEventsHandler();
+ const result = await sweepUpcomingCalendarEventsHandler();
- const { deadlineAtMs } =
- reconcileUpcomingCalendarEventBatchesMock.mock.calls[0][0];
-
- expect(deadlineAtMs).toBeLessThan(Date.now() + 900 * 1000);
- expect(deadlineAtMs).toBeGreaterThan(Date.now() + 800 * 1000);
+ expect(result).toEqual(
+ expect.objectContaining({
+ outcome: 'processed',
+ remainingCalendarEventIds: [],
+ continuationRequested: false,
+ }),
+ );
+ expect(fetchMock).not.toHaveBeenCalled();
});
});
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/post-to-own-route.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/post-to-own-route.test.ts
index c8e93de959..824117bd9d 100644
--- a/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/post-to-own-route.test.ts
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/post-to-own-route.test.ts
@@ -1,29 +1,21 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { postToOwnRoute } from 'src/logic-functions/data/post-to-own-route.util';
-const postMock = vi.hoisted(() => vi.fn());
-const restApiClientMock = vi.hoisted(() => vi.fn());
-const resolveOwnRouteBaseUrlMock = vi.hoisted(() => vi.fn());
-
-vi.mock('twenty-client-sdk/rest', () => ({
- RestApiClient: restApiClientMock,
-}));
-
-vi.mock('src/logic-functions/data/resolve-own-route-base-url.util', () => ({
- resolveOwnRouteBaseUrl: resolveOwnRouteBaseUrlMock,
-}));
+const fetchMock = vi.fn();
describe('postToOwnRoute', () => {
beforeEach(() => {
vi.clearAllMocks();
- restApiClientMock.mockImplementation(function RestApiClient() {
- return { post: postMock };
- });
- postMock.mockResolvedValue({});
- resolveOwnRouteBaseUrlMock.mockReturnValue(
- 'https://acme.functions.example.com',
- );
+ vi.stubGlobal('fetch', fetchMock);
+ vi.stubEnv('TWENTY_FUNCTIONS_URL', 'https://acme.functions.example.com');
+ vi.stubEnv('TWENTY_APP_ACCESS_TOKEN', 'app-access-token');
+ fetchMock.mockResolvedValue(new Response('{}', { status: 200 }));
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.unstubAllEnvs();
});
it('posts to the functions origin when resolved', async () => {
@@ -33,20 +25,20 @@ describe('postToOwnRoute', () => {
});
expect(result).toBe(true);
- expect(restApiClientMock).toHaveBeenCalledWith({
- baseUrl: 'https://acme.functions.example.com',
- });
- expect(postMock).toHaveBeenCalledWith(
- '/call-recorder/some-route',
- { key: 'value' },
- { signal: expect.any(AbortSignal) },
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [requestUrl, requestInit] = fetchMock.mock.calls[0];
+ expect(requestUrl).toBe(
+ 'https://acme.functions.example.com/call-recorder/some-route',
);
+ expect(requestInit.method).toBe('POST');
+ expect(requestInit.body).toBe(JSON.stringify({ key: 'value' }));
+ expect(requestInit.signal).toBeInstanceOf(AbortSignal);
});
it('treats timeout as a successfully flushed request', async () => {
const timeoutError = new Error('Timed out');
timeoutError.name = 'TimeoutError';
- postMock.mockRejectedValue(timeoutError);
+ fetchMock.mockRejectedValue(timeoutError);
await expect(
postToOwnRoute({ path: '/call-recorder/some-route', body: {} }),
@@ -54,7 +46,7 @@ describe('postToOwnRoute', () => {
});
it('returns false when the request fails before flushing', async () => {
- postMock.mockRejectedValue(new Error('Network failed'));
+ fetchMock.mockRejectedValue(new Error('Network failed'));
await expect(
postToOwnRoute({ path: '/call-recorder/some-route', body: {} }),
@@ -62,14 +54,11 @@ describe('postToOwnRoute', () => {
});
it('returns false when the route base url cannot be resolved', async () => {
- resolveOwnRouteBaseUrlMock.mockImplementation(() => {
- throw new Error('Unable to resolve target');
- });
+ vi.stubEnv('TWENTY_FUNCTIONS_URL', '');
await expect(
postToOwnRoute({ path: '/call-recorder/some-route', body: {} }),
).resolves.toBe(false);
- expect(restApiClientMock).not.toHaveBeenCalled();
- expect(postMock).not.toHaveBeenCalled();
+ expect(fetchMock).not.toHaveBeenCalled();
});
});
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/request-call-recording-summaries-backfill.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/request-call-recording-summaries-backfill.test.ts
index b096af371d..9f12afc567 100644
--- a/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/request-call-recording-summaries-backfill.test.ts
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/request-call-recording-summaries-backfill.test.ts
@@ -1,32 +1,39 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH } from 'src/constants/generate-call-recording-summaries-route-path';
import { requestCallRecordingSummariesBackfill } from 'src/logic-functions/data/request-call-recording-summaries-backfill.util';
-const postToOwnRouteMock = vi.hoisted(() => vi.fn());
-
-vi.mock('src/logic-functions/data/post-to-own-route.util', () => ({
- postToOwnRoute: postToOwnRouteMock,
-}));
+const fetchMock = vi.fn();
describe('requestCallRecordingSummariesBackfill', () => {
beforeEach(() => {
vi.clearAllMocks();
- postToOwnRouteMock.mockResolvedValue(true);
+ vi.stubGlobal('fetch', fetchMock);
+ vi.stubEnv('TWENTY_FUNCTIONS_URL', 'https://acme.functions.example.com');
+ vi.stubEnv('TWENTY_APP_ACCESS_TOKEN', 'app-access-token');
+ fetchMock.mockResolvedValue(new Response('{}', { status: 200 }));
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.unstubAllEnvs();
});
it('posts an empty body to the summary generation route', async () => {
const result = await requestCallRecordingSummariesBackfill();
expect(result).toBe(true);
- expect(postToOwnRouteMock).toHaveBeenCalledWith({
- path: GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH,
- body: {},
- });
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [requestUrl, requestInit] = fetchMock.mock.calls[0];
+ expect(requestUrl).toBe(
+ `https://acme.functions.example.com${GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH}`,
+ );
+ expect(requestInit.method).toBe('POST');
+ expect(requestInit.body).toBe(JSON.stringify({}));
});
it('reports a kickoff that failed to fire', async () => {
- postToOwnRouteMock.mockResolvedValue(false);
+ fetchMock.mockRejectedValue(new Error('Network failed'));
await expect(requestCallRecordingSummariesBackfill()).resolves.toBe(false);
});
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/request-upcoming-calendar-events-reconciliation.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/request-upcoming-calendar-events-reconciliation.test.ts
index 78da918dfe..04823969b8 100644
--- a/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/request-upcoming-calendar-events-reconciliation.test.ts
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/request-upcoming-calendar-events-reconciliation.test.ts
@@ -1,28 +1,35 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { RECONCILE_UPCOMING_CALENDAR_EVENTS_ROUTE_PATH } from 'src/constants/reconcile-upcoming-calendar-events-route-path';
import { requestUpcomingCalendarEventsReconciliation } from 'src/logic-functions/data/request-upcoming-calendar-events-reconciliation.util';
-const postToOwnRouteMock = vi.hoisted(() => vi.fn());
-
-vi.mock('src/logic-functions/data/post-to-own-route.util', () => ({
- postToOwnRoute: postToOwnRouteMock,
-}));
+const fetchMock = vi.fn();
describe('requestUpcomingCalendarEventsReconciliation', () => {
beforeEach(() => {
vi.clearAllMocks();
- postToOwnRouteMock.mockResolvedValue(true);
+ vi.stubGlobal('fetch', fetchMock);
+ vi.stubEnv('TWENTY_FUNCTIONS_URL', 'https://acme.functions.example.com');
+ vi.stubEnv('TWENTY_APP_ACCESS_TOKEN', 'app-access-token');
+ fetchMock.mockResolvedValue(new Response('{}', { status: 200 }));
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.unstubAllEnvs();
});
it('posts an empty body to start a full sweep', async () => {
const result = await requestUpcomingCalendarEventsReconciliation();
expect(result).toBe(true);
- expect(postToOwnRouteMock).toHaveBeenCalledWith({
- path: RECONCILE_UPCOMING_CALENDAR_EVENTS_ROUTE_PATH,
- body: {},
- });
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [requestUrl, requestInit] = fetchMock.mock.calls[0];
+ expect(requestUrl).toBe(
+ `https://acme.functions.example.com${RECONCILE_UPCOMING_CALENDAR_EVENTS_ROUTE_PATH}`,
+ );
+ expect(requestInit.method).toBe('POST');
+ expect(requestInit.body).toBe(JSON.stringify({}));
});
it('posts the remaining calendar event ids to continue a sweep', async () => {
@@ -30,14 +37,19 @@ describe('requestUpcomingCalendarEventsReconciliation', () => {
calendarEventIds: ['calendar-event-1', 'calendar-event-2'],
});
- expect(postToOwnRouteMock).toHaveBeenCalledWith({
- path: RECONCILE_UPCOMING_CALENDAR_EVENTS_ROUTE_PATH,
- body: { calendarEventIds: ['calendar-event-1', 'calendar-event-2'] },
- });
+ const [requestUrl, requestInit] = fetchMock.mock.calls[0];
+ expect(requestUrl).toBe(
+ `https://acme.functions.example.com${RECONCILE_UPCOMING_CALENDAR_EVENTS_ROUTE_PATH}`,
+ );
+ expect(requestInit.body).toBe(
+ JSON.stringify({
+ calendarEventIds: ['calendar-event-1', 'calendar-event-2'],
+ }),
+ );
});
it('reports a kickoff that failed to fire', async () => {
- postToOwnRouteMock.mockResolvedValue(false);
+ fetchMock.mockRejectedValue(new Error('Network failed'));
await expect(requestUpcomingCalendarEventsReconciliation()).resolves.toBe(
false,
diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/build-recall-bot-automatic-video-output.util.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/build-recall-bot-automatic-video-output.util.test.ts
index 1c99c77799..5fdb32923f 100644
--- a/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/build-recall-bot-automatic-video-output.util.test.ts
+++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/build-recall-bot-automatic-video-output.util.test.ts
@@ -1,84 +1,142 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { buildRecallBotAutomaticVideoOutput } from 'src/logic-functions/domain/build-recall-bot-automatic-video-output.util';
-const isEnabledMock = vi.hoisted(() => vi.fn());
-const getBackgroundMock = vi.hoisted(() => vi.fn());
-const getWorkspaceLogoMock = vi.hoisted(() => vi.fn());
-const buildBotImageMock = vi.hoisted(() => vi.fn());
+const sharpMock = vi.hoisted(() => vi.fn());
+const metadataQueryMock = vi.hoisted(() => vi.fn());
-vi.mock(
- 'src/logic-functions/constants/is-workspace-logo-bot-image-enabled',
- () => ({ isWorkspaceLogoBotImageEnabled: isEnabledMock }),
-);
+vi.mock('sharp', () => ({ default: sharpMock }));
-vi.mock('src/logic-functions/constants/get-bot-image-background', () => ({
- getBotImageBackground: getBackgroundMock,
+vi.mock('twenty-client-sdk/metadata', () => ({
+ MetadataApiClient: class {
+ query = metadataQueryMock;
+ },
}));
-vi.mock('src/logic-functions/data/get-workspace-logo.util', () => ({
- getWorkspaceLogo: getWorkspaceLogoMock,
-}));
+const RECORDING_JPEG_BASE64 = Buffer.from('RECORDING_JPEG').toString('base64');
+const PLAIN_JPEG_BASE64 = Buffer.from('PLAIN_JPEG').toString('base64');
-vi.mock('src/logic-functions/domain/build-bot-image.util', () => ({
- buildBotImage: buildBotImageMock,
-}));
+const fetchMock = vi.fn();
+
+let badgeBuildFails = false;
+let jpegComposeFails = false;
+
+type FakeSharpPipeline = {
+ resize: () => FakeSharpPipeline;
+ png: () => FakeSharpPipeline;
+ jpeg: () => FakeSharpPipeline;
+ composite: (composites: unknown[]) => FakeSharpPipeline;
+ toBuffer: () => Promise;
+};
+
+const buildSharpPipeline = (
+ produce: (composites: unknown[]) => Buffer,
+): FakeSharpPipeline => {
+ let appliedComposites: unknown[] = [];
+ const pipeline: FakeSharpPipeline = {
+ resize: () => pipeline,
+ png: () => pipeline,
+ jpeg: () => pipeline,
+ composite: (composites) => {
+ appliedComposites = composites;
+ return pipeline;
+ },
+ toBuffer: async () => produce(appliedComposites),
+ };
+
+ return pipeline;
+};
describe('buildRecallBotAutomaticVideoOutput', () => {
beforeEach(() => {
vi.clearAllMocks();
- isEnabledMock.mockReturnValue(true);
- getBackgroundMock.mockReturnValue('#ffffff');
- getWorkspaceLogoMock.mockResolvedValue(Buffer.from('logo'));
- buildBotImageMock.mockImplementation(({ withRecordingStatusBadge }) =>
- Promise.resolve(
- withRecordingStatusBadge ? 'RECORDING_JPEG' : 'PLAIN_JPEG',
- ),
+ vi.stubGlobal('fetch', fetchMock);
+ vi.stubEnv('CALL_RECORDER_USE_WORKSPACE_LOGO', 'true');
+ vi.stubEnv('CALL_RECORDER_BOT_IMAGE_BACKGROUND', '#ffffff');
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
+ badgeBuildFails = false;
+ jpegComposeFails = false;
+ metadataQueryMock.mockResolvedValue({
+ currentWorkspace: {
+ logo: 'https://files.example.com/workspace-logo.png',
+ },
+ });
+ fetchMock.mockResolvedValue(
+ new Response(Buffer.from('logo'), { status: 200 }),
);
+ sharpMock.mockImplementation((input?: unknown) => {
+ if (Buffer.isBuffer(input) && input.toString('utf8').startsWith('