Files
ERP/tests/concurrency.test.ts
T
denis_ireri ee5bb5beb8 Stop simultaneous transfers from overdrawing a branch
A transfer read the sending branch's stock and then wrote its movements
as two separate steps. Requests arriving together all saw the same
balance and all passed the check: five simultaneous transfers of 4 from
a stock of 10 all succeeded, sending 20.

The check and the write now run under withStockLock: a short lease on
one product's stock at one branch, taken with a single atomic upsert
(it only succeeds when no unexpired lease exists) and released when the
request finishes. A second request waits briefly, then sees the updated
balance; after 5 seconds it gets 409 "try again". A lease expires after
10 seconds, so a crashed request cannot block the stock for good.

Physical counts take the same lock, so a count is never computed from a
balance a transfer is changing. A double-tapped transfer that races
itself still moves stock once. Sales do not take the lock: they are
recorded even when stock runs out, by design.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:57:17 +03:00

120 lines
4.7 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { stockOf } from "@/lib/server/stock";
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";
async function openingStock(t: Tenant, quantity: number) {
await adjustments.POST(
req("POST", "/x", {
as: t.owner,
body: { clientUuid: uuid(), branchId: t.branchA, productId: t.water20, mode: "DELTA", quantity },
})
);
}
const transferOut = (t: Tenant, quantity: number) =>
transfers.POST(
req("POST", "/x", {
as: t.owner,
body: { clientUuid: uuid(), fromBranchId: t.branchA, toBranchId: t.branchB, productId: t.water20, quantity },
})
);
describe("concurrent stock changes", () => {
it("never lets simultaneous transfers send more than the branch holds", async () => {
const t = await makeTenant();
await openingStock(t, 10);
// Five managers press "Record transfer" for 4 units at the same moment.
const statuses = (await Promise.all(Array.from({ length: 5 }, () => transferOut(t, 4)))).map((r) => r.status);
expect(statuses.filter((s) => s === 201)).toHaveLength(2);
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);
});
it("holds across repeated races, not just once", async () => {
for (let round = 0; round < 5; round++) {
const t = await makeTenant("Round" + round);
await openingStock(t, 9);
const statuses = (await Promise.all(Array.from({ length: 4 }, () => transferOut(t, 3)))).map((r) => r.status);
expect(statuses.filter((s) => s === 201)).toHaveLength(3);
expect(await stockOf(t.businessId, t.branchA, t.water20)).toBe(0);
}
});
it("applies a double-tapped transfer once even when both taps race", async () => {
const t = await makeTenant();
await openingStock(t, 10);
const body = { clientUuid: uuid(), fromBranchId: t.branchA, toBranchId: t.branchB, productId: t.water20, quantity: 6 };
const statuses = (
await Promise.all([
transfers.POST(req("POST", "/x", { as: t.owner, body })),
transfers.POST(req("POST", "/x", { as: t.owner, body })),
])
).map((r) => r.status);
expect(statuses.sort()).toEqual([200, 201]);
expect(await stockOf(t.businessId, t.branchA, t.water20)).toBe(4);
});
it("takes over a lock left behind by a crashed request once it expires", async () => {
const t = await makeTenant();
await openingStock(t, 10);
await StockLock.create({
_id: `${t.businessId}:${t.branchA}:${t.water20}`,
owner: "crashed-request",
lockedUntil: new Date(Date.now() - 1000),
});
expect((await transferOut(t, 3)).status).toBe(201);
expect(await StockLock.countDocuments({})).toBe(0);
});
it("serialises a physical count with a transfer, so the count is never applied to a stale balance", async () => {
const t = await makeTenant();
await openingStock(t, 10);
// A count of 10 and a transfer of 6 race. Whichever runs second must
// see the first: either the count resets 4 back to 10 after the
// transfer, or the transfer moves 6 out of the counted 10.
const [count, transfer] = await Promise.all([
adjustments.POST(
req("POST", "/x", {
as: t.owner,
body: { clientUuid: uuid(), branchId: t.branchA, productId: t.water20, mode: "COUNT", quantity: 10 },
})
),
transferOut(t, 6),
]);
expect([count.status, transfer.status]).toEqual([201, 201]);
const a = await stockOf(t.businessId, t.branchA, t.water20);
expect([4, 10]).toContain(a);
expect(await stockOf(t.businessId, t.branchB, t.water20)).toBe(6);
});
it("does not block transfers of different products or branches on each other", async () => {
const t = await makeTenant();
await openingStock(t, 10);
await adjustments.POST(
req("POST", "/x", {
as: t.owner,
body: { clientUuid: uuid(), branchId: t.branchA, productId: t.water5, mode: "DELTA", quantity: 10 },
})
);
const results = await Promise.all([
transferOut(t, 5),
transfers.POST(
req("POST", "/x", {
as: t.owner,
body: { clientUuid: uuid(), fromBranchId: t.branchA, toBranchId: t.branchB, productId: t.water5, quantity: 5 },
})
),
]);
expect(results.map((r) => r.status)).toEqual([201, 201]);
});
});