server/laf-cloud/tests/starter-pack.test.mjs
guanchao e2a5c3d2f6 Squash local into main while preserving battle pass compatibility
Integrate starter pack timing, reactivation, purchase analytics, payment routing and atomic user IDs. Resolve iOS conflicts by preserving both battle pass delivery and starter pack snapshots. Add coexistence regression checks and record remaining compatibility risks. Validation: 118/125 tests passed; 7 existing failures remain, plus a separately reproduced pre-existing V2 task login failure.
2026-09-10 16:31:14 +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);
});