'use strict'; const assert = require('assert'); const fs = require('fs'); const path = require('path'); const vm = require('vm'); const { runFeishuSyncTask, syncFeishuRefundProcessStatuses, _private, } = require('../src/services/feishu_sync_service'); (async () => { assert.strictEqual(_private.normalizeEmail('Abeja.rArthuro@GoogleMail.com'), 'abejararthuro@gmail.com'); assert.strictEqual(_private.normalizeEmail('Name.Tag+work@gmail.com'), 'nametag@gmail.com'); assert.strictEqual(_private.columnName(14), 'O'); assert.strictEqual(_private.formatRechargeTimeForFeishu('2026-08-22 05:51:48'), '2026-08-22 13:51:48'); assert.strictEqual(_private.formatRechargeTimeForFeishu(''), ''); assert.strictEqual(_private.formatAccountRefundStatusForFeishu('无状态'), '未操作'); assert.strictEqual(_private.formatAccountRefundStatusForFeishu(''), '未操作'); assert.strictEqual(_private.formatAccountRefundStatusForFeishu('已退款'), '已退款'); assert.strictEqual(_private.normalizeSession(''), '', '空 Session 应允许继续同步其他字段'); assert.throws(() => _private.normalizeSession('not-json'), /不是有效 JSON/, '非空无效 Session 仍应阻止覆盖'); assert.strictEqual( _private.parseSpreadsheetUrl('https://tenant.feishu.cn/wiki/wik_test?sheet=sheet1').kind, 'wiki', ); const sessionMarker = 'SESSION_MUST_NEVER_APPEAR_IN_LOGS'; const accounts = [ { email: 'abejararthuro@gmail.com', password: 'password-one', recovery_email: 'recovery-one@example.com', secret_key: 'TWOFAONE', refund_process_status: '已发起', account_refund_status: 'gpt警告', recharged_at: '2026-08-20 01:02:03', chatgpt_session: { accessToken: sessionMarker, user: { email: 'abejararthuro@gmail.com' } }, }, { email: 'new.user@example.com', password: 'password-two', recovery_email: '', secret_key: 'TWOFATWO', refund_process_status: '未发起', recharged_at: '2026-08-22 05:51:48', // 本地无 Session,但飞书已有 AC:同步其他字段时必须保留飞书 AC。 chatgpt_session: '', }, { email: 'without.recharge@example.com', password: 'password-three', recovery_email: 'recovery-three@example.com', secret_key: 'TWOFATHREE', refund_process_status: '发起失败', chatgpt_session: JSON.stringify({ accessToken: 'third-token' }), }, ]; const headers = [ '邮件', '密码', '辅助邮件', '2FA密钥', '是否已申请退款', '是否是速刷', '去处', '周限是否已耗尽', '是否已退款', 'AC', '账号状态', '套餐', '所属账号池', '5h剩余', '7d剩余', '限流重置', '最后同步', '开卡卡号', '充值时间', ]; const requests = []; const httpClient = { async request(config) { requests.push(config); if (config.url.endsWith('/auth/v3/tenant_access_token/internal')) { return { status: 200, data: { code: 0, tenant_access_token: 'tenant-token', expire: 7200 } }; } if (config.url.includes('/sheets/query')) { return { status: 200, data: { code: 0, data: { sheets: [{ sheet_id: 'sheet1', grid_properties: { row_count: 100 } }] } }, }; } if (config.url.includes('/values/') && !config.url.includes('values_batch_update')) { const decoded = decodeURIComponent(config.url); if (decoded.includes('A1:ZZ1')) { return { status: 200, data: { code: 0, data: { valueRange: { values: [headers] } } } }; } if (decoded.includes('S2:S100')) { return { status: 200, data: { code: 0, data: { valueRange: { values: [['飞书已有时间'], ['']] } } } }; } if (decoded.includes('I2:I100')) { return { status: 200, data: { code: 0, data: { valueRange: { values: [['已退款'], ['旧状态']] } } } }; } return { status: 200, data: { code: 0, data: { valueRange: { values: [['Abeja.rArthuro@gmail.com'], ['new.user@example.com']] } } }, }; } if (config.url.includes('/insert_dimension_range')) { return { status: 200, data: { code: 0, msg: 'success', data: {} } }; } if (config.url.includes('/values_batch_update')) { return { status: 200, data: { code: 0, msg: 'success', data: {} } }; } throw new Error(`unexpected request: ${config.method} ${config.url}`); }, }; const logs = []; const progress = []; const result = await runFeishuSyncTask({ emails: ['abejararthuro@gmail.com', 'new.user@example.com', 'without.recharge@example.com'], database: { getAllAccounts: () => accounts, getSetting: (key, fallback) => fallback, }, credentialStore: { loadFeishuCredentials: async () => ({ appId: 'cli_test', appSecret: 'app-secret' }), }, settings: { feishu_document_url: 'https://tenant.feishu.cn/sheets/sht_test?sheet=sheet1', feishu_notify_mode: 'none', }, httpClient, onLog: message => logs.push(message), onProgress: (current, total) => progress.push([current, total]), }); assert.strictEqual(result.success, true); assert.deepStrictEqual(result.successEmails, ['abejararthuro@gmail.com', 'new.user@example.com', 'without.recharge@example.com']); assert.deepStrictEqual(progress.at(-1), [3, 3]); assert.strictEqual(logs.join('\n').includes(sessionMarker), false, '日志不得包含 Session 内容'); const update = requests.find(request => request.url.includes('/values_batch_update')); assert(update, '应调用飞书批量写入接口'); const insert = requests.find(request => request.url.includes('/insert_dimension_range')); assert(insert, '新增账号前应先插入带样式的数据行'); assert.deepStrictEqual(insert.data.dimension, { sheetId: 'sheet1', majorDimension: 'ROWS', startIndex: 3, endIndex: 4, }); assert.strictEqual(insert.data.inheritStyle, 'BEFORE', '新增行应继承上一数据行样式'); const ranges = update.data.valueRanges.map(item => item.range); assert(ranges.includes('sheet1!A2:A2'), 'Gmail 点号等价邮箱应更新原有第2行'); assert(ranges.includes('sheet1!A3:A3'), '本地无 Session 的已有账号也应更新其他字段'); assert(ranges.includes('sheet1!A4:A4'), '未匹配邮箱应新增到下一行'); assert(ranges.includes('sheet1!E2:E2') && ranges.includes('sheet1!E3:E3') && ranges.includes('sheet1!E4:E4'), '是否已申请退款列应同步所有账号的独立退款流程状态'); assert(!ranges.includes('sheet1!I2:I2'), '飞书是否已退款已有“已退款”时不得被本地状态覆盖'); assert(ranges.includes('sheet1!I3:I3') && ranges.includes('sheet1!I4:I4'), '非最终状态账号仍应写入本地退款状态'); assert(ranges.includes('sheet1!J2:J2'), '完整 ChatGPT Session 应写入 AC 列'); assert(!ranges.includes('sheet1!J3:J3'), '本地 ChatGPT Session 为空时不得覆盖飞书已有 AC'); assert(ranges.includes('sheet1!J4:J4'), '本地 ChatGPT Session 有值时应写入并覆盖飞书 AC'); assert(!ranges.includes('sheet1!S2:S2'), '飞书已有充值时间时不得覆盖'); assert(ranges.includes('sheet1!S3:S3'), '本地有充值时间且飞书为空时应写入'); assert(!ranges.includes('sheet1!S4:S4'), '本地没有充值时间时不得写入'); assert(!ranges.some(range => /!Q\d+:Q\d+$/.test(range)), '最后同步列不得写入'); assert(ranges.every(range => /![ABCDEIJS]\d+:[ABCDEIJS]\d+$/.test(range)), '只允许写入固定映射列和符合条件的充值时间'); const processWrites = update.data.valueRanges.filter(item => /!E\d+:E\d+$/.test(item.range)); assert.deepStrictEqual(processWrites.map(item => item.values[0][0]), ['已发起', '未发起', '发起失败']); const refundWrites = update.data.valueRanges.filter(item => /!I\d+:I\d+$/.test(item.range)); assert.deepStrictEqual(refundWrites.map(item => item.values[0][0]), ['未操作', '未操作']); const rechargeTimeWrite = update.data.valueRanges.find(item => item.range === 'sheet1!S3:S3'); assert.strictEqual(rechargeTimeWrite.values[0][0], '2026-08-22 13:51:48', '充值时间应按北京时间写入'); const writtenText = JSON.stringify(update.data); assert(writtenText.includes(sessionMarker), 'AC 列应包含完整结构化 Session JSON'); assert(!writtenText.includes('最后同步'), '写入请求不应引用最后同步列'); const statusOnlyRequests = []; const statusOnlyHttpClient = { async request(config) { statusOnlyRequests.push(config); if (config.url.endsWith('/auth/v3/tenant_access_token/internal')) { return { status: 200, data: { code: 0, tenant_access_token: 'tenant-token', expire: 7200 } }; } if (config.url.includes('/sheets/query')) { return { status: 200, data: { code: 0, data: { sheets: [{ sheet_id: 'sheet1', grid_properties: { row_count: 20 } }] } }, }; } if (config.url.includes('/values_batch_update')) { return { status: 200, data: { code: 0, msg: 'success', data: {} } }; } if (config.url.includes('/values/')) { const decoded = decodeURIComponent(config.url); if (decoded.includes('A1:ZZ1')) { return { status: 200, data: { code: 0, data: { valueRange: { values: [['邮件', '密码', '是否已授权退款', 'AC']] } } }, }; } if (decoded.includes('A2:A20')) { return { status: 200, data: { code: 0, data: { valueRange: { values: [['a@example.com'], ['B.Test@gmail.com']] } } }, }; } } throw new Error(`unexpected status-only request: ${config.method} ${config.url}`); }, }; const statusOnly = await syncFeishuRefundProcessStatuses({ entries: [ { email: 'a@example.com', refundProcessStatus: '已发起' }, { email: 'btest@gmail.com', refundProcessStatus: '发起失败' }, { email: 'missing@example.com', refundProcessStatus: '已发起' }, ], database: { getAccount: () => null, getSetting: (key, fallback) => fallback, }, credentialStore: { loadFeishuCredentials: async () => ({ appId: 'cli_test', appSecret: 'app-secret' }), }, settings: { feishu_document_url: 'https://tenant.feishu.cn/sheets/sht_test?sheet=sheet1' }, httpClient: statusOnlyHttpClient, }); assert.strictEqual(statusOnly.updatedCount, 2); assert.strictEqual(statusOnly.skippedCount, 1); assert.strictEqual(statusOnly.column, '是否已授权退款'); const statusOnlyUpdate = statusOnlyRequests.find(request => request.url.includes('/values_batch_update')); assert(statusOnlyUpdate, '定时退款后应调用飞书批量更新接口'); assert.deepStrictEqual(statusOnlyUpdate.data.valueRanges, [ { range: 'sheet1!C2:C2', values: [['已发起']] }, { range: 'sheet1!C3:C3', values: [['发起失败']] }, ]); assert(statusOnlyUpdate.data.valueRanges.every(item => /!C\d+:C\d+$/.test(item.range)), '定时退款回写只能修改退款流程状态列'); 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'); assert.match(html, /@click="runFeishuSync\(\)"/); assert.match(html, /class="btn btn-green"[^>]*@click="runFeishuSync\(\)"/); assert.match(html, /“最后同步”不会读取或写入/); assert.match(html, /x-model="feishuCredentialForm\.appSecret"/); let submittedBody = null; class FakeEventSource { constructor(url) { this.url = url; } close() {} } const context = { console, URL, EventSource: FakeEventSource, fetch: async (url, init) => { assert.strictEqual(url, '/api/feishu/sync'); submittedBody = JSON.parse(init.body); return { ok: true, json: async () => ({ success: true, taskId: 'feishu-test' }) }; }, localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} }, setTimeout, clearTimeout, setInterval: () => 0, clearInterval, confirm: () => true, prompt: () => null, document: { addEventListener: () => {}, body: { classList: { add: () => {}, remove: () => {} } } }, navigator: { clipboard: { writeText: async () => {} } }, Blob, }; vm.createContext(context); vm.runInContext(`${appSource}\nthis.__geminiApp = geminiApp;`, context, { filename: 'public/app.js' }); const app = context.__geminiApp(); app.selectedEmails = new Set(['selected@example.com']); app.feishuCredentialMeta.configured = true; app.settings.feishu_document_url = 'https://tenant.feishu.cn/sheets/sht_test?sheet=sheet1'; app.clearLogs = () => {}; app.addLog = () => {}; await app.runFeishuSync(); assert.deepStrictEqual(Array.from(submittedBody.emails), ['selected@example.com']); assert.deepStrictEqual(Object.keys(submittedBody), ['emails'], '前端同步请求只能提交邮箱列表'); console.log('feishu_sync.test.js passed'); })().catch(error => { console.error(error); process.exitCode = 1; });