353 lines
15 KiB
TypeScript
353 lines
15 KiB
TypeScript
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<typeof import("react-router")>("react-router");
|
|
return {
|
|
...actual,
|
|
Form: ({ children, ...props }: React.ComponentProps<"form">) => <form {...props}>{children}</form>,
|
|
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<string, unknown>) => {
|
|
const translations: Record<string, string> = {
|
|
"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 }[] }) => <div data-testid="steps">{steps.map((step) => `${step.title}:${step.status}`).join(",")}</div>,
|
|
}));
|
|
|
|
vi.mock("~/components/ui/buttons/ButtonPrimary", () => ({
|
|
default: ({ children, disabled, onClick }: { children: React.ReactNode; disabled?: boolean; onClick?: () => void }) => (
|
|
<button type="button" disabled={disabled} onClick={onClick}>
|
|
{children}
|
|
</button>
|
|
),
|
|
}));
|
|
|
|
vi.mock("~/components/ui/buttons/ButtonSecondary", () => ({
|
|
default: ({ children, onClick }: { children: React.ReactNode; onClick?: () => void }) => (
|
|
<button type="button" onClick={onClick}>
|
|
{children}
|
|
</button>
|
|
),
|
|
}));
|
|
|
|
vi.mock("~/components/ui/input/InputCheckboxInline", () => ({
|
|
default: ({ title, value, disabled, setValue }: { title: string; value?: boolean; disabled?: boolean; setValue: (value: boolean) => void }) => (
|
|
<label>
|
|
{title}
|
|
<input type="checkbox" checked={!!value} disabled={disabled} onChange={(event) => setValue(event.currentTarget.checked)} />
|
|
</label>
|
|
),
|
|
}));
|
|
|
|
vi.mock("~/components/ui/input/InputSelect", () => ({
|
|
default: ({ title }: { title: string }) => <label>{title}</label>,
|
|
}));
|
|
|
|
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 }[] }) => (
|
|
<select aria-label={name} value={value ?? ""} onChange={(event) => setValue(event.currentTarget.value || undefined)}>
|
|
{options.map((option) => (
|
|
<option key={option.value ?? "none"} value={option.value ?? ""}>
|
|
{option.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
),
|
|
}));
|
|
|
|
vi.mock("~/components/ui/icons/ExclamationTriangleIcon", () => ({
|
|
default: () => <span data-testid="warning-icon" />,
|
|
}));
|
|
|
|
vi.mock("~/components/ui/modals/Modal", () => ({
|
|
default: ({ open, children }: { open: boolean; children: React.ReactNode }) => (open ? <section role="dialog">{children}</section> : null),
|
|
}));
|
|
|
|
vi.mock("~/components/ui/tables/TableSimple", () => ({
|
|
default: ({ items, headers }: { items: any[]; headers: { title: string; value: (item: any) => React.ReactNode }[] }) => (
|
|
<table>
|
|
<tbody>
|
|
{items.map((item, rowIndex) => (
|
|
<tr key={rowIndex}>
|
|
{headers.map((header) => (
|
|
<td key={header.title}>{header.value(item)}</td>
|
|
))}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
),
|
|
}));
|
|
|
|
vi.mock("~/components/ui/uploaders/UploadDocument", () => ({
|
|
default: ({ onDropped }: { onDropped: (base64: string, file: File) => void }) => (
|
|
<button type="button" onClick={() => onDropped("", new File(["Email|Name\nada@example.com|Ada\nada@example.com|Duplicate"], "rows.csv", { type: "text/csv" }))}>
|
|
Upload CSV
|
|
</button>
|
|
),
|
|
}));
|
|
|
|
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(<RowsImportRoute />);
|
|
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(<RowsImportRoute />);
|
|
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);
|
|
});
|
|
});
|