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