ledgerone_backend/test/rules.service.spec.ts

214 lines
7.4 KiB
TypeScript

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