import { RulesService } from "../src/rules/rules.service"; import { createPrismaMock } from "./utils/mock-prisma"; const createService = () => { const prisma = createPrismaMock(); const service = new RulesService(prisma as any); return { service, prisma }; }; describe("RulesService", () => { it("applies rules with text, amount, source, category, and date conditions", async () => { const { service, prisma } = createService(); prisma.rule.findFirst.mockResolvedValue({ id: "rule_1", userId: "user_1", isActive: true, conditions: { textContains: "coffee", textNotContains: "refund", amountGreaterThanOrEqual: 4, amountLessThanOrEqual: 10, sourceEquals: "csv", categoryEquals: "Uncategorized", dateAfter: "2026-07-01", dateBefore: "2026-07-31", }, actions: { setCategory: "Meals" }, }); prisma.transactionRaw.findMany.mockResolvedValue([ { id: "tx_match", description: "Coffee Shop", amount: 4.25, date: new Date("2026-07-15"), source: "csv", derived: { userCategory: "Uncategorized", isHidden: false }, }, { id: "tx_skip", description: "Coffee refund", amount: 4.25, date: new Date("2026-07-15"), source: "csv", derived: { userCategory: "Uncategorized", isHidden: false }, }, ]); prisma.transactionDerived.upsert.mockResolvedValue({}); prisma.ruleExecution.create.mockResolvedValue({}); const result = await service.execute("user_1", "rule_1"); expect(result).toEqual({ id: "rule_1", status: "completed", applied: 1 }); expect(prisma.transactionDerived.upsert).toHaveBeenCalledTimes(1); expect(prisma.transactionDerived.upsert).toHaveBeenCalledWith(expect.objectContaining({ where: { rawTransactionId: "tx_match" }, update: expect.objectContaining({ userCategory: "Meals" }), })); }); it("supports regex and numeric equality conditions", async () => { const { service, prisma } = createService(); prisma.rule.findFirst.mockResolvedValue({ id: "rule_regex", userId: "user_1", isActive: true, conditions: { textRegex: "^payroll", amountEquals: "-1200", }, actions: { setHidden: true }, }); prisma.transactionRaw.findMany.mockResolvedValue([ { id: "tx_payroll", description: "Payroll deposit", amount: -1200, date: new Date("2026-07-15"), source: "plaid", derived: null, }, ]); prisma.transactionDerived.upsert.mockResolvedValue({}); prisma.ruleExecution.create.mockResolvedValue({}); const result = await service.execute("user_1", "rule_regex"); expect(result.applied).toBe(1); expect(prisma.transactionDerived.upsert).toHaveBeenCalledWith(expect.objectContaining({ update: expect.objectContaining({ isHidden: true }), })); }); it("applies category, note, append-note, clear, and unhide actions", async () => { const { service, prisma } = createService(); prisma.rule.findFirst.mockResolvedValue({ id: "rule_actions", userId: "user_1", isActive: true, conditions: { textContains: "vendor" }, actions: { clearCategory: true, setNote: "Reviewed", appendNote: "Matched by rule", setHidden: false, }, }); prisma.transactionRaw.findMany.mockResolvedValue([ { id: "tx_vendor", description: "Vendor payment", amount: 20, date: new Date("2026-07-15"), source: "csv", derived: { userCategory: "Old", userNotes: "Old note", isHidden: true }, }, ]); prisma.transactionDerived.upsert.mockResolvedValue({}); prisma.ruleExecution.create.mockResolvedValue({}); const result = await service.execute("user_1", "rule_actions"); expect(result.applied).toBe(1); expect(prisma.transactionDerived.upsert).toHaveBeenCalledWith(expect.objectContaining({ update: expect.objectContaining({ userCategory: null, userNotes: "Reviewed\nMatched by rule", isHidden: false, }), })); }); it("supports nested DSL condition groups with all, any, and not", async () => { const { service, prisma } = createService(); prisma.rule.findFirst.mockResolvedValue({ id: "rule_dsl", userId: "user_1", isActive: true, conditions: { all: [ { field: "description", operator: "contains", value: "coffee" }, { any: [ { field: "amount", operator: ">", value: 5 }, { field: "source", operator: "equals", value: "csv" }, ], }, { not: { field: "category", operator: "equals", value: "Ignore" }, }, ], }, actions: { setCategory: "Cafe" }, }); prisma.transactionRaw.findMany.mockResolvedValue([ { id: "tx_dsl_match", description: "Coffee Market", amount: 3, date: new Date("2026-07-15"), source: "csv", derived: { userCategory: "Uncategorized", isHidden: false }, }, { id: "tx_dsl_skip", description: "Coffee Market", amount: 9, date: new Date("2026-07-15"), source: "plaid", derived: { userCategory: "Ignore", isHidden: false }, }, ]); prisma.transactionDerived.upsert.mockResolvedValue({}); prisma.ruleExecution.create.mockResolvedValue({}); const result = await service.execute("user_1", "rule_dsl"); expect(result.applied).toBe(1); expect(prisma.transactionDerived.upsert).toHaveBeenCalledWith(expect.objectContaining({ where: { rawTransactionId: "tx_dsl_match" }, update: expect.objectContaining({ userCategory: "Cafe" }), })); }); it("suggests category, merchant, refund, transfer, and fee rules from transaction patterns", async () => { const { service, prisma } = createService(); prisma.transactionDerived.findMany.mockResolvedValue([ { userCategory: "Meals", raw: { description: "Starbucks 1234" } }, { userCategory: "Meals", raw: { description: "Starbucks 5678" } }, ]); prisma.transactionRaw.findMany.mockResolvedValue([ { description: "Target Store 100", amount: 25, derived: null }, { description: "Target Store 200", amount: 30, derived: null }, { description: "Target Store 300", amount: 35, derived: null }, { description: "Refund Amazon", amount: -12, derived: null }, { description: "Cashback Credit", amount: -3, derived: null }, { description: "Zelle Transfer", amount: 50, derived: null }, { description: "Venmo Transfer", amount: 20, derived: null }, { description: "Monthly Service Fee", amount: 5, derived: null }, { description: "Overdraft Fee", amount: 35, derived: null }, ]); const result = await service.suggest("user_1"); expect(result).toEqual(expect.arrayContaining([ expect.objectContaining({ type: "category", actions: expect.objectContaining({ setCategory: "Meals" }) }), expect.objectContaining({ type: "merchant-pattern", matchCount: 3 }), expect.objectContaining({ type: "refund-pattern" }), expect.objectContaining({ type: "transfer-pattern" }), expect.objectContaining({ type: "fee-pattern" }), ])); expect(result.every((item) => item.reason && item.confidence > 0)).toBe(true); }); });