180 lines
9.6 KiB
JavaScript
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);
|
|
});
|