Files
go_bt/tests/quota_monitor.test.js
T

339 lines
17 KiB
JavaScript

'use strict';
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'quota-monitor-'));
const Config = require('../src/core/config');
Config.DATA_DIR = tempDir;
Config.DB_PATH = path.join(tempDir, 'accounts.db');
const database = require('../src/core/database');
const { runQuotaMonitor, _private } = require('../src/services/quota_monitor_service');
const { sendFeishuQuotaReport } = require('../src/services/feishu_sync_service');
const schedulerPrivate = require('../src/services/quota_monitor_scheduler')._private;
(async () => {
try {
database.initDB();
const tables = database.getDb().prepare(`
SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'quota_monitor_%' ORDER BY name
`).all().map(row => row.name);
assert.deepStrictEqual(tables, ['quota_monitor_items', 'quota_monitor_runs']);
assert(database.getDb().pragma('table_info(quota_monitor_runs)').some(row => row.name === 'skipped_401_count'));
const scheduledWrites = [];
const scheduledSession = JSON.stringify({ accessToken: 'old-session-access', sessionToken: 'scheduled-session-token' });
const scheduledSessionResult = JSON.stringify({ accessToken: 'new-session-access', sessionToken: 'scheduled-session-token' });
const scheduledOnly = await _private.queryAccount({
email: 'scheduled-session-only@gmail.com',
openai_auth_json: null,
chatgpt_session: scheduledSession,
chatgpt_status: '已登录',
proxy_ip: '',
}, {
database: {
updateAccountOpenAIPlanStatus: (...args) => { scheduledWrites.push(['planStatus', ...args]); return true; },
updateAccountChatgptResult: (...args) => { scheduledWrites.push(['session', ...args]); return true; },
updateAccountOpenAIAuth: (...args) => { scheduledWrites.push(['auth', ...args]); return true; },
updateAccountOpenAIPlan: (...args) => { scheduledWrites.push(['plan', ...args]); return true; },
},
onLog: () => {},
delay: async () => {},
query: async (authJson, _proxy, _log, options) => {
assert.deepStrictEqual(authJson, {});
assert.strictEqual(options.sessionText, scheduledSession);
assert.strictEqual(options.expectedEmail, 'scheduled-session-only@gmail.com');
return {
success: true,
authJson: {
access_token: 'new-session-access',
usage: { rate_limit: { primary_window: { used_percent: 100 } } },
},
sessionText: scheduledSessionResult,
details: { usage: { rate_limit: { primary_window: { used_percent: 100 } } }, errors: [] },
};
},
});
assert.strictEqual(scheduledOnly.success, true, '定时查询不能因缺少授权 JSON 跳过 Session 账号');
assert(scheduledWrites.some(call => call[0] === 'session' && call[3] === scheduledSessionResult));
assert(scheduledWrites.some(call => call[0] === 'auth'));
const accounts = [
['steady@example.com', 'steady-pass', 99, 'steady-session', 'proxy-a:1080:user:pass'],
['new-high@example.com', 'new-pass', 100, 'new-session', 'proxy-b:1080:user:pass'],
['new-high-two@example.com', 'new-pass-two', 100, 'new-session-two', 'proxy-b2:1080:user:pass'],
['already-high@example.com', 'already-pass', 100, 'already-session', 'proxy-b3:1080:user:pass'],
['steady-failure@example.com', 'failure-pass', null, 'failure-session', 'proxy-c:1080:user:pass'],
['stale-usage@example.com', 'stale-pass', null, 'stale-session', 'proxy-d:1080:user:pass'],
['below@example.com', 'below-pass', 98.9, 'below-session', 'proxy-e:1080:user:pass'],
['proxy-fallback@example.com', 'fallback-pass', 97, 'fallback-session', 'proxy-f:1080:user:pass'],
];
for (const [email, password, usage, session, proxy] of accounts) {
database.upsertAccount({ email, password, recovery_email: `${email}.recovery`, secret_key: `2fa-${email}`, status: '已充值提取' });
database.updateAccountProxyIp(email, proxy);
database.updateAccountChatgptResult(email, '已登录', JSON.stringify({ accessToken: session }));
database.updateAccountOpenAIAuth(email, '已提取 JSON', JSON.stringify({
access_token: email,
refresh_token: `refresh-${email}`,
plan_type: 'plus',
usage: usage == null
? { rate_limit: { primary_window: { used_percent: 100 } } }
: { rate_limit: { primary_window: { used_percent: usage } } },
}));
}
database.updateAccountRefundStatus('steady@example.com', '已退款');
database.updateAccountRefundProcessStatus('already-high@example.com', '已发起');
const previousRunId = database.createQuotaMonitorRun({ taskId: 'previous', triggerType: 'scheduled', threshold: 99, eligibleCount: 2 });
database.saveQuotaMonitorItems(previousRunId, [
{ email: 'steady@example.com', category: 'threshold', usagePercent: 99, isNew: true },
{ email: 'steady-failure@example.com', category: 'failure', failureReason: 'Token失效(401)', isNew: true },
]);
database.completeQuotaMonitorRun(previousRunId, {
successCount: 1,
thresholdCount: 1,
failureCount: 1,
thresholdNewCount: 1,
failureNewCount: 1,
deliveryStatus: 'sent',
});
const proxyCalls = [];
const query = async (authJson, proxy, _log, options) => {
proxyCalls.push([authJson.access_token, proxy, options.connectionMode]);
if (authJson.access_token === 'steady-failure@example.com') {
const error = new Error('access_token 无法查询套餐和用量');
error.code = 'OPENAI_ACCOUNT_QUERY_UNAUTHORIZED';
return { success: false, authJson, error };
}
if (authJson.access_token === 'stale-usage@example.com') {
return { success: true, authJson, details: { usage: null, errors: [{ source: 'usage', status: 503 }] } };
}
if (authJson.access_token === 'proxy-fallback@example.com' && options.connectionMode === 'browser_proxy') {
const error = new Error('账号代理连接失败');
error.code = 'OPENAI_PLAN_PROXY_CONNECTION_FAILED';
return { success: false, authJson, error };
}
const source = accounts.find(item => item[0] === authJson.access_token);
const usedPercent = source[2];
return {
success: true,
authJson: { ...authJson, usage: { rate_limit: { primary_window: { used_percent: usedPercent } } } },
details: { usage: { rate_limit: { primary_window: { used_percent: usedPercent } } }, errors: [] },
};
};
let sentReport = null;
let syncedRefundEntries = null;
const refundCalls = [];
let activeRefunds = 0;
let maxActiveRefunds = 0;
const result = await runQuotaMonitor({
database,
taskId: 'quota-current',
triggerType: 'manual',
threshold: 100,
concurrency: 2,
autoRefundEnabled: true,
query,
loadRefundCredentials: async () => ({ accessToken: 'owner-token', proxies: ['socks5://proxy:1080'] }),
executeRefund: async payload => {
activeRefunds += 1;
maxActiveRefunds = Math.max(maxActiveRefunds, activeRefunds);
refundCalls.push(payload.expectedEmail);
await new Promise(resolve => setImmediate(resolve));
activeRefunds -= 1;
return { success: true, cleanupOk: true };
},
sendReport: async payload => { sentReport = payload; return { success: true }; },
syncRefundStatuses: async payload => {
syncedRefundEntries = payload.entries.map(item => ({
email: item.email,
status: item.refundProcessStatus,
result: item.refundResult,
}));
return { success: true, updatedCount: payload.entries.length, skippedCount: 0 };
},
delay: async () => {},
});
assert.strictEqual(result.success, true);
assert.strictEqual(result.eligibleCount, 7);
assert.strictEqual(result.successCount, 5);
assert.strictEqual(result.thresholdCount, 3);
assert.strictEqual(result.failureCount, 2);
assert.strictEqual(result.thresholdPreviousCount, 1);
assert.strictEqual(result.failurePreviousCount, 1);
assert.strictEqual(result.thresholdNewCount, 3);
assert.strictEqual(result.failureNewCount, 1);
assert.strictEqual(result.thresholdRemovedCount, 1);
assert.strictEqual(result.failureRecoveredCount, 0);
assert.strictEqual(result.refundCandidateCount, 2);
assert.strictEqual(result.refundAttemptedCount, 2);
assert.strictEqual(result.refundSuccessCount, 2);
assert.strictEqual(result.refundFailureCount, 0);
assert.strictEqual(result.refundSkippedCount, 0);
assert.deepStrictEqual(refundCalls, ['new-high@example.com', 'new-high-two@example.com']);
assert.strictEqual(maxActiveRefunds, 1, '自动退款必须固定单路串行');
assert.deepStrictEqual(syncedRefundEntries, [
{ email: 'new-high@example.com', status: '已发起', result: '成功' },
{ email: 'new-high-two@example.com', status: '已发起', result: '成功' },
], '额度自动退款结束后应只回写本轮已处理账号的退款流程状态');
assert.strictEqual(database.getAccount('new-high@example.com').refund_process_status, '已发起');
assert.strictEqual(database.getAccount('new-high-two@example.com').refund_process_status, '已发起');
assert.strictEqual(database.getAccount('already-high@example.com').refund_process_status, '已发起');
assert.strictEqual(database.getAccount('new-high@example.com').account_refund_status, '无状态', '自动退款不得修改账号退款字段');
assert(proxyCalls.some(call => call[0] === 'new-high@example.com' && call[1] === 'proxy-b:1080:user:pass'));
assert(!proxyCalls.some(call => call[0] === 'steady@example.com'), '退款状态为已退款的账号必须跳过查询');
assert.strictEqual(proxyCalls.filter(call => call[0] === 'proxy-fallback@example.com' && call[2] === 'browser_proxy').length, 1, '明确的代理连接失败不应重复代理请求');
assert(proxyCalls.some(call => call[0] === 'proxy-fallback@example.com' && call[1] === '' && call[2] === 'direct'), '代理连接失败后必须自动直连');
assert(sentReport, '应发送飞书报告');
assert.strictEqual(sentReport.files.length, 1);
assert(sentReport.files[0].name.startsWith('额度监控结果_'));
const reportCsv = sentReport.files[0].content.toString('utf8');
assert(reportCsv.startsWith(`\uFEFF${_private.CSV_HEADERS.map(value => `"${value}"`).join(',')}`));
assert(!reportCsv.includes('steady@example.com'), '退款状态为已退款的账号不得进入报告');
assert(reportCsv.includes('"无状态","已发起","100%","new-high@example.com","new-pass"'));
assert(reportCsv.includes('"new-high@example.com.recovery","2fa-new-high@example.com","{""accessToken"":""new-session""}","查询成功","成功","","是",""'));
assert(reportCsv.includes('"查询成功","成功","","是",""'));
assert(reportCsv.includes('"无状态","未发起","","steady-failure@example.com"'));
assert(reportCsv.includes('Token失效(401)'));
assert(reportCsv.includes('"stale-usage@example.com"'));
assert(reportCsv.includes('本轮未返回额度数据'));
assert(!reportCsv.includes('proxy-fallback@example.com'), '直连成功且额度未达到阈值的账号不应进入报告');
const currentRun = database.getQuotaMonitorRun(result.runId);
assert.strictEqual(currentRun.status, 'completed');
assert.strictEqual(currentRun.delivery_status, 'sent');
const storedItems = database.getQuotaMonitorItems(result.runId);
assert.strictEqual(storedItems.length, 5);
assert.strictEqual(currentRun.auto_refund_enabled, 1);
assert.strictEqual(currentRun.refund_success_count, 2);
assert.strictEqual(Object.prototype.hasOwnProperty.call(storedItems[0], 'password'), false, '报告历史不得保存密码');
assert.strictEqual(database.getAccount('steady@example.com').status, '已充值提取');
const skip401Email = 'skip-401@example.com';
database.upsertAccount({
email: skip401Email,
password: 'skip-pass',
recovery_email: 'skip-recovery@example.com',
secret_key: 'skip-2fa',
status: '已充值提取',
});
database.updateAccountOpenAIAuth(skip401Email, '已提取 JSON', JSON.stringify({
access_token: 'skip-access',
refresh_token: 'skip-refresh',
}));
database.updateAccountOpenAIPlanStatus(skip401Email, 'Token失效(401)');
for (const account of database.getAccountsByStatuses(['已充值提取'])) {
if (account.email !== skip401Email) database.updateAccountRefundStatus(account.email, '已退款');
}
let skippedQueryCalls = 0;
let skippedReport = null;
const skip401Result = await runQuotaMonitor({
database,
taskId: 'quota-skip-401',
triggerType: 'manual',
threshold: 100,
concurrency: 2,
skip401Enabled: true,
autoRefundEnabled: false,
query: async () => {
skippedQueryCalls += 1;
throw new Error('明确 401 账号不应进入查询');
},
sendReport: async payload => { skippedReport = payload; return { success: true }; },
delay: async () => {},
});
assert.strictEqual(skippedQueryCalls, 0, '开启设置后明确 401 账号不得再次发起套餐请求');
assert.strictEqual(skip401Result.eligibleCount, 0);
assert.strictEqual(skip401Result.skipped401Count, 1);
assert.strictEqual(skip401Result.failureCount, 0, '跳过 401 不应重复计入查询失败');
assert(skippedReport.text.includes('明确 401 跳过 1'));
assert.strictEqual(database.getQuotaMonitorRun(skip401Result.runId).skipped_401_count, 1);
const feishuRequests = [];
const feishuHttpClient = {
async request(config) {
feishuRequests.push(config);
if (config.url.endsWith('/auth/v3/tenant_access_token/internal')) {
return { status: 200, data: { code: 0, tenant_access_token: 'tenant-token' } };
}
if (config.url.endsWith('/im/v1/files')) {
return { status: 200, data: { code: 0, data: { file_key: `file-${feishuRequests.length}` } } };
}
if (config.url.endsWith('/im/v1/messages')) {
return { status: 200, data: { code: 0, data: {} } };
}
throw new Error(`unexpected request ${config.url}`);
},
};
const sent = await sendFeishuQuotaReport({
database: { getSetting: (key, fallback) => ({
feishu_notify_mode: 'private',
feishu_notify_receive_id_type: 'email',
feishu_notify_receive_id: 'owner@example.com',
}[key] || fallback) },
credentialStore: { loadFeishuCredentials: async () => ({ appId: 'cli_test', appSecret: 'secret' }) },
httpClient: feishuHttpClient,
text: '监控完成',
files: [{ name: 'report.csv', content: Buffer.from('test') }],
});
assert.strictEqual(sent.fileCount, 1);
const upload = feishuRequests.find(request => request.url.endsWith('/im/v1/files'));
assert(upload && upload.data instanceof FormData);
assert.strictEqual(Object.keys(upload.headers).some(key => key.toLowerCase() === 'content-type'), false, 'multipart 边界必须由运行时生成');
const messages = feishuRequests.filter(request => request.url.endsWith('/im/v1/messages'));
assert.strictEqual(messages.length, 2);
assert.strictEqual(messages[0].data.msg_type, 'text');
assert.strictEqual(messages[1].data.msg_type, 'file');
const fakeSettings = new Map([
['quota_monitor_enabled', '1'],
['quota_monitor_interval_value', '2'],
['quota_monitor_interval_unit', 'hours'],
['quota_monitor_concurrency', '20'],
['quota_monitor_threshold', '100'],
['quota_monitor_skip_401_enabled', '1'],
['quota_monitor_auto_refund_enabled', '1'],
]);
const fakeDb = {
getSetting: (key, fallback) => fakeSettings.has(key) ? fakeSettings.get(key) : fallback,
setSetting: (key, value) => fakeSettings.set(key, value),
};
const config = schedulerPrivate.readConfig(fakeDb);
assert.strictEqual(config.enabled, true);
assert.strictEqual(config.intervalMs, 2 * 60 * 60 * 1000);
assert.strictEqual(config.concurrency, 10);
assert.strictEqual(config.threshold, 100);
assert.strictEqual(config.skip401Enabled, true);
assert.strictEqual(config.autoRefundEnabled, true);
assert(schedulerPrivate.ensureNextRun(config, fakeDb, true) instanceof Date);
const root = path.join(__dirname, '..');
const html = fs.readFileSync(path.join(root, 'public', 'index.html'), 'utf8');
const app = fs.readFileSync(path.join(root, 'public', 'app.js'), 'utf8');
const server = fs.readFileSync(path.join(root, 'server.js'), 'utf8');
assert.match(html, /额度监控与自动退款/);
assert.match(html, /@click="runQuotaMonitorNow\(\)"/);
assert.match(html, /quota_monitor_concurrency/);
assert.match(html, /quota_monitor_threshold/);
assert.match(html, /quota_monitor_skip_401_enabled/);
assert.match(html, /quota_monitor_auto_refund_enabled/);
assert.match(html, /quota_monitor_interval_unit[\s\S]*settings-select/);
assert.match(html, /feishu_notify_mode[\s\S]*settings-select/);
assert.match(app, /\/api\/quota-monitor\/run/);
assert.match(server, /\/api\/quota-monitor/);
console.log('quota_monitor.test.js: PASS');
} finally {
try { database.getDb().close(); } catch (_) {}
fs.rmSync(tempDir, { recursive: true, force: true });
}
})().catch(error => {
console.error(error);
process.exitCode = 1;
});