Simpl-Resultat/src/pages/ImportPage.render.test.tsx
le king fu a58f8b98bc fix(onboarding): count an import only once the file brought a transaction
The "Import a first file" step counted `imported_files` rows in status
`completed`. `executeImport` writes that row before its first insert, so
an import that added nothing (a file holding only its header, or one
whose rows were all in error in the preview) ticked the step: the Import
card then said "Done." at rest and sent the user to /transactions, which
had nothing to categorize and led back to the import.

The counter now keeps a completed file only when a transaction points at
it (`EXISTS` on `transactions.file_id`, covered by
`idx_transactions_file`). Replayed with sqlite3 on
`consolidated_schema.sql`: 0 after an import without a valid row, 0
before a successful import's first insert and 1 after, 0 once the file
turns to `error`. Duplicates only change nothing: their transactions
came with an earlier completed file, and a restore writes its own.

The tests run without SQLite, so the rule is pinned on the query, and
the Import page is pinned at rest after such an import: step 4 again,
on the first source card, without "Done.".

Review of #398 (PR #404), option A chosen by Max.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:05:14 -04:00

298 lines
12 KiB
TypeScript

// ImportPage and its getting-started card (#398). No jsdom: react-dom/server
// renders in vitest's node environment. The wizard is a fake
// `useImportWizard()` over its real initial state, the path a fake
// `useOnboarding()` built from the real checklist, and the wizard's heavy
// panels are stubbed. What is pinned is the page's wiring: where the card
// sits, what it says at each step, which control carries the one highlight,
// and what waits for the folder to be read (#384).
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { MemoryRouter } from "react-router-dom";
// Importing the real wizard module (for its initial state) pulls the import
// services, then the database: stub the connection.
vi.mock("../services/db", () => ({ getDb: vi.fn(), withTransaction: vi.fn() }));
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (k: string, o?: Record<string, unknown>) =>
o ? `t(${k}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}])` : `t(${k})`,
i18n: { language: "fr" },
}),
}));
const harness = vi.hoisted(() => ({ wizard: null as unknown, onboarding: null as unknown }));
vi.mock("../hooks/useImportWizard", async (importOriginal) => ({
...(await importOriginal<typeof import("../hooks/useImportWizard")>()),
useImportWizard: () => harness.wizard,
}));
vi.mock("../contexts/OnboardingContext", () => ({ useOnboarding: () => harness.onboarding }));
const { stub } = vi.hoisted(() => ({
stub: (name: string) => ({ default: () => <i data-stub={name} /> }),
}));
vi.mock("../components/import/ImportHistoryPanel", () => stub("history"));
vi.mock("../components/import/SourceConfigPanel", () => stub("source-config"));
vi.mock("../components/import/FilePreviewTable", () => stub("file-preview"));
vi.mock("../components/import/DuplicateCheckPanel", () => stub("duplicate-check"));
vi.mock("../components/import/ImportConfirmation", () => stub("confirmation"));
vi.mock("../components/shared/PageHelp", () => ({ PageHelp: () => null }));
import ImportPage from "./ImportPage";
import { initialWizardState, type WizardState } from "../hooks/useImportWizard";
import type { OnboardingValue } from "../hooks/useOnboardingChecklist";
import { computeChecklist } from "../utils/onboardingChecklist";
import { nextStep } from "../utils/onboardingPath";
import type { ImportReport, OnboardingCounters, ScannedSource } from "../shared/types";
const ZERO: OnboardingCounters = {
sources: 0,
imports: 0,
manualCategorized: 0,
budgetEntries: 0,
transactions: 0,
};
/** Source and import checked (the re-read after a successful import). */
const IMPORTED: OnboardingCounters = { ...ZERO, sources: 1, imports: 1, transactions: 41 };
const SOURCES: ScannedSource[] = ["Desjardins", "Tangerine"].map((name) => ({
folder_name: name,
folder_path: `/home/max/Relevés/${name}`,
files: [],
}));
const report = (importedCount: number): ImportReport => ({
totalRows: 41,
importedCount,
skippedDuplicates: 41 - importedCount,
errorCount: 0,
categorizedCount: importedCount,
uncategorizedCount: 0,
errors: [],
});
const DUPLICATES = { fileAlreadyImported: false, duplicateRows: [], newRows: [] };
/** The wizard after its mount read, with `overrides`. */
function wizard(overrides: Partial<WizardState> = {}) {
const state: WizardState = { ...initialWizardState, importFolderLoaded: true, ...overrides };
const noop = vi.fn();
return new Proxy({ state } as Record<string, unknown>, {
get: (target, key) => (key in target ? target[key as string] : noop),
});
}
/** The path latched on /import (card mode), as the provider shares it. */
function path(
counters: OnboardingCounters,
importFolderSet: boolean,
overrides: Partial<OnboardingValue> = {},
): OnboardingValue {
const checklist = computeChecklist(counters, { importFolderSet, budgetAllowed: true });
return {
status: "ready",
checklist,
counters,
hidden: false,
budgetReady: true,
nextStep: nextStep(checklist),
mode: "card",
cardPage: "/import",
hide: vi.fn(async () => undefined),
show: vi.fn(async () => undefined),
skip: vi.fn(async () => undefined),
resume: vi.fn(async () => undefined),
refresh: vi.fn(async () => undefined),
...overrides,
};
}
const render = () =>
renderToStaticMarkup(
<MemoryRouter initialEntries={["/import"]}>
<ImportPage />
</MemoryRouter>,
);
const CARD = 'aria-label="t(onboarding.banner.label)"';
const HIGHLIGHT = 'data-onboarding-highlight="true"';
const count = (html: string, needle: string) => html.split(needle).length - 1;
/** The one highlighted control: its tag and its inner markup. */
function highlighted(html: string): { tag: string; inner: string } {
expect(count(html, HIGHLIGHT)).toBe(1);
const match = html.match(/<(button|a) [^>]*data-onboarding-highlight="true"[^>]*>(.*?)<\/\1>/);
expect(match).not.toBeNull();
return { tag: match![1], inner: match![2] };
}
/**
* The card's sentence: the paragraph that follows its title. The card always
* hands `t` its interpolations, so a sentence without any reads `t(key[])`.
*/
const sentence = (html: string) => html.match(/<\/h2><p [^>]*>(.*?)<\/p>/)?.[1] ?? null;
beforeEach(() => {
harness.wizard = wizard();
harness.onboarding = path(ZERO, false);
});
describe("ImportPage — the step card (#398)", () => {
it("renders the card first, before the page's heading", () => {
const html = render();
expect(count(html, CARD)).toBe(1);
expect(html.indexOf(CARD)).toBeLessThan(html.indexOf("<h1"));
expect(html).toContain("t(onboarding.card.stepOf[n=2,total=6])");
});
it("a new profile: 'Choose a folder' is the one target, and the sentence names it", () => {
const html = render();
expect(sentence(html)).toBe(
"t(onboarding.card.import.noFolder[button=t(import.empty.noFolder.cta)])",
);
const target = highlighted(html);
expect(target.tag).toBe("button");
expect(target.inner).toBe("t(import.empty.noFolder.cta)");
});
it("a folder without bank sub-folder: 'Create a sample folder', the folder named by its last segment", () => {
harness.wizard = wizard({ importFolder: "/home/max/Relevés" });
harness.onboarding = path(ZERO, true);
const html = render();
expect(html).toContain("t(onboarding.card.stepOf[n=3,total=6])");
expect(sentence(html)).toBe(
"t(onboarding.card.import.noSources[folder=Relevés,refresh=t(import.empty.refresh),sample=t(import.empty.createSample)])",
);
expect(highlighted(html).inner).toBe("t(import.empty.createSample)");
});
it("sources found: the first source card", () => {
harness.wizard = wizard({ importFolder: "/home/max/Relevés", scannedSources: SOURCES });
harness.onboarding = path(ZERO, true);
const html = render();
expect(sentence(html)).toBe("t(onboarding.card.import.sourceList[])");
const target = highlighted(html);
expect(target.inner).toContain("Desjardins");
expect(target.inner).not.toContain("Tangerine");
});
it.each([
["source-config", "sourceConfig", "import.wizard.preview"],
["file-preview", "filePreview", "import.wizard.checkDuplicates"],
["duplicate-check", "duplicateCheck", "import.wizard.confirm"],
["confirm", "confirm", "import.wizard.import"],
] as const)("%s: the next button, the one the sentence names", (step, text, label) => {
harness.wizard = wizard({
step,
importFolder: "/home/max/Relevés",
scannedSources: SOURCES,
selectedSource: SOURCES[0],
duplicateResult: DUPLICATES,
});
harness.onboarding = path({ ...ZERO, sources: 0 }, true);
const html = render();
expect(html).toContain(`data-stub="${step === "confirm" ? "confirmation" : step}"`);
expect(sentence(html)).toBe(`t(onboarding.card.import.${text}[button=t(${label})])`);
const target = highlighted(html);
expect(target.tag).toBe("button");
expect(target.inner).toContain(`t(${label})`);
});
it("importing: the card says so and points at nothing, though the counters already tick", () => {
harness.wizard = wizard({ step: "importing", importFolder: "/home/max/Relevés", scannedSources: SOURCES });
harness.onboarding = path(IMPORTED, true);
const html = render();
expect(html).toContain("t(import.progress.title)");
expect(sentence(html)).toBe("t(onboarding.card.import.importing[])");
expect(html).not.toContain("t(onboarding.card.done)");
expect(count(html, HIGHLIGHT)).toBe(0);
});
it("the report of an import that added transactions: 'Done.' and the next step", () => {
harness.wizard = wizard({
step: "report",
importFolder: "/home/max/Relevés",
scannedSources: SOURCES,
importReport: report(41),
});
harness.onboarding = path(IMPORTED, true);
const html = render();
expect(html).toMatch(/<p role="status"[^>]*>.*t\(onboarding\.card\.done\)/);
expect(html).toMatch(/href="\/transactions"[^>]*>t\(onboarding\.card\.next\[step=/);
expect(html).toContain("t(onboarding.card.stepOf[n=4,total=6])");
expect(count(html, HIGHLIGHT)).toBe(0);
});
it("the report of an import that added nothing: the failure sentence, never 'Done.'", () => {
harness.wizard = wizard({
step: "report",
importFolder: "/home/max/Relevés",
scannedSources: SOURCES,
importReport: report(0),
});
// Duplicates only: this file brought no transaction, and the step stays
// checked by the earlier import that brought the ones it duplicates.
harness.onboarding = path(IMPORTED, true);
const html = render();
expect(sentence(html)).toBe("t(onboarding.card.import.failed[])");
expect(html).not.toContain("t(onboarding.card.done)");
expect(html).not.toContain("onboarding.card.next");
});
it("after 'Done' on an import that added nothing, the wizard at rest: no 'Done.', step 4 again on the first source card", () => {
// The file row is written, but the import step only counts a file that
// brought a transaction: the re-read keeps the step to do (review of #398).
harness.wizard = wizard({ importFolder: "/home/max/Relevés", scannedSources: SOURCES });
harness.onboarding = path({ ...ZERO, sources: 1 }, true);
const html = render();
expect(html).toContain("t(onboarding.card.stepOf[n=4,total=6])");
expect(html).not.toContain("t(onboarding.card.done)");
expect(sentence(html)).toBe("t(onboarding.card.import.sourceList[])");
const target = highlighted(html);
expect(target.inner).toContain("Desjardins");
expect(target.inner).not.toContain("Tangerine");
});
it("after 'Done' or 'Cancel', the wizard at rest: the card still says 'Done.' and points at nothing", () => {
harness.wizard = wizard({ importFolder: "/home/max/Relevés", scannedSources: SOURCES });
harness.onboarding = path(IMPORTED, true);
const html = render();
expect(html).toContain("t(onboarding.card.done)");
expect(html).toContain("Desjardins");
expect(count(html, HIGHLIGHT)).toBe(0);
});
it("without the card on screen, no control is highlighted", () => {
harness.wizard = wizard({ importFolder: "/home/max/Relevés", scannedSources: SOURCES });
for (const overrides of [
{ mode: "banner" as const, cardPage: null },
{ mode: "none" as const, cardPage: null },
{ mode: "card" as const, cardPage: "/transactions" },
]) {
harness.onboarding = path(ZERO, true, overrides);
const html = render();
expect(html).not.toContain(CARD);
expect(count(html, HIGHLIGHT)).toBe(0);
expect(html).toContain("Desjardins");
}
});
});
describe("ImportPage — before the folder is read (#384)", () => {
it("shows neither variant of the empty state, nor the list's empty box, nor the card", () => {
harness.wizard = wizard({ importFolderLoaded: false });
const html = render();
expect(html).not.toContain("import.empty.noFolder.title");
expect(html).not.toContain("import.empty.noSources.title");
expect(html).not.toContain("import.sources.empty");
expect(html).not.toContain(CARD);
expect(count(html, HIGHLIGHT)).toBe(0);
// The page itself is there: its heading, the folder card and the history.
expect(html).toContain("t(import.title)");
expect(html).toContain('data-stub="history"');
});
it("shows the empty state once read", () => {
expect(render()).toContain("t(import.empty.noFolder.title)");
});
});