Simpl-Resultat/src/components/profile/ProfileFormModal.delete.test.ts
le king fu 57ac056bf2 fix(dialogs): wait for the confirmation before deleting or removing the key
On desktop, tauri-plugin-dialog 2.6.0 replaces window.confirm with an async
function: confirm(...) returned a Promise, always truthy, so every guard
`if (!confirm(...)) return;` let the action run before the user answered.
Deleting a secondary profile removed its database even on "Cancel".

- confirmAction() (src/services/confirmService.ts) calls the plugin's ask()
  with a warning icon and buttons labelled in the app language
  (common.confirm / common.cancel); a failed call resolves false and is
  logged (fail closed). @tauri-apps/plugin-dialog 2.6.0 added, the crate's
  version.
- The six sites await it: profile, category, adjustment, budget template and
  Balance category deletions, and both confirmations of the license key
  removal (performRemoveLicense takes an async confirm).
- native-dialog-guard.test.ts fails on any native confirm/alert in src/ and
  on any confirmation not awaited (TypeScript does not flag `!promise`).
- Tests: confirmService, the deletion of a profile in ProfileFormModal, and
  the license removal tests with an async confirm (test: reproduces #421:
  six of them were red on the old code).

Refs #421

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 14:29:17 -04:00

84 lines
3.4 KiB
TypeScript

// ProfileFormModal — deleting a secondary profile waits for the confirmation (#421).
//
// Before #421 the dialog plugin's async `window.confirm` returned a Promise, always truthy:
// the profile and its database were deleted before the user answered, and "Cancel" stopped
// nothing. The modal runs as a plain function under the shared hook harness (#409); the test
// clicks the delete button of the secondary profile through its props, the confirmation
// answered by a mocked `confirmAction`.
import { describe, it, expect, vi, beforeEach } from "vitest";
import { isValidElement, type ReactElement, type ReactNode } from "react";
vi.mock("react", async (orig) => (await import("../../test/hookHarness")).fakeReact(await orig()));
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
const mocks = vi.hoisted(() => ({
confirmAction: vi.fn<(message: string) => Promise<boolean>>(),
deleteProfile: vi.fn(async () => undefined),
}));
vi.mock("../../services/confirmService", () => ({ confirmAction: mocks.confirmAction }));
vi.mock("../../hooks/useEntitlement", () => ({
useEntitlement: () => ({ allowed: true, ready: true }),
}));
vi.mock("../../contexts/ProfileContext", () => ({
useProfile: () => ({
profiles: [
{ id: "default", name: "Principal", color: "#4A90A4", pin_hash: null, db_filename: "simpl_resultat.db", created_at: "" },
{ id: "second", name: "Second", color: "#C17767", pin_hash: null, db_filename: "profile_second.db", created_at: "" },
],
createProfile: vi.fn(),
updateProfile: vi.fn(),
deleteProfile: mocks.deleteProfile,
setPin: vi.fn(),
}),
}));
import { Trash2 } from "lucide-react";
import { hookHarness } from "../../test/hookHarness";
import ProfileFormModal from "./ProfileFormModal";
const harness = hookHarness();
type Props = Record<string, unknown> & { children?: ReactNode; onClick?: () => Promise<void> };
/** The elements of the tree whose children hold an element of the given type. */
function holding(node: ReactNode, type: unknown): ReactElement<Props>[] {
if (Array.isArray(node)) return node.flatMap((n) => holding(n, type));
if (!isValidElement(node)) return [];
const el = node as ReactElement<Props>;
const children = ([] as ReactNode[]).concat(el.props.children ?? []);
const holds = children.some((c) => isValidElement(c) && c.type === type);
return [...(holds ? [el] : []), ...holding(el.props.children, type)];
}
/** Clicks the only delete button, the secondary profile's, and waits for its handler. */
async function clickDelete() {
const tree = harness.render(() => ProfileFormModal({ onClose: vi.fn() }));
const buttons = holding(tree, Trash2);
expect(buttons).toHaveLength(1);
await buttons[0].props.onClick?.();
}
beforeEach(() => {
harness.clear();
mocks.confirmAction.mockReset();
mocks.deleteProfile.mockClear();
});
describe("ProfileFormModal deletion (#421)", () => {
it("keeps the profile when the confirmation is refused", async () => {
mocks.confirmAction.mockResolvedValue(false);
await clickDelete();
expect(mocks.confirmAction).toHaveBeenCalledWith("t(profile.deleteConfirm)");
expect(mocks.deleteProfile).not.toHaveBeenCalled();
});
it("deletes the profile once confirmed", async () => {
mocks.confirmAction.mockResolvedValue(true);
await clickDelete();
expect(mocks.deleteProfile).toHaveBeenCalledWith("second");
});
});