262 lines
14 KiB
JavaScript
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);
|
|
});
|