MatchMaster/server/laf-cloud/tests/starter-pack.test.mjs
2026-09-10 19:55:12 +08:00

180 lines
9.6 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { registerHooks } from 'node:module';
const NOW = 1800000000000, DAY = 86400000;
let now = NOW, beforeUpdate = null;
const realNow = Date.now;
Date.now = () => now;
test.after(() => { Date.now = realNow; });
const tables = { users: [], usersAd: [] };
const copy = value => value == null ? value : structuredClone(value);
const matches = (row, query) => Object.entries(query).every(([key, value]) => value === null ? row[key] == null : row[key] === value);
globalThis.__starterCloud = {
database: () => ({ collection: name => ({ where(query) { return {
async getOne() { return { data: copy(tables[name].find(row => matches(row, query))) }; },
async update(patch) {
if (beforeUpdate) { const fn = beforeUpdate; beforeUpdate = null; fn(); }
const rows = tables[name].filter(row => matches(row, query));
rows.forEach(row => Object.assign(row, copy(patch)));
return { updated: rows.length };
},
}; } }) }),
};
globalThis.__starterUtils = { checkToken: (got, expected) => got === expected };
registerHooks({ resolve(specifier, context, next) {
if (specifier === '@lafjs/cloud') return { url: 'data:text/javascript,export default globalThis.__starterCloud', shortCircuit: true };
if (specifier === '@/Utils') return { url: 'data:text/javascript,export default globalThis.__starterUtils', shortCircuit: true };
if (specifier.startsWith('@/')) throw new Error('Unexpected deployment dependency: ' + specifier);
return next(specifier, context);
} });
const { default: activity, refreshStarterPack } = await import('../functions/limitedTimeEvent.ts');
function reset(patch = {}) {
now = NOW; beforeUpdate = null;
for (const name of Object.keys(tables)) tables[name] = [];
tables.users.push({ _id: 'u1', token: 't1', levelAmount: 15,
starter_pack: 0, starter_packState: 0, ...patch });
return tables.users[0];
}
const ctx = extra => ({ body: { uid: 'u1', token: 't1', ...extra } });
const read = () => activity(ctx({ action: 'read', event: 'starter_pack' }));
const save = () => activity(ctx({ action: 'save', event: 'starter_pack' }));
const reactivate = (reason, extra = {}) => activity(ctx({ action: 'reactivate', event: 'starter_pack', reason, ...extra }));
const expired = (extra = {}) => reset({starter_pack:NOW-DAY, starterPackVersion:2,
starterPackLastShownAt:NOW-2*DAY-1, coinAmount:499, ...extra});
test('activation starts 48 hours, read does not activate, repeated/concurrent save does not extend', async () => {
const user = reset();
assert.equal((await read()).data.starter_pack, 0);
await Promise.all([save(), save()]);
assert.equal(user.starter_pack, NOW + 2 * DAY);
assert.equal(user.starterPackVersion, 2);
now += 5000; await save();
assert.equal(user.starter_pack, NOW + 2 * DAY);
});
test('legacy active user upgrades from original trigger exactly once, including concurrent reads', async () => {
const user = reset({ starter_pack: NOW + DAY / 2 });
await Promise.all([read(), read()]);
assert.equal(user.starter_pack, NOW + 1.5 * DAY);
await read(); assert.equal(user.starter_pack, NOW + 1.5 * DAY);
});
test('expired boundary and purchased users are never reactivated', async () => {
for (const patch of [{ starter_pack: NOW - 1 }, { starter_pack: NOW },
{ starter_pack: NOW + DAY, starter_packState: 1 }, { starter_packState: 1 }]) {
const user = reset(patch); const initial = copy(user);
await read(); await save(); assert.deepEqual(user, initial);
}
});
test('missing level, low level, missing user and wrong token cannot activate', async () => {
for (const levelAmount of [undefined, NaN, 14]) {
const user = reset({ levelAmount }); await save(); assert.equal(user.starter_pack, 0);
}
reset(); assert.equal((await activity(ctx({ action: 'save', event: 'starter_pack', token: 'wrong' }))).code, 0);
assert.equal((await activity(ctx({ action: 'save', event: 'starter_pack', uid: 'missing' }))).code, 0);
});
test('payment callback racing activation/upgrade cannot have purchased status cleared', async () => {
const user = reset({ starter_pack: NOW + DAY });
beforeUpdate = () => { user.starter_packState = 1; };
await refreshStarterPack(copy(user), true);
assert.equal(user.starter_packState, 1); assert.equal(user.starter_pack, NOW + DAY);
});
test('legacy records with absent status/version fields migrate', async () => {
const user = reset({ starter_pack: NOW + DAY }); delete user.starter_packState;
await read(); assert.equal(user.starter_pack, NOW + 2 * DAY);
});
test('existing 48-hour activity marker prevents another extension and returns server time', async () => {
const user = reset({ starter_pack: NOW + DAY, starterPackVersion: 2 });
const result = await read(); await save();
assert.equal(user.starter_pack, NOW + DAY);
assert.equal(result.data.serverTime, NOW);
assert.equal(result.data.starterPackRewards, undefined);
});
test('each reactivation reason independently opens another 48 hours', async () => {
for (const reason of ['low_coin','shop','level_purchase','four_failures']) {
const user=expired();
const result=await reactivate(reason,{failureCount:4,level:15});
assert.equal(result.data.reactivated,true); assert.equal(user.starter_pack,NOW+2*DAY);
assert.equal(user.starter_packState,0);
assert.equal(user.starterPackLastShownAt,NOW-2*DAY-1);
}
});
test('reactivation requires strictly more than 48 hours since actual popup', async () => {
for (const ago of [DAY,2*DAY,2*DAY+1]) {
const user=expired({starterPackLastShownAt:NOW-ago});
const result=await reactivate('shop');
assert.equal(result.data.reactivated,ago>2*DAY);
assert.equal(user.starter_pack,ago>2*DAY?NOW+2*DAY:NOW-DAY);
}
});
test('paid, unexpired, untriggered and low-level users cannot reactivate', async () => {
for (const patch of [{starter_packState:1},{starter_pack:NOW+1},{starter_pack:0},{levelAmount:14}]) {
const user=expired(patch), deadline=user.starter_pack;
assert.equal((await reactivate('shop')).data.reactivated,false); assert.equal(user.starter_pack,deadline);
}
});
test('coin and failure thresholds are exact; unknown reasons cannot reopen', async () => {
for (const coinAmount of [500,501,undefined,null,'invalid']) {
expired({coinAmount}); assert.equal((await reactivate('low_coin')).data.reactivated,false);
}
for (const extra of [{failureCount:3,level:15},{failureCount:4,level:16},{failureCount:4.5,level:15}]) {
expired(); assert.equal((await reactivate('four_failures',extra)).data.reactivated,false);
}
expired(); assert.equal((await reactivate('login')).data.reactivated,false);
});
test('only displaying the current live offer updates last shown; reads do not postpone', async () => {
const user=reset({starter_pack:NOW+2*DAY,starterPackVersion:2});
await read(); assert.equal(user.starterPackLastShownAt,undefined);
const show=expiry=>activity(ctx({action:'shown',event:'starter_pack',expiry}));
await show(user.starter_pack-1); assert.equal(user.starterPackLastShownAt,undefined);
await show(user.starter_pack); assert.equal(user.starterPackLastShownAt,NOW);
now+=DAY; await show(user.starter_pack); assert.equal(user.starterPackLastShownAt,now);
now+=DAY+1; await show(user.starter_pack); assert.equal(user.starterPackLastShownAt,NOW+DAY);
assert.equal((await reactivate('shop')).data.reactivated,false);
});
test('concurrent triggers never repeatedly extend, and later reads preserve reopened deadline', async () => {
const user=expired(); await Promise.all([reactivate('shop'),reactivate('level_purchase'),reactivate('low_coin')]);
assert.equal(user.starter_pack,NOW+2*DAY);
now+=10000; await read(); await save(); await reactivate('shop'); assert.equal(user.starter_pack,NOW+2*DAY);
});
test('a paid callback or recent exposure racing reactivation wins the compare-and-set', async () => {
for (const patch of [{starter_packState:1},{starterPackLastShownAt:NOW}]) {
const user=expired(), deadline=user.starter_pack;
beforeUpdate=()=>Object.assign(user,patch);
const result=await reactivate('shop');
assert.equal(result.data.reactivated,false); assert.equal(user.starter_pack,deadline);
}
});
test('legacy exposure fallback and locally remembered unsynced popup cannot reopen early', async () => {
const user=expired(); delete user.starterPackLastShownAt;
assert.equal((await reactivate('shop')).data.reactivated,false);
now=NOW+DAY+1; assert.equal((await reactivate('shop')).data.reactivated,true);
expired(); assert.equal((await reactivate('shop',{lastShownAt:NOW-DAY})).data.reactivated,false);
expired(); assert.equal((await reactivate('shop',{lastShownAt:NOW+DAY})).data.reactivated,false);
});
test('buy round counts offer cycles, never reads, daily impressions or concurrent activation requests', async () => {
const user = reset();
await Promise.all([save(), save()]);
assert.equal(user.starterPackRound, 1);
await activity(ctx({ action: 'shown', event: 'starter_pack', expiry: user.starter_pack }));
now += DAY;
await read(); await save();
await activity(ctx({ action: 'shown', event: 'starter_pack', expiry: user.starter_pack }));
assert.equal(user.starterPackRound, 1);
now += 2 * DAY + 1;
await Promise.all([reactivate('shop'), reactivate('shop')]);
assert.equal(user.starterPackRound, 2);
now += 2 * DAY + 1;
await reactivate('shop');
assert.equal(user.starterPackRound, 3);
});
test('legacy cycle starts at one and upgrade does not add a round', async () => {
const user = reset({ starter_pack: NOW + DAY });
assert.equal((await read()).data.starterPackRound, 1);
assert.equal(user.starterPackRound, 1);
expired();
assert.equal((await reactivate('shop')).data.starterPackRound, 2);
});