325 lines
16 KiB
JavaScript
325 lines
16 KiB
JavaScript
'use strict';
|
||
|
||
const assert = require('assert');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const vm = require('vm');
|
||
|
||
(async () => {
|
||
const root = path.join(__dirname, '..');
|
||
const appSource = fs.readFileSync(path.join(root, 'public', 'app.js'), 'utf8');
|
||
const html = fs.readFileSync(path.join(root, 'public', 'index.html'), 'utf8');
|
||
const styles = fs.readFileSync(path.join(root, 'public', 'style.css'), 'utf8');
|
||
const operations = fs.readFileSync(path.join(root, 'src', 'routes', 'operations.js'), 'utf8');
|
||
const accountsRoute = fs.readFileSync(path.join(root, 'src', 'routes', 'accounts.js'), 'utf8');
|
||
const runner = fs.readFileSync(path.join(root, 'src', 'workers', 'task_runner.js'), 'utf8');
|
||
const rechargeService = fs.readFileSync(path.join(root, 'src', 'services', 'recharge_task_service.js'), 'utf8');
|
||
|
||
assert.match(
|
||
html,
|
||
/runOpenAIGetAuth\(\)[\s\S]{0,200}runOpenAIQueryPlan\(\)[\s\S]{0,200}openRechargeModal\(\)/,
|
||
'查询套餐按钮应位于提取授权JSON右侧',
|
||
);
|
||
assert.match(html, /<th>ChatGPT<\/th>\s*<th>是否已充值<\/th>\s*<th>授权JSON<\/th>\s*<th>AC凭证<\/th>\s*<th>Cli_AC凭证<\/th>\s*<th>账号套餐<\/th>/);
|
||
assert.match(html, /x-model="rechargeCredentialForm\.keyId"/);
|
||
assert.match(html, /type="password"[^>]*x-model="rechargeCredentialForm\.secret"/);
|
||
assert.match(styles, /input\[type="text"\],\s*input\[type="password"\],\s*input\[type="number"\]/,
|
||
'API Secret 密码框应与 Key ID 使用同一套输入框样式');
|
||
assert.match(html, /x-model="rechargeCardKeys"/);
|
||
assert.match(html, /完整 ChatGPT Session(结构化 JSON)/);
|
||
assert.match(operations, /taskType === 'card_pay_recharge' && !isLocalRequest\(req\)/);
|
||
assert.match(operations, /hasOwnProperty\.call\(params, 'confirmDuplicate'\)[\s\S]{0,100}params\.confirmDuplicate !== false/);
|
||
assert.match(runner, /latest\.chatgpt_session/);
|
||
assert.match(runner, /sessionText:\s*latest\.chatgpt_session/);
|
||
assert.match(runner, /confirmDuplicate:\s*false/);
|
||
assert.match(runner, /parseInt\(params && params\.threadCount, 10\) \|\| 2/,
|
||
'未指定充值并发时服务端应默认使用 2');
|
||
assert.match(runner, /Math\.min\(requestedRechargeConcurrency, 5\)/,
|
||
'卡付充值应限制为最多 5 路并发');
|
||
assert.match(runner, /const rechargeStartSpacingMs\s*=\s*1000/,
|
||
'卡付充值请求应相隔 1 秒发起');
|
||
assert.match(runner, /const rechargeQueryConcurrency\s*=\s*2/,
|
||
'卡付任务查询应与提交并发分离并限制为 2 路');
|
||
assert.match(html, /id="recharge-concurrency"[\s\S]{0,180}min="1" max="5"/,
|
||
'充值弹窗应提供 1 到 5 的独立并发设置');
|
||
assert.match(runner, /type:\s*'progress',\s*current:\s*0,\s*total:\s*accounts\.length/,
|
||
'充值开始时应立即推送 0/总数进度');
|
||
assert.match(runner, /onPrepared:[\s\S]{0,800}onConfirmed:[\s\S]{0,800}onPoll:/,
|
||
'充值应逐阶段推送预创建、确认和轮询进度');
|
||
assert.match(runner, /stagePrefix[\s\S]{0,300}开始处理/,
|
||
'每个账号应实时打印开始处理信息');
|
||
assert.match(runner, /maxRechargeAttempts\s*=\s*3/,
|
||
'单个账号充值最多应尝试三次');
|
||
assert.match(runner, /const rechargeOperationId\s*=\s*operationMap\.get\(emailKey\)[\s\S]{0,100}createRechargeOperationId\(email, cardKey, false\)/,
|
||
'每个账号应复用认领阶段生成的稳定充值操作号');
|
||
assert.match(runner, /operationId:\s*attemptOffset\s*>\s*0[\s\S]{0,180}rechargeOperationId/,
|
||
'只有远端任务明确失败后才应使用新的尝试幂等号');
|
||
assert.match(runner, /使用同一卡密重试/,
|
||
'重试进度应明确提示使用同一卡密');
|
||
assert.match(rechargeService, /error\s*&&\s*error\.retryable\s*===\s*true/,
|
||
'修改型充值请求的瞬时HTTP错误必须保留为待确认');
|
||
assert.match(runner, /rechargeNeedsManualConfirmation\(error, remoteTaskId\)/,
|
||
'最终失败状态必须结合远端任务号判断是否等待人工确认');
|
||
assert.match(operations, /message:\s*account\s*\?\s*\(account\.message/,
|
||
'充值结论应通过 SSE 同步到通用处理结论');
|
||
assert.match(runner, /publishRecharge\([\s\S]{0,180}'等待已确认'/,
|
||
'结果待确认时应转为等待人工确认状态');
|
||
assert.match(operations, /const nextStatus\s*=\s*remoteTaskId\s*\?\s*'充值中'\s*:\s*'充值失败'/,
|
||
'任务中断时已提交任务应保持充值中,未提交账号才标记失败');
|
||
assert.match(accountsRoute, /router\.put\('\/:email\/recharge-status'/,
|
||
'应提供本机人工修改充值状态接口');
|
||
assert.match(html, /<option value="账号本身已有会员">账号本身已有会员<\/option>/,
|
||
'充值状态原地下拉框应包含账号本身已有会员');
|
||
assert.match(appSource, /rechargeStatusOptions:[^\n]*'账号本身已有会员'/,
|
||
'组合筛选的充值状态应包含账号本身已有会员');
|
||
assert.match(accountsRoute, /allowedStatuses[^\n]*'账号本身已有会员'/,
|
||
'服务端应允许人工保存账号本身已有会员');
|
||
assert.match(styles, /\.status-账号本身已有会员\s*\{/,
|
||
'账号本身已有会员应有独立状态样式');
|
||
assert.match(accountsRoute, /router\.get\('\/:email\/recharge-card'/,
|
||
'应提供本机按需查看最近充值卡密的接口');
|
||
assert.match(accountsRoute, /recharge_card_key,[\s\S]{0,180}has_recharge_card/,
|
||
'账号列表只能返回是否有卡密,不能返回卡密明文');
|
||
assert.match(html, /openRechargeCard\(account\)[\s\S]{0,80}查看卡密/,
|
||
'充值状态旁应提供查看卡密按钮');
|
||
assert.match(html, /showRechargeCardModal/,
|
||
'应提供本次充值卡密查看弹窗');
|
||
assert.match(html, /充值完成时间:[\s\S]{0,180}rechargeCardCompletedAt/,
|
||
'查看卡密弹窗应显示本次充值完成时间');
|
||
assert.match(appSource, /CURRENT_TIMESTAMP 保存 UTC[\s\S]{0,500}timeZone:\s*'Asia\/Shanghai'/,
|
||
'数据库时间应按UTC解析并强制显示为北京时间');
|
||
assert.match(accountsRoute, /rechargeStatus:[\s\S]{0,120}rechargedAt:/,
|
||
'卡密接口应同时返回充值状态和完成时间');
|
||
assert.match(html, /@dblclick\.stop="startInlineEditRechargeStatus\(account\.email\)"/);
|
||
assert.match(html, /inlineEditRechargeStatusEmail === account\.email[\s\S]{0,700}<select class="status-select"/,
|
||
'充值状态应使用与状态列一致的原地下拉框');
|
||
for (const status of ['未充值', '充值中', '等待已确认', '充值成功', '充值失败']) {
|
||
assert.match(html, new RegExp(`<option value="${status}">${status}<\\/option>`));
|
||
}
|
||
assert.match(html, /quickSetRechargeStatus\(account\.email, \$event\.target\.value\)/);
|
||
assert.doesNotMatch(appSource, /请输入充值状态/,
|
||
'充值状态不应继续使用手工输入提示框');
|
||
assert.match(appSource, /startsWith\('等待已确认'\)/);
|
||
assert.match(styles, /\.status-等待已确认\s*\{/);
|
||
assert.doesNotMatch(runner, /openai_auth_json[^\n]*card_pay_recharge/);
|
||
|
||
let fetchImpl = async () => ({
|
||
ok: true,
|
||
json: async () => ({ success: true, data: { configured: true, keyIdMasked: 'unit••••mask' } }),
|
||
});
|
||
let latestEventSource = null;
|
||
let promptValue = '等待已确认';
|
||
class TestEventSource {
|
||
constructor(url) {
|
||
this.url = url;
|
||
latestEventSource = this;
|
||
}
|
||
close() { this.closed = true; }
|
||
}
|
||
const context = vm.createContext({
|
||
console, Set, Map, Date, JSON, Math, String, Number, Object, Array, RegExp, Promise,
|
||
setTimeout, clearTimeout,
|
||
fetch: (...args) => fetchImpl(...args),
|
||
confirm: () => true,
|
||
prompt: () => promptValue,
|
||
EventSource: TestEventSource,
|
||
});
|
||
vm.runInContext(`${appSource}\nthis.__geminiApp = geminiApp;`, context, { filename: 'public/app.js' });
|
||
const app = context.__geminiApp();
|
||
assert.match(app.formatDate('2026-08-22 05:51:48'), /13:51:48/,
|
||
'SQLite UTC时间 05:51:48 应显示为北京时间 13:51:48');
|
||
assert.match(app.formatDate('2026-08-22T05:51:48Z'), /13:51:48/,
|
||
'标准UTC时间也应显示为北京时间');
|
||
assert.strictEqual(app.formatDate('invalid-time'), 'invalid-time');
|
||
const logs = [];
|
||
app.addLog = (message, type) => logs.push({ message, type });
|
||
app.accounts = [
|
||
{ email: 'first@example.com' },
|
||
{ email: 'second@example.com' },
|
||
];
|
||
app._accountByEmail = new Map(app.accounts.map(account => [account.email, account]));
|
||
app.selectedEmails = new Set(app.accounts.map(account => account.email));
|
||
app.rechargeCredentialMeta = { configured: true, keyIdMasked: 'unit••••mask', updatedAt: null };
|
||
app.loadRechargeCredentialMeta = async () => {};
|
||
|
||
await app.openRechargeModal();
|
||
assert.strictEqual(app.showRechargeModal, true);
|
||
assert.strictEqual(app.rechargeConcurrency, 2, '充值并发默认值应为 2');
|
||
app.rechargeConcurrency = 99;
|
||
assert.strictEqual(app.normalizedRechargeConcurrency, 5, '充值并发不得超过 5');
|
||
app.rechargeConcurrency = 0;
|
||
assert.strictEqual(app.normalizedRechargeConcurrency, 1, '充值并发不得小于 1');
|
||
app.rechargeConcurrency = 'invalid';
|
||
assert.strictEqual(app.normalizedRechargeConcurrency, 2, '无效充值并发应恢复默认值 2');
|
||
app.rechargeConcurrency = 2;
|
||
assert.deepStrictEqual(
|
||
Array.from(app.rechargeAccounts, row => row.email),
|
||
['first@example.com', 'second@example.com'],
|
||
);
|
||
|
||
app.rechargeCardKeys = 'CARD-ONE\n\nCARD-TWO';
|
||
assert.strictEqual(app.rechargeInputError, '卡密中间不能有空行');
|
||
app.rechargeCardKeys = 'CARD-ONE\nCARD-ONE';
|
||
assert.strictEqual(app.rechargeInputError, '卡密不能重复');
|
||
app.rechargeCardKeys = 'CARD-ONE\nCARD-TWO\n';
|
||
assert.strictEqual(app.rechargeInputError, '');
|
||
|
||
let submitted = null;
|
||
app.runTask = async (taskType, params, accounts) => {
|
||
submitted = { taskType, params, accounts };
|
||
return true;
|
||
};
|
||
await app.confirmRecharge();
|
||
assert.strictEqual(submitted.taskType, 'card_pay_recharge');
|
||
assert.strictEqual(submitted.params.threadCount, 2);
|
||
assert.deepStrictEqual(
|
||
JSON.parse(JSON.stringify(submitted.params.assignments)),
|
||
[
|
||
{ email: 'first@example.com', cardKey: 'CARD-ONE' },
|
||
{ email: 'second@example.com', cardKey: 'CARD-TWO' },
|
||
],
|
||
);
|
||
assert.strictEqual(Object.prototype.hasOwnProperty.call(submitted.params, 'accessToken'), false);
|
||
assert.strictEqual(Object.prototype.hasOwnProperty.call(submitted.params, 'session'), false);
|
||
assert.deepStrictEqual(
|
||
JSON.parse(JSON.stringify(submitted.accounts)),
|
||
[{ email: 'first@example.com' }, { email: 'second@example.com' }],
|
||
'充值任务只应把邮箱传给 Worker,不发送完整账号记录',
|
||
);
|
||
assert.strictEqual(app.showRechargeModal, false);
|
||
assert.strictEqual(app.rechargeCardKeys, '');
|
||
|
||
let request = null;
|
||
fetchImpl = async (url, options) => {
|
||
request = { url, options };
|
||
return {
|
||
ok: true,
|
||
json: async () => ({
|
||
success: true,
|
||
data: { configured: true, keyIdMasked: 'abcd••••wxyz', updatedAt: '2026-08-21T00:00:00Z' },
|
||
}),
|
||
};
|
||
};
|
||
app.rechargeCredentialForm = { keyId: ' api-key-id ', secret: 'unit-secret' };
|
||
await app.saveRechargeCredentials();
|
||
assert.strictEqual(request.url, '/api/recharge-credentials');
|
||
assert.strictEqual(request.options.method, 'PUT');
|
||
assert.deepStrictEqual(JSON.parse(request.options.body), { keyId: 'api-key-id', secret: 'unit-secret' });
|
||
assert.deepStrictEqual(
|
||
JSON.parse(JSON.stringify(app.rechargeCredentialForm)),
|
||
{ keyId: '', secret: '' },
|
||
'保存后必须立即清空前端凭据表单',
|
||
);
|
||
assert.strictEqual(Object.values(app.settings).includes('unit-secret'), false);
|
||
|
||
const account = app.accounts[0];
|
||
account.has_recharge_card = true;
|
||
fetchImpl = async (url, options) => {
|
||
request = { url, options };
|
||
return {
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
success: true,
|
||
data: {
|
||
cardKey: 'CARD-USED-THIS-TIME',
|
||
rechargeStatus: '充值成功',
|
||
rechargedAt: '2026-08-21T00:00:00Z',
|
||
},
|
||
}),
|
||
};
|
||
};
|
||
await app.openRechargeCard(account);
|
||
assert.strictEqual(request.url, '/api/accounts/first%40example.com/recharge-card');
|
||
assert.strictEqual(request.options.cache, 'no-store');
|
||
assert.strictEqual(app.showRechargeCardModal, true);
|
||
assert.strictEqual(app.rechargeCardText, 'CARD-USED-THIS-TIME');
|
||
assert.strictEqual(app.rechargeCardCompletedAt, '2026-08-21T00:00:00Z');
|
||
app.closeRechargeCard();
|
||
assert.strictEqual(app.rechargeCardText, '');
|
||
assert.strictEqual(app.rechargeCardCompletedAt, '');
|
||
|
||
fetchImpl = async (url, options) => {
|
||
request = { url, options };
|
||
return {
|
||
ok: true,
|
||
json: async () => ({
|
||
success: true,
|
||
data: { ...account, recharge_status: '等待已确认', recharge_message: '人工修改为等待已确认' },
|
||
}),
|
||
};
|
||
};
|
||
app._rebuildDashboardStats = () => {};
|
||
app.editRechargeStatus(account);
|
||
assert.strictEqual(app.inlineEditRechargeStatusEmail, account.email);
|
||
await app.quickSetRechargeStatus(account.email, '等待已确认');
|
||
assert.strictEqual(request.url, '/api/accounts/first%40example.com/recharge-status');
|
||
assert.strictEqual(request.options.method, 'PUT');
|
||
assert.deepStrictEqual(JSON.parse(request.options.body), { status: '等待已确认' });
|
||
assert.strictEqual(account.recharge_status, '等待已确认');
|
||
|
||
app._pendingAccountUpdates = new Map([[account.email, {
|
||
message: '充值成功',
|
||
recharge_status: '充值成功',
|
||
recharge_task_id: '123',
|
||
recharge_message: '充值成功',
|
||
recharged_at: '2026-08-21T00:00:00Z',
|
||
has_recharge_card: true,
|
||
}]]);
|
||
app._flushAccountUpdates();
|
||
assert.strictEqual(account.recharge_status, '充值成功');
|
||
assert.strictEqual(account.recharge_task_id, '123');
|
||
assert.strictEqual(account.message, '充值成功');
|
||
assert.strictEqual(account.has_recharge_card, true);
|
||
|
||
app.isRunning = false;
|
||
account.recharge_status = '充值失败';
|
||
account.recharge_message = '开放接口未查到卡密记录,请确认卡密及所属线路';
|
||
account.message = account.recharge_message;
|
||
account.failure_label = '功能失败';
|
||
app.accountLogs = {};
|
||
assert.strictEqual(
|
||
app.getLiveMessage(account),
|
||
'开放接口未查到卡密记录,请确认卡密及所属线路',
|
||
'充值完成后处理结论应显示完整充值原因,不能压缩为通用失败标签',
|
||
);
|
||
|
||
app.loadBrowsers = async () => {};
|
||
app.loadAccounts = async () => {};
|
||
app.isRunning = true;
|
||
app.currentTaskId = 'task-error';
|
||
app._activeTaskType = 'card_pay_recharge';
|
||
app._setActiveTaskEmails([account.email]);
|
||
app.startSSEListener('task-error');
|
||
latestEventSource.onmessage({
|
||
data: JSON.stringify({
|
||
type: 'done',
|
||
result: { taskType: 'card_pay_recharge', success: false, error: '手动停止' },
|
||
}),
|
||
});
|
||
assert.strictEqual(logs[logs.length - 1].message, '⚠️ 任务已停止或异常结束');
|
||
assert.strictEqual(logs[logs.length - 1].type, 'warn');
|
||
|
||
let stopRequest = null;
|
||
fetchImpl = async (url, options) => {
|
||
stopRequest = { url, options };
|
||
return { ok: true, json: async () => ({ success: true }) };
|
||
};
|
||
app.isRunning = true;
|
||
app.currentTaskId = 'task-soft-stop';
|
||
app._activeTaskType = 'card_pay_recharge';
|
||
app.startSSEListener('task-soft-stop');
|
||
const softStopEventSource = latestEventSource;
|
||
await app.stopTask();
|
||
assert.strictEqual(stopRequest.url, '/api/operations/stop/task-soft-stop');
|
||
assert.strictEqual(app.isRunning, true, '充值软停止时应等待 Worker 的 done 事件');
|
||
assert.strictEqual(app.currentTaskId, 'task-soft-stop');
|
||
assert.strictEqual(softStopEventSource.closed, undefined, '充值软停止时不得提前关闭 SSE');
|
||
assert.strictEqual(logs[logs.length - 1].message, '⏳ 正在安全停止充值任务,请等待当前请求完成...');
|
||
|
||
console.log('recharge_ui.test.js: PASS');
|
||
})().catch(error => {
|
||
console.error(error);
|
||
process.exitCode = 1;
|
||
});
|