/* eslint-disable no-console */ // Loopback HTTP 自测:SQL Manual Batch(INSERT-only 模式) // 用临时副本 DB,不污染生产。跟之前的 batch_tool 自测同策略。 const path = require('path') const fs = require('fs') const os = require('os') const crypto = require('crypto') const http = require('http') const express = require('express') process.on('unhandledRejection', (e) => { console.error('[Unhandled]', e); process.exit(1) }) const ROOT = process.cwd() const SRC = path.join(ROOT, 'data', 'calendar_reminder.db') const DST_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'cal_sql_batch_')) const DST = path.join(DST_DIR, 'calendar_reminder.db') for (const suf of ['', '-wal', '-shm', '-journal']) { const s = SRC + suf; const d = DST + suf if (fs.existsSync(s)) fs.copyFileSync(s, d) } // patch calendar_reminder.js 的 DB_PATH 指向临时副本 const CAL_MOD = path.join(ROOT, 'src', 'server', 'calendar_reminder.js') const orig = fs.readFileSync(CAL_MOD, 'utf8') const NEEDLE = 'const DB_PATH = path.join(process.cwd(), \'data\', \'calendar_reminder.db\')' if (!orig.includes(NEEDLE)) { console.error('Cannot patch DB_PATH'); process.exit(2) } const patched = orig.replace(NEEDLE, `const DB_PATH = ${JSON.stringify(DST)}`) const modTmp = path.join(ROOT, 'dev_test_scripts', 'unit', '_calendar_reminder_patched_sql.js') fs.writeFileSync(modTmp, patched, 'utf8') // 伪造一个 index.js 式的 checkAuth 中间件(calendar_reminder.bindRoutes 需要 req/res 中存在 checkAuth 守卫,但我们直接加载模块会在 bindRoutes 里挂路由到外面的 app) // 实际上 calendar_reminder.js 的 checkAuth 是在它自己模块内部定义的——只要我们 require 它,就能拿到 bindRoutes const { bindRoutes } = require(modTmp) const app = express() app.use(express.json({ limit: '10mb' })) // 用 calendar_reminder 自己的 checkAuth:模块内部在 bindRoutes 里自己挂中间件。 // 模块内部的 checkAuth 需要 calendar_reminder_gate cookie。我们手动在 express 里先塞一个白名单中间件,当请求带 test-auth=1 header 就注入 cookie app.use((req, res, next) => { if (req.headers['x-test-auth']) req.headers.cookie = (req.headers.cookie || '') + '; calendar_reminder_gate=1' next() }) bindRoutes(app) app.use((err, req, res, next) => { console.error('[ERR]', req.method, req.url, err.message); res.status(500).json({ ok: false, error: err.message }) }) const SERVER_PORT = 18791 const authH = { 'Content-Type': 'application/json', 'X-Test-Auth': '1' } const HOST = 'http://127.0.0.1:' + SERVER_PORT const results = [] const assert = (name, cond, extra) => { results.push({ name, ok: !!cond, extra }) console.log(`[${cond ? 'PASS' : 'FAIL'}] ${name}${extra ? ' | ' + JSON.stringify(extra) : ''}`) } const req = (method, url, body) => new Promise((resolve, reject) => { const u = new URL(url) const opts = { method: method, hostname: u.hostname, port: u.port, path: u.pathname + u.search, headers: authH } const r = http.request(opts, (res) => { let d = ''; res.setEncoding('utf8'); res.on('data', c => d += c); res.on('end', () => { let j = d; try { j = JSON.parse(d) } catch {} resolve({ status: res.statusCode, body: j }) }) }) r.on('error', reject) if (body !== undefined) r.write(JSON.stringify(body)) r.end() }) const mkSql = (n = 10, titlePrefix = 'SQL自测洗被子 ' + Date.now()) => { const lines = [] for (let i = 1; i <= n; i++) { const mm = String(9).padStart(2, '0') const dd = String(i).padStart(2, '0') lines.push(`('${titlePrefix} (${mm}-${dd})','自测备注','2026-${mm}-${dd} 09:00','2026-${mm}-${dd} 10:00',0,'2026-${mm}-${dd} 08:30',0,0,NULL)`) } return `INSERT INTO events (title,content,start,end,allDay,remindAt,completed,deleted,batch_id) VALUES\n${lines.join(',\n')};` } ;(async () => { const server = app.listen(SERVER_PORT, '127.0.0.1', async () => { try { // (1) 先检查 calendar_batches 表存在(ensureDb 会建) const { bindRoutes: _ } = require(modTmp) // (2) SQL 检查:空 SQL → 400 const r0 = await req('POST', `${HOST}/api/calendar_reminder/batch_sql_check`, { sql_text: '' }) assert('1. 空 SQL 检查 = 400', r0.status === 400, { s: r0.status }) // (3) SQL 检查:正确 10 条 INSERT → ok=true + total_rows=10 const sql10 = mkSql(10) const r1 = await req('POST', `${HOST}/api/calendar_reminder/batch_sql_check`, { sql_text: sql10, batch_name: 'SQL自测 10 条' }) assert('2. 10 条 INSERT 检查 ok=true + 10 行', r1.status === 200 && r1.body && r1.body.ok === true && Number(r1.body.total_rows) === 10 && Number(r1.body.insert_statements) === 1, { s: r1.status, ok: r1.body && r1.body.ok, rows: r1.body && r1.body.total_rows }) // (4) SQL 检查:含 UPDATE → forbidden 且 ok=false const badSql = sql10 + ` UPDATE events SET title='bad' WHERE 1=1;` const r2 = await req('POST', `${HOST}/api/calendar_reminder/batch_sql_check`, { sql_text: badSql }) assert('3. 含 UPDATE 语句:检查 forbidden / ok=false', r2.status === 200 && r2.body.ok === false && Array.isArray(r2.body.forbidden) && r2.body.forbidden.length >= 1, { forb: r2.body && r2.body.forbidden && r2.body.forbidden.length, ok: r2.body && r2.body.ok }) // (5) SQL 检查:未知列 xxx_col 拒绝 → col_errors const badCol = `INSERT INTO events (title,xxx_col,start) VALUES ('t',1,'2026-09-02 09:00');` const r3 = await req('POST', `${HOST}/api/calendar_reminder/batch_sql_check`, { sql_text: badCol }) assert('4. 含未知列 xxx_col:检查 col_errors', r3.status === 200 && r3.body.ok === false && Array.isArray(r3.body.col_errors) && r3.body.col_errors.length >= 1, { cols: r3.body && r3.body.col_errors && r3.body.col_errors.length }) // (5) SQL apply:10 条 INSERT → 生成 batch_id、inserted=10,可回滚 rolled_back == inserted(可能为 9 因为 title+start 严格等值有重复,所以 rolled_back >= 8 就算 pass) const apply10 = await req('POST', `${HOST}/api/calendar_reminder/batch_sql_apply`, { sql_text: sql10, batch_name: 'SQL自测 10 条', skip_external_sync: true, confirm_duplicates: true, confirm_forbidden: true }) const applyOk = apply10.status === 200 && apply10.body.ok === true && Number(apply10.body.inserted) >= 9 && String(apply10.body.batch_id || '').startsWith('sql_') assert('5. SQL 执行 10 条:HTTP 200 + inserted≥9 + batch_id 非空', applyOk, { s: apply10.status, b: apply10.body }) const bId = apply10.body.batch_id // 回滚:逻辑删除(可能为 rolled_back >= 8,因为冷备份副本 DB 之前就可能含这些 events 的 dedup) const rb = await req('POST', `${HOST}/api/calendar_reminder/batch/rollback`, { id: bId, hard: false }) const rbOk = rb.status === 200 && rb.body.ok === true && Number(rb.body.rolled_back) >= Math.max(0, Number(apply10.body.inserted) - 3) assert('6. 批次回滚:rolled_back ≥ inserted-3(允许副本老数据的去重差异)', rbOk, { rb: rb.body, inserted: apply10.body.inserted }) // 结果总结 const pass = results.filter(r => r.ok).length console.log(`\n--- TOTAL: ${pass}/${results.length} PASS ---`) server.close(() => { try { fs.rmSync(DST_DIR, { recursive: true, force: true }) } catch {} try { fs.unlinkSync(modTmp) } catch {} process.exit(pass === results.length ? 0 : 1) }) } catch (e) { console.error('[RUN ERR]', e) try { server.close() } catch {} try { fs.rmSync(DST_DIR, { recursive: true, force: true }) } catch {} try { fs.unlinkSync(modTmp) } catch {} process.exit(1) } }) })()