- rcon.ts: Node net.Socket transport speaking AtomCMS's exact protocol (raw
JSON {"key","data"} over rcon_ip:rcon_port, fire-and-forget) with a bounded
timeout + backoff retry; typed commands matching RconService EXACTLY
(sendgift `itemid`, disconnect `username`, no-data => data:null, givepoints
numeric `type`, forwarduser, setmotto/setrank/alertuser, give*). Injectable
transport keeps command shapes unit-testable.
- send-currency.ts: mirrors the SendCurrency action — RCON deliver, else DB
fallback (credits -> users.credits; duckets/diamonds/points -> users_currency
by type 0/5/101).
- env: RCON_HOST/PORT/TIMEOUT/RETRIES. vitest: SKIP_ENV_VALIDATION for unit tests.
Verified: tsc exit 0, vitest 39/39, next build exit 0. Live TCP test deferred
(needs a running emulator).
67 lines
2.3 KiB
TypeScript
67 lines
2.3 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import { sendCurrency } from "./send-currency";
|
|
|
|
function deps(delivered: boolean) {
|
|
return {
|
|
rcon: {
|
|
giveCredits: vi.fn().mockResolvedValue(delivered),
|
|
giveDuckets: vi.fn().mockResolvedValue(delivered),
|
|
giveDiamonds: vi.fn().mockResolvedValue(delivered),
|
|
givePointsGotw: vi.fn().mockResolvedValue(delivered),
|
|
},
|
|
db: {
|
|
user: { update: vi.fn().mockResolvedValue(undefined) },
|
|
usersCurrency: { upsert: vi.fn().mockResolvedValue(undefined) },
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("sendCurrency", () => {
|
|
it("rejects non-positive amounts", async () => {
|
|
const d = deps(true);
|
|
expect(await sendCurrency(d, 1, "credits", 0)).toBe(false);
|
|
expect(await sendCurrency(d, 1, "credits", null)).toBe(false);
|
|
expect(d.rcon.giveCredits).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("delivers credits via RCON when connected (no DB write)", async () => {
|
|
const d = deps(true);
|
|
expect(await sendCurrency(d, 7, "credits", 100)).toBe(true);
|
|
expect(d.rcon.giveCredits).toHaveBeenCalledWith(7, 100);
|
|
expect(d.db.user.update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("falls back to users.credits when RCON fails", async () => {
|
|
const d = deps(false);
|
|
await sendCurrency(d, 7, "credits", 100);
|
|
expect(d.db.user.update).toHaveBeenCalledWith({
|
|
where: { id: 7 },
|
|
data: { credits: { increment: 100 } },
|
|
});
|
|
});
|
|
|
|
it("falls back to users_currency by type for diamonds", async () => {
|
|
const d = deps(false);
|
|
await sendCurrency(d, 7, "diamonds", 3);
|
|
expect(d.db.usersCurrency.upsert).toHaveBeenCalledWith({
|
|
where: { userId_type: { userId: 7, type: 5 } },
|
|
update: { amount: { increment: 3 } },
|
|
create: { userId: 7, type: 5, amount: 3 },
|
|
});
|
|
});
|
|
|
|
it("uses the right type values (duckets=0, points=101)", async () => {
|
|
const d = deps(false);
|
|
await sendCurrency(d, 1, "duckets", 10);
|
|
await sendCurrency(d, 1, "points", 20);
|
|
expect(d.db.usersCurrency.upsert).toHaveBeenNthCalledWith(
|
|
1,
|
|
expect.objectContaining({ create: { userId: 1, type: 0, amount: 10 } }),
|
|
);
|
|
expect(d.db.usersCurrency.upsert).toHaveBeenNthCalledWith(
|
|
2,
|
|
expect.objectContaining({ create: { userId: 1, type: 101, amount: 20 } }),
|
|
);
|
|
});
|
|
});
|