Address the 11 October test report findings
The initial test report (run before the pilot-readiness changes) raised seven findings and a retest plan. This closes the ones still open. - F-01: a business on its free trial may run 2 active branches whatever its tier, so an owner can try transfers and per-branch stock. Only active branches count towards any limit. - F-06: owners can rename branches and deactivate/reactivate them from the Branches page. A branch can only be deactivated once it holds no stock and no active staff; its history stays in every report, and it leaves every picker. - F-04: the Products page lists deactivated products under a toggle with Reactivate. Deactivating warns about stock still on hand, which stays on the Stock page so it can be transferred or counted out. - F-05: products carry a unit (L, ml, kg, g, pcs, tray, pack) instead of everything being litres. - CAT-12: product prices must be above zero and names are unique per business, ignoring case. - F-03: a reversed report period is refused by the page and the API, and the page labels which period the cards show. Report dates are Nairobi business days. - F-07: cost of goods sold is units sold x average buying price. Stock bought in the period is reported separately, with a cash surplus line, so unsold stock no longer shows as a loss. - STA-04: staff and new branch managers can be invited with a one-time, 7-day link; the person sets their own password. Managers can issue a new link for their own cashiers. - OFF-02 / POS-02: the till shows each product's stock and deducts every sale immediately, online or offline; it warns before selling more than the recorded stock, and has a Clear sale button. - npm run backfill:stock turns purchases recorded before the stock ledger into stock, so existing tenants (and the report's ZERRO fixtures) start from what they bought. tests/retest-plan.test.ts runs the report's completion and retest plan and checks every balance and total it predicts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
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 } 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 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 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 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, at the average buying price, 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); // average (200 + 130) / 30 = 11
|
||||
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: 33, // 3 x 11; the 5L was never purchased
|
||||
unitsWithoutCost: 1,
|
||||
grossProfit: 627,
|
||||
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 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 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 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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Product } from "@/lib/server/models/Product";
|
||||
import { Purchase } from "@/lib/server/models/Purchase";
|
||||
import { Transaction } from "@/lib/server/models/Transaction";
|
||||
import { backfillPurchaseMovements, stockOf } from "@/lib/server/stock";
|
||||
import * as cash from "@/app/api/transactions/cash/route";
|
||||
import * as transfers from "@/app/api/stock/transfers/route";
|
||||
import * as profit from "@/app/api/reports/profit/route";
|
||||
import * as summary from "@/app/api/dashboard/summary/route";
|
||||
import { json, makeTenant, req, uuid, type Actor } from "./helpers";
|
||||
|
||||
/**
|
||||
* The "Completion and retest plan" from the 11 October 2026 test report
|
||||
* (AquaFlow_Initial_Test_Report, pages 9-10), with its expected numbers.
|
||||
*/
|
||||
describe("initial test report: completion and retest plan", () => {
|
||||
it("matches every planned balance and total", async () => {
|
||||
const t = await makeTenant("ZERRO");
|
||||
const [refill3] = await Product.insertMany([
|
||||
{ businessId: t.businessId, label: "QA 20261011 3L Refill", sizeLiters: 3, unitPrice: 40 },
|
||||
]);
|
||||
const l3 = refill3._id.toString();
|
||||
const l5 = t.water5; // KES 60
|
||||
|
||||
// Fixtures recorded before the ledger existed (QA-20261011-STOCK-A-3L / -5L),
|
||||
// brought into stock by the one-off backfill.
|
||||
await Purchase.insertMany([
|
||||
{ businessId: t.businessId, branchId: t.branchA, supplierName: "QA", productId: l3, productLabel: "3L", quantity: 20, unitCost: 10, totalCost: 200, recordedBy: t.owner.id },
|
||||
{ businessId: t.businessId, branchId: t.branchA, supplierName: "QA", productId: l5, productLabel: "5L", quantity: 10, unitCost: 20, totalCost: 200, recordedBy: t.owner.id },
|
||||
]);
|
||||
await backfillPurchaseMovements(t.businessId);
|
||||
const stock = async (branch: string, product: string) => stockOf(t.businessId, branch, product);
|
||||
expect([await stock(t.branchA, l3), await stock(t.branchA, l5)]).toEqual([20, 10]);
|
||||
|
||||
const sell = async (as: Actor, lines: { productId: string; quantity: number }[], offline = false) =>
|
||||
json(
|
||||
await cash.POST(
|
||||
req("POST", "/x", {
|
||||
as,
|
||||
body: {
|
||||
sales: [
|
||||
{
|
||||
clientUuid: uuid(),
|
||||
staffId: as.id,
|
||||
branchId: as.branchId,
|
||||
lines,
|
||||
...(offline ? { clientSubmittedAt: new Date().toISOString() } : {}),
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// Online sale: 2 x 3L + 1 x 5L = KES 140; A balances 18 and 9.
|
||||
const online = await sell(t.cashierA, [{ productId: l3, quantity: 2 }, { productId: l5, quantity: 1 }]);
|
||||
expect(online.body.results[0]).toMatchObject({ status: "saved", amountTotal: 140 });
|
||||
expect([await stock(t.branchA, l3), await stock(t.branchA, l5)]).toEqual([18, 9]);
|
||||
|
||||
// Transfer 5 x 3L from A to B: A 13, B 5, combined 18.
|
||||
const transfer = await transfers.POST(
|
||||
req("POST", "/x", {
|
||||
as: t.owner,
|
||||
body: { clientUuid: uuid(), fromBranchId: t.branchA, toBranchId: t.branchB, productId: l3, quantity: 5 },
|
||||
})
|
||||
);
|
||||
expect(transfer.status).toBe(201);
|
||||
expect([await stock(t.branchA, l3), await stock(t.branchB, l3)]).toEqual([13, 5]);
|
||||
|
||||
// Two offline sales replayed on reconnect (sale 2: 1 x 3L; sale 3: 2 x 3L + 1 x 5L),
|
||||
// then replayed again: exactly two new sales (three lines), A 10 x 3L and 8 x 5L.
|
||||
const before = await Transaction.countDocuments({ businessId: t.businessId });
|
||||
const queued = [
|
||||
{ clientUuid: uuid(), staffId: t.cashierA.id, branchId: t.branchA, clientSubmittedAt: new Date().toISOString(), lines: [{ productId: l3, quantity: 1 }] },
|
||||
{ clientUuid: uuid(), staffId: t.cashierA.id, branchId: t.branchA, clientSubmittedAt: new Date().toISOString(), lines: [{ productId: l3, quantity: 2 }, { productId: l5, quantity: 1 }] },
|
||||
];
|
||||
for (let pass = 0; pass < 2; pass++) {
|
||||
const r = await json(await cash.POST(req("POST", "/x", { as: t.cashierA, body: { sales: queued } })));
|
||||
expect(r.body.results.map((x: any) => x.status)).toEqual(pass === 0 ? ["saved", "saved"] : ["duplicate", "duplicate"]);
|
||||
}
|
||||
expect((await Transaction.countDocuments({ businessId: t.businessId })) - before).toBe(3);
|
||||
expect([await stock(t.branchA, l3), await stock(t.branchA, l5)]).toEqual([10, 8]);
|
||||
|
||||
// Branch B sells 1 x 3L for KES 40.
|
||||
await sell(t.cashierB, [{ productId: l3, quantity: 1 }]);
|
||||
|
||||
// Final: A 3L 10, B 3L 4, A 5L 8; combined 3L 14 = 20 - 6 sold, 5L 8 = 10 - 2 sold.
|
||||
expect([await stock(t.branchA, l3), await stock(t.branchB, l3), await stock(t.branchA, l5)]).toEqual([10, 4, 8]);
|
||||
|
||||
// Revenue: A KES 320, B KES 40, total KES 360 — on the report and the owner dashboard.
|
||||
const report = async (q: string) => (await json(await profit.GET(req("GET", "/api/reports/profit" + q, { as: t.owner })))).body;
|
||||
expect((await report("")).revenue).toBe(360);
|
||||
expect((await report("?branchId=" + t.branchA)).revenue).toBe(320);
|
||||
expect((await report("?branchId=" + t.branchB)).revenue).toBe(40);
|
||||
|
||||
const dash = (await json(await summary.GET(req("GET", "/api/dashboard/summary", { as: t.owner })))).body;
|
||||
expect(dash.totals.cashTotal).toBe(360);
|
||||
const ranking = Object.fromEntries(dash.branchRanking.map((b: any) => [b.branchName, b.revenue]));
|
||||
expect(ranking).toEqual({ "ZERRO A": 320, "ZERRO B": 40 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user