Simpl-Resultat/src/services/categoryRestoreService.test.ts
le king fu 0132e6e164
All checks were successful
PR Check / rust (push) Successful in 21m45s
PR Check / frontend (push) Successful in 2m17s
PR Check / rust (pull_request) Successful in 21m1s
PR Check / frontend (pull_request) Successful in 2m13s
feat(categories): add restore backup banner and permanent restore action (#122)
Surfaces the pre-migration SREF backup to the user so they can roll back a
category migration without digging into the filesystem:

- 90-day dismissable banner at the top of Settings > Categories pointing to
  the automatic backup (hidden once reverted, once dismissed, or past 90d).
- Permanent "Restore a backup" entry in Settings > Categories, available as
  long as a migration journal exists (even past the 90-day window).
- Confirmation modal with two-step consent, red Restore button, fallback
  file picker when the recorded path is missing, PIN prompt for encrypted
  SREF files, full-page reload on success.

Internals:
- New `categoryRestoreService` wrapping `read_import_file` +
  `importTransactionsWithCategories` with stable error codes
  (file_missing, read_failed, parse_failed, wrong_envelope_type,
  needs_password, wrong_password, import_failed).
- New `file_exists` Tauri command for the pre-flight presence check.
- On success: `categories_schema_version=v2` + merge `reverted_at` into
  `last_categories_migration`.
- Pure `shouldShowBanner` / `isWithinBannerWindow` helpers with tests.
- FR/EN i18n keys under `settings.categoriesCard.restore*`.
- CHANGELOG entries in both locales.

Closes #122
2026-04-20 21:47:43 -04:00

107 lines
3.3 KiB
TypeScript

import { describe, it, expect } from "vitest";
import {
isWithinBannerWindow,
shouldShowBanner,
type RestorableMigrationJournal,
} from "./categoryRestoreService";
const DAY = 24 * 60 * 60 * 1000;
describe("isWithinBannerWindow", () => {
it("returns true for a migration just done", () => {
const now = Date.parse("2026-04-20T00:00:00Z");
const ts = "2026-04-19T00:00:00Z";
expect(isWithinBannerWindow(ts, now)).toBe(true);
});
it("returns true on the 90-day boundary", () => {
const now = Date.parse("2026-04-20T00:00:00Z");
const ts = new Date(now - 90 * DAY).toISOString();
expect(isWithinBannerWindow(ts, now)).toBe(true);
});
it("returns false past 90 days", () => {
const now = Date.parse("2026-04-20T00:00:00Z");
const ts = new Date(now - 91 * DAY).toISOString();
expect(isWithinBannerWindow(ts, now)).toBe(false);
});
it("returns false for a timestamp in the future", () => {
const now = Date.parse("2026-04-20T00:00:00Z");
const ts = new Date(now + DAY).toISOString();
expect(isWithinBannerWindow(ts, now)).toBe(false);
});
it("returns false for an unparseable timestamp", () => {
const now = Date.parse("2026-04-20T00:00:00Z");
expect(isWithinBannerWindow("not-a-date", now)).toBe(false);
});
it("respects a custom window length", () => {
const now = Date.parse("2026-04-20T00:00:00Z");
const ts = new Date(now - 31 * DAY).toISOString();
expect(isWithinBannerWindow(ts, now, 30)).toBe(false);
expect(isWithinBannerWindow(ts, now, 60)).toBe(true);
});
});
describe("shouldShowBanner", () => {
const now = Date.parse("2026-04-20T00:00:00Z");
function journal(
overrides: Partial<RestorableMigrationJournal> = {},
): RestorableMigrationJournal {
return {
timestamp: "2026-04-15T10:00:00Z",
backupPath: "/tmp/profile_avant-migration.sref",
outcome: {
insertedV1Count: 1,
updatedTransactionsCount: 2,
updatedBudgetsCount: 0,
updatedKeywordsCount: 0,
deletedV2Count: 0,
customPreservedCount: 0,
},
...overrides,
};
}
it("hides the banner when no migration has run", () => {
expect(shouldShowBanner(null, null, now)).toBe(false);
});
it("shows the banner for a recent un-dismissed un-reverted migration", () => {
expect(shouldShowBanner(journal(), null, now)).toBe(true);
});
it("hides the banner when the user already dismissed it", () => {
expect(shouldShowBanner(journal(), "1", now)).toBe(false);
});
it("hides the banner when the migration was already reverted", () => {
expect(
shouldShowBanner(
journal({ reverted_at: "2026-04-19T12:00:00Z" }),
null,
now,
),
).toBe(false);
});
it("hides the banner past the 90-day window", () => {
expect(
shouldShowBanner(
journal({ timestamp: "2026-01-01T00:00:00Z" }),
null,
now,
),
).toBe(false);
});
it("ignores arbitrary dismissal-pref values that are not '1'", () => {
// Defensive: we treat anything other than the literal "1" as "not
// dismissed" so an accidental write of "0" or "" still shows the banner.
expect(shouldShowBanner(journal(), "0", now)).toBe(true);
expect(shouldShowBanner(journal(), "", now)).toBe(true);
});
});