diff --git a/src/actions/category-rules.ts b/src/actions/category-rules.ts new file mode 100644 index 00000000..efc647c9 --- /dev/null +++ b/src/actions/category-rules.ts @@ -0,0 +1,151 @@ +"use server"; + +import { eq } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; +import { v4 as uuid } from "uuid"; +import { z } from "zod"; +import { db as defaultDb, type LedgrDb } from "@/db"; +import { categoryRules, categories } from "@/db/schema"; +import { scopedQuery } from "@/lib/scoped-query"; +import { authorizeAction } from "@/lib/auth/authorize-action"; +import { normalizeRulePattern } from "@/lib/categorization/rule-pattern"; + +type ActionResult = { success: true } | { error: string }; + +// Pattern validity lives in normalizeRulePattern, which explains why an empty +// pattern is dangerous and is unit-tested without a database. +const ruleInputSchema = z.object({ + categoryId: z.string().min(1), + matchField: z.enum(["name", "merchant"]), + matchPattern: z + .string() + .transform(normalizeRulePattern) + .refine((p): p is string => p !== null, { message: "Enter a pattern to match on." }), + priority: z.number().int().min(0).max(999), +}); + +export type CategoryRuleInput = z.input; + +const updateInputSchema = ruleInputSchema.extend({ id: z.string().min(1) }); +export type CategoryRuleUpdateInput = z.input; + +/** + * A rule points at a category, so the category must belong to the same + * household. Without this check a caller could aim a rule at another + * household's category id and read its name back off the rules list. + */ +async function categoryBelongsToHousehold( + householdId: string, + categoryId: string, + db: LedgrDb, +): Promise { + const scoped = scopedQuery(householdId, db); + const [row] = await db + .select({ id: categories.id }) + .from(categories) + .where(scoped.where(categories, eq(categories.id, categoryId))) + .limit(1); + return !!row; +} + +export async function createCategoryRuleScoped( + householdId: string, + input: CategoryRuleInput, + db: LedgrDb = defaultDb, +): Promise { + const parsed = ruleInputSchema.safeParse(input); + if (!parsed.success) return { error: "Enter a pattern to match on." }; + + if (!(await categoryBelongsToHousehold(householdId, parsed.data.categoryId, db))) { + return { error: "Category not found" }; + } + + await db.insert(categoryRules).values({ + id: uuid(), + householdId, + categoryId: parsed.data.categoryId, + matchField: parsed.data.matchField, + matchPattern: parsed.data.matchPattern, + priority: parsed.data.priority, + }); + + revalidatePath("/settings/rules"); + return { success: true }; +} + +export async function createCategoryRule( + input: CategoryRuleInput, + db: LedgrDb = defaultDb, +): Promise { + const auth = await authorizeAction(); + if ("error" in auth) return auth; + return createCategoryRuleScoped(auth.householdId, input, db); +} + +export async function updateCategoryRuleScoped( + householdId: string, + input: CategoryRuleUpdateInput, + db: LedgrDb = defaultDb, +): Promise { + const parsed = updateInputSchema.safeParse(input); + if (!parsed.success) return { error: "Enter a pattern to match on." }; + + if (!(await categoryBelongsToHousehold(householdId, parsed.data.categoryId, db))) { + return { error: "Category not found" }; + } + + const scoped = scopedQuery(householdId, db); + const updated = await db + .update(categoryRules) + .set({ + categoryId: parsed.data.categoryId, + matchField: parsed.data.matchField, + matchPattern: parsed.data.matchPattern, + priority: parsed.data.priority, + }) + .where(scoped.where(categoryRules, eq(categoryRules.id, parsed.data.id))) + .returning({ id: categoryRules.id }); + + if (updated.length === 0) return { error: "Rule not found" }; + + revalidatePath("/settings/rules"); + return { success: true }; +} + +export async function updateCategoryRule( + input: CategoryRuleUpdateInput, + db: LedgrDb = defaultDb, +): Promise { + const auth = await authorizeAction(); + if ("error" in auth) return auth; + return updateCategoryRuleScoped(auth.householdId, input, db); +} + +export async function deleteCategoryRuleScoped( + householdId: string, + ruleId: string, + db: LedgrDb = defaultDb, +): Promise { + const parsed = z.string().min(1).safeParse(ruleId); + if (!parsed.success) return { error: "Invalid input" }; + + const scoped = scopedQuery(householdId, db); + const deleted = await db + .delete(categoryRules) + .where(scoped.where(categoryRules, eq(categoryRules.id, parsed.data))) + .returning({ id: categoryRules.id }); + + if (deleted.length === 0) return { error: "Rule not found" }; + + revalidatePath("/settings/rules"); + return { success: true }; +} + +export async function deleteCategoryRule( + ruleId: string, + db: LedgrDb = defaultDb, +): Promise { + const auth = await authorizeAction(); + if ("error" in auth) return auth; + return deleteCategoryRuleScoped(auth.householdId, ruleId, db); +} diff --git a/src/app/(dashboard)/rules/page.tsx b/src/app/(dashboard)/rules/page.tsx new file mode 100644 index 00000000..a0cee9ac --- /dev/null +++ b/src/app/(dashboard)/rules/page.tsx @@ -0,0 +1,26 @@ +import { getHouseholdId } from "@/lib/auth/session"; +import { getCategoryRules } from "@/queries/category-rules"; +import { getCategories } from "@/queries/categories"; +import { CategoryRulesManager } from "@/components/organisms/category-rules-manager"; + +export default async function RulesPage() { + const householdId = await getHouseholdId(); + + const [rules, categoryGroups] = await Promise.all([ + getCategoryRules(householdId), + getCategories(householdId), + ]); + + return ( +
+
+

