Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/modules/rows/components/RowsImportRoute.test.tsx
T

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);
});
});