From 0add7b6479bbd90a34856b68e182c344204e36ad Mon Sep 17 00:00:00 2001 From: RyuseiTaniguchi Date: Sat, 29 Aug 2026 18:23:07 -0700 Subject: [PATCH] fix(categorization): escape the NUL delimiter instead of embedding the byte engine.ts carried a literal 0x00 byte in its source, inside the template literal that builds the assignment-grouping key: const key = `${assignment.categoryId}<0x00 byte>${assignment.source}`; NUL is a sound delimiter here -- it cannot occur in a category id or a source, so the composite key cannot collide. Writing it as a raw byte rather than the \0 escape is the problem: grep classifies the whole file as binary and silently drops matching lines from its output. No error, no warning, just absent results. That is not hypothetical. Searching for `categoryRules` across src/ returned only the schema definition, which reads as "nothing consumes this table" -- while engine.ts in fact queries it on lines 106-113 and runs it on every sync. The file was invisible to the search that would have shown otherwise. `\0` is byte-identical at runtime (verified: same string, same code point at the delimiter, same length) and the file is now ASCII text, so grep, git diff and editors all read it normally. Adds a regression test for the grouping itself, which nothing covered: two transactions reaching two different categories through two different tiers in one call must each land on the right category and source. Verified it has teeth -- collapsing the key to a constant fails this test and only this test, out of the six in that file. --- src/lib/categorization/engine.ts | Bin 6654 -> 6655 bytes tests/integration/categorization-sync.test.ts | 38 ++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/src/lib/categorization/engine.ts b/src/lib/categorization/engine.ts index 6c75f5382cefbbd032fb3efd39e78c1e1250e285..8d0275b5181e4d0b770d73288aea926b95c28900 100644 GIT binary patch delta 15 Wcmexo{NH#3s{~Vw!DcpzAB+GqFa?PK delta 14 Vcmexw{Lgp;s{|v%W_F1mi~uZH1pNR2 diff --git a/tests/integration/categorization-sync.test.ts b/tests/integration/categorization-sync.test.ts index 37d1e36f..0bc7eaec 100644 --- a/tests/integration/categorization-sync.test.ts +++ b/tests/integration/categorization-sync.test.ts @@ -54,6 +54,44 @@ describe("categorizeSyncedTransactions", () => { expect(row!.categoryId).toBe(categoryId); }); + it("applies several distinct (category, source) pairs in one pass", async () => { + // categorizeSyncedTransactions groups assignments by a composite + // `categoryId \0 source` key so each distinct pair becomes one UPDATE. + // If that key collided, transactions would be written to the wrong + // category or stamped with the wrong source. Exercise it with two + // categories reached through two different tiers in a single call. + const { groupId } = await insertCategoryGroup(db, householdId, { name: "Mixed" }); + const { categoryId: ruleCategory } = await insertCategory(db, householdId, groupId, { name: "Coffee" }); + const { categoryId: merchantCategory } = await insertCategory(db, householdId, groupId, { name: "Hardware" }); + + await insertCategoryRule(db, householdId, ruleCategory, { + matchField: "name", + matchPattern: "blue bottle", + }); + // The merchant-default tier resolves through transaction.merchantId, not + // by name, so the category assignment is the only field that matters here. + const { merchantId } = await insertMerchant(db, householdId, { + name: "Ace Hardware", + categoryId: merchantCategory, + }); + + const viaRule = await insertTransaction(db, householdId, accountId, { name: "Blue Bottle Coffee" }); + const viaMerchant = await insertTransaction(db, householdId, accountId, { + name: "ACE HARDWARE 41", + merchantId, + }); + + await categorizeSyncedTransactions(plaidItemId, householdId, db); + + const [ruleRow] = await db.select().from(transactions).where(eq(transactions.id, viaRule.transactionId)); + const [merchantRow] = await db.select().from(transactions).where(eq(transactions.id, viaMerchant.transactionId)); + + expect(ruleRow!.categoryId).toBe(ruleCategory); + expect(ruleRow!.categorySource).toBe("rule"); + expect(merchantRow!.categoryId).toBe(merchantCategory); + expect(merchantRow!.categorySource).toBe("merchant_default"); + }); + it("respects rule priority — higher wins", async () => { const { groupId } = await insertCategoryGroup(db, householdId, { name: "Drinks" }); const { categoryId: catLow } = await insertCategory(db, householdId, groupId, { name: "Dining" });