diff --git a/src/services/dataExportService.test.ts b/src/services/dataExportService.test.ts index fdef24e..3e101bf 100644 --- a/src/services/dataExportService.test.ts +++ b/src/services/dataExportService.test.ts @@ -38,6 +38,7 @@ import { importCategoriesOnly, importTransactionsOnly, importTransactionsWithCategories, + parseImportedCsv, parseImportedJson, serializeToJson, validateImportedFormatRows, @@ -728,3 +729,36 @@ describe("the refusal messages are translated (#331)", () => { } }); }); + +// --------------------------------------------------------------------------- +// 5. The manual-categorization flag (#390) +// --------------------------------------------------------------------------- + +describe("a restore writes the manual flag as an integer (#390)", () => { + // A backup exported while the Transactions page bound the JS boolean carries + // the text "true"; written back as is, the checklist's `= 1` misses it. + const fromTheBug = { ...transactionFixture(1), is_manually_categorized: "true" as unknown as number }; + + it("restores the 'true' of an older JSON backup as 1", async () => { + await importTransactionsWithCategories( + envelopeData({ transactions: [fromTheBug, transactionFixture(2)] }), + "backup.json" + ); + expect(db.tables.transactions.map((t) => t.is_manually_categorized)).toEqual([1, 0]); + }); + + it("does the same in transactions_only mode", async () => { + await importTransactionsOnly(envelopeData({ transactions: [fromTheBug] }), "backup.json"); + expect(db.tables.transactions[0].is_manually_categorized).toBe(1); + }); + + it("reads the flag of a CSV export, 'true' included", () => { + const csv = + "date,description,amount,is_manually_categorized,is_split\n" + + "2026-01-01,a,-1,true,0\n" + + "2026-01-02,b,-2,1,0\n" + + "2026-01-03,c,-3,0,0\n"; + const { transactions } = parseImportedCsv(csv); + expect(transactions.map((t) => t.is_manually_categorized)).toEqual([1, 1, 0]); + }); +}); diff --git a/src/services/dataExportService.ts b/src/services/dataExportService.ts index 7050564..bd3ef9a 100644 --- a/src/services/dataExportService.ts +++ b/src/services/dataExportService.ts @@ -1,6 +1,7 @@ import type Database from "@tauri-apps/plugin-sql"; import { getDb, withTransaction } from "./db"; import Papa from "papaparse"; +import { toSqlFlag } from "../utils/sqlFlag"; import type { Category, ImportConfigTemplate, @@ -360,7 +361,8 @@ export function parseImportedCsv(content: string): { category_name: row.category_name || null, original_description: row.original_description || null, notes: row.notes || null, - is_manually_categorized: parseInt(row.is_manually_categorized) || 0, + // An export from the time of #390 carries "true" in this column. + is_manually_categorized: toSqlFlag(row.is_manually_categorized), is_split: parseInt(row.is_split) || 0, parent_transaction_id: row.parent_transaction_id ? parseInt(row.parent_transaction_id) @@ -638,7 +640,8 @@ async function attachTransactions( tx.category_id, tx.original_description, tx.notes, - tx.is_manually_categorized, + // A JSON backup from the time of #390 carries the text "true". + toSqlFlag(tx.is_manually_categorized), tx.is_split, tx.parent_transaction_id, sourceId, diff --git a/src/services/transactionService.test.ts b/src/services/transactionService.test.ts index ed615e5..317fb95 100644 --- a/src/services/transactionService.test.ts +++ b/src/services/transactionService.test.ts @@ -9,7 +9,7 @@ vi.mock("./db", () => { }); import { getDb } from "./db"; -import { getTransactionCount } from "./transactionService"; +import { getTransactionCount, updateTransactionCategory } from "./transactionService"; const mockSelect = vi.fn(); const mockExecute = vi.fn(); @@ -41,3 +41,22 @@ describe("getTransactionCount (#351)", () => { await expect(getTransactionCount()).resolves.toBe(0); }); }); + +describe("updateTransactionCategory (#390)", () => { + // tauri-plugin-sql stores a bound JS boolean as the TEXT 'true', which the + // getting-started checklist's `is_manually_categorized = 1` never matches. + it("binds the manual flag as the integer 1, never as a boolean", async () => { + mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); + await updateTransactionCategory(5, 12, true); + expect(mockExecute).toHaveBeenCalledTimes(1); + const params = mockExecute.mock.calls[0][1] as unknown[]; + expect(params).toEqual([12, 1, 5]); + expect(typeof params[1]).toBe("number"); + }); + + it("binds 0 when the category is not set by hand", async () => { + mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); + await updateTransactionCategory(5, null, false); + expect(mockExecute.mock.calls[0][1]).toEqual([null, 0, 5]); + }); +}); diff --git a/src/services/transactionService.ts b/src/services/transactionService.ts index c10ee08..0de93e0 100644 --- a/src/services/transactionService.ts +++ b/src/services/transactionService.ts @@ -4,6 +4,7 @@ import { isLinkedTransactionFkError, type LinkedTransferTooltipRow, } from "./balance.service"; +import { toSqlFlag } from "../utils/sqlFlag"; import type { Transaction, TransactionRow, @@ -299,9 +300,11 @@ export async function updateTransactionCategory( isManual: boolean ): Promise { const db = await getDb(); + // Never bind the boolean itself: tauri-plugin-sql would store the TEXT + // 'true', which `is_manually_categorized = 1` never matches (#390). await db.execute( `UPDATE transactions SET category_id = $1, is_manually_categorized = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $3`, - [categoryId, isManual, txId] + [categoryId, toSqlFlag(isManual), txId] ); } diff --git a/src/utils/sqlFlag.test.ts b/src/utils/sqlFlag.test.ts new file mode 100644 index 0000000..1676153 --- /dev/null +++ b/src/utils/sqlFlag.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from "vitest"; +import { toSqlFlag } from "./sqlFlag"; + +describe("toSqlFlag (#390)", () => { + it("maps the UI boolean to an integer, never to the boolean itself", () => { + expect(toSqlFlag(true)).toBe(1); + expect(toSqlFlag(false)).toBe(0); + }); + + it("keeps database integers", () => { + expect(toSqlFlag(1)).toBe(1); + expect(toSqlFlag(0)).toBe(0); + }); + + it("treats any finite non-zero number as set, like SQLite", () => { + expect(toSqlFlag(2)).toBe(1); + expect(toSqlFlag(-1)).toBe(1); + }); + + it("maps NaN and infinities to 0", () => { + expect(toSqlFlag(Number.NaN)).toBe(0); + expect(toSqlFlag(Number.POSITIVE_INFINITY)).toBe(0); + }); + + it("reads the 'true' a backup carries from the time of the bug", () => { + expect(toSqlFlag("true")).toBe(1); + expect(toSqlFlag("TRUE")).toBe(1); + expect(toSqlFlag(" true ")).toBe(1); + }); + + it("reads a CSV cell", () => { + expect(toSqlFlag("1")).toBe(1); + expect(toSqlFlag(" 1 ")).toBe(1); + expect(toSqlFlag("0")).toBe(0); + expect(toSqlFlag("")).toBe(0); + }); + + it("maps every other string to 0", () => { + expect(toSqlFlag("false")).toBe(0); + expect(toSqlFlag("yes")).toBe(0); + expect(toSqlFlag("01")).toBe(0); + }); + + it("maps absent values to 0", () => { + expect(toSqlFlag(null)).toBe(0); + expect(toSqlFlag(undefined)).toBe(0); + expect(toSqlFlag({})).toBe(0); + }); +}); diff --git a/src/utils/sqlFlag.ts b/src/utils/sqlFlag.ts new file mode 100644 index 0000000..2b297e5 --- /dev/null +++ b/src/utils/sqlFlag.ts @@ -0,0 +1,22 @@ +/** + * Turn any truthy-looking value into the integer SQLite expects for a 0/1 + * flag column (#390). + * + * Never bind a JS boolean through tauri-plugin-sql: the plugin only special- + * cases null, strings and numbers, so `true` reaches sqlx as a JSON value and + * is stored as the TEXT `'true'`. An INTEGER column keeps that text (it is not + * a number), and `WHERE flag = 1` never matches it. + * + * Accepts what a flag can look like on the way in: a boolean from the UI, a + * number from the database, or a string from a backup (`"1"`, or `"true"` in a + * backup exported while the bug stood). + */ +export function toSqlFlag(value: unknown): 0 | 1 { + if (value === true) return 1; + if (typeof value === "number") return Number.isFinite(value) && value !== 0 ? 1 : 0; + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + return normalized === "1" || normalized === "true" ? 1 : 0; + } + return 0; +}