214 lines
7.4 KiB
TypeScript
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);
|
|
});
|
|
});
|