* 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>
264 lines
9.6 KiB
TypeScript
264 lines
9.6 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { TranslationService } from "./TranslationService";
|
|
|
|
vi.mock("@calcom/lib/logger", () => ({
|
|
default: { error: vi.fn() },
|
|
}));
|
|
|
|
describe("TranslationService", () => {
|
|
let mockLocalizeText: ReturnType<typeof vi.fn>;
|
|
let mockWorkflowStepTranslationRepository: {
|
|
findByLocale: ReturnType<typeof vi.fn>;
|
|
};
|
|
let mockEventTypeTranslationRepository: {
|
|
findByLocale: ReturnType<typeof vi.fn>;
|
|
};
|
|
let service: TranslationService;
|
|
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
mockLocalizeText = vi.fn();
|
|
mockWorkflowStepTranslationRepository = {
|
|
findByLocale: vi.fn(),
|
|
};
|
|
mockEventTypeTranslationRepository = {
|
|
findByLocale: vi.fn(),
|
|
};
|
|
service = new TranslationService({
|
|
localizeText: mockLocalizeText,
|
|
workflowStepTranslationRepository: mockWorkflowStepTranslationRepository as never,
|
|
eventTypeTranslationRepository: mockEventTypeTranslationRepository as never,
|
|
});
|
|
});
|
|
|
|
describe("getTargetLocales", () => {
|
|
it("should exclude source locale from target locales", () => {
|
|
const targets = service.getTargetLocales("en");
|
|
expect(targets).not.toContain("en");
|
|
});
|
|
|
|
it("should return supported locales that are in i18n", () => {
|
|
const targets = service.getTargetLocales("en");
|
|
expect(targets.length).toBeGreaterThan(0);
|
|
expect(targets).toContain("es");
|
|
expect(targets).toContain("de");
|
|
});
|
|
|
|
it("should exclude different source locale", () => {
|
|
const targets = service.getTargetLocales("es");
|
|
expect(targets).not.toContain("es");
|
|
expect(targets).toContain("en");
|
|
});
|
|
});
|
|
|
|
describe("translateText", () => {
|
|
it("should return empty results for empty text", async () => {
|
|
const result = await service.translateText({ text: "", sourceLocale: "en" });
|
|
expect(result.translations).toHaveLength(0);
|
|
expect(result.failedLocales).toHaveLength(0);
|
|
expect(mockLocalizeText).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("should return empty results for whitespace-only text", async () => {
|
|
const result = await service.translateText({ text: " ", sourceLocale: "en" });
|
|
expect(result.translations).toHaveLength(0);
|
|
expect(result.failedLocales).toHaveLength(0);
|
|
expect(mockLocalizeText).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("should return empty results for null text", async () => {
|
|
const result = await service.translateText({ text: null as unknown as string, sourceLocale: "en" });
|
|
expect(result.translations).toHaveLength(0);
|
|
expect(result.failedLocales).toHaveLength(0);
|
|
});
|
|
|
|
it("should translate text to all supported locales", async () => {
|
|
mockLocalizeText.mockResolvedValue("Translated");
|
|
|
|
const result = await service.translateText({ text: "Hello", sourceLocale: "en" });
|
|
|
|
expect(result.translations.length).toBeGreaterThan(0);
|
|
expect(result.translations[0]).toEqual(
|
|
expect.objectContaining({
|
|
translatedText: "Translated",
|
|
})
|
|
);
|
|
});
|
|
|
|
it("should filter out null translations and track failed locales", async () => {
|
|
mockLocalizeText
|
|
.mockResolvedValueOnce("Hola")
|
|
.mockResolvedValueOnce(null)
|
|
.mockResolvedValue("Translated");
|
|
|
|
const result = await service.translateText({ text: "Hello", sourceLocale: "en" });
|
|
|
|
expect(result.failedLocales.length).toBe(1);
|
|
expect(result.translations.every((t) => t.translatedText !== null)).toBe(true);
|
|
});
|
|
|
|
it("should skip source locale when translating", async () => {
|
|
mockLocalizeText.mockResolvedValue("Translation");
|
|
|
|
await service.translateText({ text: "Hello", sourceLocale: "es" });
|
|
|
|
const calls = mockLocalizeText.mock.calls;
|
|
const targetLocales = calls.map((call) => call[2]);
|
|
expect(targetLocales).not.toContain("es");
|
|
});
|
|
|
|
it("should handle translation service errors gracefully", async () => {
|
|
mockLocalizeText.mockRejectedValue(new Error("API Error"));
|
|
|
|
const result = await service.translateText({ text: "Hello", sourceLocale: "en" });
|
|
|
|
expect(result.translations).toHaveLength(0);
|
|
expect(result.failedLocales.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("should preserve locale alignment in results", async () => {
|
|
mockLocalizeText.mockImplementation((_text, _source, target) =>
|
|
Promise.resolve(`Translated to ${target}`)
|
|
);
|
|
|
|
const result = await service.translateText({ text: "Hello", sourceLocale: "en" });
|
|
|
|
for (const translation of result.translations) {
|
|
expect(translation.translatedText).toBe(`Translated to ${translation.targetLocale}`);
|
|
}
|
|
});
|
|
|
|
it("should call localizeText with correct parameters", async () => {
|
|
mockLocalizeText.mockResolvedValue("Translated");
|
|
|
|
await service.translateText({ text: "Hello World", sourceLocale: "en" });
|
|
|
|
expect(mockLocalizeText).toHaveBeenCalledWith("Hello World", "en", expect.any(String));
|
|
});
|
|
});
|
|
|
|
describe("getWorkflowStepTranslation", () => {
|
|
it("should return translated body when includeBody is true", async () => {
|
|
mockWorkflowStepTranslationRepository.findByLocale.mockResolvedValue({
|
|
translatedText: "Translated body",
|
|
});
|
|
|
|
const result = await service.getWorkflowStepTranslation(1, "es", { includeBody: true });
|
|
|
|
expect(result.translatedBody).toBe("Translated body");
|
|
expect(mockWorkflowStepTranslationRepository.findByLocale).toHaveBeenCalledWith(
|
|
1,
|
|
"REMINDER_BODY",
|
|
"es"
|
|
);
|
|
});
|
|
|
|
it("should return translated subject when includeSubject is true", async () => {
|
|
mockWorkflowStepTranslationRepository.findByLocale.mockResolvedValue({
|
|
translatedText: "Translated subject",
|
|
});
|
|
|
|
const result = await service.getWorkflowStepTranslation(1, "es", { includeSubject: true });
|
|
|
|
expect(result.translatedSubject).toBe("Translated subject");
|
|
expect(mockWorkflowStepTranslationRepository.findByLocale).toHaveBeenCalledWith(
|
|
1,
|
|
"EMAIL_SUBJECT",
|
|
"es"
|
|
);
|
|
});
|
|
|
|
it("should return both body and subject when both options are true", async () => {
|
|
mockWorkflowStepTranslationRepository.findByLocale
|
|
.mockResolvedValueOnce({ translatedText: "Translated body" })
|
|
.mockResolvedValueOnce({ translatedText: "Translated subject" });
|
|
|
|
const result = await service.getWorkflowStepTranslation(1, "es", {
|
|
includeBody: true,
|
|
includeSubject: true,
|
|
});
|
|
|
|
expect(result.translatedBody).toBe("Translated body");
|
|
expect(result.translatedSubject).toBe("Translated subject");
|
|
});
|
|
|
|
it("should return empty object when no translation found", async () => {
|
|
mockWorkflowStepTranslationRepository.findByLocale.mockResolvedValue(null);
|
|
|
|
const result = await service.getWorkflowStepTranslation(1, "es", { includeBody: true });
|
|
|
|
expect(result.translatedBody).toBeUndefined();
|
|
});
|
|
|
|
it("should default to includeBody: true when no options provided", async () => {
|
|
mockWorkflowStepTranslationRepository.findByLocale.mockResolvedValue({
|
|
translatedText: "Translated body",
|
|
});
|
|
|
|
await service.getWorkflowStepTranslation(1, "es");
|
|
|
|
expect(mockWorkflowStepTranslationRepository.findByLocale).toHaveBeenCalledWith(
|
|
1,
|
|
"REMINDER_BODY",
|
|
"es"
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("getEventTypeTranslation", () => {
|
|
it("should return translated description when includeDescription is true", async () => {
|
|
mockEventTypeTranslationRepository.findByLocale.mockResolvedValue({
|
|
translatedText: "Translated description",
|
|
});
|
|
|
|
const result = await service.getEventTypeTranslation(1, "es", { includeDescription: true });
|
|
|
|
expect(result.translatedDescription).toBe("Translated description");
|
|
expect(mockEventTypeTranslationRepository.findByLocale).toHaveBeenCalledWith(1, "DESCRIPTION", "es");
|
|
});
|
|
|
|
it("should return translated title when includeTitle is true", async () => {
|
|
mockEventTypeTranslationRepository.findByLocale.mockResolvedValue({
|
|
translatedText: "Translated title",
|
|
});
|
|
|
|
const result = await service.getEventTypeTranslation(1, "es", { includeTitle: true });
|
|
|
|
expect(result.translatedTitle).toBe("Translated title");
|
|
expect(mockEventTypeTranslationRepository.findByLocale).toHaveBeenCalledWith(1, "TITLE", "es");
|
|
});
|
|
|
|
it("should return both title and description when both options are true", async () => {
|
|
mockEventTypeTranslationRepository.findByLocale
|
|
.mockResolvedValueOnce({ translatedText: "Translated title" })
|
|
.mockResolvedValueOnce({ translatedText: "Translated description" });
|
|
|
|
const result = await service.getEventTypeTranslation(1, "es", {
|
|
includeTitle: true,
|
|
includeDescription: true,
|
|
});
|
|
|
|
expect(result.translatedTitle).toBe("Translated title");
|
|
expect(result.translatedDescription).toBe("Translated description");
|
|
});
|
|
|
|
it("should return empty object when no translation found", async () => {
|
|
mockEventTypeTranslationRepository.findByLocale.mockResolvedValue(null);
|
|
|
|
const result = await service.getEventTypeTranslation(1, "es", { includeDescription: true });
|
|
|
|
expect(result.translatedDescription).toBeUndefined();
|
|
});
|
|
|
|
it("should default to includeDescription: true when no options provided", async () => {
|
|
mockEventTypeTranslationRepository.findByLocale.mockResolvedValue({
|
|
translatedText: "Translated description",
|
|
});
|
|
|
|
await service.getEventTypeTranslation(1, "es");
|
|
|
|
expect(mockEventTypeTranslationRepository.findByLocale).toHaveBeenCalledWith(1, "DESCRIPTION", "es");
|
|
});
|
|
});
|
|
});
|