Enforce tenant isolation in the data layer instead of per route
Every route had to remember to add the business filter to every query;
nothing stopped one that forgot. The audit for this change found several
that had: the M-Pesa status re-check, the stock ledger's duplicate checks,
the daily-inventory and branch-price upserts, a cash-sale replay lookup,
several load-by-id lookups, and every populate().
- tenantGuard: a Mongoose plugin on all ten business-scoped models. Any
find/update/delete/count/distinct, or an aggregate whose first stage is
not a $match, that does not pin exactly one businessId throws
TenantScopeError before reaching the database ($in, $exists and other
operators do not count).
- crossTenant("reason", fn): the explicit, documented exemption for the
queries that must span businesses - login's email lookup, refresh and
invite-token lookups, the M-Pesa callback, the platform console and the
maintenance scripts. git grep crossTenant lists them all.
- scopedPopulate: populated branch, product and staff names are also
restricted to the business.
- Every flagged query site now names the business (or is exempted with a
reason).
- Tests: a check that every model with a businessId field carries the
guard; the guard refusing unscoped queries of every kind; and a route
sweep where business A sends business B's ids to every read route
(nothing of B's comes back) and every write route (all refused, B's
data unchanged). Test-side database inspections go through db(), an
explicit test exemption. Removing one route's business filter makes the
suite fail.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -15,7 +15,7 @@ import * as login from "@/app/api/auth/login/route";
|
||||
import * as signup from "@/app/api/auth/signup/route";
|
||||
import * as stkpush from "@/app/api/mpesa/stkpush/route";
|
||||
import * as callback from "@/app/api/mpesa/callback/route";
|
||||
import { json, makeTenant, params, req, uuid } from "./helpers";
|
||||
import { json, makeTenant, params, req, uuid, db } from "./helpers";
|
||||
|
||||
describe("role-based access on the backend", () => {
|
||||
it("blocks cashiers from owner-level money endpoints with 403", async () => {
|
||||
@@ -78,7 +78,7 @@ describe("user deactivation", () => {
|
||||
params({ id: t.cashierA.id })
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect((await User.findById(t.cashierA.id))!.isActive).toBe(false);
|
||||
expect((await db(() => User.findById(t.cashierA.id)))!.isActive).toBe(false);
|
||||
expect(await UserSession.countDocuments({ userId: t.cashierA.id })).toBe(0);
|
||||
});
|
||||
|
||||
@@ -105,7 +105,7 @@ describe("tenant status", () => {
|
||||
it("refuses login for a suspended business", async () => {
|
||||
const t = await makeTenant();
|
||||
const passwordHash = await bcrypt.hash("correct-horse", 4);
|
||||
await User.updateOne({ _id: t.cashierA.id }, { passwordHash });
|
||||
await db(() => User.updateOne({ _id: t.cashierA.id }, { passwordHash }));
|
||||
await Business.updateOne({ _id: t.businessId }, { subscriptionStatus: "SUSPENDED" });
|
||||
|
||||
const res = await login.POST(
|
||||
@@ -119,7 +119,7 @@ describe("tenant status", () => {
|
||||
it("still allows login for an active business", async () => {
|
||||
const t = await makeTenant();
|
||||
const passwordHash = await bcrypt.hash("correct-horse", 4);
|
||||
await User.updateOne({ _id: t.managerA.id }, { passwordHash });
|
||||
await db(() => User.updateOne({ _id: t.managerA.id }, { passwordHash }));
|
||||
const res = await login.POST(
|
||||
req("POST", "/api/auth/login", {
|
||||
body: { email: t.managerA.email, password: "correct-horse", deviceId: uuid() },
|
||||
|
||||
@@ -4,7 +4,7 @@ import { BranchPricing } from "@/lib/server/models/Product";
|
||||
import * as prices from "@/app/api/branch-prices/route";
|
||||
import * as products from "@/app/api/products/route";
|
||||
import * as cash from "@/app/api/transactions/cash/route";
|
||||
import { json, makeTenant, req, uuid, type Actor, type Tenant } from "./helpers";
|
||||
import { json, makeTenant, req, uuid, type Actor, type Tenant, db } from "./helpers";
|
||||
|
||||
const setPrice = (as: Actor, branchId: string, productId: string, unitPrice: number) =>
|
||||
prices.PUT(req("PUT", "/api/branch-prices", { as, body: { branchId, productId, unitPrice } }));
|
||||
@@ -44,7 +44,7 @@ describe("branch prices", () => {
|
||||
const t = await makeTenant();
|
||||
await setPrice(t.owner, t.branchA, t.water20, 180);
|
||||
await setPrice(t.owner, t.branchA, t.water20, 200);
|
||||
expect(await BranchPricing.countDocuments({})).toBe(0);
|
||||
expect(await db(() => BranchPricing.countDocuments({}))).toBe(0);
|
||||
|
||||
await setPrice(t.owner, t.branchA, t.water5, 55);
|
||||
const reset = await prices.DELETE(
|
||||
@@ -85,6 +85,6 @@ describe("branch prices", () => {
|
||||
expect((await setPrice(t.owner, other.branchA, t.water20, 150)).status).toBe(404);
|
||||
expect((await setPrice(t.owner, t.branchA, other.water20, 150)).status).toBe(404);
|
||||
expect((await prices.GET(req("GET", "/api/branch-prices?branchId=" + other.branchA, { as: t.owner }))).status).toBe(404);
|
||||
expect(await BranchPricing.countDocuments({})).toBe(0);
|
||||
expect(await db(() => BranchPricing.countDocuments({}))).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import { StockTransfer } from "@/lib/server/models/StockTransfer";
|
||||
import { StockLock } from "@/lib/server/models/StockLock";
|
||||
import * as adjustments from "@/app/api/stock/adjustments/route";
|
||||
import * as transfers from "@/app/api/stock/transfers/route";
|
||||
import { makeTenant, req, uuid, type Tenant } from "./helpers";
|
||||
import { makeTenant, req, uuid, type Tenant, db } from "./helpers";
|
||||
|
||||
async function openingStock(t: Tenant, quantity: number) {
|
||||
await adjustments.POST(
|
||||
@@ -35,7 +35,7 @@ describe("concurrent stock changes", () => {
|
||||
expect(statuses.filter((s) => s === 409)).toHaveLength(3);
|
||||
expect(await stockOf(t.businessId, t.branchA, t.water20)).toBe(2);
|
||||
expect(await stockOf(t.businessId, t.branchB, t.water20)).toBe(8);
|
||||
expect(await StockTransfer.countDocuments({})).toBe(2);
|
||||
expect(await db(() => StockTransfer.countDocuments({}))).toBe(2);
|
||||
});
|
||||
|
||||
it("holds across repeated races, not just once", async () => {
|
||||
|
||||
@@ -19,7 +19,7 @@ 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";
|
||||
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 });
|
||||
@@ -39,7 +39,7 @@ describe("F-01 trial branch allowance", () => {
|
||||
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 });
|
||||
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");
|
||||
@@ -70,7 +70,7 @@ describe("F-06 branch deactivation", () => {
|
||||
expect(withStaff.status).toBe(409);
|
||||
expect((await withStaff.json()).error).toMatch(/active staff/);
|
||||
|
||||
await User.updateOne({ _id: t.cashierB.id }, { isActive: false });
|
||||
await db(() => User.updateOne({ _id: t.cashierB.id }, { isActive: false }));
|
||||
expect((await deactivate()).status).toBe(200);
|
||||
|
||||
// Hidden from pickers, kept on the Branches page.
|
||||
@@ -82,7 +82,7 @@ describe("F-06 branch deactivation", () => {
|
||||
|
||||
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 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);
|
||||
@@ -152,7 +152,7 @@ describe("F-04 / F-05 / CAT-12 catalogue", () => {
|
||||
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 });
|
||||
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 })));
|
||||
@@ -228,13 +228,13 @@ describe("STA-04 staff invites", () => {
|
||||
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) });
|
||||
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 User.updateOne({ _id: t.cashierB.id }, { inviteExpiresAt: new Date(Date.now() + 60_000) });
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import { User, UserRole } from "@/lib/server/models/User";
|
||||
import { Product } from "@/lib/server/models/Product";
|
||||
import { signAccessToken } from "@/lib/server/jwt";
|
||||
import { ACCESS_COOKIE } from "@/lib/server/auth";
|
||||
import { crossTenant } from "@/lib/server/tenantGuard";
|
||||
|
||||
let phoneSeq = 0;
|
||||
function nextPhone(): string {
|
||||
@@ -131,3 +132,8 @@ export async function json<T = any>(res: Response): Promise<{ status: number; bo
|
||||
export function uuid(): string {
|
||||
return randomUUID();
|
||||
}
|
||||
|
||||
/** Tests may inspect and arrange any business's data directly. */
|
||||
export function db<T>(fn: () => PromiseLike<T>): Promise<T> {
|
||||
return crossTenant("test inspects the database directly", fn);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import * as login from "@/app/api/auth/login/route";
|
||||
import * as me from "@/app/api/auth/me/route";
|
||||
import * as platformBusinesses from "@/app/api/platform/businesses/route";
|
||||
import { ACCESS_COOKIE } from "@/lib/server/auth";
|
||||
import { json, makeTenant, req, uuid, type Actor, type Tenant } from "./helpers";
|
||||
import { json, makeTenant, req, uuid, type Actor, type Tenant, db } from "./helpers";
|
||||
|
||||
const PERSON = { name: "Wanjiru", email: "wanjiru@aquaflow.test", phoneNumber: "0722000111" };
|
||||
const OPERATOR = "operator@aquaflow.test"; // listed in PLATFORM_ADMIN_EMAILS by tests/setup.ts
|
||||
@@ -35,7 +35,7 @@ describe("email and phone are unique per business", () => {
|
||||
const b = await makeTenant("Beta");
|
||||
expect((await hire(a, a.owner)).status).toBe(201);
|
||||
expect((await hire(b, b.owner)).status).toBe(201);
|
||||
expect(await User.countDocuments({ email: PERSON.email })).toBe(2);
|
||||
expect(await db(() => User.countDocuments({ email: PERSON.email }))).toBe(2);
|
||||
});
|
||||
|
||||
it("still refuses a duplicate inside the same business", async () => {
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import mongoose from "mongoose";
|
||||
import { readdirSync } from "fs";
|
||||
import path from "path";
|
||||
import { crossTenant, isTenantGuarded, TenantScopeError } from "@/lib/server/tenantGuard";
|
||||
import { User } from "@/lib/server/models/User";
|
||||
import { Transaction } from "@/lib/server/models/Transaction";
|
||||
import { Expense } from "@/lib/server/models/Expense";
|
||||
import { StockMovement } from "@/lib/server/models/StockMovement";
|
||||
import { stockOf } from "@/lib/server/stock";
|
||||
import * as cash from "@/app/api/transactions/cash/route";
|
||||
import * as transactions from "@/app/api/transactions/route";
|
||||
import * as expenses from "@/app/api/expenses/route";
|
||||
import * as purchases from "@/app/api/purchases/route";
|
||||
import * as stock from "@/app/api/stock/route";
|
||||
import * as movements from "@/app/api/stock/movements/route";
|
||||
import * as transfers from "@/app/api/stock/transfers/route";
|
||||
import * as adjustments from "@/app/api/stock/adjustments/route";
|
||||
import * as summary from "@/app/api/dashboard/summary/route";
|
||||
import * as reconciliation from "@/app/api/dashboard/reconciliation/route";
|
||||
import * as profit from "@/app/api/reports/profit/route";
|
||||
import * as users from "@/app/api/users/route";
|
||||
import * as userById from "@/app/api/users/[id]/route";
|
||||
import * as reinvite from "@/app/api/users/[id]/invite/route";
|
||||
import * as branchById from "@/app/api/branches/[id]/route";
|
||||
import * as productById from "@/app/api/products/[id]/route";
|
||||
import * as branchPrices from "@/app/api/branch-prices/route";
|
||||
import * as dispatch from "@/app/api/inventory/dispatch/route";
|
||||
import * as eveningCount from "@/app/api/inventory/evening-count/route";
|
||||
import { db, json, makeTenant, params, req, uuid, type Tenant } from "./helpers";
|
||||
|
||||
// Models that hold one business's data but are deliberately unguarded (see tenantGuard.ts).
|
||||
const UNGUARDED = new Set(["Business", "UserSession", "MpesaCallbackLog", "StockLock"]);
|
||||
|
||||
describe("the tenant guard", () => {
|
||||
it("is on every model that stores a businessId", async () => {
|
||||
// Load every model file so none can be skipped by accident.
|
||||
const dir = path.resolve(__dirname, "../src/lib/server/models");
|
||||
for (const file of readdirSync(dir)) await import(path.join(dir, file));
|
||||
|
||||
const scoped = Object.values(mongoose.models).filter((m) => m.schema.path("businessId") && !UNGUARDED.has(m.modelName));
|
||||
expect(scoped.length).toBeGreaterThanOrEqual(10);
|
||||
const unguarded = scoped.filter((m) => !isTenantGuarded(m.schema)).map((m) => m.modelName);
|
||||
expect(unguarded).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses queries that do not name exactly one business", async () => {
|
||||
const t = await makeTenant();
|
||||
const attempts: [string, () => PromiseLike<unknown>][] = [
|
||||
["find", () => Transaction.find({ branchId: t.branchA })],
|
||||
["findById", () => User.findById(t.cashierA.id)],
|
||||
["exists", () => StockMovement.exists({ type: "SALE" })],
|
||||
["updateMany", () => Expense.updateMany({}, { amount: 0 })],
|
||||
["deleteMany", () => Transaction.deleteMany({ paymentMethod: "CASH" })],
|
||||
["countDocuments", () => User.countDocuments({})],
|
||||
["$in over businesses", () => User.find({ businessId: { $in: [t.businessId, new mongoose.Types.ObjectId()] } })],
|
||||
["$exists", () => User.find({ businessId: { $exists: true } })],
|
||||
["aggregate without $match", () => Transaction.aggregate([{ $group: { _id: "$businessId" } }])],
|
||||
["aggregate matching later", () => Transaction.aggregate([{ $sort: { createdAt: -1 } }, { $match: { businessId: t.businessId } }])],
|
||||
["unscoped populate", () => Expense.find({ businessId: t.businessId }).populate("branchId")],
|
||||
];
|
||||
await Expense.create({ businessId: t.businessId, branchId: t.branchA, category: "Rent", amount: 1, description: "x", recordedBy: t.owner.id });
|
||||
for (const [label, run] of attempts) {
|
||||
await expect(Promise.resolve().then(run), label).rejects.toBeInstanceOf(TenantScopeError);
|
||||
}
|
||||
});
|
||||
|
||||
it("allows scoped queries, and cross-business ones only with a stated reason", async () => {
|
||||
const t = await makeTenant();
|
||||
expect(await User.countDocuments({ businessId: t.businessId })).toBe(4);
|
||||
expect(await User.countDocuments({ $and: [{ businessId: t.businessId }, { role: "DELIVERY" }] })).toBe(2);
|
||||
expect(await crossTenant("test", () => User.countDocuments({}))).toBe(4);
|
||||
expect(() => crossTenant(" ", () => User.countDocuments({}))).toThrow(/reason/);
|
||||
// The exemption ends with the callback.
|
||||
await expect(Promise.resolve().then(() => User.countDocuments({}))).rejects.toBeInstanceOf(TenantScopeError);
|
||||
});
|
||||
});
|
||||
|
||||
/** Give tenant B one of everything, so a leak into tenant A's view would show. */
|
||||
async function seedVictim(b: Tenant) {
|
||||
await adjustments.POST(
|
||||
req("POST", "/x", { as: b.owner, body: { clientUuid: uuid(), branchId: b.branchA, productId: b.water20, mode: "DELTA", quantity: 50 } })
|
||||
);
|
||||
await cash.POST(
|
||||
req("POST", "/x", { as: b.cashierA, body: { sales: [{ clientUuid: uuid(), lines: [{ productId: b.water20, quantity: 3 }] }] } })
|
||||
);
|
||||
await expenses.POST(req("POST", "/x", { as: b.owner, body: { branchId: b.branchA, category: "Rent", amount: 7777, description: "B rent" } }));
|
||||
await purchases.POST(
|
||||
req("POST", "/x", { as: b.owner, body: { branchId: b.branchA, supplierName: "B supplier", productId: b.water20, quantity: 5, unitCost: 99 } })
|
||||
);
|
||||
await transfers.POST(
|
||||
req("POST", "/x", { as: b.owner, body: { clientUuid: uuid(), fromBranchId: b.branchA, toBranchId: b.branchB, productId: b.water20, quantity: 2 } })
|
||||
);
|
||||
}
|
||||
|
||||
describe("route sweep: another business's ids get nothing", () => {
|
||||
it("every read route returns none of business B's data to business A, whatever ids A sends", async () => {
|
||||
const a = await makeTenant("Alpha");
|
||||
const b = await makeTenant("Beta");
|
||||
await seedVictim(b);
|
||||
|
||||
const reads: [string, Response][] = [];
|
||||
const get = async (label: string, handler: (r: any) => Promise<Response>, url: string) =>
|
||||
reads.push([label, await handler(req("GET", url, { as: a.owner }))]);
|
||||
const q = "?branchId=" + b.branchA;
|
||||
await get("transactions", transactions.GET, "/api/transactions" + q);
|
||||
await get("expenses", expenses.GET, "/api/expenses" + q);
|
||||
await get("purchases", purchases.GET, "/api/purchases" + q);
|
||||
await get("stock", stock.GET, "/api/stock" + q);
|
||||
await get("movements", movements.GET, "/api/stock/movements" + q);
|
||||
await get("transfers", transfers.GET, "/api/stock/transfers" + q);
|
||||
await get("summary", summary.GET, "/api/dashboard/summary" + q);
|
||||
await get("reconciliation", reconciliation.GET, "/api/dashboard/reconciliation" + q);
|
||||
await get("profit", profit.GET, "/api/reports/profit" + q);
|
||||
await get("users", users.GET, "/api/users" + q);
|
||||
await get("branch prices", branchPrices.GET, "/api/branch-prices" + q);
|
||||
|
||||
const bIds = [b.businessId, b.branchA, b.branchB, b.water20, b.owner.id, b.cashierA.id];
|
||||
for (const [label, res] of reads) {
|
||||
const text = await res.text();
|
||||
for (const id of bIds) expect(text.includes(id), `${label} leaked ${id}`).toBe(false);
|
||||
expect(text.includes("7777") || text.includes("B supplier"), `${label} leaked B's figures`).toBe(false);
|
||||
}
|
||||
// Totals A sees are A's own (zero), not B's sale.
|
||||
const totals = JSON.parse(await (await summary.GET(req("GET", "/api/dashboard/summary" + q, { as: a.owner }))).text());
|
||||
expect(totals.totals.cashTotal).toBe(0);
|
||||
});
|
||||
|
||||
it("every write route refuses business B's ids and leaves B's data untouched", async () => {
|
||||
const a = await makeTenant("Alpha");
|
||||
const b = await makeTenant("Beta");
|
||||
await seedVictim(b);
|
||||
const before = {
|
||||
stockA: await stockOf(b.businessId, b.branchA, b.water20),
|
||||
cashier: await db(() => User.findById(b.cashierA.id).lean()),
|
||||
sales: await Transaction.countDocuments({ businessId: b.businessId }),
|
||||
};
|
||||
|
||||
const as = a.owner;
|
||||
const writes: [string, Response][] = [
|
||||
["deactivate B's cashier", await userById.PATCH(req("PATCH", "/x", { as, body: { isActive: false } }), params({ id: b.cashierA.id }))],
|
||||
["move B's cashier", await userById.PATCH(req("PATCH", "/x", { as, body: { branchId: a.branchA } }), params({ id: b.cashierA.id }))],
|
||||
["re-invite B's cashier", await reinvite.POST(req("POST", "/x", { as }), params({ id: b.cashierA.id }))],
|
||||
["rename B's branch", await branchById.PATCH(req("PATCH", "/x", { as, body: { branchName: "Pwned" } }), params({ id: b.branchA }))],
|
||||
["reprice B's product", await productById.PATCH(req("PATCH", "/x", { as, body: { unitPrice: 1 } }), params({ id: b.water20 }))],
|
||||
["branch price on B", await branchPrices.PUT(req("PUT", "/x", { as, body: { branchId: b.branchA, productId: b.water20, unitPrice: 1 } }))],
|
||||
["adjust B's stock", await adjustments.POST(req("POST", "/x", { as, body: { clientUuid: uuid(), branchId: b.branchA, productId: b.water20, mode: "COUNT", quantity: 0 } }))],
|
||||
["transfer out of B", await transfers.POST(req("POST", "/x", { as, body: { clientUuid: uuid(), fromBranchId: b.branchA, toBranchId: a.branchA, productId: b.water20, quantity: 1 } }))],
|
||||
["transfer into B", await transfers.POST(req("POST", "/x", { as, body: { clientUuid: uuid(), fromBranchId: a.branchA, toBranchId: b.branchA, productId: a.water20, quantity: 1 } }))],
|
||||
["expense on B", await expenses.POST(req("POST", "/x", { as, body: { branchId: b.branchA, category: "Rent", amount: 1, description: "x" } }))],
|
||||
["purchase into B", await purchases.POST(req("POST", "/x", { as, body: { branchId: b.branchA, supplierName: "x", productId: b.water20, quantity: 1, unitCost: 1 } }))],
|
||||
["dispatch at B", await dispatch.POST(req("POST", "/x", { as, body: { branchId: b.branchA, productId: b.water20, morningDispatched: 1 } }))],
|
||||
["count at B", await eveningCount.POST(req("POST", "/x", { as, body: { branchId: b.branchA, productId: b.water20, eveningPhysicalCount: 1 } }))],
|
||||
["sale of B's product", await cash.POST(req("POST", "/x", { as: a.cashierA, body: { sales: [{ clientUuid: uuid(), lines: [{ productId: b.water20, quantity: 1 }] }] } }))],
|
||||
];
|
||||
for (const [label, res] of writes) {
|
||||
if (label.startsWith("sale")) {
|
||||
expect((await json(res)).body.results[0].status, label).toBe("rejected");
|
||||
} else {
|
||||
expect(res.status, label).toBeGreaterThanOrEqual(400);
|
||||
}
|
||||
}
|
||||
|
||||
expect(await stockOf(b.businessId, b.branchA, b.water20)).toBe(before.stockA);
|
||||
expect(await db(() => User.findById(b.cashierA.id).lean())).toMatchObject({
|
||||
isActive: before.cashier!.isActive,
|
||||
branchId: before.cashier!.branchId,
|
||||
});
|
||||
expect(await Transaction.countDocuments({ businessId: b.businessId })).toBe(before.sales);
|
||||
expect(await Expense.countDocuments({ businessId: b.businessId })).toBe(1);
|
||||
});
|
||||
});
|
||||
+7
-7
@@ -4,7 +4,7 @@ import { Product, BranchPricing } from "@/lib/server/models/Product";
|
||||
import { stockOf } from "@/lib/server/stock";
|
||||
import * as cash from "@/app/api/transactions/cash/route";
|
||||
import type { SaleResult } from "@/lib/syncProtocol";
|
||||
import { json, makeTenant, req, uuid, type Actor, type Tenant } from "./helpers";
|
||||
import { json, makeTenant, req, uuid, type Actor, type Tenant, db } from "./helpers";
|
||||
|
||||
function sale(t: Tenant, cashier: Actor, lines: { productId: string; quantity: number; unitPrice?: number }[], extra = {}) {
|
||||
return {
|
||||
@@ -31,7 +31,7 @@ describe("offline sale sync", () => {
|
||||
const { status, body } = await post(t.cashierA, [s]);
|
||||
expect(status).toBe(200);
|
||||
expect(body.results).toEqual([{ clientUuid: s.clientUuid, status: "saved", amountTotal: 580, priceMismatch: false }]);
|
||||
expect(await Transaction.countDocuments({ saleId: s.clientUuid })).toBe(2);
|
||||
expect(await db(() => Transaction.countDocuments({ saleId: s.clientUuid }))).toBe(2);
|
||||
expect(await stockOf(t.businessId, t.branchA, t.water20)).toBe(-2);
|
||||
expect(await stockOf(t.businessId, t.branchA, t.water5)).toBe(-3);
|
||||
});
|
||||
@@ -42,7 +42,7 @@ describe("offline sale sync", () => {
|
||||
await post(t.cashierA, [s]);
|
||||
const replay = await post(t.cashierA, [s]);
|
||||
expect(replay.body.results[0]).toMatchObject({ clientUuid: s.clientUuid, status: "duplicate" });
|
||||
expect(await Transaction.countDocuments({ saleId: s.clientUuid })).toBe(1);
|
||||
expect(await db(() => Transaction.countDocuments({ saleId: s.clientUuid }))).toBe(1);
|
||||
expect(await stockOf(t.businessId, t.branchA, t.water20)).toBe(-1);
|
||||
});
|
||||
|
||||
@@ -78,7 +78,7 @@ describe("offline sale sync", () => {
|
||||
const otherCashiersSale = sale(t, t.cashierB, [{ productId: t.water20, quantity: 1 }]);
|
||||
const { body } = await post(t.cashierA, [otherCashiersSale]);
|
||||
expect(body.results[0].status).toBe("rejected");
|
||||
expect(await Transaction.countDocuments({})).toBe(0);
|
||||
expect(await db(() => Transaction.countDocuments({}))).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps a sale at the branch where it happened after the cashier is moved", async () => {
|
||||
@@ -86,7 +86,7 @@ describe("offline sale sync", () => {
|
||||
const s = sale(t, t.cashierA, [{ productId: t.water20, quantity: 1 }]); // recorded at branch A
|
||||
const moved = { ...t.cashierA, branchId: t.branchB };
|
||||
await post(moved, [s]);
|
||||
const txn = await Transaction.findOne({ saleId: s.clientUuid });
|
||||
const txn = await db(() => Transaction.findOne({ saleId: s.clientUuid }));
|
||||
expect(txn!.branchId.toString()).toBe(t.branchA);
|
||||
});
|
||||
|
||||
@@ -97,13 +97,13 @@ describe("offline sale sync", () => {
|
||||
const foreignBranch = sale(t, t.cashierA, [{ productId: t.water20, quantity: 1 }], { branchId: other.branchA });
|
||||
const { body } = await post(t.cashierA, [foreignProduct, foreignBranch]);
|
||||
expect(body.results.map((r) => r.status)).toEqual(["rejected", "rejected"]);
|
||||
expect(await Transaction.countDocuments({})).toBe(0);
|
||||
expect(await db(() => Transaction.countDocuments({}))).toBe(0);
|
||||
});
|
||||
|
||||
it("uses the branch price, flags a price the device showed differently, and keeps deactivated products sellable", async () => {
|
||||
const t = await makeTenant();
|
||||
await BranchPricing.create({ businessId: t.businessId, branchId: t.branchA, productId: t.water20, unitPrice: 180 });
|
||||
await Product.updateOne({ _id: t.water5 }, { isActive: false });
|
||||
await db(() => Product.updateOne({ _id: t.water5 }, { isActive: false }));
|
||||
const s = sale(t, t.cashierA, [
|
||||
{ productId: t.water20, quantity: 1, unitPrice: 200 }, // stale price on the device
|
||||
{ productId: t.water5, quantity: 1, unitPrice: 60 },
|
||||
|
||||
Reference in New Issue
Block a user