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); });