445 lines
17 KiB
JavaScript
445 lines
17 KiB
JavaScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { existsSync } from "node:fs";
|
|
import { readFile } from "node:fs/promises";
|
|
import { registerHooks } from "node:module";
|
|
|
|
const infoUrl = new URL("../functions/signInActivityInfo.ts", import.meta.url);
|
|
const claimUrl = new URL("../functions/signInClaim.ts", import.meta.url);
|
|
const testAdminUrl = new URL("../functions/signInTestAdmin.ts", import.meta.url);
|
|
const domainUrl = new URL("../functions/SignInActivity.ts", import.meta.url);
|
|
const productionConfigUrl = new URL("../sign-in-activity-config.json", import.meta.url);
|
|
|
|
const state = {
|
|
user: null,
|
|
configs: [],
|
|
claims: [],
|
|
lastUserUpdate: null,
|
|
};
|
|
|
|
globalThis.__signInCloudMock = {
|
|
database() {
|
|
return {
|
|
collection(name) {
|
|
return {
|
|
async add(payload) {
|
|
if (name !== "sign_in_activity_claims") throw new Error("unexpected add");
|
|
if (state.claims.some(item => item._id === payload._id)) throw new Error("duplicate key");
|
|
state.claims.push(structuredClone(payload));
|
|
return { ok: true };
|
|
},
|
|
where(query) {
|
|
return {
|
|
async get() {
|
|
return { data: getCollection(name).filter(item => matches(item, query)) };
|
|
},
|
|
async getOne() {
|
|
return { data: getCollection(name).find(item => matches(item, query)) || null };
|
|
},
|
|
async update(payload) {
|
|
const targets = getCollection(name).filter(item => matches(item, query));
|
|
if (name === "users") state.lastUserUpdate = structuredClone(payload);
|
|
targets.forEach(item => {
|
|
if (item.signInActivity === null
|
|
&& payload.signInActivity
|
|
&& typeof payload.signInActivity === "object") {
|
|
throw new Error("Cannot create field 'activatedAt' in element {signInActivity: null}");
|
|
}
|
|
Object.assign(item, structuredClone(payload));
|
|
});
|
|
return { updated: targets.length };
|
|
},
|
|
async remove() {
|
|
if (name !== "sign_in_activity_claims") throw new Error("unexpected remove");
|
|
const kept = state.claims.filter(item => !matches(item, query));
|
|
const deleted = state.claims.length - kept.length;
|
|
state.claims = kept;
|
|
return { deleted };
|
|
},
|
|
};
|
|
},
|
|
};
|
|
},
|
|
};
|
|
},
|
|
};
|
|
|
|
globalThis.__signInUtilsMock = {
|
|
checkToken(received, expected) {
|
|
return received === expected;
|
|
},
|
|
};
|
|
|
|
registerHooks({
|
|
resolve(specifier, context, nextResolve) {
|
|
if (specifier === "@lafjs/cloud") {
|
|
return { url: "data:text/javascript,export default globalThis.__signInCloudMock", shortCircuit: true };
|
|
}
|
|
if (specifier === "@/Utils") {
|
|
return { url: "data:text/javascript,export default globalThis.__signInUtilsMock", shortCircuit: true };
|
|
}
|
|
if (specifier === "@/SignInActivity") {
|
|
return { url: domainUrl.href, shortCircuit: true };
|
|
}
|
|
if (specifier === "@/signInClaim") {
|
|
return { url: claimUrl.href, shortCircuit: true };
|
|
}
|
|
return nextResolve(specifier, context);
|
|
},
|
|
});
|
|
|
|
const { default: activityInfo } = await import(infoUrl);
|
|
const { default: claimReward } = await import(claimUrl);
|
|
const { default: signInTestAdmin } = await import(testAdminUrl);
|
|
const { getChinaDayStart, isValidSignInConfig } = await import(domainUrl);
|
|
|
|
const config = {
|
|
activityId: "sign_in_v1",
|
|
enabled: true,
|
|
triggerLevel: 10,
|
|
durationDays: 14,
|
|
timezone: "UTC+8",
|
|
rewards: Array.from({ length: 7 }, (_, index) => ({
|
|
day: index + 1,
|
|
items: [{ type: "coin", count: (index + 1) * 100 }],
|
|
})),
|
|
updatedAt: 1,
|
|
};
|
|
|
|
function reset(userOverrides = {}) {
|
|
state.user = {
|
|
_id: "u1",
|
|
token: "secret",
|
|
levelAmount: 0,
|
|
userLevel: 1,
|
|
...userOverrides,
|
|
};
|
|
state.configs = [structuredClone(config)];
|
|
state.claims = [];
|
|
state.lastUserUpdate = null;
|
|
}
|
|
|
|
function getCollection(name) {
|
|
if (name === "users") return state.user ? [state.user] : [];
|
|
if (name === "sign_in_activity_config") return state.configs;
|
|
if (name === "sign_in_activity_claims") return state.claims;
|
|
if (name === "usersAd") return [];
|
|
return [];
|
|
}
|
|
|
|
function matches(item, query) {
|
|
return Object.entries(query || {}).every(([key, expected]) => item?.[key] === expected);
|
|
}
|
|
|
|
function withNow(now, callback) {
|
|
const original = Date.now;
|
|
Date.now = () => now;
|
|
return Promise.resolve(callback()).finally(() => { Date.now = original; });
|
|
}
|
|
|
|
test("cloud functions and yaml files exist", () => {
|
|
assert.equal(existsSync(infoUrl), true);
|
|
assert.equal(existsSync(claimUrl), true);
|
|
assert.equal(existsSync(testAdminUrl), true);
|
|
assert.equal(existsSync(new URL("../functions/signInActivityInfo.yaml", import.meta.url)), true);
|
|
assert.equal(existsSync(new URL("../functions/signInClaim.yaml", import.meta.url)), true);
|
|
assert.equal(existsSync(new URL("../functions/signInTestAdmin.yaml", import.meta.url)), true);
|
|
});
|
|
|
|
test("production configuration contains the approved level and rewards", async () => {
|
|
const productionConfig = JSON.parse(await readFile(productionConfigUrl, "utf8"));
|
|
assert.equal(isValidSignInConfig(productionConfig), true);
|
|
assert.equal(productionConfig.triggerLevel, 23);
|
|
assert.deepEqual(productionConfig.rewards, [
|
|
{ day: 1, items: [{ type: "infinite_health", count: 900 }] },
|
|
{ day: 2, items: [{ type: "hammer", count: 1 }] },
|
|
{ day: 3, items: [{ type: "infinite_health", count: 1800 }] },
|
|
{ day: 4, items: [{ type: "freeze", count: 1 }, { type: "magic_wand", count: 1 }] },
|
|
{ day: 5, items: [{ type: "coin", count: 600 }] },
|
|
{ day: 6, items: [{ type: "hammer", count: 1 }, { type: "freeze", count: 1 }, { type: "magic_wand", count: 1 }] },
|
|
{ day: 7, items: [{ type: "infinite_health", count: 3600 }, { type: "cat_skin", count: 1, itemId: 12 }] },
|
|
]);
|
|
});
|
|
|
|
test("activity info uses levelAmount and returns false before the trigger level", async () => {
|
|
reset({ levelAmount: 9, endLevelNum: 999 });
|
|
const result = await activityInfo({ body: { uid: "u1", token: "secret" } });
|
|
assert.equal(result.code, 1);
|
|
assert.equal(result.data, false);
|
|
assert.equal(state.user.signInActivity, undefined);
|
|
});
|
|
|
|
test("activity info activates an eligible player and returns rewards", async () => {
|
|
const now = Date.UTC(2026, 8, 2, 1);
|
|
reset({ levelAmount: 10, endLevelNum: 0 });
|
|
const result = await withNow(now, () => activityInfo({ body: { uid: "u1", token: "secret" } }));
|
|
assert.equal(result.code, 1);
|
|
assert.equal(result.data.status, "active");
|
|
assert.equal(result.data.triggerLevel, 10);
|
|
assert.equal(result.data.triggerReached, true);
|
|
assert.equal(result.data.rewards.length, 7);
|
|
assert.equal(result.data.todayClaimed, true);
|
|
assert.equal(result.data.canClaim, false);
|
|
assert.equal(result.data.claimedCount, 1);
|
|
assert.equal(result.data.todayClaim.rewardDay, 1);
|
|
assert.deepEqual(result.data.todayClaim.rewards, config.rewards[0].items);
|
|
assert.equal(typeof state.lastUserUpdate.signInActivity, "string");
|
|
assert.equal(JSON.parse(state.lastUserUpdate.signInActivity).startAt, getChinaDayStart(now));
|
|
});
|
|
|
|
test("activity info activates when the persisted activity state is null", async () => {
|
|
const now = Date.UTC(2026, 8, 2, 1);
|
|
reset({ levelAmount: 10, signInActivity: null });
|
|
const result = await withNow(now, () => activityInfo({ body: { uid: "u1", token: "secret" } }));
|
|
assert.equal(result.code, 1);
|
|
assert.equal(result.data.status, "active");
|
|
assert.equal(typeof state.lastUserUpdate.signInActivity, "string");
|
|
assert.equal(JSON.parse(state.lastUserUpdate.signInActivity).activatedAt, now);
|
|
});
|
|
|
|
test("automatic claims return the same receipt on retry and advance on a later day", async () => {
|
|
const now = Date.UTC(2026, 8, 2, 1);
|
|
reset({ levelAmount: 10 });
|
|
const request = () => activityInfo({ body: { uid: "u1", token: "secret" } });
|
|
const first = await withNow(now, request);
|
|
const retry = await withNow(now + 1000, request);
|
|
assert.deepEqual(retry.data.todayClaim, first.data.todayClaim);
|
|
assert.equal(retry.data.claimedCount, 1);
|
|
assert.equal(retry.data.startAt, first.data.startAt);
|
|
const later = await withNow(now + 2 * 86400000, request);
|
|
assert.equal(later.data.todayClaim.rewardDay, 2);
|
|
assert.notEqual(later.data.todayClaim.claimId, first.data.todayClaim.claimId);
|
|
assert.equal(state.claims.length, 2);
|
|
});
|
|
|
|
test("concurrent automatic and manual claims share the same daily unique record", async () => {
|
|
const now = Date.UTC(2026, 8, 2, 1);
|
|
reset({ levelAmount: 10, signInActivity: {
|
|
activityId: config.activityId, startAt: getChinaDayStart(now),
|
|
endAt: getChinaDayStart(now) + 14 * 86400000, activatedAt: now,
|
|
} });
|
|
const results = await withNow(now, () => Promise.all([
|
|
activityInfo({ body: { uid: "u1", token: "secret" } }),
|
|
activityInfo({ body: { uid: "u1", token: "secret" } }),
|
|
claimReward({ body: { uid: "u1", token: "secret" } }),
|
|
]));
|
|
assert.equal(state.claims.length, 1);
|
|
for (const result of results.slice(0, 2)) {
|
|
assert.equal(result.code, 1);
|
|
assert.equal(result.data.canClaim, false);
|
|
assert.equal(result.data.todayClaim.claimId, state.claims[0].claimId);
|
|
}
|
|
});
|
|
|
|
test("invalid configuration cannot create an automatic claim", async () => {
|
|
reset({ levelAmount: 10 });
|
|
state.configs = [];
|
|
const result = await activityInfo({ body: { uid: "u1", token: "secret" } });
|
|
assert.equal(result.code, 0);
|
|
assert.equal(state.user.signInActivity, undefined);
|
|
assert.equal(state.claims.length, 0);
|
|
});
|
|
|
|
test("claims accumulate across non-consecutive days and reject same-day repeats", async () => {
|
|
const firstDay = Date.UTC(2026, 8, 2, 1);
|
|
reset({
|
|
levelAmount: 10,
|
|
signInActivity: {
|
|
activityId: config.activityId,
|
|
startAt: getChinaDayStart(firstDay),
|
|
endAt: getChinaDayStart(firstDay) + 14 * 24 * 60 * 60 * 1000,
|
|
activatedAt: firstDay,
|
|
completedAt: null,
|
|
},
|
|
});
|
|
const first = await withNow(firstDay, () => claimReward({ body: { uid: "u1", token: "secret" } }));
|
|
assert.equal(first.code, 1);
|
|
assert.equal(first.data.rewardDay, 1);
|
|
|
|
const duplicate = await withNow(firstDay + 1000, () => claimReward({ body: { uid: "u1", token: "secret" } }));
|
|
assert.equal(duplicate.code, 0);
|
|
assert.match(duplicate.msg, /今天已经领取/);
|
|
|
|
const thirdDay = await withNow(firstDay + 2 * 24 * 60 * 60 * 1000, () => claimReward({
|
|
body: { uid: "u1", token: "secret" },
|
|
}));
|
|
assert.equal(thirdDay.code, 1);
|
|
assert.equal(thirdDay.data.rewardDay, 2);
|
|
assert.equal(state.claims.length, 2);
|
|
});
|
|
|
|
test("concurrent same-day claims create only one claim record", async () => {
|
|
const firstDay = Date.UTC(2026, 8, 2, 1);
|
|
reset({
|
|
levelAmount: 10,
|
|
signInActivity: {
|
|
activityId: config.activityId,
|
|
startAt: getChinaDayStart(firstDay),
|
|
endAt: getChinaDayStart(firstDay) + 14 * 24 * 60 * 60 * 1000,
|
|
activatedAt: firstDay,
|
|
completedAt: null,
|
|
},
|
|
});
|
|
const results = await withNow(firstDay, () => Promise.all([
|
|
claimReward({ body: { uid: "u1", token: "secret" } }),
|
|
claimReward({ body: { uid: "u1", token: "secret" } }),
|
|
]));
|
|
assert.deepEqual(results.map(result => result.code).sort(), [0, 1]);
|
|
assert.equal(state.claims.length, 1);
|
|
});
|
|
|
|
test("the activity expires exactly at UTC+8 midnight after day fourteen", async () => {
|
|
const startAt = Date.UTC(2026, 8, 1, 16);
|
|
reset({
|
|
levelAmount: 10,
|
|
signInActivity: {
|
|
activityId: config.activityId,
|
|
startAt,
|
|
endAt: startAt + 14 * 24 * 60 * 60 * 1000,
|
|
activatedAt: startAt,
|
|
completedAt: null,
|
|
},
|
|
});
|
|
const beforeEnd = await withNow(state.user.signInActivity.endAt - 1, () => activityInfo({
|
|
body: { uid: "u1", token: "secret" },
|
|
}));
|
|
assert.equal(beforeEnd.data.status, "active");
|
|
|
|
const atEnd = await withNow(state.user.signInActivity.endAt, () => activityInfo({
|
|
body: { uid: "u1", token: "secret" },
|
|
}));
|
|
assert.equal(atEnd.data.status, "expired");
|
|
assert.equal(atEnd.data.canClaim, false);
|
|
});
|
|
|
|
test("seven different days complete the activity", async () => {
|
|
const firstDay = Date.UTC(2026, 8, 2, 1);
|
|
reset({
|
|
levelAmount: 10,
|
|
signInActivity: {
|
|
activityId: config.activityId,
|
|
startAt: getChinaDayStart(firstDay),
|
|
endAt: getChinaDayStart(firstDay) + 14 * 24 * 60 * 60 * 1000,
|
|
activatedAt: firstDay,
|
|
completedAt: null,
|
|
},
|
|
});
|
|
let result;
|
|
for (let day = 0; day < 7; day++) {
|
|
result = await withNow(firstDay + day * 24 * 60 * 60 * 1000, () => claimReward({
|
|
body: { uid: "u1", token: "secret" },
|
|
}));
|
|
}
|
|
assert.equal(result.code, 1);
|
|
assert.equal(result.data.completed, true);
|
|
assert.equal(result.data.claimedCount, 7);
|
|
assert.equal(typeof JSON.parse(state.user.signInActivity).completedAt, "number");
|
|
});
|
|
|
|
test("invalid token and usersAd requests are rejected without writes", async () => {
|
|
reset({ levelAmount: 10 });
|
|
const badToken = await activityInfo({ body: { uid: "u1", token: "wrong" } });
|
|
assert.equal(badToken.code, 0);
|
|
|
|
const usersAd = await activityInfo({ body: { uid: "u1", token: "secret", gameName: "iaa" } });
|
|
assert.equal(usersAd.code, 0);
|
|
assert.equal(state.claims.length, 0);
|
|
assert.equal(state.user.signInActivity, undefined);
|
|
});
|
|
|
|
test("test admin is disabled without the server-side admin token", async () => {
|
|
reset({ levelAmount: 10 });
|
|
const oldToken = process.env.SIGN_IN_TEST_ADMIN_TOKEN;
|
|
delete process.env.SIGN_IN_TEST_ADMIN_TOKEN;
|
|
try {
|
|
const result = await signInTestAdmin({
|
|
body: { action: "inspect", uid: "u1", adminToken: "guess" },
|
|
query: {},
|
|
});
|
|
assert.equal(result.code, 0);
|
|
assert.match(result.msg, /未启用|密钥错误/);
|
|
} finally {
|
|
if (oldToken === undefined) delete process.env.SIGN_IN_TEST_ADMIN_TOKEN;
|
|
else process.env.SIGN_IN_TEST_ADMIN_TOKEN = oldToken;
|
|
}
|
|
});
|
|
|
|
test("one account can be prepared for locked and eligible scenarios", async () => {
|
|
reset({ levelAmount: 99, signInActivity: { activityId: "old", startAt: 1, endAt: 2, activatedAt: 1 } });
|
|
await withAdminToken(async adminToken => {
|
|
const locked = await signInTestAdmin({
|
|
body: { action: "prepare", scenario: "locked", uid: "u1", adminToken },
|
|
query: {},
|
|
});
|
|
assert.equal(locked.code, 1);
|
|
assert.equal(state.user.levelAmount, 9);
|
|
assert.equal(state.user.signInActivity, null);
|
|
|
|
const eligible = await signInTestAdmin({
|
|
body: { action: "prepare", scenario: "eligible", uid: "u1", adminToken },
|
|
query: {},
|
|
});
|
|
assert.equal(eligible.code, 1);
|
|
assert.equal(state.user.levelAmount, 10);
|
|
assert.equal(state.user.signInActivity, null);
|
|
const info = await activityInfo({ body: { uid: "u1", token: "secret" } });
|
|
assert.equal(info.data.status, "active");
|
|
});
|
|
});
|
|
|
|
test("test admin prepares every reward day for the same account", async () => {
|
|
const now = Date.UTC(2026, 8, 4, 2);
|
|
reset({ levelAmount: 10 });
|
|
await withNow(now, () => withAdminToken(async adminToken => {
|
|
for (let day = 1; day <= 7; day++) {
|
|
const prepared = await signInTestAdmin({
|
|
body: { action: "prepare", scenario: `day_${day}_ready`, uid: "u1", adminToken },
|
|
query: {},
|
|
});
|
|
assert.equal(prepared.code, 1);
|
|
assert.equal(prepared.data.claimedCount, day - 1);
|
|
const claim = await activityInfo({ body: { uid: "u1", token: "secret" } });
|
|
assert.equal(claim.code, 1);
|
|
assert.equal(claim.data.todayClaim.rewardDay, day);
|
|
assert.equal(claim.data.canClaim, false);
|
|
assert.equal(claim.data.status, day === 7 ? "completed" : "active");
|
|
}
|
|
}));
|
|
});
|
|
|
|
test("test admin prepares claimed, expired, and completed states", async () => {
|
|
const now = Date.UTC(2026, 8, 4, 2);
|
|
reset({ levelAmount: 10 });
|
|
await withNow(now, () => withAdminToken(async adminToken => {
|
|
for (const expected of [
|
|
{ scenario: "claimed_today", status: "active", claimMessage: /今天已经领取/ },
|
|
{ scenario: "expired", status: "expired", claimMessage: /活动已结束/ },
|
|
{ scenario: "completed", status: "completed", claimMessage: /全部领取/ },
|
|
]) {
|
|
const prepared = await signInTestAdmin({
|
|
body: { action: "prepare", scenario: expected.scenario, uid: "u1", adminToken },
|
|
query: {},
|
|
});
|
|
assert.equal(prepared.code, 1);
|
|
const countBeforeInfo = state.claims.length;
|
|
const info = await activityInfo({ body: { uid: "u1", token: "secret" } });
|
|
assert.equal(info.data.status, expected.status);
|
|
assert.equal(state.claims.length, countBeforeInfo);
|
|
const claim = await claimReward({ body: { uid: "u1", token: "secret" } });
|
|
assert.equal(claim.code, 0);
|
|
assert.match(claim.msg, expected.claimMessage);
|
|
}
|
|
}));
|
|
});
|
|
|
|
async function withAdminToken(callback) {
|
|
const oldToken = process.env.SIGN_IN_TEST_ADMIN_TOKEN;
|
|
process.env.SIGN_IN_TEST_ADMIN_TOKEN = "test-admin-secret";
|
|
try {
|
|
return await callback("test-admin-secret");
|
|
} finally {
|
|
if (oldToken === undefined) delete process.env.SIGN_IN_TEST_ADMIN_TOKEN;
|
|
else process.env.SIGN_IN_TEST_ADMIN_TOKEN = oldToken;
|
|
}
|
|
}
|