144 lines
6.5 KiB
JavaScript
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 });
|
|
});
|