MatchMaster/server/laf-cloud/tests/starter-pack.test.mjs

262 lines
14 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { registerHooks, stripTypeScriptTypes } from 'node:module';
import { readFileSync } from 'node:fs';
const NOW = Date.parse('2026-09-30T12:00:00+08:00'), DAY = 86400000;
let now = NOW, beforeUpdate = null, failProfile = false;
const realNow = Date.now;
Date.now = () => now;
test.after(() => { Date.now = realNow; });
const tables = { users: [], userRechargeStats: [] };
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() {
if (name === 'userRechargeStats' && failProfile) throw new Error('profile unavailable');
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 === '@/paymentProfile') return { url: new URL('../functions/paymentProfile.ts', import.meta.url).href, shortCircuit: true };
if (specifier.startsWith('@/')) throw new Error('Unexpected dependency: ' + specifier);
return next(specifier, context);
},
load(url, context, next) {
if (url.endsWith('.ts')) return { format: 'module', source: stripTypeScriptTypes(readFileSync(new URL(url), 'utf8')), shortCircuit: true };
return next(url, context);
},
});
const { default: activity, starterPackPatch, starterPackOrderSnapshot } = await import('../functions/limitedTimeEvent.ts');
const { PAYMENT_PROFILE_RULE } = await import('../functions/paymentProfile.ts');
function reset(patch = {}) {
now = NOW; beforeUpdate = null; failProfile = false;
for (const name of Object.keys(tables)) tables[name] = [];
tables.users.push({ _id: 'u1', openid: 'o1', token: 't1', levelAmount: 15, pay_user: false,
register_time: NOW - 90 * DAY, starter_pack: 0, starter_packState: 0, ...patch });
return tables.users[0];
}
const ctx = extra => ({ body: { uid: 'u1', token: 't1', event: 'starter_pack', ...extra } });
const read = () => activity(ctx({ action: 'read' }));
const trigger = (reason = 'shop', extra = {}) => activity(ctx({ action: 'reactivate', reason, ...extra }));
function profile(user, daily = 600, anchor = 600, patch = {}) {
const row = { _id: user._id, openid: user.openid, data_valid: true, amount_basis: 'confirmed_order_amount',
profile_rule_version: PAYMENT_PROFILE_RULE.version,
profile_calculated_as_of: Date.parse('2026-09-30T00:00:00+08:00'),
maintenance_identity: { pay_user: true, register_time: user.register_time },
spending: { daily_reference_fen: daily }, ticket_factors: { lucky: { anchor_fen: anchor } }, ...patch };
tables.userRechargeStats.push(row); return row;
}
test('read or save without a reason cannot start; first unpaid offer lasts 12 hours', async () => {
const user = reset();
await read(); await activity(ctx({ action: 'save' }));
assert.equal(user.starter_pack, 0);
await Promise.all([trigger(), trigger('level_purchase')]);
assert.equal(user.starter_pack, NOW + DAY / 2);
assert.equal(user.starterPackRound, 1);
assert.equal(user.starterPackOffer.productId, 'starter_pack');
assert.deepEqual(user.starterPackOffer.reward, { coin: 3000, hammer: 1, freeze: 1, magic_wand: 1, infinite_health: 1800 });
});
test('formula thresholds select four paid products and exact rewards for six hours', async () => {
for (const [daily, id, price, coin, props, minutes] of [
[0, 'starter_pack_2', 600, 3000, 1, 30], [1199, 'starter_pack_2', 600, 3000, 1, 30],
[1200, 'starter_pack_3', 1200, 4500, 3, 45], [1800, 'starter_pack_4', 1800, 5000, 5, 60],
[3000, 'starter_pack_5', 3000, 8000, 8, 180], [100000, 'starter_pack_5', 3000, 8000, 8, 180],
]) {
const user = reset({ pay_user: true }); profile(user, daily);
await trigger();
assert.equal(user.starter_pack, NOW + DAY / 4);
assert.equal(user.starterPackOffer.productId, id);
assert.equal(user.starterPackOffer.priceFen, price);
assert.deepEqual(user.starterPackOffer.reward, { coin, hammer: props, freeze: props, magic_wand: props, infinite_health: minutes * 60 });
}
});
test('ticket reference is multiplied by 0.7 and history never overrides the formula', async () => {
const user = reset({ pay_user: true, lastStarterPackPrice: 3000 }); profile(user, 1000, 3000);
await trigger(); assert.equal(user.starterPackOffer.priceFen, 1800);
const second = reset({ pay_user: true, lastStarterPackPrice: 3000 }); profile(second, 1200, 600);
await trigger(); assert.equal(second.starterPackOffer.priceFen, 1200);
});
test('missing, invalid, stale, future, mismatched and unavailable paid profiles fall back to 6 yuan', async () => {
for (const patch of [null, { data_valid: false }, { profile_rule_version: 'old' },
{ profile_calculated_as_of: NOW - 3 * DAY }, { profile_calculated_as_of: NOW + DAY },
{ openid: 'someone-else' }, { maintenance_identity: { pay_user: false } },
{ amount_basis: 'unknown' }, { spending: { daily_reference_fen: null } },
{ ticket_factors: { lucky: { anchor_fen: NaN } } }]) {
const user = reset({ pay_user: true }); if (patch) profile(user, 100000, 100000, patch);
await trigger(); assert.equal(user.starterPackOffer.priceFen, 600);
assert.equal(user.starterPackOffer.selectionBasis, 'fallback');
}
const user = reset({ pay_user: true }); failProfile = true;
await trigger(); assert.equal(user.starterPackOffer.priceFen, 600);
});
test('one daily batch of lag is allowed and pay_user determines first-purchase identity', async () => {
const paid = reset({ pay_user: true });
profile(paid, 3000, 600, { profile_calculated_as_of: Date.parse('2026-09-29T00:00:00+08:00') });
await trigger(); assert.equal(paid.starterPackOffer.priceFen, 3000);
const free = reset({ pay_user: false, vip_level: 5 }); profile(free, 100000, 100000);
await trigger(); assert.equal(free.starterPackOffer.priceFen, 300);
for (const pay_user of [undefined, null, 'false', 0]) {
const unknown = reset({ pay_user }); await trigger(); assert.equal(unknown.starter_pack, 0);
}
});
test('invalid registration timestamps use the same null identity as rechargeStats', async () => {
for (const register_time of [null, undefined, 0, '0', -1, '2026-09-01']) {
const user = reset({ pay_user: true, register_time });
profile(user, 3000, 600, { maintenance_identity: { pay_user: true, register_time: null } });
await trigger(); assert.equal(user.starterPackOffer.priceFen, 3000);
}
});
test('a displayed offer stays fixed when payment identity and daily factors change', async () => {
const user = reset(); await trigger(); const offer = copy(user.starterPackOffer);
user.pay_user = true; profile(user, 100000, 100000); now += 3600000;
await read(); await trigger();
assert.deepEqual(user.starterPackOffer, offer);
assert.equal(starterPackOrderSnapshot(user, 'starter_pack').starterPackOffer.priceFen, 300);
});
test('legacy active offers retain their original deadline and old promised reward', async () => {
for (const version of [undefined, 1, 2]) {
const user = reset({ starter_pack: NOW + DAY, starterPackVersion: version });
await Promise.all([read(), read()]);
assert.equal(user.starter_pack, NOW + DAY);
assert.equal(user.starterPackRound, 1);
assert.equal(user.starterPackOffer.legacy, true);
assert.equal(user.starterPackOffer.reward.hammer, 3);
assert.equal(user.starterPackOffer.reward.infinite_health, 1800);
const locked = copy(user); await read(); assert.deepEqual(user, locked);
}
});
test('reopening uses Beijing end date plus two days even after early purchase', async () => {
for (const ending of ['2026-09-23T02:00:00+08:00', '2026-09-23T23:59:00+08:00']) {
const user = reset({ pay_user: true, starter_pack: Date.parse(ending), starter_packState: 1, starterPackRound: 4 });
now = Date.parse('2026-09-24T23:59:59.999+08:00'); await trigger();
assert.equal(user.starterPackRound, 4);
now++; await Promise.all([trigger(), trigger('level_purchase')]);
assert.equal(user.starterPackRound, 5);
assert.equal(user.starter_packState, 0);
assert.equal(user.starter_pack, now + DAY / 4);
}
});
test('old paid users with no historical deadline can enter a new paid round', async () => {
const user = reset({ pay_user: true, starter_packState: 1 }); await trigger();
assert.equal(user.starterPackOffer.priceFen, 600); assert.equal(user.starterPackRound, 2);
});
test('approved triggers are independent; unknown resources are not treated as zero', async () => {
const valid = [
['low_coin', { coinAmount: 499 }], ['low_health', { healthAmount: 0, userPowerTime: 0, healthtimestamp: NOW }],
['low_props', { freezeAmount: 3, hammerAmount: 2, magicAmount: 0 }],
['shop', {}], ['level_purchase', {}], ['purchase', {}], ['four_failures', {}],
];
for (const [reason, patch] of valid) {
const user = reset(patch); await trigger(reason, { level: 15, failureCount: 4 });
assert.equal(user.starterPackRound, 1, reason);
}
for (const [reason, patch, extra] of [
['low_coin', { coinAmount: 500 }], ['low_coin', { coinAmount: null }],
['low_health', { healthAmount: 0 }], ['low_health', { healthAmount: 0, userPowerTime: NOW / 1000 + 1 }],
['low_health', { healthAmount: 0, userPowerTime: 0, healthtimestamp: NOW - 1800000 }],
['low_props', { freezeAmount: 3, hammerAmount: 3 }], ['low_props', { freezeAmount: 3, hammerAmount: 3, magicAmount: 4 }],
['four_failures', {}, { level: 15, failureCount: 3 }], ['four_failures', {}, { level: 16, failureCount: 4 }], ['login', {}],
]) {
const user = reset(patch); await trigger(reason, extra); assert.equal(user.starter_pack, 0, reason);
}
});
test('level 15 is mandatory; user identity and authentication remain enforced', async () => {
for (const levelAmount of [undefined, NaN, 14]) {
const user = reset({ levelAmount }); await trigger(); assert.equal(user.starter_pack, 0);
}
reset(); assert.equal((await activity(ctx({ action: 'reactivate', reason: 'shop', token: 'wrong' }))).code, 0);
assert.equal((await activity(ctx({ action: 'read', uid: 'missing' }))).code, 0);
});
test('concurrent identity or resource changes cannot publish stale offers', async () => {
const user = reset(); beforeUpdate = () => { user.pay_user = true; };
await trigger(); assert.equal(user.starterPackOffer.priceFen, 600);
const poor = reset({ coinAmount: 499 }); beforeUpdate = () => { poor.coinAmount = 500; };
await trigger('low_coin'); assert.equal(poor.starter_pack, 0);
});
test('automatic popup tracking is per round and never changes reopening time', async () => {
const user = reset(); await trigger(); const expiry = user.starter_pack;
await activity(ctx({ action: 'shown', expiry: expiry - 1, automatic: true }));
assert.equal(user.starterPackAutoShownRound, undefined);
await activity(ctx({ action: 'shown', expiry }));
assert.equal(user.starterPackAutoShownRound, undefined);
await activity(ctx({ action: 'shown', expiry, automatic: true }));
assert.equal(user.starterPackAutoShownRound, 1);
assert.equal(user.starter_pack, expiry);
});
test('form-encoded failure counts and automatic flags match the client transport', async () => {
const user = reset();
await trigger('four_failures', { level: '15', failureCount: '4' });
assert.equal(user.starterPackRound, 1);
const expiry = String(user.starter_pack);
await activity(ctx({ action: 'shown', expiry, automatic: 'false' }));
assert.equal(user.starterPackAutoShownRound, undefined);
await activity(ctx({ action: 'shown', expiry, automatic: 'true' }));
assert.equal(user.starterPackAutoShownRound, 1);
for (const failureCount of ['', '3', '4.5', 'invalid']) {
const rejected = reset();
await trigger('four_failures', { level: '15', failureCount });
assert.equal(rejected.starter_pack, 0);
}
});
test('order snapshots bind a valid current SKU, price, reward and cycle', async () => {
const user = reset({ pay_user: true }); profile(user, 3000); await trigger();
const snapshot = starterPackOrderSnapshot(user, 'starter_pack_5');
assert.equal(snapshot.starterPackOffer.priceFen, 3000);
assert.equal(snapshot.starterPackOffer.reward.coin, 8000);
assert.equal(snapshot.activityType, 'lucky');
assert.equal(snapshot.rewardVersion, PAYMENT_PROFILE_RULE.starterPackRewardVersion);
assert.throws(() => starterPackOrderSnapshot(user, 'starter_pack'), /档位/);
user.starter_packState = 1; assert.throws(() => starterPackOrderSnapshot(user, 'starter_pack_5'), /已购买/);
user.starter_packState = 0; now = user.starter_pack;
assert.throws(() => starterPackOrderSnapshot(user, 'starter_pack_5'), /未开放/);
assert.deepEqual(starterPackOrderSnapshot(user, 'other_product'), {});
});
test('formula ignores rejection state and historical successful price', () => {
const user = reset({ pay_user: true, lastStarterPackPrice: 3000, rejectionCount: 2 });
const row = profile(user, 1200);
const patch = starterPackPatch(user, now, true, { reason: 'shop' }, row);
assert.equal(patch.starterPackOffer.priceFen, 1200);
assert.equal(patch.starterPackOffer.rewardVersion, PAYMENT_PROFILE_RULE.starterPackRewardVersion);
});
test('account migration preserves the locked offer and its per-round popup record', async () => {
const { normalizeWucaiUser } = await import('../functions/wucaiMigration.ts');
const user = reset(); await trigger();
user.starterPackLastShownAt = NOW; user.starterPackAutoShownRound = 1;
const migrated = normalizeWucaiUser(user);
assert.deepEqual(migrated.starterPackOffer, user.starterPackOffer);
assert.notEqual(migrated.starterPackOffer, user.starterPackOffer);
assert.equal(migrated.starterPackAutoShownRound, 1);
assert.equal(migrated.starterPackLastShownAt, NOW);
});