fix(housekeeping): coordinate all rank mutation paths
This commit is contained in:
1 parent
3d53321575
commit
866f38818b
14 files changed
+1070
-317
No files matched your search
@@ -0,0 +1,148 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const d = vi.hoisted(() => ({
|
||||
rows: [] as unknown[][],
|
||||
events: [] as string[],
|
||||
set: vi.fn(),
|
||||
execute: vi.fn(),
|
||||
audit: vi.fn(),
|
||||
synchronize: vi.fn(),
|
||||
}));
|
||||
vi.mock("server-only", () => ({}));
|
||||
vi.mock("@/lib/auth", () => ({ auth: async () => ({ user: { id: 8 } }) }));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
clientIp: async () => "local",
|
||||
rateLimit: async () => ({ ok: true }),
|
||||
}));
|
||||
vi.mock("@/lib/services/paypal", () => ({ creditsPerUnit: () => 10 }));
|
||||
vi.mock("@/lib/services/send-currency", () => ({
|
||||
currencyDb: {},
|
||||
sendCurrency: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/services/rcon", () => ({ rcon: { giveBadge: vi.fn() } }));
|
||||
vi.mock("@/lib/services/audit", () => ({ logAudit: d.audit }));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: (url: string) => {
|
||||
throw Object.assign(new Error(url), { digest: "NEXT_REDIRECT" });
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/db", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/db")>();
|
||||
const facade = {
|
||||
execute: d.execute,
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: () => ({
|
||||
limit: async () => d.rows.shift() ?? [],
|
||||
for: async () => {
|
||||
d.events.push("user-lock");
|
||||
return d.rows.shift() ?? [];
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
update: () => ({ set: d.set }),
|
||||
};
|
||||
return {
|
||||
...actual,
|
||||
db: {
|
||||
...facade,
|
||||
transaction: async (run: (tx: typeof facade) => unknown) => {
|
||||
const result = await run(facade);
|
||||
d.events.push("commit");
|
||||
return result;
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("@/lib/services/rank-assignment", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/services/rank-assignment")>()),
|
||||
rankAssignmentCoordinator: { synchronize: d.synchronize },
|
||||
}));
|
||||
|
||||
import { buyShopArticle } from "./shop";
|
||||
|
||||
function input() {
|
||||
const data = new FormData();
|
||||
data.set("articleId", "1");
|
||||
data.set("categoryId", "1");
|
||||
return data;
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
d.rows.length = 0;
|
||||
d.events.length = 0;
|
||||
d.rows.push(
|
||||
[
|
||||
{
|
||||
id: 1,
|
||||
name: "Member",
|
||||
costs: 100,
|
||||
giveRank: 4,
|
||||
badges: "",
|
||||
credits: 0,
|
||||
duckets: 0,
|
||||
diamonds: 0,
|
||||
},
|
||||
],
|
||||
[{ credits: 100, rank: 2 }],
|
||||
);
|
||||
d.execute.mockImplementation(async () => {
|
||||
d.events.push("rank-lock");
|
||||
return [[{ id: 4 }]];
|
||||
});
|
||||
d.set.mockImplementation(() => ({
|
||||
where: async () => {
|
||||
d.events.push("update");
|
||||
},
|
||||
}));
|
||||
d.audit.mockResolvedValue(undefined);
|
||||
d.synchronize.mockImplementation(async () => {
|
||||
d.events.push("delivery");
|
||||
return { status: "delivered", rank: 4 };
|
||||
});
|
||||
});
|
||||
|
||||
describe("shop rank coordination", () => {
|
||||
it("locks rank before user, commits the purchase, then synchronizes", async () => {
|
||||
d.rows.push([{ credits: 100, rank: 2 }]);
|
||||
await expect(buyShopArticle(input())).rejects.toThrow("bought=1");
|
||||
expect(d.events).toEqual([
|
||||
"rank-lock",
|
||||
"user-lock",
|
||||
"update",
|
||||
"update",
|
||||
"commit",
|
||||
"delivery",
|
||||
]);
|
||||
expect(d.set).toHaveBeenCalledWith({ rank: 4 });
|
||||
expect(d.audit.mock.calls.map(([entry]) => entry.outcome)).toEqual([
|
||||
"intent",
|
||||
"success",
|
||||
]);
|
||||
});
|
||||
it("does not overwrite a newer staff promotion with the previously observed buyer rank", async () => {
|
||||
d.rows.push([{ credits: 100, rank: 6 }]);
|
||||
await expect(buyShopArticle(input())).rejects.toThrow("bought=1");
|
||||
expect(d.set).not.toHaveBeenCalledWith({ rank: 4 });
|
||||
expect(d.synchronize).not.toHaveBeenCalled();
|
||||
});
|
||||
it("does not charge when the configured package rank was deleted", async () => {
|
||||
d.execute.mockResolvedValueOnce([[]]);
|
||||
await expect(buyShopArticle(input())).rejects.toThrow("error=error");
|
||||
expect(d.set).not.toHaveBeenCalled();
|
||||
expect(d.synchronize).not.toHaveBeenCalled();
|
||||
});
|
||||
it("keeps a durable partial audit without turning a committed purchase into a retry", async () => {
|
||||
d.rows.push([{ credits: 100, rank: 2 }]);
|
||||
d.synchronize.mockResolvedValueOnce({ status: "pending", rank: 4 });
|
||||
await expect(buyShopArticle(input())).rejects.toThrow("bought=1");
|
||||
expect(d.audit).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
outcome: "partial",
|
||||
correlationId: expect.any(String),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user