Category rules

+

+ Send transactions to a category by matching their name or merchant. +

+
+ + +
+ ); +} diff --git a/src/components/organisms/category-rules-manager.tsx b/src/components/organisms/category-rules-manager.tsx new file mode 100644 index 00000000..bb53ce72 --- /dev/null +++ b/src/components/organisms/category-rules-manager.tsx @@ -0,0 +1,288 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { Pencil, Trash2, Plus, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + createCategoryRule, + updateCategoryRule, + deleteCategoryRule, +} from "@/actions/category-rules"; +import type { CategoryRuleRow } from "@/queries/category-rules"; +import type { CategoryGroup } from "@/queries/categories"; + +interface CategoryRulesManagerProps { + rules: CategoryRuleRow[]; + categoryGroups: CategoryGroup[]; +} + +type MatchField = "name" | "merchant"; + +const FIELD_LABEL: Record = { + name: "Transaction name", + merchant: "Merchant name", +}; + +interface DraftState { + id: string | null; + categoryId: string; + matchField: MatchField; + matchPattern: string; + priority: string; +} + +function emptyDraft(categoryId: string): DraftState { + return { id: null, categoryId, matchField: "name", matchPattern: "", priority: "0" }; +} + +export function CategoryRulesManager({ rules, categoryGroups }: CategoryRulesManagerProps) { + const flatCategories = categoryGroups.flatMap((g) => g.categories); + const firstCategoryId = flatCategories[0]?.id ?? ""; + + const [draft, setDraft] = useState(null); + const [error, setError] = useState(null); + const [pending, startTransition] = useTransition(); + + function openCreate() { + setError(null); + setDraft(emptyDraft(firstCategoryId)); + } + + function openEdit(rule: CategoryRuleRow) { + setError(null); + setDraft({ + id: rule.id, + categoryId: rule.categoryId, + matchField: rule.matchField, + matchPattern: rule.matchPattern, + priority: String(rule.priority), + }); + } + + function save() { + if (!draft) return; + setError(null); + const payload = { + categoryId: draft.categoryId, + matchField: draft.matchField, + matchPattern: draft.matchPattern, + priority: Number(draft.priority) || 0, + }; + + startTransition(async () => { + const result = draft.id + ? await updateCategoryRule({ ...payload, id: draft.id }) + : await createCategoryRule(payload); + + if ("error" in result) { + setError(result.error); + return; + } + setDraft(null); + }); + } + + function remove(ruleId: string) { + setError(null); + startTransition(async () => { + const result = await deleteCategoryRule(ruleId); + if ("error" in result) setError(result.error); + }); + } + + return ( +
+ {/* Rules only run during a sync, and only on transactions that have no + category yet. Saying so up front stops the page reading as broken + when a new rule does not visibly change anything. */} +
+

Rules apply on your next sync

+

+ A new rule categorizes matching transactions the next time an account syncs. It does not + re-file transactions that are already in your review queue, and it never changes a + category you set yourself. +

+
+ + {error && ( +
+ {error} +
+ )} + +
+
+
+

Your rules

+

+ {rules.length === 0 + ? "Checked before every other categorization step" + : `${rules.length} rule${rules.length === 1 ? "" : "s"}, highest priority first`} +

+
+ {!draft && ( + + )} +
+ + {draft && ( +
+
+
+ + +
+ +
+ + +
+
+ +
+
+ + setDraft({ ...draft, matchPattern: e.target.value })} + /> + {/* The engine does target.includes(pattern), so "contains" is + literally what happens. Saying "matches" would invite regex. */} +

+ Plain text, not a pattern language. Capitalization is ignored. +

+
+ +
+ + setDraft({ ...draft, priority: e.target.value })} + /> +

Higher wins

+
+
+ +
+ + +
+
+ )} + + {rules.length === 0 && !draft ? ( +
+

No rules yet

+

+ Rules catch transactions before Ledgr guesses. They run ahead of merchant defaults + and your bank's own category, so a rule always wins. +

+ +
+ ) : ( +
    + {rules.map((rule) => ( +
  • + + {rule.priority} + +
    + + {rule.matchField} + + “{rule.matchPattern}” + + {rule.categoryName} +
    +
    + + +
    +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/src/components/organisms/sidebar-nav.tsx b/src/components/organisms/sidebar-nav.tsx index f6cafee0..82d67913 100644 --- a/src/components/organisms/sidebar-nav.tsx +++ b/src/components/organisms/sidebar-nav.tsx @@ -4,6 +4,7 @@ import { useCallback } from "react"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { + Tags, LayoutDashboard, Building2, ArrowLeftRight, @@ -50,6 +51,7 @@ const NAV_GROUPS: { label: string | null; items: NavItem[] }[] = [ items: [ { href: "/accounts", label: "Accounts", icon: Building2 }, { href: "/transactions", label: "Transactions", icon: ArrowLeftRight }, + { href: "/rules", label: "Rules", icon: Tags }, { href: "/investments", label: "Investments", icon: TrendingUp }, ], }, diff --git a/src/lib/categorization/rule-pattern.test.ts b/src/lib/categorization/rule-pattern.test.ts new file mode 100644 index 00000000..7af87cb0 --- /dev/null +++ b/src/lib/categorization/rule-pattern.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from "vitest"; +import { test, fc } from "@fast-check/vitest"; +import { normalizeRulePattern, MAX_RULE_PATTERN_LENGTH } from "./rule-pattern"; + +describe("normalizeRulePattern", () => { + it("trims surrounding whitespace", () => { + // The engine does not trim, so a stored leading space would stop the + // pattern matching anything the user expected. + expect(normalizeRulePattern(" spotify ")).toBe("spotify"); + }); + + it("keeps a usable pattern unchanged", () => { + expect(normalizeRulePattern("twitterapi")).toBe("twitterapi"); + }); + + it("preserves inner whitespace", () => { + expect(normalizeRulePattern(" blue bottle ")).toBe("blue bottle"); + }); + + it("rejects an empty pattern", () => { + expect(normalizeRulePattern("")).toBeNull(); + }); + + it("rejects a whitespace-only pattern", () => { + // `"".includes()` is true for every string, so a blank rule would match the + // entire feed — and as tier 1 it would outrank every other step. + expect(normalizeRulePattern(" \t \n ")).toBeNull(); + }); + + it("accepts a pattern exactly at the length limit", () => { + const atLimit = "a".repeat(MAX_RULE_PATTERN_LENGTH); + expect(normalizeRulePattern(atLimit)).toBe(atLimit); + }); + + it("rejects a pattern one character over the limit", () => { + expect(normalizeRulePattern("a".repeat(MAX_RULE_PATTERN_LENGTH + 1))).toBeNull(); + }); + + it("measures the limit after trimming, not before", () => { + const padded = ` ${"a".repeat(MAX_RULE_PATTERN_LENGTH)} `; + expect(normalizeRulePattern(padded)).toBe("a".repeat(MAX_RULE_PATTERN_LENGTH)); + }); + + test.prop([fc.string()])("never returns an empty or untrimmed string", (raw) => { + const result = normalizeRulePattern(raw); + if (result !== null) { + expect(result.length).toBeGreaterThan(0); + expect(result).toBe(result.trim()); + expect(result.length).toBeLessThanOrEqual(MAX_RULE_PATTERN_LENGTH); + } + }); + + test.prop([fc.string()])( + "accepts exactly when the trimmed input is usable", + (raw) => { + const trimmed = raw.trim(); + const usable = trimmed.length > 0 && trimmed.length <= MAX_RULE_PATTERN_LENGTH; + expect(normalizeRulePattern(raw) !== null).toBe(usable); + }, + ); +}); diff --git a/src/lib/categorization/rule-pattern.ts b/src/lib/categorization/rule-pattern.ts new file mode 100644 index 00000000..02c51b38 --- /dev/null +++ b/src/lib/categorization/rule-pattern.ts @@ -0,0 +1,31 @@ +/** + * Validation for a category rule's match pattern. + * + * Kept separate from the server action so it can be exercised without a + * database: the rule it enforces is the one that matters most, and a rule this + * consequential should not be reachable only through a round trip. + * + * The categorization engine matches with + * `target.includes(pattern.toLowerCase())` (`lib/categorization/engine.ts`). + * `"".includes()` is true for *every* string, so a single blank pattern would + * match every transaction in the feed and, being tier 1, would outrank every + * other categorization step. One empty rule can therefore miscategorize an + * entire account. + */ + +/** Longest pattern we store. Generous for a merchant name; bounded so the column can't be abused. */ +export const MAX_RULE_PATTERN_LENGTH = 200; + +/** + * Normalize a user-entered pattern, or return null if it is not usable. + * + * Trimming happens before the empty check, so a pattern of only whitespace is + * rejected rather than stored — and a pattern the user typed with a stray + * leading space still matches, since the engine does no trimming of its own. + */ +export function normalizeRulePattern(raw: string): string | null { + const trimmed = raw.trim(); + if (trimmed.length === 0) return null; + if (trimmed.length > MAX_RULE_PATTERN_LENGTH) return null; + return trimmed; +} diff --git a/src/queries/category-rules.ts b/src/queries/category-rules.ts new file mode 100644 index 00000000..33e3f899 --- /dev/null +++ b/src/queries/category-rules.ts @@ -0,0 +1,51 @@ +import { desc, eq } from "drizzle-orm"; +import { db as defaultDb, type LedgrDb } from "@/db"; +import { categoryRules, categories } from "@/db/schema"; +import { scopedQuery } from "@/lib/scoped-query"; + +export interface CategoryRuleRow { + id: string; + categoryId: string; + categoryName: string; + matchField: "name" | "merchant"; + matchPattern: string; + priority: number; +} + +/** + * Rules for the management page, in the order the categorization engine + * evaluates them. + * + * The engine sorts by priority descending and stops at the first match + * (`categorizeTransactions`, `lib/categorization/engine.ts`), so listing them + * in any other order would misrepresent which rule actually wins. + */ +export async function getCategoryRules( + householdId: string, + db: LedgrDb = defaultDb, +): Promise { + const scoped = scopedQuery(householdId, db); + + const rows = await db + .select({ + id: categoryRules.id, + categoryId: categoryRules.categoryId, + categoryName: categories.name, + matchField: categoryRules.matchField, + matchPattern: categoryRules.matchPattern, + priority: categoryRules.priority, + }) + .from(categoryRules) + .innerJoin(categories, eq(categories.id, categoryRules.categoryId)) + .where(scoped.where(categoryRules)) + .orderBy(desc(categoryRules.priority), categoryRules.matchPattern); + + return rows.map((r) => ({ + id: r.id, + categoryId: r.categoryId, + categoryName: r.categoryName, + matchField: (r.matchField ?? "name") as "name" | "merchant", + matchPattern: r.matchPattern, + priority: r.priority ?? 0, + })); +} diff --git a/tests/integration/category-rule-actions.test.ts b/tests/integration/category-rule-actions.test.ts new file mode 100644 index 00000000..e2c2f64e --- /dev/null +++ b/tests/integration/category-rule-actions.test.ts @@ -0,0 +1,270 @@ +import { describe, it, expect, beforeAll, afterAll, vi } from "vitest"; +import { createTestDb } from "./setup"; +import { insertHousehold, insertCategoryGroup, insertCategory } from "./helpers"; +import { + createCategoryRule, + updateCategoryRule, + deleteCategoryRule, +} from "../../src/actions/category-rules"; +import { getCategoryRules } from "../../src/queries/category-rules"; +import { categoryRules } from "../../src/db/schema"; +import { eq } from "drizzle-orm"; +import type { LedgrDb } from "../../src/db"; + +vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); +vi.mock("../../src/lib/demo-mode", () => ({ guardDemoMode: vi.fn(() => null) })); + +const mockUserId = "test-user-id"; +let mockHouseholdId: string; +vi.mock("../../src/lib/auth/session", () => ({ + getHouseholdId: vi.fn(() => Promise.resolve(mockHouseholdId)), + getSession: vi.fn(() => Promise.resolve({ user: { id: mockUserId } })), +})); + +describe("category rule actions", () => { + let db: LedgrDb; + let close: () => Promise; + let categoryId: string; + let otherCategoryId: string; + + beforeAll(async () => { + ({ db, close } = await createTestDb()); + + const hh = await insertHousehold(db); + mockHouseholdId = hh.householdId; + const { groupId } = await insertCategoryGroup(db, hh.householdId); + ({ categoryId } = await insertCategory(db, hh.householdId, groupId, { name: "Subscriptions" })); + ({ categoryId: otherCategoryId } = await insertCategory(db, hh.householdId, groupId, { + name: "Groceries", + })); + }); + + afterAll(async () => { + await close(); + }); + + describe("createCategoryRule", () => { + it("creates a rule the engine can load", async () => { + const result = await createCategoryRule( + { categoryId, matchField: "name", matchPattern: "twitterapi", priority: 10 }, + db, + ); + + expect(result).toMatchObject({ success: true }); + const [row] = await db + .select() + .from(categoryRules) + .where(eq(categoryRules.matchPattern, "twitterapi")); + expect(row!.categoryId).toBe(categoryId); + expect(row!.matchField).toBe("name"); + expect(row!.priority).toBe(10); + expect(row!.householdId).toBe(mockHouseholdId); + }); + + it("trims the pattern, so a stray space cannot stop a rule matching", async () => { + await createCategoryRule( + { categoryId, matchField: "name", matchPattern: " spotify ", priority: 0 }, + db, + ); + + const [row] = await db + .select() + .from(categoryRules) + .where(eq(categoryRules.categoryId, categoryId)); + const patterns = ( + await db.select().from(categoryRules).where(eq(categoryRules.householdId, mockHouseholdId)) + ).map((r) => r.matchPattern); + expect(patterns).toContain("spotify"); + expect(row).toBeDefined(); + }); + + it("rejects an empty pattern rather than creating a rule that matches everything", async () => { + // The engine does target.includes(pattern); an empty string is true for + // every transaction, so a blank rule would swallow the whole feed. + const result = await createCategoryRule( + { categoryId, matchField: "name", matchPattern: " ", priority: 0 }, + db, + ); + + expect(result).toMatchObject({ error: expect.any(String) }); + }); + + it("rejects a category belonging to another household", async () => { + const other = await insertHousehold(db); + const { groupId } = await insertCategoryGroup(db, other.householdId); + const foreign = await insertCategory(db, other.householdId, groupId, { name: "Foreign" }); + + const result = await createCategoryRule( + { categoryId: foreign.categoryId, matchField: "name", matchPattern: "leak", priority: 0 }, + db, + ); + + expect(result).toMatchObject({ error: expect.any(String) }); + const rows = await db + .select() + .from(categoryRules) + .where(eq(categoryRules.matchPattern, "leak")); + expect(rows).toHaveLength(0); + }); + }); + + describe("updateCategoryRule", () => { + it("changes the pattern, field, category and priority", async () => { + await createCategoryRule( + { categoryId, matchField: "name", matchPattern: "before", priority: 1 }, + db, + ); + const [created] = await db + .select() + .from(categoryRules) + .where(eq(categoryRules.matchPattern, "before")); + + const result = await updateCategoryRule( + { + id: created!.id, + categoryId: otherCategoryId, + matchField: "merchant", + matchPattern: "after", + priority: 99, + }, + db, + ); + + expect(result).toMatchObject({ success: true }); + const [row] = await db.select().from(categoryRules).where(eq(categoryRules.id, created!.id)); + expect(row!.matchPattern).toBe("after"); + expect(row!.matchField).toBe("merchant"); + expect(row!.categoryId).toBe(otherCategoryId); + expect(row!.priority).toBe(99); + }); + + it("will not update a rule belonging to another household", async () => { + const other = await insertHousehold(db); + const { groupId } = await insertCategoryGroup(db, other.householdId); + const foreignCat = await insertCategory(db, other.householdId, groupId, { name: "Theirs" }); + const foreignRuleId = crypto.randomUUID(); + await db.insert(categoryRules).values({ + id: foreignRuleId, + householdId: other.householdId, + categoryId: foreignCat.categoryId, + matchField: "name", + matchPattern: "theirs", + priority: 0, + }); + + const result = await updateCategoryRule( + { + id: foreignRuleId, + categoryId, + matchField: "name", + matchPattern: "hijacked", + priority: 0, + }, + db, + ); + + expect(result).toMatchObject({ error: expect.any(String) }); + const [row] = await db.select().from(categoryRules).where(eq(categoryRules.id, foreignRuleId)); + expect(row!.matchPattern).toBe("theirs"); + }); + }); + + describe("deleteCategoryRule", () => { + it("removes the rule", async () => { + await createCategoryRule( + { categoryId, matchField: "name", matchPattern: "doomed", priority: 0 }, + db, + ); + const [created] = await db + .select() + .from(categoryRules) + .where(eq(categoryRules.matchPattern, "doomed")); + + const result = await deleteCategoryRule(created!.id, db); + + expect(result).toMatchObject({ success: true }); + const rows = await db.select().from(categoryRules).where(eq(categoryRules.id, created!.id)); + expect(rows).toHaveLength(0); + }); + + it("will not delete a rule belonging to another household", async () => { + const other = await insertHousehold(db); + const { groupId } = await insertCategoryGroup(db, other.householdId); + const foreignCat = await insertCategory(db, other.householdId, groupId, { name: "Safe" }); + const foreignRuleId = crypto.randomUUID(); + await db.insert(categoryRules).values({ + id: foreignRuleId, + householdId: other.householdId, + categoryId: foreignCat.categoryId, + matchField: "name", + matchPattern: "survives", + priority: 0, + }); + + const result = await deleteCategoryRule(foreignRuleId, db); + + expect(result).toMatchObject({ error: expect.any(String) }); + const rows = await db.select().from(categoryRules).where(eq(categoryRules.id, foreignRuleId)); + expect(rows).toHaveLength(1); + }); + }); + + describe("getCategoryRules", () => { + it("returns rules in the order the engine evaluates them, highest priority first", async () => { + const hh = await insertHousehold(db); + const { groupId } = await insertCategoryGroup(db, hh.householdId); + const cat = await insertCategory(db, hh.householdId, groupId, { name: "Ordered" }); + for (const [pattern, priority] of [["low", 1], ["high", 100], ["mid", 50]] as const) { + await db.insert(categoryRules).values({ + id: crypto.randomUUID(), + householdId: hh.householdId, + categoryId: cat.categoryId, + matchField: "name", + matchPattern: pattern, + priority, + }); + } + + const rules = await getCategoryRules(hh.householdId, db); + + expect(rules.map((r) => r.matchPattern)).toEqual(["high", "mid", "low"]); + }); + + it("includes the target category name, so the list need not re-query", async () => { + const hh = await insertHousehold(db); + const { groupId } = await insertCategoryGroup(db, hh.householdId); + const cat = await insertCategory(db, hh.householdId, groupId, { name: "Coffee Shops" }); + await db.insert(categoryRules).values({ + id: crypto.randomUUID(), + householdId: hh.householdId, + categoryId: cat.categoryId, + matchField: "name", + matchPattern: "blue bottle", + priority: 0, + }); + + const [rule] = await getCategoryRules(hh.householdId, db); + + expect(rule.categoryName).toBe("Coffee Shops"); + }); + + it("does not return another household's rules", async () => { + const mine = await insertHousehold(db); + const theirs = await insertHousehold(db); + const { groupId } = await insertCategoryGroup(db, theirs.householdId); + const cat = await insertCategory(db, theirs.householdId, groupId, { name: "Hidden" }); + await db.insert(categoryRules).values({ + id: crypto.randomUUID(), + householdId: theirs.householdId, + categoryId: cat.categoryId, + matchField: "name", + matchPattern: "secret", + priority: 0, + }); + + const rules = await getCategoryRules(mine.householdId, db); + + expect(rules).toHaveLength(0); + }); + }); +});