MatchMaster/server/laf-cloud/tests/jungle-settlement.test.mjs

144 lines
6.5 KiB
JavaScript

import test, { beforeEach } from "node:test";
import assert from "node:assert/strict";
import { registerHooks } from "node:module";
const NOW = Date.parse("2026-10-09T12:00:00+08:00");
const state = {
activityId: "jungle_treasure_v1", configVersion: 2, activityType: "calendar",
periodKey: "2026-09", startsAt: 1790524800000, firstTriggered: true,
lastCalendarPeriodKey: "2026-09",
tierStates: [1, 1, 1, 0, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1],
paidOrderNos: {}, revision: 2, expiresAt: 1791129600000, updatedAt: 1790678556233,
};
const resources = { coinAmount: 1000, freezeAmount: 2, hammerAmount: 3, magicAmount: 4, userPowerTime: 0 };
const expectedBalances = { coinAmount: 1300, freezeAmount: 3, hammerAmount: 4, magicAmount: 5, userPowerTime: 0 };
let user;
let updateCount;
class SetValue {
constructor(value) { this.value = value; }
}
// Model the relevant Laf update behavior: plain objects update child fields;
// command.set replaces the whole value, including an existing null parent.
function applyUpdate(target, payload) {
for (const [key, value] of Object.entries(payload)) {
if (value instanceof SetValue) {
target[key] = structuredClone(value.value);
} else if (value && typeof value === "object" && !Array.isArray(value)) {
if (target[key] === undefined) target[key] = {};
if (target[key] === null || typeof target[key] !== "object") {
throw new Error(`Cannot create field in element { ${key}: ${target[key]} }`);
}
applyUpdate(target[key], value);
} else {
target[key] = structuredClone(value);
}
}
}
globalThis.__jungleSettlementCloud = {
database: () => ({
command: { set: value => new SetValue(value) },
collection(name) {
assert.equal(name, "users");
return { where(query) {
const matches = () => Object.entries(query).every(([path, expected]) =>
path.split(".").reduce((value, key) => value?.[key], user) === expected);
return {
async getOne() { return { data: matches() ? structuredClone(user) : null }; },
async update(payload) {
if (!matches()) return { ok: true, updated: 0 };
const next = structuredClone(user);
applyUpdate(next, payload);
user = next; // Commit atomically; a rejected update cannot partially grant rewards.
updateCount++;
return { ok: true, updated: 1 };
},
};
} };
},
}),
};
registerHooks({
load(url, context, next) {
return next(url, url.endsWith(".ts") ? { ...context, format: "module-typescript" } : context);
},
resolve(specifier, context, next) {
const aliases = {
"@lafjs/cloud": "data:text/javascript,export default globalThis.__jungleSettlementCloud",
"@/Utils": "data:text/javascript,export default { checkToken: (received, expected) => received === expected }",
"@/jungleConfig": new URL("../functions/jungleConfig.ts", import.meta.url).href,
};
return aliases[specifier] ? { url: aliases[specifier], shortCircuit: true } : next(specifier, context);
},
});
const { default: jungleTreasure } = await import("../functions/jungleTreasure.ts");
beforeEach(t => {
t.mock.method(Date, "now", () => NOW);
user = { _id: "u1", token: "secret", jungleTreasureState: JSON.stringify(state),
pendingJungleSettlement: null, ...resources };
updateCount = 0;
});
function request(action, extra = {}) {
return jungleTreasure({ body: { uid: "u1", token: "secret", action, ...extra } });
}
test("expired September activity settles the first three tiers when pending is null", async () => {
const result = await request("settle_expired", { resources: JSON.stringify(resources) });
assert.equal(result.code, 1);
const pending = result.data.pendingSettlement;
assert.deepEqual(pending.claimedTierIds, [1, 2, 3]);
assert.deepEqual(pending.rewards, { coin: 300, freeze: 1, hammer: 1, magic_wand: 1, infinite_health: 0 });
assert.deepEqual(pending.balances, expectedBalances);
assert.deepEqual(user.pendingJungleSettlement, pending);
for (const [key, value] of Object.entries(expectedBalances)) assert.equal(user[key], value);
const saved = JSON.parse(user.jungleTreasureState);
assert.deepEqual(saved.tierStates, [2, 2, 2, ...state.tierStates.slice(3)]);
assert.equal(saved.revision, 3);
});
test("settlement also works when the pending field is absent", async () => {
delete user.pendingJungleSettlement;
const result = await request("settle_expired", { resources });
assert.equal(result.code, 1);
assert.deepEqual(result.data.pendingSettlement.balances, expectedBalances);
assert.deepEqual(user.pendingJungleSettlement, result.data.pendingSettlement);
});
test("repeated settle and read return the pending settlement without granting again", async () => {
delete user.pendingJungleSettlement;
const first = await request("settle_expired", { resources });
assert.equal(first.code, 1);
const afterSettlement = structuredClone(user);
const writes = updateCount;
for (const action of ["settle_expired", "read"]) {
const result = await request(action, { resources: expectedBalances });
assert.equal(result.code, 1);
assert.deepEqual(result.data.pendingSettlement, first.data.pendingSettlement);
}
assert.deepEqual(user, afterSettlement);
assert.equal(updateCount, writes);
});
test("ack clears pending to null and a later activity can settle again", async () => {
delete user.pendingJungleSettlement;
user.jungleTreasureState = JSON.stringify({ ...state, periodKey: "2026-08", lastCalendarPeriodKey: "2026-08",
startsAt: Date.parse("2026-08-28T00:00:00+08:00"), expiresAt: Date.parse("2026-09-04T00:00:00+08:00") });
const first = await request("settle_expired", { resources });
assert.equal(first.code, 1);
const ack = await request("ack_settlement", { settlementId: first.data.pendingSettlement.settlementId });
assert.equal(ack.code, 1);
assert.equal(user.pendingJungleSettlement, null);
user.jungleTreasureState = JSON.stringify(state); // Seed the next period; calendar activation is unrelated.
const next = await request("settle_expired", { resources: first.data.pendingSettlement.balances });
assert.equal(next.code, 1);
assert.notEqual(next.data.pendingSettlement.settlementId, first.data.pendingSettlement.settlementId);
assert.deepEqual(next.data.pendingSettlement.claimedTierIds, [1, 2, 3]);
assert.deepEqual(next.data.pendingSettlement.balances,
{ coinAmount: 1600, freezeAmount: 4, hammerAmount: 5, magicAmount: 6, userPowerTime: 0 });
});