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
107 lines
3.3 KiB
TypeScript
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);
|
|
});
|
|
});
|