Files
calendar/packages/features/tasker/tasks/translateWorkflowStepData.test.ts
T
Udit TakkarGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>Udit TakkarUdit Takkar
4081d11fbe feat: workflow auto translation (#27087)
* feat: workflow auto translation

* tests: add unit tests

* refactor: tests and workflow

* fix: type err

* fix: type err

* fix: remove redundant index on WorkflowStepTranslation

The @@index on [workflowStepId, field, targetLocale] duplicates the @@unique
constraint on the same columns. A unique index already provides efficient
lookups, so the separate @@index adds storage overhead and write latency
without benefit.

Addresses Cubic AI review feedback (confidence 9/10).

Co-Authored-By: unknown <>

* fix: correct locale mapping when translation API returns null

Map translations with their corresponding locales before filtering to
preserve correct locale-to-translation associations. Previously, filtering
out null translations would reindex the array, causing incorrect locale
mappings when any translation in the batch failed.

Also fixes pre-existing lint warnings:
- Move exports to end of file
- Add explicit return type to processTranslations
- Replace ternary with if-else for upsertMany selection

Co-Authored-By: udit@cal.com <udit222001@gmail.com>

* fix: address review feedback for workflow auto-translation

- Add change detection before creating translation tasks
- Rename userLocale to sourceLocale in task props for clarity
- Show source language in UI with new translation key
- Extract SUPPORTED_LOCALES to shared translationConstants.ts
- Fix locale mapping bug in translateEventTypeData.ts
- Add WhatsApp translation support
- Abstract translation lookup into shared translationLookup.ts helper
- Restore if-else readability for SCANNING_WORKFLOW_STEPS

Co-authored-by: Udit Takkar <udit.takkar@cal.com>
Co-Authored-By: unknown <>

* fix: update test to use sourceLocale instead of userLocale

Co-Authored-By: unknown <>

* refactor: feedback

* fix: handle first time

* fix: tests

* fix: tests

* fix: address Cubic AI review feedback (confidence 9/10 issues)

- WhatsApp translation: Apply variable substitution using getSMSMessageWithVariables
  and clear contentSid when using translated body to ensure Twilio uses the
  translated text instead of the original template

- update.handler.ts: Change sourceLocale assignment from ?? to || for consistency
  with tasker payload behavior (line 481)

- ITranslationService.ts: Rename methods from plural to singular naming:
  - getWorkflowStepTranslations -> getWorkflowStepTranslation
  - getEventTypeTranslations -> getEventTypeTranslation
  Updated all call sites and tests accordingly

Co-Authored-By: unknown <>

* fix: address Cubic AI review feedback (confidence 9/10+ issues)

- Fix getSMSMessageWithVariables to handle WHATSAPP_ATTENDEE action for
  locale and timezone (confidence 9/10)
- Remove WhatsApp translation feature that set contentSid to undefined
  since Twilio ignores body parameter for WhatsApp and requires
  pre-approved Message Templates (confidence 10/10)

Co-Authored-By: unknown <>

* fix: translatio

* Add tests: packages/features/eventTypeTranslation/repositories/EventTypeTranslationRepository.test.ts

Generated by Paragon from proposal for PR #27087

* Add tests: packages/features/tasker/tasks/translateWorkflowStepData.test.ts

Generated by Paragon from proposal for PR #27087

* chore: nit

* chore: verfied atg

* fix: set sourceLocale for new steps, add shouldDirty to checkbox, remove spec docs

- Set sourceLocale fallback in addedSteps mapping to fix stale detection mismatch
- Add { shouldDirty: true } to autoTranslateEnabled checkbox onChange
- Remove specs/workflow-translation/ directory (planning docs, not for repo)

Co-authored-by: Udit Takkar <udit.07.takkar@gmail.com>
Co-Authored-By: unknown <>

* chore: add specs back

* fix: type error

* fix: type error

* fix: type err

* fix: tests

* refactor: feedback

* fix: type err

* refactor

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit.takkar@cal.com>
Co-authored-by: Udit Takkar <udit.07.takkar@gmail.com>
2026-02-25 01:03:55 +05:30

324 lines
10 KiB
TypeScript

import type { ITranslationService } from "@calcom/features/translation/services/ITranslationService";
import { beforeEach, describe, expect, it, vi } from "vitest";
import logger from "@calcom/lib/logger";
const mockPrisma = vi.hoisted(() => ({
workflowStep: {
findUnique: vi.fn(),
},
}));
const mockTranslationService: ITranslationService = {
translateText: vi.fn(),
getTargetLocales: vi.fn(),
getWorkflowStepTranslation: vi.fn(),
getEventTypeTranslation: vi.fn(),
};
const mockWorkflowStepTranslationRepository = {
upsertManyBodyTranslations: vi.fn(),
upsertManySubjectTranslations: vi.fn(),
findByLocale: vi.fn(),
deleteByWorkflowStepId: vi.fn(),
};
vi.mock("@calcom/features/di/containers/TranslationService", () => ({
getTranslationService: vi.fn(() => Promise.resolve(mockTranslationService)),
}));
vi.mock("@calcom/features/ee/workflows/di/WorkflowStepTranslationRepository.container", () => ({
getWorkflowStepTranslationRepository: vi.fn(() => mockWorkflowStepTranslationRepository),
}));
const mockLogger = vi.hoisted(() => ({
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
}));
vi.mock("@calcom/lib/logger", () => ({
default: mockLogger,
}));
vi.mock("@calcom/prisma", () => ({
__esModule: true,
default: mockPrisma,
}));
import { translateWorkflowStepData } from "./translateWorkflowStepData";
describe("translateWorkflowStepData", () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(mockPrisma.workflowStep.findUnique).mockResolvedValue({
reminderBody: "Hello",
emailSubject: null,
sourceLocale: "en",
});
});
it("should translate reminderBody to all supported locales", async () => {
vi.mocked(mockPrisma.workflowStep.findUnique).mockResolvedValue({
reminderBody: "Hello {ATTENDEE_NAME}",
emailSubject: null,
sourceLocale: "en",
});
vi.mocked(mockTranslationService.translateText).mockResolvedValue({
translations: [
{ translatedText: "Translated text", targetLocale: "es" },
{ translatedText: "Translated text", targetLocale: "de" },
],
failedLocales: [],
});
const payload = JSON.stringify({
workflowStepId: 1,
reminderBody: "Hello {ATTENDEE_NAME}",
emailSubject: null,
sourceLocale: "en",
});
await translateWorkflowStepData(payload);
expect(mockTranslationService.translateText).toHaveBeenCalledWith({
text: "Hello {ATTENDEE_NAME}",
sourceLocale: "en",
});
expect(mockWorkflowStepTranslationRepository.upsertManyBodyTranslations).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
workflowStepId: 1,
sourceLocale: "en",
translatedText: "Translated text",
}),
])
);
});
it("should translate emailSubject when provided", async () => {
vi.mocked(mockPrisma.workflowStep.findUnique).mockResolvedValue({
reminderBody: null,
emailSubject: "Booking Reminder",
sourceLocale: "en",
});
vi.mocked(mockTranslationService.translateText).mockResolvedValue({
translations: [{ translatedText: "Translated subject", targetLocale: "es" }],
failedLocales: [],
});
const payload = JSON.stringify({
workflowStepId: 1,
reminderBody: null,
emailSubject: "Booking Reminder",
sourceLocale: "en",
});
await translateWorkflowStepData(payload);
expect(mockWorkflowStepTranslationRepository.upsertManySubjectTranslations).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
workflowStepId: 1,
sourceLocale: "en",
translatedText: "Translated subject",
}),
])
);
});
it("should translate both body and subject when provided", async () => {
vi.mocked(mockPrisma.workflowStep.findUnique).mockResolvedValue({
reminderBody: "Body text",
emailSubject: "Subject text",
sourceLocale: "en",
});
vi.mocked(mockTranslationService.translateText).mockResolvedValue({
translations: [{ translatedText: "Translated", targetLocale: "es" }],
failedLocales: [],
});
const payload = JSON.stringify({
workflowStepId: 1,
reminderBody: "Body text",
emailSubject: "Subject text",
sourceLocale: "en",
});
await translateWorkflowStepData(payload);
expect(mockTranslationService.translateText).toHaveBeenCalledTimes(2);
expect(mockWorkflowStepTranslationRepository.upsertManyBodyTranslations).toHaveBeenCalled();
expect(mockWorkflowStepTranslationRepository.upsertManySubjectTranslations).toHaveBeenCalled();
});
it("should not call repository when no translations are returned", async () => {
vi.mocked(mockPrisma.workflowStep.findUnique).mockResolvedValue({
reminderBody: "Hello",
emailSubject: null,
sourceLocale: "en",
});
vi.mocked(mockTranslationService.translateText).mockResolvedValue({
translations: [],
failedLocales: ["es", "de"],
});
const payload = JSON.stringify({
workflowStepId: 1,
reminderBody: "Hello",
emailSubject: null,
sourceLocale: "en",
});
await translateWorkflowStepData(payload);
expect(mockWorkflowStepTranslationRepository.upsertManyBodyTranslations).not.toHaveBeenCalled();
});
it("should throw on invalid payload", async () => {
await expect(translateWorkflowStepData("invalid-json")).rejects.toThrow();
});
it("should preserve targetLocale in translation data", async () => {
vi.mocked(mockPrisma.workflowStep.findUnique).mockResolvedValue({
reminderBody: "Hello",
emailSubject: null,
sourceLocale: "en",
});
vi.mocked(mockTranslationService.translateText).mockResolvedValue({
translations: [
{ translatedText: "Hola", targetLocale: "es" },
{ translatedText: "Bonjour", targetLocale: "fr" },
],
failedLocales: [],
});
const payload = JSON.stringify({
workflowStepId: 1,
reminderBody: "Hello",
emailSubject: null,
sourceLocale: "en",
});
await translateWorkflowStepData(payload);
expect(mockWorkflowStepTranslationRepository.upsertManyBodyTranslations).toHaveBeenCalledWith([
expect.objectContaining({ targetLocale: "es", translatedText: "Hola" }),
expect.objectContaining({ targetLocale: "fr", translatedText: "Bonjour" }),
]);
});
describe("stale task detection", () => {
it("should skip translation when workflow step is not found", async () => {
vi.mocked(mockPrisma.workflowStep.findUnique).mockResolvedValue(null);
const payload = JSON.stringify({
workflowStepId: 999,
reminderBody: "Hello",
emailSubject: null,
sourceLocale: "en",
});
await translateWorkflowStepData(payload);
expect(mockLogger.warn).toHaveBeenCalledWith(
"Workflow step 999 not found for translation task"
);
expect(mockTranslationService.translateText).not.toHaveBeenCalled();
});
it("should skip translation when source locale has changed", async () => {
vi.mocked(mockPrisma.workflowStep.findUnique).mockResolvedValue({
reminderBody: "Hello",
emailSubject: null,
sourceLocale: "fr", // Different from payload
});
const payload = JSON.stringify({
workflowStepId: 1,
reminderBody: "Hello",
emailSubject: null,
sourceLocale: "en",
});
await translateWorkflowStepData(payload);
expect(mockLogger.info).toHaveBeenCalledWith(
"Skipping stale translation task for workflow step 1"
);
expect(mockTranslationService.translateText).not.toHaveBeenCalled();
});
it("should skip translation when reminderBody has changed", async () => {
vi.mocked(mockPrisma.workflowStep.findUnique).mockResolvedValue({
reminderBody: "Updated body", // Different from payload
emailSubject: null,
sourceLocale: "en",
});
const payload = JSON.stringify({
workflowStepId: 1,
reminderBody: "Original body",
emailSubject: null,
sourceLocale: "en",
});
await translateWorkflowStepData(payload);
expect(mockLogger.info).toHaveBeenCalledWith(
"Skipping stale translation task for workflow step 1"
);
expect(mockTranslationService.translateText).not.toHaveBeenCalled();
});
it("should skip translation when emailSubject has changed", async () => {
vi.mocked(mockPrisma.workflowStep.findUnique).mockResolvedValue({
reminderBody: null,
emailSubject: "Updated subject", // Different from payload
sourceLocale: "en",
});
const payload = JSON.stringify({
workflowStepId: 1,
reminderBody: null,
emailSubject: "Original subject",
sourceLocale: "en",
});
await translateWorkflowStepData(payload);
expect(mockLogger.info).toHaveBeenCalledWith(
"Skipping stale translation task for workflow step 1"
);
expect(mockTranslationService.translateText).not.toHaveBeenCalled();
});
it("should only translate body when subject has changed but body matches", async () => {
vi.mocked(mockPrisma.workflowStep.findUnique).mockResolvedValue({
reminderBody: "Hello body",
emailSubject: "Updated subject", // Different from payload
sourceLocale: "en",
});
vi.mocked(mockTranslationService.translateText).mockResolvedValue({
translations: [{ translatedText: "Hola cuerpo", targetLocale: "es" }],
failedLocales: [],
});
const payload = JSON.stringify({
workflowStepId: 1,
reminderBody: "Hello body",
emailSubject: "Original subject",
sourceLocale: "en",
});
await translateWorkflowStepData(payload);
expect(mockTranslationService.translateText).toHaveBeenCalledTimes(1);
expect(mockTranslationService.translateText).toHaveBeenCalledWith({
text: "Hello body",
sourceLocale: "en",
});
expect(mockWorkflowStepTranslationRepository.upsertManyBodyTranslations).toHaveBeenCalled();
expect(mockWorkflowStepTranslationRepository.upsertManySubjectTranslations).not.toHaveBeenCalled();
});
});
});