import { describe, expect, it } from "vitest"; import mongoose from "mongoose"; import { Business } from "@/lib/server/models/Business"; import { Branch } from "@/lib/server/models/Branch"; import { User } from "@/lib/server/models/User"; import { Product } from "@/lib/server/models/Product"; import { Purchase } from "@/lib/server/models/Purchase"; import { backfillPurchaseMovements, stockOf, getStockLevels } from "@/lib/server/stock"; import * as branches from "@/app/api/branches/route"; import * as branchById from "@/app/api/branches/[id]/route"; import * as users from "@/app/api/users/route"; import * as invites from "@/app/api/invites/[token]/route"; import * as reinvite from "@/app/api/users/[id]/invite/route"; import * as login from "@/app/api/auth/login/route"; import * as products from "@/app/api/products/route"; import * as productById from "@/app/api/products/[id]/route"; import * as purchases from "@/app/api/purchases/route"; import * as profit from "@/app/api/reports/profit/route"; import * as cash from "@/app/api/transactions/cash/route"; import * as adjustments from "@/app/api/stock/adjustments/route"; import * as transfers from "@/app/api/stock/transfers/route"; import { json, makeTenant, params, req, uuid, type Tenant, db } from "./helpers"; async function setPlan(t: Tenant, tier: string, subscriptionStatus: string) { await Business.updateOne({ _id: t.businessId }, { tier, subscriptionStatus }); } const newBranch = (name: string) => ({ branchName: name, locationCity: "Nakuru" }); describe("F-01 trial branch allowance", () => { it("lets a trialling Starter business run two branches, not three", async () => { const t = await makeTenant(); // already has branches A and B await setPlan(t, "STARTER", "TRIALING"); const third = await branches.POST(req("POST", "/x", { as: t.owner, body: newBranch("C") })); expect(third.status).toBe(403); expect((await third.json()).error).toMatch(/free trial allows 2/); }); it("counts only active branches, and a paid Starter plan allows one", async () => { const t = await makeTenant(); await setPlan(t, "STARTER", "TRIALING"); await db(() => Branch.updateOne({ _id: t.branchB }, { isActive: false })); expect((await branches.POST(req("POST", "/x", { as: t.owner, body: newBranch("C") }))).status).toBe(201); await setPlan(t, "STARTER", "ACTIVE"); expect((await branches.POST(req("POST", "/x", { as: t.owner, body: newBranch("D") }))).status).toBe(403); }); }); describe("F-06 branch deactivation", () => { it("refuses while the branch still holds stock or active staff, then succeeds once emptied", async () => { const t = await makeTenant(); await adjustments.POST( req("POST", "/x", { as: t.owner, body: { clientUuid: uuid(), branchId: t.branchB, productId: t.water20, mode: "DELTA", quantity: 4 } }) ); const deactivate = () => branchById.PATCH(req("PATCH", "/x", { as: t.owner, body: { isActive: false } }), params({ id: t.branchB })); const withStock = await deactivate(); expect(withStock.status).toBe(409); expect((await withStock.json()).error).toMatch(/4 x 20L Jerrycan/); await transfers.POST( req("POST", "/x", { as: t.owner, body: { clientUuid: uuid(), fromBranchId: t.branchB, toBranchId: t.branchA, productId: t.water20, quantity: 4 }, }) ); const withStaff = await deactivate(); expect(withStaff.status).toBe(409); expect((await withStaff.json()).error).toMatch(/active staff/); await db(() => User.updateOne({ _id: t.cashierB.id }, { isActive: false })); expect((await deactivate()).status).toBe(200); // Hidden from pickers, kept on the Branches page. const active = await json(await branches.GET(req("GET", "/api/branches", { as: t.owner }))); const all = await json(await branches.GET(req("GET", "/api/branches?includeInactive=1", { as: t.owner }))); expect(active.body.branches.map((b: any) => b._id)).toEqual([t.branchA]); expect(all.body.branches).toHaveLength(2); }); it("will not reactivate a branch past the plan's limit", async () => { const t = await makeTenant(); await db(() => Branch.updateOne({ _id: t.branchB }, { isActive: false })); await setPlan(t, "STARTER", "ACTIVE"); const res = await branchById.PATCH(req("PATCH", "/x", { as: t.owner, body: { isActive: true } }), params({ id: t.branchB })); expect(res.status).toBe(403); }); }); describe("F-03 report period validation", () => { it("rejects a reversed period instead of showing an empty report", async () => { const t = await makeTenant(); const res = await profit.GET(req("GET", "/api/reports/profit?fromDate=2026-10-12&toDate=2026-10-11", { as: t.owner })); expect(res.status).toBe(400); const sameDay = await profit.GET(req("GET", "/api/reports/profit?fromDate=2026-10-11&toDate=2026-10-11", { as: t.owner })); expect(sameDay.status).toBe(200); }); }); describe("F-07 cost of goods sold", () => { it("costs only the units sold, first-in-first-out, and reports purchases separately", async () => { const t = await makeTenant(); const buy = (quantity: number, unitCost: number) => purchases.POST(req("POST", "/x", { as: t.managerA, body: { supplierName: "Kiwanja", productId: t.water20, quantity, unitCost } })); await buy(20, 10); await buy(10, 13); await cash.POST( req("POST", "/x", { as: t.cashierA, body: { sales: [{ clientUuid: uuid(), lines: [{ productId: t.water20, quantity: 3 }, { productId: t.water5, quantity: 1 }] }] }, }) ); const { body } = await json(await profit.GET(req("GET", "/api/reports/profit", { as: t.owner }))); expect(body).toMatchObject({ revenue: 660, // 3 x 200 + 1 x 60 unitsSold: 4, costOfGoodsSold: 30, // 3 x 10 from the oldest purchase; the 5L was never purchased unitsWithoutCost: 1, grossProfit: 630, stockPurchases: 330, cashSurplus: 330, }); }); }); describe("purchase-to-stock backfill", () => { it("turns pre-ledger purchases into stock, once", async () => { const t = await makeTenant(); await Purchase.create({ businessId: t.businessId, branchId: t.branchA, supplierName: "Old supplier", productId: t.water20, productLabel: "20L Jerrycan", quantity: 20, unitCost: 10, totalCost: 200, recordedBy: t.owner.id, }); expect(await stockOf(t.businessId, t.branchA, t.water20)).toBe(0); expect(await backfillPurchaseMovements()).toBe(1); expect(await backfillPurchaseMovements()).toBe(0); expect(await stockOf(t.businessId, t.branchA, t.water20)).toBe(20); }); }); describe("F-04 / F-05 / CAT-12 catalogue", () => { it("lists deactivated products for managers and keeps their stock visible", async () => { const t = await makeTenant(); await adjustments.POST( req("POST", "/x", { as: t.owner, body: { clientUuid: uuid(), branchId: t.branchA, productId: t.water5, mode: "DELTA", quantity: 7 } }) ); await db(() => Product.updateOne({ _id: t.water5 }, { isActive: false })); const forManager = await json(await products.GET(req("GET", "/api/products?includeInactive=1", { as: t.managerA }))); const forCashier = await json(await products.GET(req("GET", "/api/products?includeInactive=1", { as: t.cashierA }))); expect(forManager.body.products).toHaveLength(2); expect(forCashier.body.products).toHaveLength(1); const levels = await getStockLevels(t.businessId); expect(levels.find((l) => l.productId === t.water5 && l.branchId === t.branchA)).toMatchObject({ quantity: 7, productActive: false, }); expect(levels.some((l) => l.productId === t.water5 && l.branchId === t.branchB)).toBe(false); }); it("stores count units, refuses duplicate names and zero prices", async () => { const t = await makeTenant(); const create = (body: object) => products.POST(req("POST", "/x", { as: t.owner, body })); const tray = await json(await create({ label: "Tray of Eggs", sizeLiters: 30, unit: "pcs", unitPrice: 400 })); expect(tray.status).toBe(201); expect(tray.body.product.unit).toBe("pcs"); expect((await create({ label: "tray of eggs", sizeLiters: 1, unit: "tray", unitPrice: 400 })).status).toBe(409); expect((await create({ label: "Free sample", sizeLiters: 1, unit: "pcs", unitPrice: 0 })).status).toBe(400); expect((await create({ label: "Bucket", sizeLiters: 1, unit: "buckets", unitPrice: 50 })).status).toBe(400); const rename = await productById.PATCH( req("PATCH", "/x", { as: t.owner, body: { label: "20L JERRYCAN" } }), params({ id: t.water5 }) ); expect(rename.status).toBe(409); }); }); describe("STA-04 staff invites", () => { async function invite(t: Tenant) { const res = await json( await users.POST( req("POST", "/api/users", { as: t.managerA, body: { name: "New Cashier", role: "DELIVERY", phoneNumber: "0711222333", email: "new.cashier@aquaflow.test" }, }) ) ); expect(res.status).toBe(201); return { userId: res.body.user._id as string, token: (res.body.inviteUrl as string).split("/invite/")[1] }; } const signIn = (password: string) => login.POST(req("POST", "/x", { body: { email: "new.cashier@aquaflow.test", password, deviceId: uuid() } })); it("lets the invited person set their own password, once", async () => { const t = await makeTenant(); const { token } = await invite(t); const who = await json(await invites.GET(req("GET", "/x"), params({ token }))); expect(who.body).toMatchObject({ name: "New Cashier", email: "new.cashier@aquaflow.test" }); expect((await invites.POST(req("POST", "/x", { body: { password: "short" } }), params({ token }))).status).toBe(400); expect((await invites.POST(req("POST", "/x", { body: { password: "my-own-secret" } }), params({ token }))).status).toBe(200); expect((await signIn("my-own-secret")).status).toBe(200); // The link is single-use. expect((await invites.GET(req("GET", "/x"), params({ token }))).status).toBe(404); }); it("issues a fresh link that replaces the old one, and rejects expired links", async () => { const t = await makeTenant(); const { userId, token } = await invite(t); const again = await json(await reinvite.POST(req("POST", "/x", { as: t.managerA }), params({ id: userId }))); expect(again.status).toBe(200); const fresh = (again.body.inviteUrl as string).split("/invite/")[1]; expect((await invites.GET(req("GET", "/x"), params({ token }))).status).toBe(404); expect((await invites.GET(req("GET", "/x"), params({ token: fresh }))).status).toBe(200); await db(() => User.updateOne({ _id: new mongoose.Types.ObjectId(userId) }, { inviteExpiresAt: new Date(Date.now() - 1000) })); expect((await invites.GET(req("GET", "/x"), params({ token: fresh }))).status).toBe(404); }); it("does not let a manager re-invite another branch's staff", async () => { const t = await makeTenant(); await db(() => User.updateOne({ _id: t.cashierB.id }, { inviteExpiresAt: new Date(Date.now() + 60_000) })); const res = await reinvite.POST(req("POST", "/x", { as: t.managerA }), params({ id: t.cashierB.id })); expect(res.status).toBe(403); }); });