Files
EpicNext-Cms/src/actions/shop-rank.test.ts
T
Simo 866f38818b
CI / check (pull_request) Successful in 1m44s
CI / deploy (pull_request) Skipped
CI / e2e (pull_request) Skipped
fix(housekeeping): coordinate all rank mutation paths
2026-09-05 09:48:30 +02:00

149 lines
4.1 KiB
TypeScript

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),
}),
);
});
});