111 lines
3.9 KiB
TypeScript
111 lines
3.9 KiB
TypeScript
import { BadRequestException } from "@nestjs/common";
|
|
import { PlanningService } from "../src/planning/planning.service";
|
|
|
|
const model = () => ({
|
|
findMany: jest.fn(),
|
|
findFirst: jest.fn(),
|
|
create: jest.fn(),
|
|
update: jest.fn(),
|
|
});
|
|
|
|
const createService = () => {
|
|
const prisma = {
|
|
householdMember: model(),
|
|
householdBudget: model(),
|
|
personalGoal: model(),
|
|
investmentHolding: model(),
|
|
netWorthSnapshot: model(),
|
|
recurringTransaction: model(),
|
|
account: model(),
|
|
transactionRaw: model(),
|
|
auditLog: { create: jest.fn() },
|
|
};
|
|
const service = new PlanningService(prisma as any);
|
|
return { service, prisma };
|
|
};
|
|
|
|
describe("PlanningService", () => {
|
|
it("creates shared household budgets for household managers", async () => {
|
|
const { service, prisma } = createService();
|
|
prisma.householdMember.findFirst.mockResolvedValue({ role: "owner" });
|
|
prisma.householdBudget.create.mockResolvedValue({
|
|
id: "budget_1",
|
|
householdId: "household_1",
|
|
limitAmount: 1000,
|
|
spentAmount: 250,
|
|
});
|
|
|
|
await expect(service.createBudget("user_1", {
|
|
householdId: "household_1",
|
|
name: "Groceries",
|
|
limitAmount: 1000,
|
|
spentAmount: 250,
|
|
})).resolves.toMatchObject({
|
|
id: "budget_1",
|
|
progressPercent: 25,
|
|
remainingAmount: 750,
|
|
});
|
|
expect(prisma.householdBudget.create).toHaveBeenCalledWith(expect.objectContaining({
|
|
data: expect.objectContaining({ householdId: "household_1", createdByUserId: "user_1" }),
|
|
}));
|
|
});
|
|
|
|
it("blocks budget management for non-managers", async () => {
|
|
const { service, prisma } = createService();
|
|
prisma.householdMember.findFirst.mockResolvedValue({ role: "member" });
|
|
|
|
await expect(service.createBudget("user_1", {
|
|
householdId: "household_1",
|
|
name: "Groceries",
|
|
limitAmount: 1000,
|
|
})).rejects.toBeInstanceOf(BadRequestException);
|
|
});
|
|
|
|
it("creates personal goals with progress", async () => {
|
|
const { service, prisma } = createService();
|
|
prisma.personalGoal.create.mockResolvedValue({
|
|
id: "goal_1",
|
|
targetAmount: 5000,
|
|
currentAmount: 1250,
|
|
});
|
|
|
|
await expect(service.createPersonalGoal("user_1", {
|
|
name: "Emergency fund",
|
|
targetAmount: 5000,
|
|
currentAmount: 1250,
|
|
})).resolves.toMatchObject({ progressPercent: 25, remainingAmount: 3750 });
|
|
});
|
|
|
|
it("creates investment holdings and includes them in net worth", async () => {
|
|
const { service, prisma } = createService();
|
|
prisma.investmentHolding.create.mockResolvedValue({ id: "holding_1", symbol: "VOO", marketValue: 1200 });
|
|
prisma.netWorthSnapshot.findMany.mockResolvedValue([]);
|
|
prisma.account.findMany.mockResolvedValue([{ currentBalance: 800 }, { currentBalance: -200 }]);
|
|
prisma.investmentHolding.findMany.mockResolvedValue([{ marketValue: 1200 }]);
|
|
|
|
await service.createInvestment("user_1", { symbol: "voo", name: "Vanguard S&P 500", quantity: 4, price: 300 });
|
|
await expect(service.netWorthSummary("user_1")).resolves.toMatchObject({
|
|
computed: {
|
|
assets: 2000,
|
|
liabilities: 200,
|
|
netWorth: 1800,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("detects monthly recurring transactions", async () => {
|
|
const { service, prisma } = createService();
|
|
prisma.transactionRaw.findMany.mockResolvedValue([
|
|
{ date: new Date("2026-01-01"), amount: -20, description: "Netflix", account: { isoCurrencyCode: "USD" } },
|
|
{ date: new Date("2026-02-01"), amount: -20, description: "Netflix", account: { isoCurrencyCode: "USD" } },
|
|
{ date: new Date("2026-03-01"), amount: -22, description: "Netflix", account: { isoCurrencyCode: "USD" } },
|
|
]);
|
|
prisma.recurringTransaction.create.mockImplementation(async ({ data }) => ({ id: "rec_1", ...data }));
|
|
|
|
await expect(service.detectRecurring("user_1")).resolves.toMatchObject({
|
|
detected: 1,
|
|
recurring: [expect.objectContaining({ merchant: "netflix", cadence: "monthly", occurrenceCount: 3 })],
|
|
});
|
|
});
|
|
});
|