import "@testing-library/jest-dom"; import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import RowsImportRoute from "./RowsImportRoute"; let routeLoaderData: any; let appOrAdminData: any; vi.mock("react-router", async () => { const actual = await vi.importActual("react-router"); return { ...actual, Form: ({ children, ...props }: React.ComponentProps<"form">) =>
{children}
, useLoaderData: () => routeLoaderData, useActionData: () => undefined, useNavigation: () => ({ state: "idle" }), useSubmit: () => vi.fn(), }; }); vi.mock("react-i18next", () => ({ useTranslation: () => ({ i18n: { language: "en-US", exists: () => false }, t: (key: string, options?: Record) => { const translations: Record = { "models.row.import.previewCta": "click here to preview", "models.row.import.downloadSample": "Download sample", "models.row.import.delimiter": "Delimiter", "models.row.import.headers": "Headers", "models.row.import.primary": "Primary", "models.row.import.mapFieldsFor": "Map fields for", "models.row.import.rowsCount": "{{formattedCount}} rows", "models.row.import.notMapped": "Not mapped", "models.row.import.values": "Values", "models.row.import.steps.uploadCsvFile": "Upload CSV file", "models.row.import.steps.mapColumns": "Map columns", "models.row.import.steps.confirm": "Confirm", "shared.continue": "Continue", "shared.cancel": "Cancel", "models.row.import.dryRunSummary": "Dry-run summary: {{formattedAccepted}} accepted, {{formattedRejected}} rejected, {{formattedTotal}} total.", "shared.importRecords": "Import {{formattedCount}} records", "models.row.import.tagPromptLabel": "Import label", "models.row.import.tagPromptDescription": "Add an optional label to the imported records.", "models.row.import.defaultTag": "import", "models.row.import.diagnosticRow": "Row {{formattedRow}}: ", "models.row.import.diagnosticColumn": "{{column}}: ", "models.row.import.diagnostics.duplicate_key": "Duplicate row detected. Review the duplicate group {{value}}.", "models.row.import.diagnostics.missing_required_field": "{{column}} is required for this import preview.", "models.row.import.diagnostics.unknown": "Review this row before importing.", "models.row.import.allPreviewRowsRejected": "All preview rows were rejected. Fix mappings before continuing.", "models.row.import.errors.networkPreview": "Network error while previewing import.", "models.row.import.errors.previewFailed": "Import dry-run failed.", "models.row.import.defaultColumnName": "Column {{number}}", "models.row.import.sampleValues.text": "Sample text {{number}}", "models.row.import.sampleValues.option": "Option {{number}}", }; let value = translations[key] ?? key; for (const [name, replacement] of Object.entries(options ?? {})) { value = value.replaceAll(`{{${name}}}`, String(replacement)); } return value; }, }), })); vi.mock("~/utils/data/useAppOrAdminData", () => ({ useAppOrAdminData: () => appOrAdminData, })); vi.mock("~/components/ui/steps/ProgressBar", () => ({ default: ({ steps }: { steps: { title: string; status: string }[] }) =>
{steps.map((step) => `${step.title}:${step.status}`).join(",")}
, })); vi.mock("~/components/ui/buttons/ButtonPrimary", () => ({ default: ({ children, disabled, onClick }: { children: React.ReactNode; disabled?: boolean; onClick?: () => void }) => ( ), })); vi.mock("~/components/ui/buttons/ButtonSecondary", () => ({ default: ({ children, onClick }: { children: React.ReactNode; onClick?: () => void }) => ( ), })); vi.mock("~/components/ui/input/InputCheckboxInline", () => ({ default: ({ title, value, disabled, setValue }: { title: string; value?: boolean; disabled?: boolean; setValue: (value: boolean) => void }) => ( ), })); vi.mock("~/components/ui/input/InputSelect", () => ({ default: ({ title }: { title: string }) => , })); vi.mock("~/components/ui/input/InputSelector", () => ({ default: ({ name, value, setValue, options }: { name: string; value?: string; setValue: (value: string | undefined) => void; options: { name: string; value?: string }[] }) => ( ), })); vi.mock("~/components/ui/icons/ExclamationTriangleIcon", () => ({ default: () => , })); vi.mock("~/components/ui/modals/Modal", () => ({ default: ({ open, children }: { open: boolean; children: React.ReactNode }) => (open ?
{children}
: null), })); vi.mock("~/components/ui/tables/TableSimple", () => ({ default: ({ items, headers }: { items: any[]; headers: { title: string; value: (item: any) => React.ReactNode }[] }) => ( {items.map((item, rowIndex) => ( {headers.map((header) => ( ))} ))}
{header.value(item)}
), })); vi.mock("~/components/ui/uploaders/UploadDocument", () => ({ default: ({ onDropped }: { onDropped: (base64: string, file: File) => void }) => ( ), })); const entity = { id: "entity_1", name: "contacts", titlePlural: "Contacts", properties: [ { name: "email", title: "Email", type: 0, isDefault: false }, { name: "name", title: "Name", type: 0, isDefault: false }, ], }; const sampleEntity = { ...entity, properties: [ { name: "notes", title: "Notes", type: 1, isDefault: false }, { name: "status", title: "Status", type: 8, isDefault: false }, ], }; function mockFetch(payload: unknown, ok = true) { const fetchMock = vi.fn().mockResolvedValue({ ok, json: vi.fn().mockResolvedValue(payload) }); vi.stubGlobal("fetch", fetchMock); return fetchMock; } async function renderMappedImport() { render(); fireEvent.click(screen.getByRole("button", { name: "Upload CSV" })); fireEvent.change(await screen.findByLabelText("map-to-0"), { target: { value: "email" } }); } describe("RowsImportRoute dry-run mapping preview", () => { beforeEach(() => { vi.clearAllMocks(); vi.unstubAllGlobals(); routeLoaderData = { entity, allTenants: [{ id: "tenant_selected", name: "Selected", slug: "selected" }] }; appOrAdminData = { currentTenant: { id: "tenant_current", slug: "current" } }; }); it("calls the dry-run API and renders accepted/rejected summary diagnostics", async () => { const fetchMock = mockFetch({ summary: { totalRows: 1, acceptedRows: 1, rejectedRows: 0, diagnostics: [{ code: "duplicate_key", message: "Duplicate primary skipped", rowNumber: 1234, valuePreview: "duplicate-group-0001" }] }, rows: [{ ok: true, rowNumber: 1 }], diagnostics: [], }); await renderMappedImport(); fireEvent.click(screen.getByText("(click here to preview)")); expect(await screen.findByText("Dry-run summary: 1 accepted, 0 rejected, 1 total.")).toBeInTheDocument(); expect(screen.getByText("Row 1,234: Duplicate row detected. Review the duplicate group duplicate-group-0001.")).toBeInTheDocument(); expect(screen.queryByText(/Duplicate primary skipped/)).not.toBeInTheDocument(); expect(fetchMock).toHaveBeenCalledWith( "/current/internal/imports/dry-run", expect.objectContaining({ method: "POST", body: expect.stringContaining('"tenantId":"tenant_current"'), }) ); const requestBody = JSON.parse(fetchMock.mock.calls[0][1].body); expect(requestBody.format).toBe("csv"); expect(requestBody.importType).toBe("contacts"); expect(requestBody.source).toEqual(expect.objectContaining({ tenantId: "tenant_current", sourceKey: "tenants/tenant_current/imports/dry-run/contacts-import.csv" })); expect(requestBody.mappings).toEqual([expect.objectContaining({ sourceColumn: "Column 1", targetField: "email" })]); expect(requestBody.rows[0]).toMatchObject({ rowNumber: 1, values: { "Column 1": "ada@example.com", "Column 2": "Ada" } }); expect(screen.getAllByRole("button", { name: "Continue" }).at(-1)).not.toBeDisabled(); }); it("keeps admin dry-run requests on the admin internal route", async () => { appOrAdminData = { currentTenant: null }; const fetchMock = mockFetch({ summary: { totalRows: 1, acceptedRows: 1, rejectedRows: 0, diagnostics: [] }, rows: [{ ok: true, rowNumber: 1 }], diagnostics: [], }); await renderMappedImport(); fireEvent.click(screen.getByText("(click here to preview)")); await waitFor(() => expect(fetchMock).toHaveBeenCalled()); expect(fetchMock.mock.calls[0][0]).toBe("/internal/imports/dry-run"); }); it("blocks continue when every preview row is rejected", async () => { mockFetch({ summary: { totalRows: 1, acceptedRows: 0, rejectedRows: 1, diagnostics: [{ code: "missing_required_field", message: "Email is required", rowNumber: 1, column: "Email" }] }, rows: [{ ok: false, rowNumber: 1 }], diagnostics: [], }); await renderMappedImport(); fireEvent.click(screen.getByText("(click here to preview)")); expect(await screen.findByText("All preview rows were rejected. Fix mappings before continuing.")).toBeInTheDocument(); expect(screen.getByText("Row 1: Email: Email is required for this import preview.")).toBeInTheDocument(); expect(screen.queryByText(/Email is required$/)).not.toBeInTheDocument(); expect(within(screen.getByRole("dialog")).getByRole("button", { name: "Continue" })).toBeDisabled(); }); it("renders row-level dry-run diagnostics and warnings with localized copy", async () => { mockFetch({ summary: { totalRows: 2, acceptedRows: 1, rejectedRows: 1, diagnostics: [] }, rows: [ { ok: true, rowNumber: 1 }, { ok: false, rowNumber: 2, diagnostics: [{ code: "missing_required_field", message: "Email is required", column: "Email" }], warnings: [{ code: "duplicate_key", message: "Duplicate primary skipped", valuePreview: "duplicate-group-2" }], }, ], diagnostics: [], }); await renderMappedImport(); fireEvent.click(screen.getByText("(click here to preview)")); expect(await screen.findByText("Dry-run summary: 1 accepted, 1 rejected, 2 total.")).toBeInTheDocument(); expect(screen.getByText("Row 2: Email: Email is required for this import preview.")).toBeInTheDocument(); expect(screen.getByText("Row 2: Duplicate row detected. Review the duplicate group duplicate-group-2.")).toBeInTheDocument(); expect(screen.queryByText(/Duplicate primary skipped/)).not.toBeInTheDocument(); }); it("renders unknown diagnostic codes as generic localized copy without leaking parser text", async () => { mockFetch({ summary: { totalRows: 1, acceptedRows: 0, rejectedRows: 1, diagnostics: [{ code: "future_parser_code", message: "rawBody={secret-token} Email is required", rowNumber: 1 }], }, rows: [{ ok: false, rowNumber: 1 }], diagnostics: [], }); await renderMappedImport(); fireEvent.click(screen.getByText("(click here to preview)")); expect(await screen.findByText("Row 1: Review this row before importing.")).toBeInTheDocument(); expect(screen.queryByText(/rawBody/)).not.toBeInTheDocument(); expect(screen.queryByText(/secret-token/)).not.toBeInTheDocument(); expect(screen.queryByText(/Email is required/)).not.toBeInTheDocument(); }); it("marks a successful preview stale when mappings change", async () => { mockFetch({ summary: { totalRows: 1, acceptedRows: 1, rejectedRows: 0, diagnostics: [] }, rows: [{ ok: true, rowNumber: 1 }], diagnostics: [] }); await renderMappedImport(); fireEvent.click(screen.getByText("(click here to preview)")); expect(await screen.findByText("Dry-run summary: 1 accepted, 0 rejected, 1 total.")).toBeInTheDocument(); await act(async () => { fireEvent.change(screen.getByLabelText("map-to-0"), { target: { value: "name" } }); }); await waitFor(() => expect(within(screen.getByRole("dialog")).getByRole("button", { name: "Continue" })).toBeDisabled()); expect(within(screen.getByRole("dialog")).getByRole("button", { name: "Continue" })).toBeDisabled(); }); it("renders localized API errors and keeps continue disabled without leaking raw backend text", async () => { mockFetch( { error: "authorization: Bearer secret-token failed", diagnostics: [{ code: "future_parser_code", message: "rawBody={secret-token} password=secret-token", rowNumber: 1 }], }, false ); await renderMappedImport(); fireEvent.click(screen.getByText("(click here to preview)")); await waitFor(() => expect(screen.getByText("Import dry-run failed.")).toBeInTheDocument()); expect(screen.getByText("Row 1: Review this row before importing.")).toBeInTheDocument(); expect(screen.queryByText(/authorization/)).not.toBeInTheDocument(); expect(screen.queryByText(/rawBody/)).not.toBeInTheDocument(); expect(screen.queryByText(/password/)).not.toBeInTheDocument(); expect(screen.queryByText(/secret-token/)).not.toBeInTheDocument(); expect(within(screen.getByRole("dialog")).getByRole("button", { name: "Continue" })).toBeDisabled(); }); }); describe("RowsImportRoute sample download", () => { beforeEach(() => { vi.clearAllMocks(); vi.unstubAllGlobals(); routeLoaderData = { entity: sampleEntity, allTenants: [] }; appOrAdminData = { currentTenant: { id: "tenant_current", slug: "current" } }; }); it("uses localized sample text in generated CSV downloads", () => { const appendChild = vi.spyOn(document.body, "appendChild"); const removeChild = vi.spyOn(document.body, "removeChild"); const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined); render(); fireEvent.click(screen.getByRole("button", { name: "Download sample" })); const link = appendChild.mock.calls.at(-1)?.[0] as HTMLAnchorElement; expect(link).toBeInstanceOf(HTMLAnchorElement); expect(decodeURI(link.getAttribute("href") ?? "")).toContain('"Sample text 1"|"Option 1"'); expect(decodeURI(link.getAttribute("href") ?? "")).not.toContain("Abc, efg"); expect(link.getAttribute("download")).toBe("contacts-import-sample.csv"); expect(click).toHaveBeenCalled(); expect(removeChild).toHaveBeenCalledWith(link); }); });