import { describe, it, expect, vi, beforeEach } from "vitest"; // `getDb` is stubbed with a fake DB that captures every SQL statement issued // during applyMigration. This is a unit test — we do NOT run a real SQLite. vi.mock("./db", () => { const getDb = vi.fn(); return { getDb, // Tests don't exercise the real lock; withTransaction just runs the // callback with whatever db the test wired getDb to resolve, so every // existing BEGIN/COMMIT/ROLLBACK ordering assertion still holds (the // service issues them on the mock db inside the callback). withTransaction: vi.fn(async (fn: (db: any) => any) => fn(await getDb())), }; }); // `setPreference` goes through the same fake DB — no extra stubbing needed // because userPreferenceService itself calls getDb(). import { getDb } from "./db"; import { applyMigration, markSchemaVersionV1 } from "./categoryMigrationService"; import type { MigrationPlan, MappingRow } from "./categoryMappingService"; import type { BackupResult } from "./categoryBackupService"; interface CapturedCall { sql: string; params?: unknown[]; } interface FakeDb { calls: CapturedCall[]; failAt: { sql: RegExp; error: string } | null; select: ReturnType; execute: ReturnType; } function makeFakeDb(): FakeDb { const db: FakeDb = { calls: [], failAt: null, select: vi.fn(async () => []), execute: vi.fn(), }; db.execute.mockImplementation(async (sql: string, params?: unknown[]) => { db.calls.push({ sql, params }); if (db.failAt && db.failAt.sql.test(sql)) { throw new Error(db.failAt.error); } // Return a rowsAffected=1 for UPDATE/DELETE/INSERT, 0 for BEGIN/COMMIT/ROLLBACK. const upper = sql.trim().toUpperCase(); if (/^(BEGIN|COMMIT|ROLLBACK)/.test(upper)) return { rowsAffected: 0 }; // Heuristic: only simulate a write on statements that look like real // UPDATE/DELETE/INSERT OR IGNORE — this gives us stable counts in tests. return { rowsAffected: 1 }; }); return db; } const FAKE_BACKUP: BackupResult = { path: "/tmp/profile-backup.sref", size: 4096, checksum: "abc123", verifiedAt: "2026-04-20T12:00:00Z", encrypted: false, }; function makeRow(v2: number, v1: number | null): MappingRow { return { v2CategoryId: v2, v2CategoryName: `v2-${v2}`, v1TargetId: v1, v1TargetName: v1 === null ? null : `v1-${v1}`, confidence: v1 === null ? "none" : "high", reason: v1 === null ? "review" : "keyword", }; } function makePlan( rows: MappingRow[], preserved: MappingRow[] = [], ): MigrationPlan { const unresolved = rows.filter((r) => r.v1TargetId === null); return { rows, preserved, unresolved, stats: { total: rows.length, high: rows.filter((r) => r.confidence === "high").length, medium: rows.filter((r) => r.confidence === "medium").length, low: rows.filter((r) => r.confidence === "low").length, none: unresolved.length, }, }; } let fake: FakeDb; beforeEach(() => { fake = makeFakeDb(); vi.mocked(getDb).mockResolvedValue(fake as never); }); // --------------------------------------------------------------------------- // Guard: invalid backup short-circuits before any SQL runs // --------------------------------------------------------------------------- describe("applyMigration — backup guard", () => { it("returns succeeded=false with no DB calls when backup has no path", async () => { const outcome = await applyMigration( makePlan([makeRow(10, 1011)]), { ...FAKE_BACKUP, path: "" }, ); expect(outcome.succeeded).toBe(false); expect(outcome.error).toMatch(/invalid_backup/); expect(fake.calls).toHaveLength(0); }); it("returns succeeded=false when backup has no checksum", async () => { const outcome = await applyMigration( makePlan([makeRow(10, 1011)]), { ...FAKE_BACKUP, checksum: "" }, ); expect(outcome.succeeded).toBe(false); expect(outcome.error).toMatch(/invalid_backup/); expect(fake.calls).toHaveLength(0); }); }); // --------------------------------------------------------------------------- // Happy path sequencing // --------------------------------------------------------------------------- describe("applyMigration — happy path sequencing", () => { it("wraps the entire run in BEGIN / COMMIT", async () => { const outcome = await applyMigration( makePlan([makeRow(22, 1111)]), FAKE_BACKUP, ); expect(outcome.succeeded).toBe(true); const trimmed = fake.calls.map((c) => c.sql.trim().toUpperCase()); expect(trimmed[0]).toBe("BEGIN"); expect(trimmed[trimmed.length - 1]).toBe("COMMIT"); }); it("emits an UPDATE on transactions for each mapped v2 id", async () => { await applyMigration( makePlan([makeRow(22, 1111), makeRow(24, 1121)]), FAKE_BACKUP, ); const txUpdates = fake.calls.filter((c) => /UPDATE transactions SET category_id/i.test(c.sql), ); expect(txUpdates.length).toBe(2); expect(txUpdates[0].params).toEqual([1111, 22]); expect(txUpdates[1].params).toEqual([1121, 24]); }); it("rewrites budget_entries and budget_template_entries", async () => { await applyMigration( makePlan([makeRow(22, 1111)]), FAKE_BACKUP, ); const budgetEntries = fake.calls.filter((c) => /UPDATE budget_entries SET category_id/i.test(c.sql), ); const templateEntries = fake.calls.filter((c) => /UPDATE budget_template_entries SET category_id/i.test(c.sql), ); expect(budgetEntries.length).toBe(1); expect(templateEntries.length).toBe(1); }); it("rewrites keywords and suppliers", async () => { await applyMigration( makePlan([makeRow(22, 1111)]), FAKE_BACKUP, ); const kwUpdates = fake.calls.filter((c) => /UPDATE keywords SET category_id/i.test(c.sql), ); const supUpdates = fake.calls.filter((c) => /UPDATE suppliers SET category_id/i.test(c.sql), ); expect(kwUpdates.length).toBe(1); expect(supUpdates.length).toBe(1); }); it("inserts all v1 taxonomy rows (INSERT OR IGNORE)", async () => { await applyMigration( makePlan([makeRow(22, 1111)]), FAKE_BACKUP, ); const v1Inserts = fake.calls.filter((c) => /INSERT OR IGNORE INTO categories/i.test(c.sql), ); // The v1 taxonomy has >100 rows; a low bound is safe as a regression // signal without coupling to the exact count. expect(v1Inserts.length).toBeGreaterThan(50); }); it("soft-deletes v2 seeded categories (is_active=0) and the 1..6 structural parents", async () => { await applyMigration( makePlan([makeRow(22, 1111)]), FAKE_BACKUP, ); const deactivateSeeded = fake.calls.filter( (c) => /UPDATE categories SET is_active = 0 WHERE id = \$1/i.test(c.sql) && (c.params?.[0] as number) === 22, ); const deactivateParents = fake.calls.filter((c) => /WHERE id IN \(1, 2, 3, 4, 5, 6\)/i.test(c.sql), ); expect(deactivateSeeded.length).toBe(1); expect(deactivateParents.length).toBe(1); }); it("writes categories_schema_version=v1 and a journal entry to user_preferences", async () => { await applyMigration( makePlan([makeRow(22, 1111)]), FAKE_BACKUP, ); const prefWrites = fake.calls.filter((c) => /INSERT INTO user_preferences/i.test(c.sql), ); expect(prefWrites.length).toBe(2); expect(prefWrites[0].params?.[0]).toBe("categories_schema_version"); expect(prefWrites[1].params?.[0]).toBe("last_categories_migration"); // Journal is serialized JSON; sniff a stable key to avoid snapshot churn. const journalJson = prefWrites[1].params?.[1] as string; expect(journalJson).toContain('"timestamp"'); expect(journalJson).toContain('"backupPath"'); expect(journalJson).toContain(FAKE_BACKUP.path); }); it("ignores rows whose v1TargetId is null (never emits UPDATE for them)", async () => { await applyMigration( makePlan([makeRow(22, 1111), makeRow(73, null)]), FAKE_BACKUP, ); const txUpdates = fake.calls.filter((c) => /UPDATE transactions SET category_id/i.test(c.sql), ); // Only one mapping (22 → 1111) was resolved; the unresolved row is // skipped inside buildMappingFromRows. expect(txUpdates.length).toBe(1); }); }); // --------------------------------------------------------------------------- // Custom categories (preserved) // --------------------------------------------------------------------------- describe("applyMigration — preserved custom categories", () => { it("creates the 'Catégories personnalisées (migration)' parent when preserved is non-empty", async () => { const outcome = await applyMigration( makePlan([makeRow(22, 1111)], [makeRow(9001, null)]), FAKE_BACKUP, ); expect(outcome.succeeded).toBe(true); const parentInserts = fake.calls.filter( (c) => /INSERT OR IGNORE INTO categories/i.test(c.sql) && (c.params?.[0] as number) === 2000, ); expect(parentInserts.length).toBe(1); expect(parentInserts[0].params?.[4]).toBe( "categoriesSeed.migration.customParent", ); }); it("does NOT create the custom parent when preserved is empty", async () => { await applyMigration( makePlan([makeRow(22, 1111)]), FAKE_BACKUP, ); const parentInserts = fake.calls.filter( (c) => /INSERT OR IGNORE INTO categories/i.test(c.sql) && (c.params?.[0] as number) === 2000, ); expect(parentInserts.length).toBe(0); }); it("re-parents each preserved v2 category under the new parent id (2000)", async () => { await applyMigration( makePlan( [makeRow(22, 1111)], [makeRow(9001, null), makeRow(9002, null), makeRow(9003, null)], ), FAKE_BACKUP, ); const reparent = fake.calls.filter((c) => /UPDATE categories SET parent_id = \$1 WHERE id = \$2/i.test(c.sql), ); expect(reparent.length).toBe(3); for (const call of reparent) { expect(call.params?.[0]).toBe(2000); } expect(reparent.map((c) => c.params?.[1])).toEqual([9001, 9002, 9003]); }); }); // --------------------------------------------------------------------------- // Rollback on SQL failure // --------------------------------------------------------------------------- describe("applyMigration — rollback on SQL failure", () => { it("emits ROLLBACK when an UPDATE throws mid-run", async () => { fake.failAt = { sql: /UPDATE transactions SET category_id/i, error: "fk_violation", }; const outcome = await applyMigration( makePlan([makeRow(22, 1111)]), FAKE_BACKUP, ); expect(outcome.succeeded).toBe(false); expect(outcome.error).toMatch(/fk_violation/); const upper = fake.calls.map((c) => c.sql.trim().toUpperCase()); expect(upper).toContain("ROLLBACK"); expect(upper).not.toContain("COMMIT"); }); it("does not clobber the outcome.error even when ROLLBACK itself fails", async () => { // First throw on transactions UPDATE; the subsequent ROLLBACK must not // overwrite the original error. let seen = 0; fake.execute.mockImplementation(async (sql: string) => { fake.calls.push({ sql }); seen++; if (/UPDATE transactions SET category_id/i.test(sql)) { throw new Error("original_sql_error"); } if (/^ROLLBACK/i.test(sql.trim())) { throw new Error("rollback_also_failed"); } return { rowsAffected: 1 }; }); const outcome = await applyMigration( makePlan([makeRow(22, 1111)]), FAKE_BACKUP, ); expect(outcome.succeeded).toBe(false); expect(outcome.error).toMatch(/original_sql_error/); expect(seen).toBeGreaterThan(2); }); }); // --------------------------------------------------------------------------- // markSchemaVersionV1 — helper // --------------------------------------------------------------------------- describe("markSchemaVersionV1", () => { it("writes an upsert into user_preferences with key=categories_schema_version", async () => { await markSchemaVersionV1(); const prefWrite = fake.calls.find((c) => /INSERT INTO user_preferences/i.test(c.sql), ); expect(prefWrite).toBeDefined(); expect(prefWrite!.params?.[0]).toBe("categories_schema_version"); expect(prefWrite!.params?.[1]).toBe("v1"); }); });