import request from "supertest"; import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; import appFactory from "./appFactory"; import { prisma, resetUser, ensureUser, U, cid, closePrisma } from "./helpers"; import type { FastifyInstance } from "fastify"; import { randomUUID } from "node:crypto"; let app: FastifyInstance; beforeAll(async () => { app = await appFactory(); }); afterAll(async () => { if (app) await app.close(); await closePrisma(); }); async function seedBasics() { await resetUser(U); await ensureUser(U); await prisma.variableCategory.createMany({ data: [ { id: cid("s"), userId: U, name: "savings", percent: 30, priority: 1, isSavings: true, balanceCents: 3000n }, { id: cid("f"), userId: U, name: "food", percent: 20, priority: 2, isSavings: false, balanceCents: 2000n }, { id: cid("g"), userId: U, name: "gas", percent: 30, priority: 3, isSavings: false, balanceCents: 3000n }, { id: cid("m"), userId: U, name: "misc", percent: 20, priority: 4, isSavings: false, balanceCents: 2000n }, ], }); await prisma.budgetSession.create({ data: { userId: U, periodStart: new Date("2026-03-01T00:00:00Z"), periodEnd: new Date("2026-04-01T00:00:00Z"), totalBudgetCents: 10_000n, allocatedCents: 0n, fundedCents: 0n, availableCents: 10_000n, }, }); } describe("manual rebalance", () => { beforeEach(async () => { await seedBasics(); }); it("uses live category balances when session availableCents is stale", async () => { await prisma.budgetSession.updateMany({ where: { userId: U }, data: { availableCents: 1234n }, }); const getRes = await request(app.server) .get("/variable-categories/manual-rebalance") .set("x-user-id", U); expect(getRes.statusCode).toBe(200); expect(getRes.body?.availableCents).toBe(10_000); const cats = await prisma.variableCategory.findMany({ where: { userId: U }, orderBy: { priority: "asc" } }); const targets = cats.map((c) => ({ id: c.id, targetCents: 2500 })); // 4 * 2500 = 10000 const csrf = randomUUID().replace(/-/g, ""); const postRes = await request(app.server) .post("/variable-categories/manual-rebalance") .set("x-user-id", U) .set("x-csrf-token", csrf) .set("Cookie", `csrf=${csrf}`) .send({ targets, forceLowerSavings: true }); expect(postRes.statusCode).toBe(200); expect(postRes.body?.availableCents).toBe(10_000); const session = await prisma.budgetSession.findFirst({ where: { userId: U }, orderBy: { periodStart: "desc" }, select: { availableCents: true }, }); expect(Number(session?.availableCents ?? 0n)).toBe(10_000); }); it("rebalances when sums match available", async () => { const cats = await prisma.variableCategory.findMany({ where: { userId: U }, orderBy: { priority: "asc" } }); const targets = cats.map((c) => ({ id: c.id, targetCents: 2500 })); // 4 * 2500 = 10000 const csrf = randomUUID().replace(/-/g, ""); const res = await request(app.server) .post("/variable-categories/manual-rebalance") .set("x-user-id", U) .set("x-csrf-token", csrf) .set("Cookie", `csrf=${csrf}`) .send({ targets, forceLowerSavings: true }); expect(res.statusCode).toBe(200); expect(res.body?.ok).toBe(true); const updated = await prisma.variableCategory.findMany({ where: { userId: U } }); expect(updated.every((c) => Number(c.balanceCents) === 2500)).toBe(true); }); it("rejects sum mismatch", async () => { const cats = await prisma.variableCategory.findMany({ where: { userId: U }, orderBy: { priority: "asc" } }); const targets = cats.map((c, i) => ({ id: c.id, targetCents: i === 0 ? 5000 : 1000 })); // 5000 + 3*1000 = 8000 const csrf = randomUUID().replace(/-/g, ""); const res = await request(app.server) .post("/variable-categories/manual-rebalance") .set("x-user-id", U) .set("x-csrf-token", csrf) .set("Cookie", `csrf=${csrf}`) .send({ targets }); expect(res.statusCode).toBe(400); expect(res.body?.code).toBe("SUM_MISMATCH"); }); it("requires savings confirmation when lowering below floor", async () => { const cats = await prisma.variableCategory.findMany({ where: { userId: U }, orderBy: { priority: "asc" } }); // savings to 500 (below 20% of 10000 = 2000) const targets = cats.map((c) => ({ id: c.id, targetCents: c.isSavings ? 500 : 3166 })); // 500 + 3*3166 = 9998 -> adjust targets[1].targetCents += 2; // total 10000 const csrf = randomUUID().replace(/-/g, ""); const res = await request(app.server) .post("/variable-categories/manual-rebalance") .set("x-user-id", U) .set("x-csrf-token", csrf) .set("Cookie", `csrf=${csrf}`) .send({ targets }); expect(res.statusCode).toBe(400); expect(res.body?.code).toBe("SAVINGS_FLOOR"); const resOk = await request(app.server) .post("/variable-categories/manual-rebalance") .set("x-user-id", U) .set("x-csrf-token", csrf) .set("Cookie", `csrf=${csrf}`) .send({ targets, forceLowerSavings: true }); expect(resOk.statusCode).toBe(200); }); it("blocks >80% single category", async () => { const cats = await prisma.variableCategory.findMany({ where: { userId: U }, orderBy: { priority: "asc" } }); const targets = cats.map((c, i) => ({ id: c.id, targetCents: i === 0 ? 9000 : 333 })); targets[1].targetCents += 1; // sum 10000 const csrf = randomUUID().replace(/-/g, ""); const res = await request(app.server) .post("/variable-categories/manual-rebalance") .set("x-user-id", U) .set("x-csrf-token", csrf) .set("Cookie", `csrf=${csrf}`) .send({ targets }); expect(res.statusCode).toBe(400); expect(res.body?.code).toBe("OVER_80_CONFIRM_REQUIRED"); }); });