fix: checklist step 5, secondary-profile migrations and Linux database paths (#390) #394

Merged
maximus merged 5 commits from issue-390-manual-flag-profile-migrations into main 2026-10-07 02:00:03 +00:00
6 changed files with 134 additions and 4 deletions
Showing only changes of commit f758282c2e - Show all commits

View file

@ -38,6 +38,7 @@ import {
importCategoriesOnly, importCategoriesOnly,
importTransactionsOnly, importTransactionsOnly,
importTransactionsWithCategories, importTransactionsWithCategories,
parseImportedCsv,
parseImportedJson, parseImportedJson,
serializeToJson, serializeToJson,
validateImportedFormatRows, 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]);
});
});

View file

@ -1,6 +1,7 @@
import type Database from "@tauri-apps/plugin-sql"; import type Database from "@tauri-apps/plugin-sql";
import { getDb, withTransaction } from "./db"; import { getDb, withTransaction } from "./db";
import Papa from "papaparse"; import Papa from "papaparse";
import { toSqlFlag } from "../utils/sqlFlag";
import type { import type {
Category, Category,
ImportConfigTemplate, ImportConfigTemplate,
@ -360,7 +361,8 @@ export function parseImportedCsv(content: string): {
category_name: row.category_name || null, category_name: row.category_name || null,
original_description: row.original_description || null, original_description: row.original_description || null,
notes: row.notes || 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, is_split: parseInt(row.is_split) || 0,
parent_transaction_id: row.parent_transaction_id parent_transaction_id: row.parent_transaction_id
? parseInt(row.parent_transaction_id) ? parseInt(row.parent_transaction_id)
@ -638,7 +640,8 @@ async function attachTransactions(
tx.category_id, tx.category_id,
tx.original_description, tx.original_description,
tx.notes, 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.is_split,
tx.parent_transaction_id, tx.parent_transaction_id,
sourceId, sourceId,

View file

@ -9,7 +9,7 @@ vi.mock("./db", () => {
}); });
import { getDb } from "./db"; import { getDb } from "./db";
import { getTransactionCount } from "./transactionService"; import { getTransactionCount, updateTransactionCategory } from "./transactionService";
const mockSelect = vi.fn(); const mockSelect = vi.fn();
const mockExecute = vi.fn(); const mockExecute = vi.fn();
@ -41,3 +41,22 @@ describe("getTransactionCount (#351)", () => {
await expect(getTransactionCount()).resolves.toBe(0); 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]);
});
});

View file

@ -4,6 +4,7 @@ import {
isLinkedTransactionFkError, isLinkedTransactionFkError,
type LinkedTransferTooltipRow, type LinkedTransferTooltipRow,
} from "./balance.service"; } from "./balance.service";
import { toSqlFlag } from "../utils/sqlFlag";
import type { import type {
Transaction, Transaction,
TransactionRow, TransactionRow,
@ -299,9 +300,11 @@ export async function updateTransactionCategory(
isManual: boolean isManual: boolean
): Promise<void> { ): Promise<void> {
const db = await getDb(); 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( await db.execute(
`UPDATE transactions SET category_id = $1, is_manually_categorized = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $3`, `UPDATE transactions SET category_id = $1, is_manually_categorized = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $3`,
[categoryId, isManual, txId] [categoryId, toSqlFlag(isManual), txId]
); );
} }

49
src/utils/sqlFlag.test.ts Normal file
View file

@ -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);
});
});

22
src/utils/sqlFlag.ts Normal file
View file

@ -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;
}