ee5bb5beb8
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>
120 lines
4.7 KiB
TypeScript
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]);
|
|
});
|
|
});
|