288 lines
12 KiB
JavaScript
288 lines
12 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 DAY = 86400000;
|
|
const OFFSET = 8 * 3600000;
|
|
const NOW = Date.parse('2026-09-15T03:00:00+08:00');
|
|
const CUTOFF = Date.parse('2026-09-14T23:59:59.999+08:00');
|
|
const state = { users: [], order: [], writes: 0, closed: 0, beforeWrite: null, failRead: false };
|
|
const realNow = Date.now;
|
|
Date.now = () => NOW;
|
|
test.after(() => { Date.now = realNow; });
|
|
|
|
function matches(row, query) {
|
|
return Object.entries(query).every(([key, expected]) => {
|
|
if (key === '$or') return expected.some(part => matches(row, part));
|
|
const actual = key.split('.').reduce((value, part) => value?.[part], row);
|
|
if (expected === null) return actual == null;
|
|
if (typeof expected !== 'object') return actual === expected;
|
|
return Object.entries(expected).every(([op, value]) => {
|
|
if (op === '$in') return value.includes(actual);
|
|
if (op === '$ne') return actual !== value;
|
|
if (op === '$gt') return actual > value;
|
|
if (op === '$lte') return actual <= value;
|
|
if (op === '$exists') return (actual !== undefined) === value;
|
|
if (op === '$not') return !value.test(actual);
|
|
throw new Error(`Unsupported query operator: ${op}`);
|
|
});
|
|
});
|
|
}
|
|
|
|
globalThis.__rechargeStatsCloud = { mongo: { db: {
|
|
collection(name) {
|
|
assert.ok(['users', 'order'].includes(name), 'must not count iosOrder temporary records');
|
|
return {
|
|
find(query) {
|
|
let rows = state[name].filter(row => matches(row, query));
|
|
return {
|
|
sort(sort) {
|
|
rows.sort((a, b) => {
|
|
for (const key of Object.keys(sort)) {
|
|
if (a[key] < b[key]) return -1;
|
|
if (a[key] > b[key]) return 1;
|
|
}
|
|
return 0;
|
|
});
|
|
return this;
|
|
},
|
|
limit(size) { rows = rows.slice(0, size); return this; },
|
|
async toArray() { return rows.map(row => ({ ...row })); },
|
|
async *[Symbol.asyncIterator]() {
|
|
if (state.failRead) throw new Error('database read failed');
|
|
yield* rows;
|
|
},
|
|
async close() { state.closed++; },
|
|
};
|
|
},
|
|
async bulkWrite(operations) {
|
|
state.writes++;
|
|
state.beforeWrite?.();
|
|
let modifiedCount = 0;
|
|
for (const { updateOne } of operations) {
|
|
const user = state.users.find(row => matches(row, updateOne.filter));
|
|
if (user) {
|
|
Object.assign(user, structuredClone(updateOne.update.$set));
|
|
modifiedCount++;
|
|
}
|
|
}
|
|
return { modifiedCount };
|
|
},
|
|
};
|
|
},
|
|
} } };
|
|
registerHooks({ resolve(specifier, context, next) {
|
|
if (specifier === '@lafjs/cloud') return {
|
|
url: 'data:text/javascript,export default globalThis.__rechargeStatsCloud', shortCircuit: true,
|
|
};
|
|
return next(specifier, context);
|
|
}, load(url, context, next) {
|
|
if (url === new URL('../functions/rechargeStats.ts', import.meta.url).href) {
|
|
return { format: 'module', source: stripTypeScriptTypes(readFileSync(new URL(url), 'utf8')), shortCircuit: true };
|
|
}
|
|
return next(url, context);
|
|
} });
|
|
const { default: run } = await import('../functions/rechargeStats.ts');
|
|
|
|
function reset() {
|
|
Object.assign(state, {
|
|
users: [{ _id: 'u1', openid: 'o1', pay_user: true }], order: [],
|
|
writes: 0, closed: 0, beforeWrite: null, failRead: false,
|
|
});
|
|
}
|
|
function order(id, age, extra = {}) {
|
|
return {
|
|
_id: id, outTradeNo: id, openid: 'o1', state: 2, goodsPrice: 100, itemCount: 1,
|
|
chargeTime: new Date(CUTOFF - age + OFFSET), time: CUTOFF - age - DAY, ...extra,
|
|
};
|
|
}
|
|
|
|
test('windows exclude the previous end-of-day boundary and include yesterday final millisecond', async () => {
|
|
reset();
|
|
state.order = [
|
|
order('yesterday-end', 0), order('15d-start', 15 * DAY - 1), order('15d-excluded', 15 * DAY),
|
|
order('30d-start', 30 * DAY - 1), order('30d-excluded', 30 * DAY), order('today-start', -1),
|
|
order('early-morning', -2 * 3600000), order('future', -4 * 3600000),
|
|
];
|
|
await run();
|
|
const stats = state.users[0].rechargeStats;
|
|
assert.equal(stats.amount15d, 200);
|
|
assert.equal(stats.amount30d, 400);
|
|
assert.equal(stats.amountTotal, 500);
|
|
assert.equal(stats.orderCount, 5);
|
|
assert.equal(stats.asOf, CUTOFF);
|
|
assert.equal(stats.version, 3);
|
|
assert.equal(stats.unit, 'fen');
|
|
});
|
|
|
|
test('counts quantities, numeric strings, paid states and deduplicates; excludes unpaid and test orders', async () => {
|
|
reset();
|
|
state.order = [
|
|
order('a', DAY, { goodsPrice: '101', itemCount: '3', state: 1 }),
|
|
order('b', DAY, { goodsPrice: 7, itemCount: 1 }),
|
|
order('duplicate', DAY, { outTradeNo: 'a', goodsPrice: 101, itemCount: 3 }),
|
|
order('pending', DAY, { state: 0 }), order('string-state', DAY, { state: '2' }),
|
|
order('test-env', DAY, { paymentAppEnv: 'test' }), order('wct_test', DAY),
|
|
];
|
|
await run();
|
|
assert.equal(state.users[0].rechargeStats.amountTotal, 310);
|
|
assert.equal(state.users[0].rechargeStats.orderCount, 2);
|
|
assert.equal(state.users[0].rechargeStats.duplicateOrderCount, 1);
|
|
});
|
|
|
|
test('falls back to unshifted creation time, tracks unknown times, and rejects malformed amounts', async () => {
|
|
reset();
|
|
state.order = [
|
|
order('fallback', 0, { chargeTime: 0, time: CUTOFF - 15 * DAY + 1 }),
|
|
order('unknown', 0, { chargeTime: 0, time: null }),
|
|
order('iso', 0, { chargeTime: new Date(CUTOFF - 30 * DAY + 1 + OFFSET).toISOString() }),
|
|
order('bad-price', 0, { goodsPrice: 'bad' }), order('bad-count', 0, { itemCount: null }),
|
|
order('negative', 0, { goodsPrice: -1 }), order('missing-id', 0, { outTradeNo: '' }),
|
|
order('overflow', 0, { goodsPrice: Number.MAX_SAFE_INTEGER, itemCount: 2 }),
|
|
];
|
|
await run();
|
|
const stats = state.users[0].rechargeStats;
|
|
assert.equal(stats.amount15d, 100);
|
|
assert.equal(stats.amount30d, 200);
|
|
assert.equal(stats.amountTotal, 300);
|
|
assert.equal(stats.fallbackTimeOrderCount, 1);
|
|
assert.equal(stats.missingTimeOrderCount, 1);
|
|
assert.equal(stats.invalidOrderCount, 5);
|
|
});
|
|
|
|
test('processes every page, restricts to boolean true, resets old totals, and flags missing openid', async () => {
|
|
reset();
|
|
state.users = Array.from({ length: 251 }, (_, i) => ({
|
|
_id: `u${String(i).padStart(4, '0')}`, openid: `o${i}`, pay_user: true,
|
|
rechargeStats: { asOf: NOW - DAY, amountTotal: 999 },
|
|
}));
|
|
state.users.push({ _id: 'no-openid', pay_user: true });
|
|
state.users.push({ _id: 'free', openid: 'free', pay_user: false });
|
|
state.users.push({ _id: 'string', openid: 'string', pay_user: 'true' });
|
|
state.order = [order('last', 1, { openid: 'o250' }), order('free', 1, { openid: 'free' })];
|
|
const result = await run();
|
|
assert.equal(result.data.processedUsers, 252);
|
|
assert.equal(state.writes, 3);
|
|
assert.equal(state.users.find(user => user.openid === 'o250').rechargeStats.amountTotal, 100);
|
|
assert.equal(state.users.find(user => user.openid === 'o0').rechargeStats.amountTotal, 0);
|
|
assert.equal(state.users.find(user => user._id === 'no-openid').rechargeStats.missingOpenid, true);
|
|
assert.equal(state.users.find(user => user._id === 'free').rechargeStats, undefined);
|
|
assert.equal(state.users.find(user => user._id === 'string').rechargeStats, undefined);
|
|
});
|
|
|
|
test('reruns overwrite instead of accumulating; older runs cannot overwrite newer snapshots', async () => {
|
|
reset();
|
|
state.order = [order('paid', 1)];
|
|
await run();
|
|
await run();
|
|
assert.equal(state.users[0].rechargeStats.amountTotal, 100);
|
|
state.users[0].rechargeStats = { version: 3, asOf: CUTOFF + DAY, amountTotal: 200 };
|
|
await run();
|
|
assert.equal(state.users[0].rechargeStats.amountTotal, 200);
|
|
});
|
|
|
|
test('streams more than 1000 orders and expired windows clear on the next run', async () => {
|
|
reset();
|
|
state.order = Array.from({ length: 1005 }, (_, i) => order(`paid-${i}`, 30 * DAY - 1, { goodsPrice: 1 }));
|
|
await run();
|
|
assert.equal(state.users[0].rechargeStats.amountTotal, 1005);
|
|
assert.equal(state.users[0].rechargeStats.amount30d, 1005);
|
|
try {
|
|
Date.now = () => NOW + DAY;
|
|
await run();
|
|
assert.equal(state.users[0].rechargeStats.amount30d, 0);
|
|
assert.equal(state.users[0].rechargeStats.amountTotal, 1005);
|
|
} finally { Date.now = () => NOW; }
|
|
});
|
|
|
|
test('rechecks user eligibility and openid when writing', async () => {
|
|
reset();
|
|
state.beforeWrite = () => { state.users[0].pay_user = false; };
|
|
await run();
|
|
assert.equal(state.users[0].rechargeStats, undefined);
|
|
reset();
|
|
state.beforeWrite = () => { state.users[0].openid = 'changed'; };
|
|
await run();
|
|
assert.equal(state.users[0].rechargeStats, undefined);
|
|
});
|
|
|
|
test('read failure propagates, closes cursor and never replaces existing totals with zero', async () => {
|
|
reset();
|
|
state.users[0].rechargeStats = { asOf: NOW - 1, amountTotal: 9 };
|
|
state.failRead = true;
|
|
await assert.rejects(run(), /database read failed/);
|
|
assert.equal(state.closed, 1);
|
|
assert.equal(state.writes, 0);
|
|
assert.equal(state.users[0].rechargeStats.amountTotal, 9);
|
|
});
|
|
|
|
test('timer function does not expose a public HTTP endpoint', () => {
|
|
const config = readFileSync(new URL('../functions/rechargeStats.yaml', import.meta.url), 'utf8');
|
|
assert.match(config, /methods: \[\]/);
|
|
const trigger = JSON.parse(readFileSync(new URL('../recharge-stats.trigger.json', import.meta.url), 'utf8'));
|
|
assert.equal(trigger.target, 'rechargeStats');
|
|
assert.equal(trigger.cron, '0 3 * * *');
|
|
});
|
|
|
|
test('recalculates version 1 and 2 snapshots despite their later cutoffs', async () => {
|
|
for (const version of [1, 2]) {
|
|
reset();
|
|
state.users[0].rechargeStats = { version, asOf: NOW - 1, amount15d: 1, amount30d: 1, amountTotal: 1 };
|
|
state.order = [order('paid', DAY, { goodsPrice: 101, itemCount: 3 })];
|
|
await run();
|
|
const stats = state.users[0].rechargeStats;
|
|
assert.equal(stats.version, 3);
|
|
assert.equal(stats.unit, 'fen');
|
|
assert.equal(stats.asOf, CUTOFF);
|
|
assert.equal(stats.amount15d, 303);
|
|
assert.equal(stats.amount30d, 303);
|
|
assert.equal(stats.amountTotal, 303);
|
|
}
|
|
});
|
|
|
|
test('payments between midnight and 03:00 are included on the following daily run', async () => {
|
|
reset();
|
|
state.order = [order('midnight', -1), order('02:00', -2 * 3600000 - 1)];
|
|
await run();
|
|
assert.equal(state.users[0].rechargeStats.amountTotal, 0);
|
|
try {
|
|
Date.now = () => NOW + DAY;
|
|
await run();
|
|
assert.equal(state.users[0].rechargeStats.amount15d, 200);
|
|
assert.equal(state.users[0].rechargeStats.amount30d, 200);
|
|
assert.equal(state.users[0].rechargeStats.amountTotal, 200);
|
|
} finally { Date.now = () => NOW; }
|
|
});
|
|
|
|
test('midnight, scheduled, delayed and manual runs on the same Beijing date have the same cutoff', async () => {
|
|
reset();
|
|
state.order = [order('yesterday-end', 0), order('today-start', -1)];
|
|
try {
|
|
for (const time of ['00:00:00.000', '01:00:00.000', '03:00:00.000', '03:27:16.321', '12:00:00.000', '23:59:59.999']) {
|
|
Date.now = () => Date.parse(`2026-09-15T${time}+08:00`);
|
|
await run();
|
|
const stats = state.users[0].rechargeStats;
|
|
assert.equal(stats.asOf, CUTOFF, time);
|
|
assert.equal(stats.amount15d, 100, time);
|
|
assert.equal(stats.amount30d, 100, time);
|
|
assert.equal(stats.amountTotal, 100, time);
|
|
}
|
|
} finally { Date.now = () => NOW; }
|
|
});
|
|
|
|
test('Beijing midnight advances the cutoff across month, year and leap-day boundaries', async () => {
|
|
try {
|
|
for (const [runAt, cutoff] of [
|
|
['2026-10-01T00:00:00+08:00', '2026-09-30T23:59:59.999+08:00'],
|
|
['2027-01-01T03:00:00+08:00', '2026-12-31T23:59:59.999+08:00'],
|
|
['2028-03-01T12:00:00+08:00', '2028-02-29T23:59:59.999+08:00'],
|
|
]) {
|
|
reset();
|
|
Date.now = () => Date.parse(runAt);
|
|
await run();
|
|
assert.equal(state.users[0].rechargeStats.asOf, Date.parse(cutoff));
|
|
}
|
|
} finally { Date.now = () => NOW; }
|
|
});
|