149 lines
4.1 KiB
TypeScript
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),
|
|
}),
|
|
);
|
|
});
|
|
});
|