diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 7027e6d279..2e45d4b8c0 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -11,7 +11,7 @@ When generating test files inside the "/app/web" path, follow these rules: - Follow the same test pattern used for other files in the package where the file is located - All imports should be at the top of the file, not inside individual tests - For mocking inside "test" blocks use "vi.mocked" -- Add the original file path to the "test.coverage.include"array in the "apps/web/vite.config.mts" file. Do this only when the test file is created. +- If the file is located in the "packages/survey" path, use "@testing-library/preact" instead of "@testing-library/react" - Don't mock functions that are already mocked in the "apps/web/vitestSetup.ts" file - When using "screen.getByText" check for the tolgee string if it is being used in the file. - The types for mocked variables can be found in the "packages/types" path. Be sure that every imported type exists before using it. Don't create types that are not already in the codebase. @@ -28,4 +28,5 @@ afterEach(() => { - The "afterEach" function should only have the "cleanup()" line inside it and should be adde to the "vitest" imports. - For click events, import userEvent from "@testing-library/user-event" - Mock other components that can make the text more complex and but at the same time mocking it wouldn't make the test flaky. It's ok to leave basic and simple components. -- You don't need to mock @tolgee/react \ No newline at end of file +- You don't need to mock @tolgee/react +- Use "import "@testing-library/jest-dom/vitest";" \ No newline at end of file diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns.test.tsx index 43f5533fc3..19819d9a16 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns.test.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns.test.tsx @@ -1,6 +1,10 @@ import { processResponseData } from "@/lib/responses"; +import { getContactIdentifier } from "@/lib/utils/contact"; +import { getFormattedDateTimeString } from "@/lib/utils/datetime"; import { getSelectionColumn } from "@/modules/ui/components/data-table"; +import { ResponseBadges } from "@/modules/ui/components/response-badges"; import { cleanup } from "@testing-library/react"; +import { AnyActionArg } from "react"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { TResponseNote, TResponseNoteUser, TResponseTableData } from "@formbricks/types/responses"; import { @@ -257,3 +261,238 @@ describe("generateResponseTableColumns", () => { expect(vi.mocked(processResponseData)).toHaveBeenCalledWith(["This is a note"]); }); }); + +describe("ResponseTableColumns", () => { + afterEach(() => { + cleanup(); + }); + + test("includes verifiedEmailColumn when isVerifyEmailEnabled is true", () => { + // Arrange + const mockSurvey = { + questions: [], + variables: [], + hiddenFields: { fieldIds: [] }, + isVerifyEmailEnabled: true, + } as unknown as TSurvey; + + const mockT = vi.fn((key) => key); + const isExpanded = false; + const isReadOnly = false; + + // Act + const columns = generateResponseTableColumns(mockSurvey, isExpanded, isReadOnly, mockT); + + // Assert + const verifiedEmailColumn: any = columns.find((col: any) => col.accessorKey === "verifiedEmail"); + expect(verifiedEmailColumn).toBeDefined(); + expect(verifiedEmailColumn?.accessorKey).toBe("verifiedEmail"); + + // Call the header function to trigger the t function call with "common.verified_email" + if (verifiedEmailColumn && typeof verifiedEmailColumn.header === "function") { + verifiedEmailColumn.header(); + expect(mockT).toHaveBeenCalledWith("common.verified_email"); + } + }); + + test("excludes verifiedEmailColumn when isVerifyEmailEnabled is false", () => { + // Arrange + const mockSurvey = { + questions: [], + variables: [], + hiddenFields: { fieldIds: [] }, + isVerifyEmailEnabled: false, + } as unknown as TSurvey; + + const mockT = vi.fn((key) => key); + const isExpanded = false; + const isReadOnly = false; + + // Act + const columns = generateResponseTableColumns(mockSurvey, isExpanded, isReadOnly, mockT); + + // Assert + const verifiedEmailColumn = columns.find((col: any) => col.accessorKey === "verifiedEmail"); + expect(verifiedEmailColumn).toBeUndefined(); + }); +}); + +describe("ResponseTableColumns - Column Implementations", () => { + afterEach(() => { + cleanup(); + }); + + test("dateColumn renders with formatted date", () => { + const columns = generateResponseTableColumns(mockSurvey, false, true, t as any); + const dateColumn: any = columns.find((col) => (col as any).accessorKey === "createdAt"); + expect(dateColumn).toBeDefined(); + + // Call the header function to test it returns the expected value + expect(dateColumn?.header?.()).toBe("common.date"); + + // Mock a response with a date to test the cell function + const mockRow = { + original: { createdAt: "2023-01-01T12:00:00Z" }, + } as any; + + // Call the cell function and check the formatted date + dateColumn?.cell?.({ row: mockRow } as any); + expect(vi.mocked(getFormattedDateTimeString)).toHaveBeenCalledWith(new Date("2023-01-01T12:00:00Z")); + }); + + test("personColumn renders anonymous when person is null", () => { + const columns = generateResponseTableColumns(mockSurvey, false, true, t as any); + const personColumn: any = columns.find((col) => (col as any).accessorKey === "personId"); + expect(personColumn).toBeDefined(); + + // Test header content + const headerResult = personColumn?.header?.(); + expect(headerResult).toBeDefined(); + + // Mock a response with no person + const mockRow = { + original: { person: null }, + } as any; + + // Mock the t function for this specific call + t.mockReturnValueOnce("Anonymous User"); + + // Call the cell function and check it returns "Anonymous" + const cellResult = personColumn?.cell?.({ row: mockRow } as any); + expect(t).toHaveBeenCalledWith("common.anonymous"); + expect(cellResult?.props?.children).toBe("Anonymous User"); + }); + + test("personColumn renders person identifier when person exists", () => { + const columns = generateResponseTableColumns(mockSurvey, false, true, t as any); + const personColumn: any = columns.find((col) => (col as any).accessorKey === "personId"); + expect(personColumn).toBeDefined(); + + // Mock a response with a person + const mockRow = { + original: { + person: { id: "123", attributes: { email: "test@example.com" } }, + contactAttributes: { name: "John Doe" }, + }, + } as any; + + // Call the cell function + personColumn?.cell?.({ row: mockRow } as any); + expect(vi.mocked(getContactIdentifier)).toHaveBeenCalledWith( + mockRow.original.person, + mockRow.original.contactAttributes + ); + }); + + test("tagsColumn returns undefined when tags is not an array", () => { + const columns = generateResponseTableColumns(mockSurvey, false, true, t as any); + const tagsColumn: any = columns.find((col) => (col as any).accessorKey === "tags"); + expect(tagsColumn).toBeDefined(); + + // Mock a response with no tags + const mockRow = { + original: { tags: null }, + } as any; + + // Call the cell function + const cellResult = tagsColumn?.cell?.({ row: mockRow } as any); + expect(cellResult).toBeUndefined(); + }); + + test("notesColumn renders when notes is an array", () => { + const columns = generateResponseTableColumns(mockSurvey, false, true, t as any); + const notesColumn: any = columns.find((col) => (col as any).accessorKey === "notes"); + expect(notesColumn).toBeDefined(); + + // Mock a response with notes + const mockRow = { + original: { notes: [{ text: "Note 1" }, { text: "Note 2" }] }, + } as any; + + // Call the cell function + notesColumn?.cell?.({ row: mockRow } as any); + expect(vi.mocked(processResponseData)).toHaveBeenCalledWith(["Note 1", "Note 2"]); + }); + + test("notesColumn returns undefined when notes is not an array", () => { + const columns = generateResponseTableColumns(mockSurvey, false, true, t as any); + const notesColumn: any = columns.find((col) => (col as any).accessorKey === "notes"); + expect(notesColumn).toBeDefined(); + + // Mock a response with no notes + const mockRow = { + original: { notes: null }, + } as any; + + // Call the cell function + const cellResult = notesColumn?.cell?.({ row: mockRow } as any); + expect(cellResult).toBeUndefined(); + }); + + test("variableColumns render variable values correctly", () => { + const columns = generateResponseTableColumns(mockSurvey, false, true, t as any); + + // Find the variable column for var1 + const var1Column: any = columns.find((col) => (col as any).accessorKey === "var1"); + expect(var1Column).toBeDefined(); + + // Test the header + const headerResult = var1Column?.header?.(); + expect(headerResult).toBeDefined(); + + // Mock a response with a string variable + const mockRow = { + original: { variables: { var1: "Test Value" } }, + } as any; + + // Call the cell function + const cellResult = var1Column?.cell?.({ row: mockRow } as any); + expect(cellResult?.props.children).toBe("Test Value"); + + // Test with a number variable + const var2Column: any = columns.find((col) => (col as any).accessorKey === "var2"); + expect(var2Column).toBeDefined(); + + const mockRowNumber = { + original: { variables: { var2: 42 } }, + } as any; + + const cellResultNumber = var2Column?.cell?.({ row: mockRowNumber } as any); + expect(cellResultNumber?.props.children).toBe(42); + }); + + test("hiddenFieldColumns render when fieldIds exist", () => { + const columns = generateResponseTableColumns(mockSurvey, false, true, t as any); + + // Find the hidden field column + const hfColumn: any = columns.find((col) => (col as any).accessorKey === "hf1"); + expect(hfColumn).toBeDefined(); + + // Test the header + const headerResult = hfColumn?.header?.(); + expect(headerResult).toBeDefined(); + + // Mock a response with a hidden field value + const mockRow = { + original: { responseData: { hf1: "Hidden Value" } }, + } as any; + + // Call the cell function + const cellResult = hfColumn?.cell?.({ row: mockRow } as any); + expect(cellResult?.props.children).toBe("Hidden Value"); + }); + + test("hiddenFieldColumns are empty when fieldIds don't exist", () => { + // Create a survey with no hidden field IDs + const surveyWithNoHiddenFields = { + ...mockSurvey, + hiddenFields: { enabled: true }, // no fieldIds + }; + + const columns = generateResponseTableColumns(surveyWithNoHiddenFields, false, true, t as any); + + // Check that no hidden field columns were created + const hfColumn = columns.find((col) => (col as any).accessorKey === "hf1"); + expect(hfColumn).toBeUndefined(); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary.test.ts b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary.test.ts index 961ca3fe84..72eb6a58d7 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary.test.ts +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary.test.ts @@ -4,9 +4,10 @@ import { getLocalizedValue } from "@/lib/i18n/utils"; import { getResponseCountBySurveyId } from "@/lib/response/service"; import { getSurvey } from "@/lib/survey/service"; import { evaluateLogic, performActions } from "@/lib/surveyLogic/utils"; -import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { Prisma } from "@prisma/client"; +import { beforeEach, describe, expect, test, vi } from "vitest"; import { prisma } from "@formbricks/database"; -import { ResourceNotFoundError } from "@formbricks/types/errors"; +import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; import { TLanguage } from "@formbricks/types/project"; import { TResponseFilterCriteria } from "@formbricks/types/responses"; import { @@ -46,7 +47,11 @@ vi.mock("@/lib/display/service", () => ({ getDisplayCountBySurveyId: vi.fn(), })); vi.mock("@/lib/i18n/utils", () => ({ - getLocalizedValue: vi.fn((value, lang) => value[lang] || value.default || ""), + getLocalizedValue: vi.fn((value, lang) => { + // Handle the case when value is undefined or null + if (!value) return ""; + return value[lang] || value.default || ""; + }), })); vi.mock("@/lib/response/service", () => ({ getResponseCountBySurveyId: vi.fn(), @@ -398,7 +403,325 @@ describe("getQuestionSummary", () => { expect(hiddenFieldSummary?.samples[0].value).toBe("Hidden val"); }); - // Add more tests for other question types (NPS, CTA, Rating, etc.) + describe("Ranking question type tests", () => { + test("getQuestionSummary correctly processes ranking question with default language responses", async () => { + const question = { + id: "ranking-q1", + type: TSurveyQuestionTypeEnum.Ranking, + headline: { default: "Rank these items" }, + required: true, + choices: [ + { id: "item1", label: { default: "Item 1" } }, + { id: "item2", label: { default: "Item 2" } }, + { id: "item3", label: { default: "Item 3" } }, + ], + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { + id: "response-1", + data: { "ranking-q1": ["Item 1", "Item 2", "Item 3"] }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + { + id: "response-2", + data: { "ranking-q1": ["Item 2", "Item 1", "Item 3"] }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + ]; + + const dropOff = [ + { questionId: "ranking-q1", impressions: 2, dropOffCount: 0, dropOffPercentage: 0 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.Ranking); + expect(summary[0].responseCount).toBe(2); + expect((summary[0] as any).choices).toHaveLength(3); + + // Item 1 is in position 1 once and position 2 once, so avg ranking should be (1+2)/2 = 1.5 + const item1 = (summary[0] as any).choices.find((c) => c.value === "Item 1"); + expect(item1.count).toBe(2); + expect(item1.avgRanking).toBe(1.5); + + // Item 2 is in position 1 once and position 2 once, so avg ranking should be (1+2)/2 = 1.5 + const item2 = (summary[0] as any).choices.find((c) => c.value === "Item 2"); + expect(item2.count).toBe(2); + expect(item2.avgRanking).toBe(1.5); + + // Item 3 is in position 3 twice, so avg ranking should be 3 + const item3 = (summary[0] as any).choices.find((c) => c.value === "Item 3"); + expect(item3.count).toBe(2); + expect(item3.avgRanking).toBe(3); + }); + + test("getQuestionSummary correctly processes ranking question with non-default language responses", async () => { + const question = { + id: "ranking-q1", + type: TSurveyQuestionTypeEnum.Ranking, + headline: { default: "Rank these items", es: "Clasifica estos elementos" }, + required: true, + choices: [ + { id: "item1", label: { default: "Item 1", es: "Elemento 1" } }, + { id: "item2", label: { default: "Item 2", es: "Elemento 2" } }, + { id: "item3", label: { default: "Item 3", es: "Elemento 3" } }, + ], + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [{ language: { code: "es" }, default: false }], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + // Spanish response with Spanish labels + const responses = [ + { + id: "response-1", + data: { "ranking-q1": ["Elemento 2", "Elemento 1", "Elemento 3"] }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: "es", + ttc: {}, + finished: true, + }, + ]; + + // Mock checkForI18n for this test case + vi.mock("./surveySummary", async (importOriginal) => { + const originalModule = await importOriginal(); + return { + ...(originalModule as object), + checkForI18n: vi.fn().mockImplementation(() => { + // NOSONAR + // Convert Spanish labels to default language labels + return ["Item 2", "Item 1", "Item 3"]; + }), + }; + }); + + const dropOff = [ + { questionId: "ranking-q1", impressions: 1, dropOffCount: 0, dropOffPercentage: 0 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.Ranking); + expect(summary[0].responseCount).toBe(1); + + // Item 1 is in position 2, so avg ranking should be 2 + const item1 = (summary[0] as any).choices.find((c) => c.value === "Item 1"); + expect(item1.count).toBe(1); + expect(item1.avgRanking).toBe(2); + + // Item 2 is in position 1, so avg ranking should be 1 + const item2 = (summary[0] as any).choices.find((c) => c.value === "Item 2"); + expect(item2.count).toBe(1); + expect(item2.avgRanking).toBe(1); + + // Item 3 is in position 3, so avg ranking should be 3 + const item3 = (summary[0] as any).choices.find((c) => c.value === "Item 3"); + expect(item3.count).toBe(1); + expect(item3.avgRanking).toBe(3); + }); + + test("getQuestionSummary handles ranking question with no ranking data in responses", async () => { + const question = { + id: "ranking-q1", + type: TSurveyQuestionTypeEnum.Ranking, + headline: { default: "Rank these items" }, + required: false, + choices: [ + { id: "item1", label: { default: "Item 1" } }, + { id: "item2", label: { default: "Item 2" } }, + { id: "item3", label: { default: "Item 3" } }, + ], + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + // Responses without any ranking data + const responses = [ + { + id: "response-1", + data: {}, // No ranking data + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + } as any, + { + id: "response-2", + data: { "other-q": "some value" }, // No ranking data + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + } as any, + ]; + + const dropOff = [ + { questionId: "ranking-q1", impressions: 2, dropOffCount: 2, dropOffPercentage: 100 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.Ranking); + expect(summary[0].responseCount).toBe(0); + expect((summary[0] as any).choices).toHaveLength(3); + + // All items should have count 0 and avgRanking 0 + (summary[0] as any).choices.forEach((choice) => { + expect(choice.count).toBe(0); + expect(choice.avgRanking).toBe(0); + }); + }); + + test("getQuestionSummary handles ranking question with non-array answers", async () => { + const question = { + id: "ranking-q1", + type: TSurveyQuestionTypeEnum.Ranking, + headline: { default: "Rank these items" }, + required: true, + choices: [ + { id: "item1", label: { default: "Item 1" } }, + { id: "item2", label: { default: "Item 2" } }, + { id: "item3", label: { default: "Item 3" } }, + ], + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + // Responses with invalid ranking data (not an array) + const responses = [ + { + id: "response-1", + data: { "ranking-q1": "Item 1" }, // Not an array + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + ]; + + const dropOff = [ + { questionId: "ranking-q1", impressions: 1, dropOffCount: 0, dropOffPercentage: 0 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.Ranking); + expect(summary[0].responseCount).toBe(0); // No valid responses + expect((summary[0] as any).choices).toHaveLength(3); + + // All items should have count 0 and avgRanking 0 since we had no valid ranking data + (summary[0] as any).choices.forEach((choice) => { + expect(choice.count).toBe(0); + expect(choice.avgRanking).toBe(0); + }); + }); + + test("getQuestionSummary handles ranking question with values not in choices", async () => { + const question = { + id: "ranking-q1", + type: TSurveyQuestionTypeEnum.Ranking, + headline: { default: "Rank these items" }, + required: true, + choices: [ + { id: "item1", label: { default: "Item 1" } }, + { id: "item2", label: { default: "Item 2" } }, + { id: "item3", label: { default: "Item 3" } }, + ], + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + // Response with some values not in choices + const responses = [ + { + id: "response-1", + data: { "ranking-q1": ["Item 1", "Unknown Item", "Item 3"] }, // "Unknown Item" is not in choices + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + ]; + + const dropOff = [ + { questionId: "ranking-q1", impressions: 1, dropOffCount: 0, dropOffPercentage: 0 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.Ranking); + expect(summary[0].responseCount).toBe(1); + expect((summary[0] as any).choices).toHaveLength(3); + + // Item 1 is in position 1, so avg ranking should be 1 + const item1 = (summary[0] as any).choices.find((c) => c.value === "Item 1"); + expect(item1.count).toBe(1); + expect(item1.avgRanking).toBe(1); + + // Item 2 was not ranked, so should have count 0 and avgRanking 0 + const item2 = (summary[0] as any).choices.find((c) => c.value === "Item 2"); + expect(item2.count).toBe(0); + expect(item2.avgRanking).toBe(0); + + // Item 3 is in position 3, so avg ranking should be 3 + const item3 = (summary[0] as any).choices.find((c) => c.value === "Item 3"); + expect(item3.count).toBe(1); + expect(item3.avgRanking).toBe(3); + }); + }); }); describe("getSurveySummary", () => { @@ -508,9 +831,2552 @@ describe("getResponsesForSummary", () => { vi.mocked(prisma.response.findMany).mockRejectedValue(new Error("DB error")); await expect(getResponsesForSummary(mockSurveyId, 10, 0)).rejects.toThrow("DB error"); }); + + test("getResponsesForSummary handles null contact properly", async () => { + const mockSurvey = { id: "survey-1" } as unknown as TSurvey; + const mockResponse = { + id: "response-1", + data: {}, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: "en", + ttc: {}, + finished: true, + }; + + vi.mocked(getSurvey).mockResolvedValue(mockSurvey); + vi.mocked(prisma.response.findMany).mockResolvedValue([mockResponse]); + + const result = await getResponsesForSummary("survey-1", 10, 0); + + expect(result).toHaveLength(1); + expect(result[0].contact).toBeNull(); + expect(prisma.response.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { surveyId: "survey-1" }, + }) + ); + }); + + test("getResponsesForSummary extracts contact id and userId when contact exists", async () => { + const mockSurvey = { id: "survey-1" } as unknown as TSurvey; + const mockResponse = { + id: "response-1", + data: {}, + updatedAt: new Date(), + contact: { + id: "contact-1", + attributes: [ + { attributeKey: { key: "userId" }, value: "user-123" }, + { attributeKey: { key: "email" }, value: "test@example.com" }, + ], + }, + contactAttributes: {}, + language: "en", + ttc: {}, + finished: true, + }; + + vi.mocked(getSurvey).mockResolvedValue(mockSurvey); + vi.mocked(prisma.response.findMany).mockResolvedValue([mockResponse]); + + const result = await getResponsesForSummary("survey-1", 10, 0); + + expect(result).toHaveLength(1); + expect(result[0].contact).toEqual({ + id: "contact-1", + userId: "user-123", + }); + }); + + test("getResponsesForSummary handles contact without userId attribute", async () => { + const mockSurvey = { id: "survey-1" } as unknown as TSurvey; + const mockResponse = { + id: "response-1", + data: {}, + updatedAt: new Date(), + contact: { + id: "contact-1", + attributes: [{ attributeKey: { key: "email" }, value: "test@example.com" }], + }, + contactAttributes: {}, + language: "en", + ttc: {}, + finished: true, + }; + + vi.mocked(getSurvey).mockResolvedValue(mockSurvey); + vi.mocked(prisma.response.findMany).mockResolvedValue([mockResponse]); + + const result = await getResponsesForSummary("survey-1", 10, 0); + + expect(result).toHaveLength(1); + expect(result[0].contact).toEqual({ + id: "contact-1", + userId: undefined, + }); + }); + + test("getResponsesForSummary throws DatabaseError when Prisma throws PrismaClientKnownRequestError", async () => { + vi.mocked(getSurvey).mockResolvedValue({ id: "survey-1" } as unknown as TSurvey); + + const prismaError = new Prisma.PrismaClientKnownRequestError("Database connection error", { + code: "P2002", + clientVersion: "4.0.0", + }); + + vi.mocked(prisma.response.findMany).mockRejectedValue(prismaError); + + await expect(getResponsesForSummary("survey-1", 10, 0)).rejects.toThrow(DatabaseError); + await expect(getResponsesForSummary("survey-1", 10, 0)).rejects.toThrow("Database connection error"); + }); + + test("getResponsesForSummary rethrows non-Prisma errors", async () => { + vi.mocked(getSurvey).mockResolvedValue({ id: "survey-1" } as unknown as TSurvey); + + const genericError = new Error("Something else went wrong"); + vi.mocked(prisma.response.findMany).mockRejectedValue(genericError); + + await expect(getResponsesForSummary("survey-1", 10, 0)).rejects.toThrow("Something else went wrong"); + await expect(getResponsesForSummary("survey-1", 10, 0)).rejects.toThrow(Error); + await expect(getResponsesForSummary("survey-1", 10, 0)).rejects.not.toThrow(DatabaseError); + }); + + test("getSurveySummary throws DatabaseError when Prisma throws PrismaClientKnownRequestError", async () => { + vi.mocked(getSurvey).mockResolvedValue({ + id: "survey-1", + questions: [], + welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"], + languages: [], + } as unknown as TSurvey); + + vi.mocked(getResponseCountBySurveyId).mockResolvedValue(10); + + const prismaError = new Prisma.PrismaClientKnownRequestError("Database connection error", { + code: "P2002", + clientVersion: "4.0.0", + }); + + vi.mocked(prisma.response.findMany).mockRejectedValue(prismaError); + + await expect(getSurveySummary("survey-1")).rejects.toThrow(DatabaseError); + await expect(getSurveySummary("survey-1")).rejects.toThrow("Database connection error"); + }); + + test("getSurveySummary rethrows non-Prisma errors", async () => { + vi.mocked(getSurvey).mockResolvedValue({ + id: "survey-1", + questions: [], + welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"], + languages: [], + } as unknown as TSurvey); + + vi.mocked(getResponseCountBySurveyId).mockResolvedValue(10); + + const genericError = new Error("Something else went wrong"); + vi.mocked(prisma.response.findMany).mockRejectedValue(genericError); + + await expect(getSurveySummary("survey-1")).rejects.toThrow("Something else went wrong"); + await expect(getSurveySummary("survey-1")).rejects.toThrow(Error); + await expect(getSurveySummary("survey-1")).rejects.not.toThrow(DatabaseError); + }); }); -// Add afterEach to clear mocks if not using vi.resetAllMocks() in beforeEach -afterEach(() => { - vi.clearAllMocks(); +describe("Address and ContactInfo question types", () => { + test("getQuestionSummary correctly processes Address question with valid responses", async () => { + const question = { + id: "address-q1", + type: TSurveyQuestionTypeEnum.Address, + headline: { default: "What's your address?" }, + required: true, + fields: ["line1", "line2", "city", "state", "zip", "country"], + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { + id: "response-1", + data: { + "address-q1": [ + { type: "line1", value: "123 Main St" }, + { type: "city", value: "San Francisco" }, + { type: "state", value: "CA" }, + ], + }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + } as any, + { + id: "response-2", + data: { + "address-q1": [ + { type: "line1", value: "456 Oak Ave" }, + { type: "city", value: "Seattle" }, + { type: "state", value: "WA" }, + ], + }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + } as any, + ]; + + const dropOff = [ + { questionId: "address-q1", impressions: 2, dropOffCount: 0, dropOffPercentage: 0 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.Address); + expect(summary[0].responseCount).toBe(2); + expect((summary[0] as any).samples).toHaveLength(2); + expect((summary[0] as any).samples[0].value).toEqual(responses[0].data["address-q1"]); + expect((summary[0] as any).samples[1].value).toEqual(responses[1].data["address-q1"]); + }); + + test("getQuestionSummary correctly processes ContactInfo question with valid responses", async () => { + const question = { + id: "contact-q1", + type: TSurveyQuestionTypeEnum.ContactInfo, + headline: { default: "Your contact information" }, + required: true, + fields: ["firstName", "lastName", "email", "phone"], + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { + id: "response-1", + data: { + "contact-q1": [ + { type: "firstName", value: "John" }, + { type: "lastName", value: "Doe" }, + { type: "email", value: "john@example.com" }, + ], + }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + { + id: "response-2", + data: { + "contact-q1": [ + { type: "firstName", value: "Jane" }, + { type: "lastName", value: "Smith" }, + { type: "email", value: "jane@example.com" }, + ], + }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + ] as any; + + const dropOff = [ + { questionId: "contact-q1", impressions: 2, dropOffCount: 0, dropOffPercentage: 0 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.ContactInfo); + expect((summary[0] as any).responseCount).toBe(2); + expect((summary[0] as any).samples).toHaveLength(2); + expect((summary[0] as any).samples[0].value).toEqual(responses[0].data["contact-q1"]); + expect((summary[0] as any).samples[1].value).toEqual(responses[1].data["contact-q1"]); + }); + + test("getQuestionSummary handles empty array answers for Address type", async () => { + const question = { + id: "address-q1", + type: TSurveyQuestionTypeEnum.Address, + headline: { default: "What's your address?" }, + required: false, + fields: ["line1", "line2", "city", "state", "zip", "country"], + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { + id: "response-1", + data: { "address-q1": [] }, // Empty array + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + ]; + + const dropOff = [ + { questionId: "address-q1", impressions: 1, dropOffCount: 0, dropOffPercentage: 0 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect((summary[0] as any).type).toBe(TSurveyQuestionTypeEnum.Address); + expect((summary[0] as any).responseCount).toBe(0); // Should be 0 as empty array doesn't count as response + expect((summary[0] as any).samples).toHaveLength(0); + }); + + test("getQuestionSummary handles non-array answers for ContactInfo type", async () => { + const question = { + id: "contact-q1", + type: TSurveyQuestionTypeEnum.ContactInfo, + headline: { default: "Your contact information" }, + required: true, + fields: ["firstName", "lastName", "email", "phone"], + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { + id: "response-1", + data: { "contact-q1": "Not an array" }, // String instead of array + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + { + id: "response-2", + data: { "contact-q1": { name: "John" } }, // Object instead of array + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + { + id: "response-3", + data: {}, // No data for this question + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + ] as any; + + const dropOff = [ + { questionId: "contact-q1", impressions: 3, dropOffCount: 3, dropOffPercentage: 100 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect((summary[0] as any).type).toBe(TSurveyQuestionTypeEnum.ContactInfo); + expect((summary[0] as any).responseCount).toBe(0); // Should be 0 as no valid responses + expect((summary[0] as any).samples).toHaveLength(0); + }); + + test("getQuestionSummary handles mix of valid and invalid responses for Address type", async () => { + const question = { + id: "address-q1", + type: TSurveyQuestionTypeEnum.Address, + headline: { default: "What's your address?" }, + required: true, + fields: ["line1", "line2", "city", "state", "zip", "country"], + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + // One valid response, one invalid + const responses = [ + { + id: "response-1", + data: { + "address-q1": [ + { type: "line1", value: "123 Main St" }, + { type: "city", value: "San Francisco" }, + ], + }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + { + id: "response-2", + data: { "address-q1": "Invalid format" }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + ] as any; + + const dropOff = [ + { questionId: "address-q1", impressions: 2, dropOffCount: 0, dropOffPercentage: 0 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect((summary[0] as any).type).toBe(TSurveyQuestionTypeEnum.Address); + expect((summary[0] as any).responseCount).toBe(1); // Should be 1 as only one valid response + expect((summary[0] as any).samples).toHaveLength(1); + expect((summary[0] as any).samples[0].value).toEqual(responses[0].data["address-q1"]); + }); + + test("getQuestionSummary applies VALUES_LIMIT correctly for ContactInfo type", async () => { + const question = { + id: "contact-q1", + type: TSurveyQuestionTypeEnum.ContactInfo, + headline: { default: "Your contact information" }, + required: true, + fields: ["firstName", "lastName", "email"], + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + // Create 100 responses (more than VALUES_LIMIT which is 50) + const responses = Array.from( + { length: 100 }, + (_, i) => + ({ + id: `response-${i}`, + data: { + "contact-q1": [ + { type: "firstName", value: `First${i}` }, + { type: "lastName", value: `Last${i}` }, + { type: "email", value: `user${i}@example.com` }, + ], + }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }) as any + ); + + const dropOff = [ + { questionId: "contact-q1", impressions: 100, dropOffCount: 0, dropOffPercentage: 0 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect((summary[0] as any).type).toBe(TSurveyQuestionTypeEnum.ContactInfo); + expect((summary[0] as any).responseCount).toBe(100); // All responses are valid + expect((summary[0] as any).samples).toHaveLength(50); // Limited to VALUES_LIMIT (50) + }); +}); + +describe("Matrix question type tests", () => { + test("getQuestionSummary correctly processes Matrix question with valid responses", async () => { + const question = { + id: "matrix-q1", + type: TSurveyQuestionTypeEnum.Matrix, + headline: { default: "Rate these aspects" }, + required: true, + rows: [{ default: "Speed" }, { default: "Quality" }, { default: "Price" }], + columns: [{ default: "Poor" }, { default: "Average" }, { default: "Good" }, { default: "Excellent" }], + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { + id: "response-1", + data: { + "matrix-q1": { + Speed: "Good", + Quality: "Excellent", + Price: "Average", + }, + }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + { + id: "response-2", + data: { + "matrix-q1": { + Speed: "Average", + Quality: "Good", + Price: "Poor", + }, + }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + ]; + + const dropOff = [ + { questionId: "matrix-q1", impressions: 2, dropOffCount: 0, dropOffPercentage: 0 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.Matrix); + expect(summary[0].responseCount).toBe(2); + + // Verify Speed row + const speedRow = summary[0].data.find((row) => row.rowLabel === "Speed"); + expect(speedRow.totalResponsesForRow).toBe(2); + expect(speedRow.columnPercentages).toHaveLength(4); // 4 columns + expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(50); + expect(speedRow.columnPercentages.find((col) => col.column === "Average").percentage).toBe(50); + + // Verify Quality row + const qualityRow = summary[0].data.find((row) => row.rowLabel === "Quality"); + expect(qualityRow.totalResponsesForRow).toBe(2); + expect(qualityRow.columnPercentages.find((col) => col.column === "Excellent").percentage).toBe(50); + expect(qualityRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(50); + + // Verify Price row + const priceRow = summary[0].data.find((row) => row.rowLabel === "Price"); + expect(priceRow.totalResponsesForRow).toBe(2); + expect(priceRow.columnPercentages.find((col) => col.column === "Poor").percentage).toBe(50); + expect(priceRow.columnPercentages.find((col) => col.column === "Average").percentage).toBe(50); + }); + + test("getQuestionSummary correctly processes Matrix question with non-default language responses", async () => { + const question = { + id: "matrix-q1", + type: TSurveyQuestionTypeEnum.Matrix, + headline: { default: "Rate these aspects", es: "Califica estos aspectos" }, + required: true, + rows: [ + { default: "Speed", es: "Velocidad" }, + { default: "Quality", es: "Calidad" }, + { default: "Price", es: "Precio" }, + ], + columns: [ + { default: "Poor", es: "Malo" }, + { default: "Average", es: "Promedio" }, + { default: "Good", es: "Bueno" }, + { default: "Excellent", es: "Excelente" }, + ], + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [{ language: { code: "es" }, default: false }], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + // Spanish response with Spanish labels + const responses = [ + { + id: "response-1", + data: { + "matrix-q1": { + Velocidad: "Bueno", + Calidad: "Excelente", + Precio: "Promedio", + }, + }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: "es", + ttc: {}, + finished: true, + }, + ]; + + const dropOff = [ + { questionId: "matrix-q1", impressions: 1, dropOffCount: 0, dropOffPercentage: 0 }, + ] as unknown as TSurveySummary["dropOff"]; + + // Mock getLocalizedValue for this test + const getLocalizedValueOriginal = getLocalizedValue; + vi.mocked(getLocalizedValue).mockImplementation((obj, langCode) => { + if (!obj) return ""; + + if (langCode === "es" && typeof obj === "object" && "es" in obj) { + return obj.es; + } + + if (typeof obj === "object" && "default" in obj) { + return obj.default; + } + + return ""; + }); + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + // Reset the mock after test + vi.mocked(getLocalizedValue).mockImplementation(getLocalizedValueOriginal); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.Matrix); + expect(summary[0].responseCount).toBe(1); + + // Verify Speed row with localized values mapped to default language + const speedRow = summary[0].data.find((row) => row.rowLabel === "Speed"); + expect(speedRow.totalResponsesForRow).toBe(1); + expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(100); + + // Verify Quality row + const qualityRow = summary[0].data.find((row) => row.rowLabel === "Quality"); + expect(qualityRow.totalResponsesForRow).toBe(1); + expect(qualityRow.columnPercentages.find((col) => col.column === "Excellent").percentage).toBe(100); + + // Verify Price row + const priceRow = summary[0].data.find((row) => row.rowLabel === "Price"); + expect(priceRow.totalResponsesForRow).toBe(1); + expect(priceRow.columnPercentages.find((col) => col.column === "Average").percentage).toBe(100); + }); + + test("getQuestionSummary handles missing or invalid data for Matrix questions", async () => { + const question = { + id: "matrix-q1", + type: TSurveyQuestionTypeEnum.Matrix, + headline: { default: "Rate these aspects" }, + required: false, + rows: [{ default: "Speed" }, { default: "Quality" }], + columns: [{ default: "Poor" }, { default: "Good" }], + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { + id: "response-1", + data: {}, // No matrix data + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + { + id: "response-2", + data: { + "matrix-q1": "Not an object", // Invalid format - not an object + }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + { + id: "response-3", + data: { + "matrix-q1": {}, // Empty object + }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + { + id: "response-4", + data: { + "matrix-q1": { + Speed: "Invalid", // Value not in columns + }, + }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + ] as any; + + const dropOff = [ + { questionId: "matrix-q1", impressions: 4, dropOffCount: 4, dropOffPercentage: 100 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.Matrix); + expect(summary[0].responseCount).toBe(3); // Count is 3 because responses 2, 3, and 4 have the "matrix-q1" property + + // All rows should have zero responses for all columns + summary[0].data.forEach((row) => { + expect(row.totalResponsesForRow).toBe(0); + row.columnPercentages.forEach((col) => { + expect(col.percentage).toBe(0); + }); + }); + }); + + test("getQuestionSummary handles partial and incomplete matrix responses", async () => { + const question = { + id: "matrix-q1", + type: TSurveyQuestionTypeEnum.Matrix, + headline: { default: "Rate these aspects" }, + required: true, + rows: [{ default: "Speed" }, { default: "Quality" }, { default: "Price" }], + columns: [{ default: "Poor" }, { default: "Average" }, { default: "Good" }], + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { + id: "response-1", + data: { + "matrix-q1": { + Speed: "Good", + // Quality is missing + Price: "Average", + }, + }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + { + id: "response-2", + data: { + "matrix-q1": { + Speed: "Average", + Quality: "Good", + Price: "Poor", + ExtraRow: "Poor", // Row not in question definition + }, + }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + ] as any; + + const dropOff = [ + { questionId: "matrix-q1", impressions: 2, dropOffCount: 0, dropOffPercentage: 0 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.Matrix); + expect(summary[0].responseCount).toBe(2); + + // Verify Speed row - both responses provided data + const speedRow = summary[0].data.find((row) => row.rowLabel === "Speed"); + expect(speedRow.totalResponsesForRow).toBe(2); + expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(50); + expect(speedRow.columnPercentages.find((col) => col.column === "Average").percentage).toBe(50); + + // Verify Quality row - only one response provided data + const qualityRow = summary[0].data.find((row) => row.rowLabel === "Quality"); + expect(qualityRow.totalResponsesForRow).toBe(1); + expect(qualityRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(100); + + // Verify Price row - both responses provided data + const priceRow = summary[0].data.find((row) => row.rowLabel === "Price"); + expect(priceRow.totalResponsesForRow).toBe(2); + + // ExtraRow should not appear in the summary + expect(summary[0].data.find((row) => row.rowLabel === "ExtraRow")).toBeUndefined(); + }); + + test("getQuestionSummary handles zero responses for Matrix question correctly", async () => { + const question = { + id: "matrix-q1", + type: TSurveyQuestionTypeEnum.Matrix, + headline: { default: "Rate these aspects" }, + required: true, + rows: [{ default: "Speed" }, { default: "Quality" }], + columns: [{ default: "Poor" }, { default: "Good" }], + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + // No responses with matrix data + const responses = [ + { + id: "response-1", + data: { "other-question": "value" }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + ] as any; + + const dropOff = [ + { questionId: "matrix-q1", impressions: 1, dropOffCount: 1, dropOffPercentage: 100 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.Matrix); + expect(summary[0].responseCount).toBe(0); + + // All rows should have proper structure but zero counts + expect(summary[0].data).toHaveLength(2); // 2 rows + + summary[0].data.forEach((row) => { + expect(row.columnPercentages).toHaveLength(2); // 2 columns + expect(row.totalResponsesForRow).toBe(0); + expect(row.columnPercentages[0].percentage).toBe(0); + expect(row.columnPercentages[1].percentage).toBe(0); + }); + }); + + test("getQuestionSummary handles Matrix question with mixed valid and invalid column values", async () => { + const question = { + id: "matrix-q1", + type: TSurveyQuestionTypeEnum.Matrix, + headline: { default: "Rate these aspects" }, + required: true, + rows: [{ default: "Speed" }, { default: "Quality" }, { default: "Price" }], + columns: [{ default: "Poor" }, { default: "Average" }, { default: "Good" }], + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { + id: "response-1", + data: { + "matrix-q1": { + Speed: "Good", // Valid + Quality: "Invalid Column", // Invalid + Price: "Average", // Valid + }, + }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + ]; + + const dropOff = [ + { questionId: "matrix-q1", impressions: 1, dropOffCount: 0, dropOffPercentage: 0 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.Matrix); + expect(summary[0].responseCount).toBe(1); + + // Speed row should have a valid response + const speedRow = summary[0].data.find((row) => row.rowLabel === "Speed"); + expect(speedRow.totalResponsesForRow).toBe(1); + expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(100); + + // Quality row should have no valid responses + const qualityRow = summary[0].data.find((row) => row.rowLabel === "Quality"); + expect(qualityRow.totalResponsesForRow).toBe(0); + qualityRow.columnPercentages.forEach((col) => { + expect(col.percentage).toBe(0); + }); + + // Price row should have a valid response + const priceRow = summary[0].data.find((row) => row.rowLabel === "Price"); + expect(priceRow.totalResponsesForRow).toBe(1); + expect(priceRow.columnPercentages.find((col) => col.column === "Average").percentage).toBe(100); + }); + + test("getQuestionSummary handles Matrix question with invalid row labels", async () => { + const question = { + id: "matrix-q1", + type: TSurveyQuestionTypeEnum.Matrix, + headline: { default: "Rate these aspects" }, + required: true, + rows: [{ default: "Speed" }, { default: "Quality" }], + columns: [{ default: "Poor" }, { default: "Good" }], + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { + id: "response-1", + data: { + "matrix-q1": { + Speed: "Good", // Valid + InvalidRow: "Poor", // Invalid row + AnotherInvalidRow: "Good", // Invalid row + }, + }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + ]; + + const dropOff = [ + { questionId: "matrix-q1", impressions: 1, dropOffCount: 0, dropOffPercentage: 0 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.Matrix); + expect(summary[0].responseCount).toBe(1); + + // There should only be rows for the defined question rows + expect(summary[0].data).toHaveLength(2); // 2 rows + + // Speed row should have a valid response + const speedRow = summary[0].data.find((row) => row.rowLabel === "Speed"); + expect(speedRow.totalResponsesForRow).toBe(1); + expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(100); + + // Quality row should have no responses + const qualityRow = summary[0].data.find((row) => row.rowLabel === "Quality"); + expect(qualityRow.totalResponsesForRow).toBe(0); + + // Invalid rows should not appear in the summary + expect(summary[0].data.find((row) => row.rowLabel === "InvalidRow")).toBeUndefined(); + expect(summary[0].data.find((row) => row.rowLabel === "AnotherInvalidRow")).toBeUndefined(); + }); + + test("getQuestionSummary handles Matrix question with mixed language responses", async () => { + const question = { + id: "matrix-q1", + type: TSurveyQuestionTypeEnum.Matrix, + headline: { default: "Rate these aspects", fr: "Évaluez ces aspects" }, + required: true, + rows: [ + { default: "Speed", fr: "Vitesse" }, + { default: "Quality", fr: "Qualité" }, + ], + columns: [ + { default: "Poor", fr: "Médiocre" }, + { default: "Good", fr: "Bon" }, + ], + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [ + { language: { code: "en" }, default: true }, + { language: { code: "fr" }, default: false }, + ], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { + id: "response-1", + data: { + "matrix-q1": { + Speed: "Good", // English + }, + }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: "en", + ttc: {}, + finished: true, + }, + { + id: "response-2", + data: { + "matrix-q1": { + Vitesse: "Bon", // French + }, + }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: "fr", + ttc: {}, + finished: true, + }, + ] as any; + + const dropOff = [ + { questionId: "matrix-q1", impressions: 2, dropOffCount: 0, dropOffPercentage: 0 }, + ] as unknown as TSurveySummary["dropOff"]; + + // Mock getLocalizedValue to handle our specific test case + const originalGetLocalizedValue = getLocalizedValue; + vi.mocked(getLocalizedValue).mockImplementation((obj, langCode) => { + if (!obj) return ""; + + if (langCode === "fr" && typeof obj === "object" && "fr" in obj) { + return obj.fr; + } + + if (typeof obj === "object" && "default" in obj) { + return obj.default; + } + + return ""; + }); + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + // Reset mock + vi.mocked(getLocalizedValue).mockImplementation(originalGetLocalizedValue); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.Matrix); + expect(summary[0].responseCount).toBe(2); + + // Speed row should have both responses + const speedRow = summary[0].data.find((row) => row.rowLabel === "Speed"); + expect(speedRow.totalResponsesForRow).toBe(2); + expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(100); + + // Quality row should have no responses + const qualityRow = summary[0].data.find((row) => row.rowLabel === "Quality"); + expect(qualityRow.totalResponsesForRow).toBe(0); + }); + + test("getQuestionSummary handles Matrix question with null response data", async () => { + const question = { + id: "matrix-q1", + type: TSurveyQuestionTypeEnum.Matrix, + headline: { default: "Rate these aspects" }, + required: true, + rows: [{ default: "Speed" }, { default: "Quality" }], + columns: [{ default: "Poor" }, { default: "Good" }], + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { + id: "response-1", + data: { + "matrix-q1": null, // Null response data + }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + ] as any; + + const dropOff = [ + { questionId: "matrix-q1", impressions: 1, dropOffCount: 0, dropOffPercentage: 0 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.Matrix); + expect(summary[0].responseCount).toBe(0); // Counts as response even with null data + + // Both rows should have zero responses + summary[0].data.forEach((row) => { + expect(row.totalResponsesForRow).toBe(0); + row.columnPercentages.forEach((col) => { + expect(col.percentage).toBe(0); + }); + }); + }); +}); + +describe("NPS question type tests", () => { + test("getQuestionSummary correctly processes NPS question with valid responses", async () => { + const question = { + id: "nps-q1", + type: TSurveyQuestionTypeEnum.NPS, + headline: { default: "How likely are you to recommend us?" }, + required: true, + lowerLabel: { default: "Not likely" }, + upperLabel: { default: "Very likely" }, + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { + id: "response-1", + data: { "nps-q1": 10 }, // Promoter (9-10) + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + { + id: "response-2", + data: { "nps-q1": 7 }, // Passive (7-8) + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + { + id: "response-3", + data: { "nps-q1": 3 }, // Detractor (0-6) + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + { + id: "response-4", + data: { "nps-q1": 9 }, // Promoter (9-10) + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + ]; + + const dropOff = [ + { questionId: "nps-q1", impressions: 4, dropOffCount: 0, dropOffPercentage: 0 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.NPS); + expect(summary[0].responseCount).toBe(4); + + // NPS score = (promoters - detractors) / total * 100 + // Promoters: 2, Detractors: 1, Total: 4 + // (2 - 1) / 4 * 100 = 25 + expect(summary[0].score).toBe(25); + + // Verify promoters + expect(summary[0].promoters.count).toBe(2); + expect(summary[0].promoters.percentage).toBe(50); // 2/4 * 100 + + // Verify passives + expect(summary[0].passives.count).toBe(1); + expect(summary[0].passives.percentage).toBe(25); // 1/4 * 100 + + // Verify detractors + expect(summary[0].detractors.count).toBe(1); + expect(summary[0].detractors.percentage).toBe(25); // 1/4 * 100 + + // Verify dismissed (none in this test) + expect(summary[0].dismissed.count).toBe(0); + expect(summary[0].dismissed.percentage).toBe(0); + }); + + test("getQuestionSummary handles NPS question with dismissed responses", async () => { + const question = { + id: "nps-q1", + type: TSurveyQuestionTypeEnum.NPS, + headline: { default: "How likely are you to recommend us?" }, + required: false, + lowerLabel: { default: "Not likely" }, + upperLabel: { default: "Very likely" }, + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { + id: "response-1", + data: { "nps-q1": 10 }, // Promoter + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: { "nps-q1": 5 }, + finished: true, + }, + { + id: "response-2", + data: {}, // No answer but has time tracking + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: { "nps-q1": 3 }, + finished: true, + }, + { + id: "response-3", + data: {}, // No answer but has time tracking + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: { "nps-q1": 2 }, + finished: true, + }, + ] as any; + + const dropOff = [ + { questionId: "nps-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.NPS); + expect(summary[0].responseCount).toBe(3); + + // NPS score = (promoters - detractors) / total * 100 + // Promoters: 1, Detractors: 0, Total: 3 + // (1 - 0) / 3 * 100 = 33.33 + expect(summary[0].score).toBe(33.33); + + // Verify promoters + expect(summary[0].promoters.count).toBe(1); + expect(summary[0].promoters.percentage).toBe(33.33); // 1/3 * 100 + + // Verify dismissed + expect(summary[0].dismissed.count).toBe(2); + expect(summary[0].dismissed.percentage).toBe(66.67); // 2/3 * 100 + }); + + test("getQuestionSummary handles NPS question with no responses", async () => { + const question = { + id: "nps-q1", + type: TSurveyQuestionTypeEnum.NPS, + headline: { default: "How likely are you to recommend us?" }, + required: true, + lowerLabel: { default: "Not likely" }, + upperLabel: { default: "Very likely" }, + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + // No responses with NPS data + const responses = [ + { + id: "response-1", + data: { "other-q": "value" }, // No NPS data + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + ]; + + const dropOff = [ + { questionId: "nps-q1", impressions: 1, dropOffCount: 1, dropOffPercentage: 100 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.NPS); + expect(summary[0].responseCount).toBe(0); + expect(summary[0].score).toBe(0); + + expect(summary[0].promoters.count).toBe(0); + expect(summary[0].promoters.percentage).toBe(0); + + expect(summary[0].passives.count).toBe(0); + expect(summary[0].passives.percentage).toBe(0); + + expect(summary[0].detractors.count).toBe(0); + expect(summary[0].detractors.percentage).toBe(0); + + expect(summary[0].dismissed.count).toBe(0); + expect(summary[0].dismissed.percentage).toBe(0); + }); + + test("getQuestionSummary handles NPS question with invalid values", async () => { + const question = { + id: "nps-q1", + type: TSurveyQuestionTypeEnum.NPS, + headline: { default: "How likely are you to recommend us?" }, + required: true, + lowerLabel: { default: "Not likely" }, + upperLabel: { default: "Very likely" }, + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { + id: "response-1", + data: { "nps-q1": "invalid" }, // String instead of number + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + { + id: "response-2", + data: { "nps-q1": null }, // Null value + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + { + id: "response-3", + data: { "nps-q1": 5 }, // Valid detractor + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + ] as any; + + const dropOff = [ + { questionId: "nps-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.NPS); + expect(summary[0].responseCount).toBe(1); // Only one valid response + + // Only one valid response is a detractor + expect(summary[0].detractors.count).toBe(1); + expect(summary[0].detractors.percentage).toBe(100); + + // Score should be -100 since all valid responses are detractors + expect(summary[0].score).toBe(-100); + }); +}); + +describe("Rating question type tests", () => { + test("getQuestionSummary correctly processes Rating question with valid responses", async () => { + const question = { + id: "rating-q1", + type: TSurveyQuestionTypeEnum.Rating, + headline: { default: "How would you rate our service?" }, + required: true, + scale: "number", + range: 5, // 1-5 rating + lowerLabel: { default: "Poor" }, + upperLabel: { default: "Excellent" }, + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { + id: "response-1", + data: { "rating-q1": 5 }, // Highest rating + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + { + id: "response-2", + data: { "rating-q1": 4 }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + { + id: "response-3", + data: { "rating-q1": 3 }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + { + id: "response-4", + data: { "rating-q1": 5 }, // Another highest rating + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + ]; + + const dropOff = [ + { questionId: "rating-q1", impressions: 4, dropOffCount: 0, dropOffPercentage: 0 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.Rating); + expect(summary[0].responseCount).toBe(4); + + // Average rating = (5 + 4 + 3 + 5) / 4 = 4.25 + expect(summary[0].average).toBe(4.25); + + // Verify each rating option count and percentage + const rating5 = summary[0].choices.find((c) => c.rating === 5); + expect(rating5.count).toBe(2); + expect(rating5.percentage).toBe(50); // 2/4 * 100 + + const rating4 = summary[0].choices.find((c) => c.rating === 4); + expect(rating4.count).toBe(1); + expect(rating4.percentage).toBe(25); // 1/4 * 100 + + const rating3 = summary[0].choices.find((c) => c.rating === 3); + expect(rating3.count).toBe(1); + expect(rating3.percentage).toBe(25); // 1/4 * 100 + + const rating2 = summary[0].choices.find((c) => c.rating === 2); + expect(rating2.count).toBe(0); + expect(rating2.percentage).toBe(0); + + const rating1 = summary[0].choices.find((c) => c.rating === 1); + expect(rating1.count).toBe(0); + expect(rating1.percentage).toBe(0); + + // Verify dismissed (none in this test) + expect(summary[0].dismissed.count).toBe(0); + }); + + test("getQuestionSummary handles Rating question with dismissed responses", async () => { + const question = { + id: "rating-q1", + type: TSurveyQuestionTypeEnum.Rating, + headline: { default: "How would you rate our service?" }, + required: false, + scale: "number", + range: 5, + lowerLabel: { default: "Poor" }, + upperLabel: { default: "Excellent" }, + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { + id: "response-1", + data: { "rating-q1": 5 }, // Valid rating + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: { "rating-q1": 3 }, + finished: true, + }, + { + id: "response-2", + data: {}, // No answer, but has time tracking + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: { "rating-q1": 2 }, + finished: true, + }, + { + id: "response-3", + data: {}, // No answer, but has time tracking + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: { "rating-q1": 4 }, + finished: true, + }, + ] as any; + + const dropOff = [ + { questionId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.Rating); + expect(summary[0].responseCount).toBe(1); // Only one valid rating + expect(summary[0].average).toBe(5); // Average of the one valid rating + + // Verify dismissed count + expect(summary[0].dismissed.count).toBe(2); + }); + + test("getQuestionSummary handles Rating question with no responses", async () => { + const question = { + id: "rating-q1", + type: TSurveyQuestionTypeEnum.Rating, + headline: { default: "How would you rate our service?" }, + required: true, + scale: "number", + range: 5, + lowerLabel: { default: "Poor" }, + upperLabel: { default: "Excellent" }, + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + // No responses with rating data + const responses = [ + { + id: "response-1", + data: { "other-q": "value" }, // No rating data + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + ]; + + const dropOff = [ + { questionId: "rating-q1", impressions: 1, dropOffCount: 1, dropOffPercentage: 100 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.Rating); + expect(summary[0].responseCount).toBe(0); + expect(summary[0].average).toBe(0); + + // Verify all ratings have 0 count and percentage + summary[0].choices.forEach((choice) => { + expect(choice.count).toBe(0); + expect(choice.percentage).toBe(0); + }); + + // Verify dismissed is 0 + expect(summary[0].dismissed.count).toBe(0); + }); +}); + +describe("PictureSelection question type tests", () => { + test("getQuestionSummary correctly processes PictureSelection with valid responses", async () => { + const question = { + id: "picture-q1", + type: TSurveyQuestionTypeEnum.PictureSelection, + headline: { default: "Select the images you like" }, + required: true, + choices: [ + { id: "img1", imageUrl: "https://example.com/img1.jpg" }, + { id: "img2", imageUrl: "https://example.com/img2.jpg" }, + { id: "img3", imageUrl: "https://example.com/img3.jpg" }, + ], + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { + id: "response-1", + data: { "picture-q1": ["img1", "img3"] }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + { + id: "response-2", + data: { "picture-q1": ["img2"] }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + ]; + + const dropOff = [ + { questionId: "picture-q1", impressions: 2, dropOffCount: 0, dropOffPercentage: 0 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.PictureSelection); + expect(summary[0].responseCount).toBe(2); + expect(summary[0].selectionCount).toBe(3); // Total selections: img1, img2, img3 + + // Check individual choice counts + const img1 = summary[0].choices.find((c) => c.id === "img1"); + expect(img1.count).toBe(1); + expect(img1.percentage).toBe(50); + + const img2 = summary[0].choices.find((c) => c.id === "img2"); + expect(img2.count).toBe(1); + expect(img2.percentage).toBe(50); + + const img3 = summary[0].choices.find((c) => c.id === "img3"); + expect(img3.count).toBe(1); + expect(img3.percentage).toBe(50); + }); + + test("getQuestionSummary handles PictureSelection with no valid responses", async () => { + const question = { + id: "picture-q1", + type: TSurveyQuestionTypeEnum.PictureSelection, + headline: { default: "Select the images you like" }, + required: true, + choices: [ + { id: "img1", imageUrl: "https://example.com/img1.jpg" }, + { id: "img2", imageUrl: "https://example.com/img2.jpg" }, + ], + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { + id: "response-1", + data: { "picture-q1": "not-an-array" }, // Invalid format + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + { + id: "response-2", + data: {}, // No data + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + ] as any; + + const dropOff = [ + { questionId: "picture-q1", impressions: 2, dropOffCount: 2, dropOffPercentage: 100 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.PictureSelection); + expect(summary[0].responseCount).toBe(0); + expect(summary[0].selectionCount).toBe(0); + + // All choices should have zero count + summary[0].choices.forEach((choice) => { + expect(choice.count).toBe(0); + expect(choice.percentage).toBe(0); + }); + }); + + test("getQuestionSummary handles PictureSelection with invalid choice ids", async () => { + const question = { + id: "picture-q1", + type: TSurveyQuestionTypeEnum.PictureSelection, + headline: { default: "Select the images you like" }, + required: true, + choices: [ + { id: "img1", imageUrl: "https://example.com/img1.jpg" }, + { id: "img2", imageUrl: "https://example.com/img2.jpg" }, + ], + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { + id: "response-1", + data: { "picture-q1": ["invalid-id", "img1"] }, // One valid, one invalid ID + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + ]; + + const dropOff = [ + { questionId: "picture-q1", impressions: 1, dropOffCount: 0, dropOffPercentage: 0 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.PictureSelection); + expect(summary[0].responseCount).toBe(1); + expect(summary[0].selectionCount).toBe(2); // Total selections including invalid one + + // img1 should be counted + const img1 = summary[0].choices.find((c) => c.id === "img1"); + expect(img1.count).toBe(1); + expect(img1.percentage).toBe(100); + + // img2 should not be counted + const img2 = summary[0].choices.find((c) => c.id === "img2"); + expect(img2.count).toBe(0); + expect(img2.percentage).toBe(0); + + // Invalid ID should not appear in choices + expect(summary[0].choices.find((c) => c.id === "invalid-id")).toBeUndefined(); + }); +}); + +describe("CTA question type tests", () => { + test("getQuestionSummary correctly processes CTA with valid responses", async () => { + const question = { + id: "cta-q1", + type: TSurveyQuestionTypeEnum.CTA, + headline: { default: "Would you like to try our product?" }, + buttonLabel: { default: "Try Now" }, + buttonExternal: false, + buttonUrl: "https://example.com", + required: true, + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { + id: "response-1", + data: { "cta-q1": "clicked" }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + { + id: "response-2", + data: { "cta-q1": "dismissed" }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + { + id: "response-3", + data: { "cta-q1": "clicked" }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + ]; + + const dropOff = [ + { + questionId: "cta-q1", + impressions: 5, // 5 total impressions (including 2 that didn't respond) + dropOffCount: 0, + dropOffPercentage: 0, + }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.CTA); + expect(summary[0].responseCount).toBe(3); + expect(summary[0].impressionCount).toBe(5); + expect(summary[0].clickCount).toBe(2); + expect(summary[0].skipCount).toBe(1); + + // CTR calculation: clicks / impressions * 100 + expect(summary[0].ctr.count).toBe(2); + expect(summary[0].ctr.percentage).toBe(40); // (2/5)*100 = 40% + }); + + test("getQuestionSummary handles CTA with no responses", async () => { + const question = { + id: "cta-q1", + type: TSurveyQuestionTypeEnum.CTA, + headline: { default: "Would you like to try our product?" }, + buttonLabel: { default: "Try Now" }, + buttonExternal: false, + buttonUrl: "https://example.com", + required: false, + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { + id: "response-1", + data: {}, // No data + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + ]; + + const dropOff = [ + { + questionId: "cta-q1", + impressions: 3, // 3 total impressions + dropOffCount: 3, + dropOffPercentage: 100, + }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.CTA); + expect(summary[0].responseCount).toBe(0); + expect(summary[0].impressionCount).toBe(3); + expect(summary[0].clickCount).toBe(0); + expect(summary[0].skipCount).toBe(0); + + expect(summary[0].ctr.count).toBe(0); + expect(summary[0].ctr.percentage).toBe(0); + }); +}); + +describe("Consent question type tests", () => { + test("getQuestionSummary correctly processes Consent with valid responses", async () => { + const question = { + id: "consent-q1", + type: TSurveyQuestionTypeEnum.Consent, + headline: { default: "Do you consent to our terms?" }, + required: true, + label: { default: "I agree to the terms" }, + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { + id: "response-1", + data: { "consent-q1": "accepted" }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + { + id: "response-2", + data: {}, // Nothing, but time was spent so it's dismissed + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: { "consent-q1": 5 }, + finished: true, + }, + { + id: "response-3", + data: { "consent-q1": "accepted" }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + ] as any; + + const dropOff = [ + { questionId: "consent-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.Consent); + expect(summary[0].responseCount).toBe(3); + + // 2 accepted / 3 total = 66.67% + expect(summary[0].accepted.count).toBe(2); + expect(summary[0].accepted.percentage).toBe(66.67); + + // 1 dismissed / 3 total = 33.33% + expect(summary[0].dismissed.count).toBe(1); + expect(summary[0].dismissed.percentage).toBe(33.33); + }); + + test("getQuestionSummary handles Consent with no responses", async () => { + const question = { + id: "consent-q1", + type: TSurveyQuestionTypeEnum.Consent, + headline: { default: "Do you consent to our terms?" }, + required: false, + label: { default: "I agree to the terms" }, + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { + id: "response-1", + data: { "other-q": "value" }, // No consent data + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + ]; + + const dropOff = [ + { questionId: "consent-q1", impressions: 1, dropOffCount: 1, dropOffPercentage: 100 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.Consent); + expect(summary[0].responseCount).toBe(0); + expect(summary[0].accepted.count).toBe(0); + expect(summary[0].accepted.percentage).toBe(0); + expect(summary[0].dismissed.count).toBe(0); + expect(summary[0].dismissed.percentage).toBe(0); + }); + + test("getQuestionSummary handles Consent with invalid values", async () => { + const question = { + id: "consent-q1", + type: TSurveyQuestionTypeEnum.Consent, + headline: { default: "Do you consent to our terms?" }, + required: true, + label: { default: "I agree to the terms" }, + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { + id: "response-1", + data: { "consent-q1": "invalid-value" }, // Invalid value + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: { "consent-q1": 3 }, + finished: true, + }, + ]; + + const dropOff = [ + { questionId: "consent-q1", impressions: 1, dropOffCount: 0, dropOffPercentage: 0 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.Consent); + expect(summary[0].responseCount).toBe(1); // Counted as response due to ttc + expect(summary[0].accepted.count).toBe(0); // Not accepted + expect(summary[0].dismissed.count).toBe(1); // Counted as dismissed + }); +}); + +describe("Date question type tests", () => { + test("getQuestionSummary correctly processes Date question with valid responses", async () => { + const question = { + id: "date-q1", + type: TSurveyQuestionTypeEnum.Date, + headline: { default: "When is your birthday?" }, + required: true, + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { + id: "response-1", + data: { "date-q1": "2023-01-15" }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + { + id: "response-2", + data: { "date-q1": "1990-05-20" }, + updatedAt: new Date(), + contact: { id: "contact-1", userId: "user-1" }, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + ]; + + const dropOff = [ + { questionId: "date-q1", impressions: 2, dropOffCount: 0, dropOffPercentage: 0 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.Date); + expect(summary[0].responseCount).toBe(2); + expect(summary[0].samples).toHaveLength(2); + + // Check sample values + expect(summary[0].samples[0].value).toBe("2023-01-15"); + expect(summary[0].samples[1].value).toBe("1990-05-20"); + + // Check contact information is preserved + expect(summary[0].samples[1].contact).toEqual({ id: "contact-1", userId: "user-1" }); + }); + + test("getQuestionSummary handles Date question with no responses", async () => { + const question = { + id: "date-q1", + type: TSurveyQuestionTypeEnum.Date, + headline: { default: "When is your birthday?" }, + required: false, + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { + id: "response-1", + data: {}, // No date data + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + ]; + + const dropOff = [ + { questionId: "date-q1", impressions: 1, dropOffCount: 1, dropOffPercentage: 100 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.Date); + expect(summary[0].responseCount).toBe(0); + expect(summary[0].samples).toHaveLength(0); + }); + + test("getQuestionSummary applies VALUES_LIMIT correctly for Date question", async () => { + const question = { + id: "date-q1", + type: TSurveyQuestionTypeEnum.Date, + headline: { default: "When is your birthday?" }, + required: true, + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + // Create 100 responses (more than VALUES_LIMIT which is 50) + const responses = Array.from({ length: 100 }, (_, i) => ({ + id: `response-${i}`, + data: { "date-q1": `2023-01-${(i % 28) + 1}` }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + })); + + const dropOff = [ + { questionId: "date-q1", impressions: 100, dropOffCount: 0, dropOffPercentage: 0 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.Date); + expect(summary[0].responseCount).toBe(100); + expect(summary[0].samples).toHaveLength(50); // Limited to VALUES_LIMIT (50) + }); +}); + +describe("FileUpload question type tests", () => { + test("getQuestionSummary correctly processes FileUpload question with valid responses", async () => { + const question = { + id: "file-q1", + type: TSurveyQuestionTypeEnum.FileUpload, + headline: { default: "Upload your documents" }, + required: true, + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { + id: "response-1", + data: { + "file-q1": ["https://example.com/file1.pdf", "https://example.com/file2.jpg"], + }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + { + id: "response-2", + data: { + "file-q1": ["https://example.com/file3.docx"], + }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + ]; + + const dropOff = [ + { questionId: "file-q1", impressions: 2, dropOffCount: 0, dropOffPercentage: 0 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.FileUpload); + expect(summary[0].responseCount).toBe(2); + expect(summary[0].files).toHaveLength(2); + + // Check file values + expect(summary[0].files[0].value).toEqual([ + "https://example.com/file1.pdf", + "https://example.com/file2.jpg", + ]); + expect(summary[0].files[1].value).toEqual(["https://example.com/file3.docx"]); + }); + + test("getQuestionSummary handles FileUpload question with no responses", async () => { + const question = { + id: "file-q1", + type: TSurveyQuestionTypeEnum.FileUpload, + headline: { default: "Upload your documents" }, + required: false, + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { + id: "response-1", + data: {}, // No file data + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + ]; + + const dropOff = [ + { questionId: "file-q1", impressions: 1, dropOffCount: 1, dropOffPercentage: 100 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.FileUpload); + expect(summary[0].responseCount).toBe(0); + expect(summary[0].files).toHaveLength(0); + }); +}); + +describe("Cal question type tests", () => { + test("getQuestionSummary correctly processes Cal with valid responses", async () => { + const question = { + id: "cal-q1", + type: TSurveyQuestionTypeEnum.Cal, + headline: { default: "Book a meeting with us" }, + required: true, + calUserName: "test-user", + calEventSlug: "15min", + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { + id: "response-1", + data: { "cal-q1": "booked" }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + { + id: "response-2", + data: {}, // Skipped but spent time + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: { "cal-q1": 10 }, + finished: true, + }, + { + id: "response-3", + data: { "cal-q1": "booked" }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + ] as any; + + const dropOff = [ + { questionId: "cal-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.Cal); + expect(summary[0].responseCount).toBe(3); + + // 2 booked / 3 total = 66.67% + expect(summary[0].booked.count).toBe(2); + expect(summary[0].booked.percentage).toBe(66.67); + + // 1 skipped / 3 total = 33.33% + expect(summary[0].skipped.count).toBe(1); + expect(summary[0].skipped.percentage).toBe(33.33); + }); + + test("getQuestionSummary handles Cal with no responses", async () => { + const question = { + id: "cal-q1", + type: TSurveyQuestionTypeEnum.Cal, + headline: { default: "Book a meeting with us" }, + required: false, + calUserName: "test-user", + calEventSlug: "15min", + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { + id: "response-1", + data: { "other-q": "value" }, // No Cal data + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + ]; + + const dropOff = [ + { questionId: "cal-q1", impressions: 1, dropOffCount: 1, dropOffPercentage: 100 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.Cal); + expect(summary[0].responseCount).toBe(0); + expect(summary[0].booked.count).toBe(0); + expect(summary[0].booked.percentage).toBe(0); + expect(summary[0].skipped.count).toBe(0); + expect(summary[0].skipped.percentage).toBe(0); + }); + + test("getQuestionSummary handles Cal with invalid values", async () => { + const question = { + id: "cal-q1", + type: TSurveyQuestionTypeEnum.Cal, + headline: { default: "Book a meeting with us" }, + required: true, + calUserName: "test-user", + calEventSlug: "15min", + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { + id: "response-1", + data: { "cal-q1": "invalid-value" }, // Invalid value + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: { "cal-q1": 5 }, + finished: true, + }, + ]; + + const dropOff = [ + { questionId: "cal-q1", impressions: 1, dropOffCount: 0, dropOffPercentage: 0 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.Cal); + expect(summary[0].responseCount).toBe(1); // Counted as response due to ttc + expect(summary[0].booked.count).toBe(0); + expect(summary[0].skipped.count).toBe(1); // Counted as skipped + }); }); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter.test.tsx new file mode 100644 index 0000000000..920cfa5206 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter.test.tsx @@ -0,0 +1,263 @@ +import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext"; +import { getSurveyFilterDataAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions"; +import { generateQuestionAndFilterOptions } from "@/app/lib/surveys/surveys"; +import { getSurveyFilterDataBySurveySharingKeyAction } from "@/app/share/[sharingKey]/actions"; +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useParams } from "next/navigation"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; +import { ResponseFilter } from "./ResponseFilter"; + +// Mock dependencies +vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext", () => ({ + useResponseFilter: vi.fn(), +})); + +vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions", () => ({ + getSurveyFilterDataAction: vi.fn(), +})); + +vi.mock("@/app/share/[sharingKey]/actions", () => ({ + getSurveyFilterDataBySurveySharingKeyAction: vi.fn(), +})); + +vi.mock("@/app/lib/surveys/surveys", () => ({ + generateQuestionAndFilterOptions: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + useParams: vi.fn(), +})); + +vi.mock("@formkit/auto-animate/react", () => ({ + useAutoAnimate: () => [[vi.fn()]], +})); + +vi.mock("./QuestionsComboBox", () => ({ + QuestionsComboBox: ({ onChangeValue }) => ( +
+ +
+ ), + OptionsType: { + QUESTIONS: "Questions", + ATTRIBUTES: "Attributes", + TAGS: "Tags", + LANGUAGES: "Languages", + }, +})); + +// Update the mock for QuestionFilterComboBox to always render +vi.mock( + "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionFilterComboBox", + () => ({ + QuestionFilterComboBox: () => ( +
+ + +
+ ), + }) +); + +describe("ResponseFilter", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + const mockSelectedFilter = { + filter: [], + onlyComplete: false, + }; + + const mockSelectedOptions = { + questionFilterOptions: [ + { + type: TSurveyQuestionTypeEnum.OpenText, + filterOptions: ["equals", "does not equal"], + filterComboBoxOptions: [], + id: "q1", + }, + ], + questionOptions: [ + { + label: "Questions", + type: "Questions", + option: [ + { id: "q1", label: "Question 1", type: "OpenText", questionType: TSurveyQuestionTypeEnum.OpenText }, + ], + }, + ], + } as any; + + const mockSetSelectedFilter = vi.fn(); + const mockSetSelectedOptions = vi.fn(); + + const mockSurvey = { + id: "survey1", + environmentId: "env1", + name: "Test Survey", + createdAt: new Date(), + updatedAt: new Date(), + status: "draft", + createdBy: "user1", + questions: [], + welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"], + triggers: [], + displayOption: "displayOnce", + } as unknown as TSurvey; + + beforeEach(() => { + vi.mocked(useResponseFilter).mockReturnValue({ + selectedFilter: mockSelectedFilter, + setSelectedFilter: mockSetSelectedFilter, + selectedOptions: mockSelectedOptions, + setSelectedOptions: mockSetSelectedOptions, + } as any); + + vi.mocked(useParams).mockReturnValue({ environmentId: "env1", surveyId: "survey1" }); + + vi.mocked(getSurveyFilterDataAction).mockResolvedValue({ + data: { + attributes: [], + meta: {}, + environmentTags: [], + hiddenFields: [], + } as any, + }); + + vi.mocked(generateQuestionAndFilterOptions).mockReturnValue({ + questionFilterOptions: mockSelectedOptions.questionFilterOptions, + questionOptions: mockSelectedOptions.questionOptions, + }); + }); + + test("renders with default state", () => { + render(); + expect(screen.getByText("Filter")).toBeInTheDocument(); + }); + + test("opens the filter popover when clicked", async () => { + render(); + + await userEvent.click(screen.getByText("Filter")); + + expect( + screen.getByText("environments.surveys.summary.show_all_responses_that_match") + ).toBeInTheDocument(); + expect(screen.getByText("environments.surveys.summary.only_completed")).toBeInTheDocument(); + }); + + test("fetches filter data when opened", async () => { + render(); + + await userEvent.click(screen.getByText("Filter")); + + expect(getSurveyFilterDataAction).toHaveBeenCalledWith({ surveyId: "survey1" }); + expect(mockSetSelectedOptions).toHaveBeenCalled(); + }); + + test("handles adding new filter", async () => { + // Start with an empty filter + vi.mocked(useResponseFilter).mockReturnValue({ + selectedFilter: { filter: [], onlyComplete: false }, + setSelectedFilter: mockSetSelectedFilter, + selectedOptions: mockSelectedOptions, + setSelectedOptions: mockSetSelectedOptions, + } as any); + + render(); + + await userEvent.click(screen.getByText("Filter")); + // Verify there's no filter yet + expect(screen.queryByTestId("questions-combo-box")).not.toBeInTheDocument(); + + // Add a new filter and check that the questions combo box appears + await userEvent.click(screen.getByText("common.add_filter")); + + expect(screen.getByTestId("questions-combo-box")).toBeInTheDocument(); + }); + + test("handles only complete checkbox toggle", async () => { + render(); + + await userEvent.click(screen.getByText("Filter")); + await userEvent.click(screen.getByRole("checkbox")); + await userEvent.click(screen.getByText("common.apply_filters")); + + expect(mockSetSelectedFilter).toHaveBeenCalledWith({ filter: [], onlyComplete: true }); + }); + + test("handles selecting question and filter options", async () => { + // Setup with a pre-populated filter to ensure the filter components are rendered + const setSelectedFilterMock = vi.fn(); + vi.mocked(useResponseFilter).mockReturnValue({ + selectedFilter: { + filter: [ + { + questionType: { id: "q1", label: "Question 1", type: "OpenText" }, + filterType: { filterComboBoxValue: undefined, filterValue: undefined }, + }, + ], + onlyComplete: false, + }, + setSelectedFilter: setSelectedFilterMock, + selectedOptions: mockSelectedOptions, + setSelectedOptions: mockSetSelectedOptions, + } as any); + + render(); + + await userEvent.click(screen.getByText("Filter")); + + // Verify both combo boxes are rendered + expect(screen.getByTestId("questions-combo-box")).toBeInTheDocument(); + expect(screen.getByTestId("filter-combo-box")).toBeInTheDocument(); + + // Use data-testid to find our buttons instead of text + await userEvent.click(screen.getByText("Select Question")); + await userEvent.click(screen.getByTestId("select-filter-btn")); + await userEvent.click(screen.getByText("common.apply_filters")); + + expect(setSelectedFilterMock).toHaveBeenCalled(); + }); + + test("handles clear all filters", async () => { + render(); + + await userEvent.click(screen.getByText("Filter")); + await userEvent.click(screen.getByText("common.clear_all")); + + expect(mockSetSelectedFilter).toHaveBeenCalledWith({ filter: [], onlyComplete: false }); + }); + + test("uses sharing key action when on sharing page", async () => { + vi.mocked(useParams).mockReturnValue({ + environmentId: "env1", + surveyId: "survey1", + sharingKey: "share123", + }); + vi.mocked(getSurveyFilterDataBySurveySharingKeyAction).mockResolvedValue({ + data: { + attributes: [], + meta: {}, + environmentTags: [], + hiddenFields: [], + } as any, + }); + + render(); + + await userEvent.click(screen.getByText("Filter")); + + expect(getSurveyFilterDataBySurveySharingKeyAction).toHaveBeenCalledWith({ + sharingKey: "share123", + environmentId: "env1", + }); + }); +}); diff --git a/apps/web/lib/utils/datetime.test.ts b/apps/web/lib/utils/datetime.test.ts index 6bcadf1a7d..635f6306db 100644 --- a/apps/web/lib/utils/datetime.test.ts +++ b/apps/web/lib/utils/datetime.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "vitest"; +import { describe, expect, test, vi } from "vitest"; import { diffInDays, formatDateWithOrdinal, getFormattedDateTimeString, isValidDateString } from "./datetime"; describe("datetime utils", () => { @@ -9,7 +9,11 @@ describe("datetime utils", () => { }); test("formatDateWithOrdinal formats a date with ordinal suffix", () => { - const date = new Date("2025-05-06"); + // Create a date that's fixed to May 6, 2025 at noon UTC + // Using noon ensures the date won't change in most timezones + const date = new Date(Date.UTC(2025, 4, 6, 12, 0, 0)); + + // Test the function expect(formatDateWithOrdinal(date)).toBe("Tuesday, May 6th, 2025"); }); diff --git a/apps/web/modules/ee/whitelabel/email-customization/components/email-customization-settings.tsx b/apps/web/modules/ee/whitelabel/email-customization/components/email-customization-settings.tsx index ff68bee140..2de060fbf3 100644 --- a/apps/web/modules/ee/whitelabel/email-customization/components/email-customization-settings.tsx +++ b/apps/web/modules/ee/whitelabel/email-customization/components/email-customization-settings.tsx @@ -193,7 +193,7 @@ export const EmailCustomizationSettings = ({
{t("environments.settings.general.logo_in_email_header")} -
+
{logoUrl && (
@@ -256,7 +256,7 @@ export const EmailCustomizationSettings = ({
-
+
+ {t("common.only_owners_managers_and_manage_access_members_can_perform_this_action")} diff --git a/apps/web/modules/ui/components/file-input/index.tsx b/apps/web/modules/ui/components/file-input/index.tsx index 8f5cf3b265..c6304d5ae8 100644 --- a/apps/web/modules/ui/components/file-input/index.tsx +++ b/apps/web/modules/ui/components/file-input/index.tsx @@ -237,7 +237,7 @@ export const FileInput = ({ /> {file.uploaded ? (
handleRemove(idx)}>
@@ -255,7 +255,7 @@ export const FileInput = ({

{file.uploaded ? (
handleRemove(idx)}>
@@ -295,7 +295,7 @@ export const FileInput = ({ /> {selectedFiles[0].uploaded ? (
handleRemove(0)}>
@@ -311,7 +311,7 @@ export const FileInput = ({

{selectedFiles[0].uploaded ? (
handleRemove(0)}>
diff --git a/apps/web/vite.config.mts b/apps/web/vite.config.mts index 82d5ba8e41..61b119cb9b 100644 --- a/apps/web/vite.config.mts +++ b/apps/web/vite.config.mts @@ -24,7 +24,7 @@ export default defineConfig({ "**/mocks/**", // Mock directories "**/__mocks__/**", // Jest-style mock directories "**/constants.ts", // Constants files - "**/route.ts", // Next.js API routes + "**/route.{ts,tsx}", // Next.js API routes "**/openapi.ts", // OpenAPI spec files "**/openapi-document.ts", // OpenAPI-related document files "**/types/**", // Type definition folders @@ -37,6 +37,7 @@ export default defineConfig({ "**/instrumentation.ts", // Next.js instrumentation files "**/instrumentation-node.ts", // Next.js Node.js instrumentation files "**/vitestSetup.ts", // Vitest setup files + "**/*.setup.*", // Vitest setup files "**/*.json", // JSON files "**/*.mdx", // MDX files "**/playwright/**", // Playwright E2E test files @@ -74,6 +75,7 @@ export default defineConfig({ "lib/airtable/**", "app/api/v1/integrations/**", "lib/env.ts", + "**/cache/**", ], }, }, diff --git a/packages/surveys/src/components/general/label.tsx b/packages/surveys/src/components/general/label.tsx index 111dcc17e0..e161805db2 100644 --- a/packages/surveys/src/components/general/label.tsx +++ b/packages/surveys/src/components/general/label.tsx @@ -1,7 +1,12 @@ interface LabelProps { text: string; + htmlForId?: string; } -export function Label({ text }: Readonly) { - return ; +export function Label({ text, htmlForId }: Readonly) { + return ( + + ); } diff --git a/packages/surveys/src/components/questions/address-question.test.tsx b/packages/surveys/src/components/questions/address-question.test.tsx new file mode 100644 index 0000000000..280fc8ce20 --- /dev/null +++ b/packages/surveys/src/components/questions/address-question.test.tsx @@ -0,0 +1,335 @@ +import { getUpdatedTtc } from "@/lib/ttc"; +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/preact"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { type TSurveyAddressQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; +import { AddressQuestion } from "./address-question"; + +vi.mock("@/lib/i18n", () => ({ + getLocalizedValue: vi + .fn() + .mockImplementation((val, lang) => (typeof val === "object" ? val[lang] || val.default : val)), +})); + +vi.mock("@/lib/ttc", () => ({ + getUpdatedTtc: vi.fn().mockReturnValue({}), + useTtc: vi.fn(), +})); + +const mockOnChange = vi.fn(); +const mockOnSubmit = vi.fn(); +const mockOnBack = vi.fn(); +const mockSetTtc = vi.fn(); + +describe("AddressQuestion", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + const mockQuestion: TSurveyAddressQuestion = { + id: "address-1", + type: TSurveyQuestionTypeEnum.Address, + headline: { default: "Address Question" }, + subheader: { default: "Enter your address" }, + required: true, + buttonLabel: { default: "Submit" }, + backButtonLabel: { default: "Back" }, + addressLine1: { show: true, required: false, placeholder: { default: "Address Line 1" } }, + addressLine2: { show: true, required: false, placeholder: { default: "Address Line 2" } }, + city: { show: true, required: false, placeholder: { default: "City" } }, + state: { show: true, required: false, placeholder: { default: "State" } }, + zip: { show: true, required: false, placeholder: { default: "ZIP" } }, + country: { show: true, required: false, placeholder: { default: "Country" } }, + }; + + test("renders the address question with all fields", () => { + render( + + ); + + expect(screen.getByText("Address Question")).toBeInTheDocument(); + expect(screen.getByText("Enter your address")).toBeInTheDocument(); + expect(screen.getByText("Address Line 1*")).toBeInTheDocument(); + expect(screen.getByText("Address Line 2*")).toBeInTheDocument(); + expect(screen.getByText("City*")).toBeInTheDocument(); + expect(screen.getByText("State*")).toBeInTheDocument(); + expect(screen.getByText("ZIP*")).toBeInTheDocument(); + expect(screen.getByText("Country*")).toBeInTheDocument(); + expect(screen.getByText("Submit")).toBeInTheDocument(); + expect(screen.getByText("Back")).toBeInTheDocument(); + }); + + test("renders question with media when available", () => { + const questionWithMedia = { + ...mockQuestion, + imageUrl: "https://example.com/image.jpg", + }; + + render( + + ); + + expect(screen.getByRole("img")).toBeInTheDocument(); + }); + + test("updates value when fields are changed", async () => { + const user = userEvent.setup(); + + render( + + ); + + const addressLine1Input = screen.getByLabelText("Address Line 1*"); + await user.type(addressLine1Input, "123 Main St"); + + expect(mockOnChange).toHaveBeenCalledWith({ + "address-1": ["123 Main St", "", "", "", "", ""], + }); + }); + + test("submits data when form is submitted", async () => { + const user = userEvent.setup(); + vi.mocked(getUpdatedTtc).mockReturnValue({ "address-1": 1000 }); + + render( + + ); + + const submitButton = screen.getByText("Submit"); + await user.click(submitButton); + + expect(getUpdatedTtc).toHaveBeenCalled(); + expect(mockSetTtc).toHaveBeenCalledWith({ "address-1": 1000 }); + expect(mockOnSubmit).toHaveBeenCalledWith( + { "address-1": ["123 Main St", "Apt 4", "City", "State", "12345", "Country"] }, + { "address-1": 1000 } + ); + }); + + test("submits empty array when all fields are empty", async () => { + const user = userEvent.setup(); + vi.mocked(getUpdatedTtc).mockReturnValue({ "address-1": 1000 }); + + // Create a modified question with no required fields to allow empty submission + const nonRequiredQuestion = { + ...mockQuestion, + required: false, + addressLine1: { ...mockQuestion.addressLine1, required: false }, + addressLine2: { ...mockQuestion.addressLine2, required: false }, + city: { ...mockQuestion.city, required: false }, + state: { ...mockQuestion.state, required: false }, + zip: { ...mockQuestion.zip, required: false }, + country: { ...mockQuestion.country, required: false }, + }; + + render( + + ); + + const submitButton = screen.getByRole("button", { name: "Submit" }); + await user.click(submitButton); + + expect(getUpdatedTtc).toHaveBeenCalled(); + expect(mockSetTtc).toHaveBeenCalledWith({ "address-1": 1000 }); + expect(mockOnSubmit).toHaveBeenCalledWith({ "address-1": [] }, { "address-1": 1000 }); + }); + + test("calls onBack when back button is clicked", async () => { + const user = userEvent.setup(); + vi.mocked(getUpdatedTtc).mockReturnValue({ "address-1": 500 }); + + render( + + ); + + const backButton = screen.getByText("Back"); + await user.click(backButton); + + expect(getUpdatedTtc).toHaveBeenCalled(); + expect(mockSetTtc).toHaveBeenCalledWith({ "address-1": 500 }); + expect(mockOnBack).toHaveBeenCalled(); + }); + + test("doesn't render back button when isFirstQuestion is true", () => { + render( + + ); + + expect(screen.queryByText("Back")).not.toBeInTheDocument(); + }); + + test("handles field visibility based on question config", () => { + const customQuestion = { + ...mockQuestion, + addressLine2: { ...mockQuestion.addressLine2, show: false }, + state: { ...mockQuestion.state, show: false }, + }; + + render( + + ); + + expect(screen.getByLabelText("Address Line 1*")).toBeInTheDocument(); + expect(screen.queryByLabelText("Address Line 2")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("State*")).not.toBeInTheDocument(); + expect(screen.getByLabelText("City*")).toBeInTheDocument(); + }); + + test("handles required fields correctly", () => { + const customQuestion = { + ...mockQuestion, + required: false, + addressLine1: { ...mockQuestion.addressLine1, required: true }, + }; + + render( + + ); + + expect(screen.getByLabelText("Address Line 1*")).toBeInTheDocument(); + expect(screen.getByLabelText("City")).toBeInTheDocument(); // Not required anymore + }); + + test("auto focuses the first field when autoFocusEnabled is true", () => { + render( + + ); + + const addressLine1Input = screen.getByLabelText("Address Line 1*"); + expect(document.activeElement).toBe(addressLine1Input); + }); +}); diff --git a/packages/surveys/src/components/questions/address-question.tsx b/packages/surveys/src/components/questions/address-question.tsx index d87f2d6b30..e4ed26185a 100644 --- a/packages/surveys/src/components/questions/address-question.tsx +++ b/packages/surveys/src/components/questions/address-question.tsx @@ -157,8 +157,9 @@ export function AddressQuestion({ return ( field.show && (
-