140 lines
7.9 KiB
JavaScript
140 lines
7.9 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { registerHooks } from 'node:module';
|
|
|
|
const functionsUrl = new URL('../functions/', import.meta.url).href;
|
|
const moduleUrl = name => new URL(name + '.ts', functionsUrl).href;
|
|
const R = await import(moduleUrl('goldMiner/config'));
|
|
const NOW = Date.parse('2026-10-02T00:00:00+08:00');
|
|
const clone = value => structuredClone(value);
|
|
const players = new Map();
|
|
let temporary = [], writes = 0;
|
|
const legacyConfig = {
|
|
configVersion: 'legacy41', effectiveFromPeriodId: 'goldMiner:2026-10-01', publishedAt: NOW - 2 * R.DAY,
|
|
unlockPassedLevel: 41, timezone: 'Asia/Shanghai', currency: 'CNY', productId: 'gold_miner', priceFen: 100,
|
|
tasks: [{ taskId: 'task1', sequence: 1, targetWins: 10, items: [{ type: 'coin', count: 100 }] }]
|
|
};
|
|
const legacyVersion = { enabled: true, configVersion: legacyConfig.configVersion,
|
|
publishedAt: legacyConfig.publishedAt, config: legacyConfig };
|
|
const mocks = {
|
|
cloud: { mongo: { db: { collection(name) {
|
|
assert.ok(['activityConfigs', R.TABLES.players].includes(name), 'unlock checks must not touch orders or balances');
|
|
return {
|
|
async findOne(query) { return name === R.TABLES.players ? clone(players.get(query._id) || null) : null; },
|
|
async updateOne(query, update, options = {}) {
|
|
assert.equal(name, R.TABLES.players);
|
|
let row = players.get(query._id);
|
|
if (!row && options.upsert) {
|
|
row = { _id: query._id, ...clone(update.$setOnInsert) };
|
|
players.set(query._id, row); writes++;
|
|
return { modifiedCount: 0, upsertedCount: 1 };
|
|
}
|
|
if (!row || (query.revision !== undefined && row.revision !== query.revision)) return { modifiedCount: 0 };
|
|
if (query.$expr && !(Date.now() < query.$expr.$lt[1].getTime())) return { modifiedCount: 0 };
|
|
Object.assign(row, clone(update.$set || {}));
|
|
for (const [key, value] of Object.entries(update.$inc || {})) row[key] = (row[key] || 0) + value;
|
|
writes++; return { modifiedCount: 1 };
|
|
}
|
|
};
|
|
} } } },
|
|
store: { latest: async () => clone(legacyVersion), isEnabled: value => !!value && value.enabled !== false },
|
|
periods: {
|
|
testPeriods: async () => clone(temporary),
|
|
resolvePeriodDates: async id => R.isTestPeriod(id) ? clone(temporary.find(p => p.periodId === id)) : R.periodDates(id)
|
|
}
|
|
};
|
|
globalThis.__activityUnlockMocks = mocks;
|
|
// The existing missing paymentProductId export otherwise prevents service.ts from loading.
|
|
// Only provide a fail-fast placeholder: these cases do not exercise payment offer refreshes.
|
|
const configShim = `export * from ${JSON.stringify(moduleUrl('goldMiner/config'))};`
|
|
+ ('paymentProductId' in R ? '' : 'export function paymentProductId() { throw Error("payment offer refresh is outside unlock tests"); }');
|
|
const modules = {
|
|
'@lafjs/cloud': 'export default globalThis.__activityUnlockMocks.cloud;',
|
|
'@/Utils': 'export default { checkToken: (a, b) => a === b };',
|
|
'@/goldMiner/config': configShim,
|
|
'@/activityConfig/store': 'export const { latest, isEnabled } = globalThis.__activityUnlockMocks.store;',
|
|
'@/goldMiner/testPeriods': 'export const { testPeriods, resolvePeriodDates } = globalThis.__activityUnlockMocks.periods;',
|
|
'@/cloudRise/periods': 'export const openPeriodForUser = () => null, openingRuleForUser = async () => ({});'
|
|
};
|
|
const hooks = registerHooks({ resolve(specifier, context, next) {
|
|
if (modules[specifier]) return { url: 'data:text/javascript,' + encodeURIComponent(modules[specifier]), shortCircuit: true };
|
|
if (specifier.startsWith('@/')) return { url: moduleUrl(specifier.slice(2)), shortCircuit: true };
|
|
return next(specifier, context);
|
|
} });
|
|
const S = await import(moduleUrl('goldMiner/service'));
|
|
const { activityList } = await import(moduleUrl('activityConfig/list'));
|
|
hooks.deregister();
|
|
const realNow = Date.now;
|
|
test.beforeEach(() => { players.clear(); temporary = []; writes = 0; Date.now = () => NOW; });
|
|
test.afterEach(() => { Date.now = realNow; });
|
|
test.after(() => { delete globalThis.__activityUnlockMocks; });
|
|
const user = levelAmount => ({ _id: 'unlock-user', levelAmount });
|
|
const period = () => S.periodById(R.calendar(NOW).periodId);
|
|
const victory = level => ({ periodId: R.calendar(NOW).periodId, eventId: 'win-' + level,
|
|
mode: 'main', outcome: 'win', clearedMainLevel: level });
|
|
|
|
function historicalPlayer() {
|
|
const configSnapshot = R.validateConfig(legacyConfig), dates = R.calendar(NOW);
|
|
const row = {
|
|
_id: S.playerId('unlock-user', dates.periodId), uid: 'unlock-user', ...dates, revision: 7,
|
|
configSnapshot, configVersion: configSnapshot.configVersion, configHash: R.key(configSnapshot),
|
|
qualifiedPassedLevel: 41, progressWins: 7, progressDedup: { entries: [] },
|
|
entitlementStatus: 'unlocked', paidOrderNo: 'existing-paid-order', claimedThrough: 1,
|
|
tasks: [{ taskId: 'task1', targetWins: 10, itemsSnapshot: [{ type: 'coin', count: 100 }], claimStatus: 'claimed', grantId: 'existing-grant' }],
|
|
settlementState: 'client_saved', settlementAcknowledgedAt: NOW - 1000
|
|
};
|
|
players.set(row._id, row); return row;
|
|
}
|
|
|
|
test('49 cleared levels remain locked despite a legacy 41-level configuration', async () => {
|
|
const before = clone(legacyVersion);
|
|
assert.equal((await S.activityInfo('unlock-user', user(49))).status, 'locked');
|
|
await assert.rejects(S.ensurePlayer('unlock-user', user(49), await period()), { errorCode: 'NOT_QUALIFIED' });
|
|
assert.equal(players.size, 0); assert.equal(writes, 0); assert.deepEqual(legacyVersion, before);
|
|
});
|
|
|
|
test('50 cleared levels allow joining without rewriting legacy reward snapshots', async () => {
|
|
const info = await S.activityInfo('unlock-user', user(50));
|
|
assert.equal(info.status, 'purchasable'); assert.equal(info.progressWins, 0);
|
|
const row = [...players.values()][0];
|
|
assert.equal(row.qualifiedPassedLevel, 50);
|
|
assert.deepEqual(row.configSnapshot, R.validateConfig(legacyConfig));
|
|
assert.equal(row.configSnapshot.unlockPassedLevel, 41);
|
|
assert.equal(row.configHash, R.key(row.configSnapshot));
|
|
});
|
|
|
|
test('qualification win 50 is excluded and win 51 is the first activity win', async () => {
|
|
assert.deepEqual(await S.recordProgress('unlock-user', user(50), victory(50)),
|
|
{ activityCounted: false, reason: 'qualification_level', progressWins: 0 });
|
|
assert.equal(players.size, 0); assert.equal(writes, 0);
|
|
assert.deepEqual(await S.recordProgress('unlock-user', user(51), victory(51)),
|
|
{ activityCounted: true, duplicate: false, progressWins: 1 });
|
|
const row = [...players.values()][0];
|
|
assert.equal(row.configSnapshot.unlockPassedLevel, 41);
|
|
assert.equal(row.progressDedup.entries[0].businessKey, 'main:51');
|
|
});
|
|
|
|
test('regular and temporary lists advertise 50 without modifying configurations or history', async () => {
|
|
historicalPlayer();
|
|
for (const isTemporary of [false, true]) {
|
|
temporary = isTemporary ? [{ periodId: 'goldMiner:test:unlock', startsAt: NOW - 1000,
|
|
endsAt: NOW + R.DAY, enabled: true, configVersion: 'legacy41', configSnapshot: clone(legacyConfig) }] : [];
|
|
const before = clone({ legacyVersion, temporary, players: [...players.values()] });
|
|
const result = await activityList(NOW, user(49));
|
|
assert.equal(result.activities.length, 1);
|
|
assert.equal(result.activities[0].periodId, isTemporary ? temporary[0].periodId : R.calendar(NOW).periodId);
|
|
assert.equal(result.activities[0].unlockLevel, 50);
|
|
assert.deepEqual({ legacyVersion, temporary, players: [...players.values()] }, before);
|
|
assert.equal(writes, 0);
|
|
}
|
|
});
|
|
|
|
test('existing paid progress, claims and snapshots survive the new unlock threshold', async () => {
|
|
const row = historicalPlayer(), before = clone(row);
|
|
assert.equal((await S.activityInfo('unlock-user', user(49))).status, 'locked');
|
|
const info = await S.activityInfo('unlock-user', user(50));
|
|
assert.equal(info.status, 'unlocked'); assert.equal(info.progressWins, 7);
|
|
assert.equal(info.tasks[0].claimStatus, 'claimed');
|
|
assert.deepEqual(players.get(row._id), before); assert.equal(writes, 0);
|
|
});
|