// 集成测试:角色隔离验证 + 审核状态流转 const http = require('http') const { spawn } = require('child_process') const path = require('path') const { mkdirSync, readFileSync, readdirSync } = require('node:fs') const { DatabaseSync } = require('node:sqlite') const { randomUUID } = require('node:crypto') const ROOT = path.resolve(__dirname, '../..') const API_PORT = 18787 const BASE_URL = `http://127.0.0.1:${API_PORT}` let serverProc = null // ── 在测试进程内创建独立测试数据库 ───────────────────────────────────────── const testDataDir = path.join(ROOT, 'data', 'test-' + randomUUID().slice(0, 8)) mkdirSync(testDataDir, { recursive: true }) const TEST_DB_PATH = path.join(testDataDir, 'test.db') const testDb = new DatabaseSync(TEST_DB_PATH) testDb.exec(`PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;`) const migrationFiles = readdirSync(path.join(ROOT, 'migrations')) .filter(f => f.endsWith('.sql') && !f.startsWith('_')).sort() for (const file of migrationFiles) { testDb.exec(readFileSync(path.join(ROOT, 'migrations', file), 'utf-8')) } const now = new Date().toISOString().slice(0, 10) testDb.exec(` INSERT INTO stations (code, name, region, address, leader, phone, established_at, status) VALUES ('STAT_BJ', '北京记者站', '华北', '北京市朝阳区', '苏明远', '010-12345678', '${now}', 'active'), ('STAT_SH', '上海记者站', '华东', '上海市浦东新区', '王海涛', '021-87654321', '${now}', 'active'), ('STAT_GZ', '广州记者站', '华南', '广州市天河区', '李娜', '020-11112222', '${now}', 'inactive'); INSERT INTO people (code, name, station, title, phone, joined_at, status) VALUES ('P_BJ_01', '林致远', '北京记者站', '总编辑', '13800000001', '${now}', 'active'), ('P_BJ_02', '苏明远', '北京记者站', '站长', '13800000002', '${now}', 'active'), ('P_BJ_03', '林晓', '北京记者站', '记者', '13800000003', '${now}', 'active'), ('P_SH_01', '王海涛', '上海记者站', '站长', '13900000001', '${now}', 'active'), ('P_SH_02', '张文', '上海记者站', '记者', '13900000002', '${now}', 'active'); INSERT INTO work_records (id, title, type, reporter, station, occurred_date, platform, status, score, description) VALUES ('WK-20260801-0001', '采访人工智能大会', 'interview', '林晓', '北京记者站', '${now}', '新华社客户端', 'archived', 85, '报道AI前沿技术'), ('WK-20260801-0002', '深度调研报告', 'report', '林晓', '北京记者站', '${now}', '自主平台', 'archived', 90, '产业调研'), ('WK-20260801-0003', '突发新闻采集', 'news', '林晓', '北京记者站', '${now}', '微博', 'station_review', null, '地震新闻'), ('WK-20260801-0004', '市场分析', 'report', '张文', '上海记者站', '${now}', '财经网站', 'headquarters_review', 78, '金融市场'); `) testDb.close() // ── 工具函数 ──────────────────────────────────────────────────────────────── function api(role, method, urlPath, body) { return new Promise((resolve, reject) => { const bodyStr = body ? JSON.stringify(body) : undefined const opts = { hostname: '127.0.0.1', port: API_PORT, path: urlPath, method, headers: { 'Content-Type': 'application/json', 'x-user-role': role, ...(bodyStr ? { 'Content-Length': Buffer.byteLength(bodyStr) } : {}), }, } const req = http.request({ ...opts, path: encodeURI(urlPath) }, (res) => { let data = '' res.on('data', d => data += d) res.on('end', () => { try { resolve({ status: res.statusCode, body: JSON.parse(data) }) } catch { resolve({ status: res.statusCode, body: data }) } }) }) req.on('error', reject) if (bodyStr) req.write(bodyStr) req.end() }) } function sleep(ms) { return new Promise(r => setTimeout(r, ms)) } beforeAll(async () => { serverProc = spawn('node', ['server/index.js'], { cwd: ROOT, env: { ...process.env, API_PORT: String(API_PORT), DATABASE_PATH: TEST_DB_PATH, NODE_ENV: 'test' }, stdio: ['ignore', 'pipe', 'pipe'], }) serverProc.on('error', err => { throw err }) // 等待服务器就绪 for (let i = 0; i < 60; i++) { await sleep(200) try { const res = await fetch(`${BASE_URL}/api/health`) if (res.ok) return } catch {} } throw new Error(`测试服务器启动超时(端口 ${API_PORT})`) }, 30000) afterAll(() => { serverProc?.kill('SIGTERM') }) // ═══════════════════════════════════════════════════════════════════════════ // 测试集 1:角色隔离 // ═══════════════════════════════════════════════════════════════════════════ describe('角色隔离', () => { describe('GET /api/records', () => { it('总部可见所有站点记录', async () => { const res = await api('headquarters', 'GET', '/api/records') expect(res.status).toBe(200) expect(Array.isArray(res.body)).toBe(true) }) it('站点仅可见本站记录', async () => { const res = await api('station', 'GET', '/api/records') expect(res.status).toBe(200) for (const r of res.body) expect(r.station).toBe('北京记者站') }) it('记者仅可见本人记录', async () => { const res = await api('reporter', 'GET', '/api/records') expect(res.status).toBe(200) for (const r of res.body) expect(r.reporter).toBe('林晓') }) }) describe('POST /api/people(权限校验)', () => { it('站点角色不能新增人员', async () => { const res = await api('station', 'POST', '/api/people', { name: '测试人员', station: '北京记者站', title: '记者', }) expect(res.status).toBe(403) }) it('记者角色不能新增人员', async () => { const res = await api('reporter', 'POST', '/api/people', { name: '测试人员', station: '北京记者站', title: '记者', }) expect(res.status).toBe(403) }) }) describe('DELETE /api/stations(权限校验)', () => { it('站点角色不能删除记者站', async () => { const list = await api('headquarters', 'GET', '/api/stations') const bj = list.body.find(s => s.code === 'STAT_BJ') const res = await api('station', 'DELETE', `/api/stations/${bj.id}`) expect(res.status).toBe(403) }) }) describe('POST /api/notices(权限校验)', () => { it('站点角色不能发布通知', async () => { const res = await api('station', 'POST', '/api/notices', { title: '测试通知', content: '内容', priority: 'normal', scope: 'all', }) expect(res.status).toBe(403) }) }) }) // ═══════════════════════════════════════════════════════════════════════════ // 测试集 2:审核状态流转 // ═══════════════════════════════════════════════════════════════════════════ describe('审核状态流转', () => { it('记者提交记录 -> station_review', async () => { const res = await api('reporter', 'POST', '/api/records', { title: '测试采访', type: 'interview', date: '2026-08-01', platform: '客户端', description: '集成测试提交', }) expect(res.status).toBe(201) expect(res.body.status).toBe('station_review') expect(res.body.reporter).toBe('林晓') }) it('总部不能代替记者填报', async () => { const res = await api('headquarters', 'POST', '/api/records', { title: '总部代写', type: 'news', date: '2026-08-01', platform: '网站', }) expect(res.status).toBe(403) expect(res.body.message).toContain('不能代替记者') }) it('站点审核通过 -> headquarters_review', async () => { const list = await api('station', 'GET', '/api/records') const pending = list.body.find(r => r.status === 'station_review') if (!pending) { console.warn('无可待审记录,跳过'); return } const res = await api('station', 'PATCH', `/api/records/${pending.id}/review`, { decision: 'pass', score: 88, note: '测试通过', }) expect(res.status).toBe(200) expect(res.body.status).toBe('headquarters_review') expect(res.body.score).toBe(88) }) it('站点审核退回 -> returned(必须填原因)', async () => { const list = await api('station', 'GET', '/api/records') const pending = list.body.find(r => r.status === 'station_review') if (!pending) { console.warn('无可待审记录,跳过'); return } const res = await api('station', 'PATCH', `/api/records/${pending.id}/review`, { decision: 'return', note: '材料不完整', }) expect(res.status).toBe(200) expect(res.body.status).toBe('returned') }) it('站点退回时无 note -> 400', async () => { const list = await api('station', 'GET', '/api/records') const pending = list.body.find(r => r.status === 'station_review') if (!pending) { console.warn('无可待审记录,跳过'); return } const res = await api('station', 'PATCH', `/api/records/${pending.id}/review`, { decision: 'return', }) expect(res.status).toBe(400) expect(res.body.message).toContain('退回') }) it('总部审核通过 -> archived', async () => { const list = await api('headquarters', 'GET', '/api/records') const pending = list.body.find(r => r.status === 'headquarters_review') if (!pending) { console.warn('无总部待审记录,跳过'); return } const res = await api('headquarters', 'PATCH', `/api/records/${pending.id}/review`, { decision: 'pass', score: 92, note: '优秀稿件', }) expect(res.status).toBe(200) expect(res.body.status).toBe('archived') expect(res.body.score).toBe(92) }) it('总部退回 -> returned', async () => { const list = await api('headquarters', 'GET', '/api/records') const pending = list.body.find(r => r.status === 'headquarters_review') if (!pending) { console.warn('无总部待审记录,跳过'); return } const res = await api('headquarters', 'PATCH', `/api/records/${pending.id}/review`, { decision: 'return', note: '数据需核实', }) expect(res.status).toBe(200) expect(res.body.status).toBe('returned') }) it('状态不匹配时审核返回 409', async () => { const list = await api('headquarters', 'GET', '/api/records') const archived = list.body.find(r => r.status === 'archived') if (!archived) { console.warn('无已归档记录,跳过'); return } const res = await api('headquarters', 'PATCH', `/api/records/${archived.id}/review`, { decision: 'pass', score: 80, }) expect(res.status).toBe(409) }) it('无效 decision 返回 400', async () => { const list = await api('station', 'GET', '/api/records') const pending = list.body.find(r => r.status === 'station_review') if (!pending) { console.warn('无可待审记录,跳过'); return } const res = await api('station', 'PATCH', `/api/records/${pending.id}/review`, { decision: 'unknown', score: 80, }) expect(res.status).toBe(400) }) it('审核日志正确记录', async () => { const submit = await api('reporter', 'POST', '/api/records', { title: '日志测试', type: 'news', date: '2026-08-01', platform: 'APP', }) expect(submit.status).toBe(201) const logs = await api('headquarters', 'GET', `/api/records/${submit.body.id}/audit`) expect(logs.status).toBe(200) expect(logs.body[0].action).toBe('submit') expect(logs.body[0].toStatus).toBe('station_review') }) }) // ═══════════════════════════════════════════════════════════════════════════ // 测试集 3:人员 CRUD // ═══════════════════════════════════════════════════════════════════════════ describe('人员 CRUD', () => { it('总部可新增人员', async () => { const res = await api('headquarters', 'POST', '/api/people', { name: '测试记者', station: '北京记者站', title: '实习记者', phone: '13900009999', }) expect(res.status).toBe(201) expect(res.body.name).toBe('测试记者') expect(res.body.code).toMatch(/^PERSON_/) }) it('新增人员必填字段校验', async () => { const res = await api('headquarters', 'POST', '/api/people', { name: '', station: '', }) expect(res.status).toBe(400) }) it('总部可编辑人员', async () => { const list = await api('headquarters', 'GET', '/api/people') const person = list.body.find(p => p.code === 'P_BJ_03') const res = await api('headquarters', 'PATCH', `/api/people/${person.id}`, { title: '资深记者', status: 'active', }) expect(res.status).toBe(200) expect(res.body.title).toBe('资深记者') }) it('总部可删除人员', async () => { const created = await api('headquarters', 'POST', '/api/people', { name: '临时人员', station: '上海记者站', }) const res = await api('headquarters', 'DELETE', `/api/people/${created.body.id}`) expect(res.status).toBe(200) expect(res.body.message).toContain('成功') }) it('人员筛选', async () => { const byStation = await api('headquarters', 'GET', '/api/people?station=北京记者站') expect(byStation.status).toBe(200) for (const p of byStation.body) expect(p.station).toBe('北京记者站') const byStatus = await api('headquarters', 'GET', '/api/people?status=active') expect(byStatus.status).toBe(200) const byName = await api('headquarters', 'GET', '/api/people?name=林') expect(byName.status).toBe(200) }) }) // ═══════════════════════════════════════════════════════════════════════════ // 测试集 4:记者站 CRUD // ═══════════════════════════════════════════════════════════════════════════ describe('记者站 CRUD', () => { it('总部可新增记者站', async () => { const res = await api('headquarters', 'POST', '/api/stations', { name: '深圳记者站', code: 'STAT_SZ', region: '华南', address: '深圳市南山区', }) expect(res.status).toBe(201) expect(res.body.name).toBe('深圳记者站') }) it('新增站点编码重复 -> 409', async () => { const res = await api('headquarters', 'POST', '/api/stations', { name: '另一个北京站', code: 'STAT_BJ', region: '华北', }) expect(res.status).toBe(409) }) it('删除有人员的站点 -> 409', async () => { const list = await api('headquarters', 'GET', '/api/stations') const bj = list.body.find(s => s.code === 'STAT_BJ') const res = await api('headquarters', 'DELETE', `/api/stations/${bj.id}`) expect(res.status).toBe(409) expect(res.body.message).toContain('人员') }) it('删除无人员站点成功', async () => { const list = await api('headquarters', 'GET', '/api/stations') const gz = list.body.find(s => s.code === 'STAT_GZ') const res = await api('headquarters', 'DELETE', `/api/stations/${gz.id}`) expect(res.status).toBe(200) }) }) // ═══════════════════════════════════════════════════════════════════════════ // 测试集 5:通知公告 // ═══════════════════════════════════════════════════════════════════════════ describe('通知公告', () => { it('总部可发布通知', async () => { const res = await api('headquarters', 'POST', '/api/notices', { title: '集成测试通知', content: '内容正文', priority: 'normal', scope: 'all', }) expect(res.status).toBe(201) expect(res.body.title).toBe('集成测试通知') }) it('发布时 priority/scope 校验', async () => { const bad = await api('headquarters', 'POST', '/api/notices', { title: '测试', priority: 'invalid', }) expect(bad.status).toBe(400) }) it('通知读取接口参数校验', async () => { const notices = await api('headquarters', 'GET', '/api/notices') const notice = notices.body[0] // 无对应回执记录时返回 404(符合业务逻辑:发布者不在回执名单中) const res = await api('headquarters', 'POST', `/api/notices/${notice.id}/read`) expect([200, 404]).toContain(res.status) }) }) // ═══════════════════════════════════════════════════════════════════════════ // 测试集 6:数据统计 // ═══════════════════════════════════════════════════════════════════════════ describe('数据统计', () => { it('GET /api/stats/overview 返回正确字段', async () => { const res = await api('headquarters', 'GET', '/api/stats/overview') expect(res.status).toBe(200) expect(res.body).toHaveProperty('total') expect(res.body).toHaveProperty('archived') expect(res.body).toHaveProperty('avgScore') expect(res.body).toHaveProperty('monthly') }) it('GET /api/stats/records 支持 station/reporter/type 分组', async () => { for (const g of ['station', 'reporter', 'type']) { const res = await api('headquarters', 'GET', `/api/stats/records?groupBy=${g}`) expect(res.status).toBe(200) expect(Array.isArray(res.body)).toBe(true) } }) it('GET /api/stats/records 无效 groupBy -> 400', async () => { const res = await api('headquarters', 'GET', '/api/stats/records?groupBy=invalid') expect(res.status).toBe(400) }) it('GET /api/stats/scores 返回记者评分', async () => { const res = await api('headquarters', 'GET', '/api/stats/scores') expect(res.status).toBe(200) expect(Array.isArray(res.body)).toBe(true) }) it('站点角色看统计仅限本站', async () => { const res = await api('station', 'GET', '/api/stats/overview') expect(res.status).toBe(200) }) }) // ═══════════════════════════════════════════════════════════════════════════ // 测试集 7:考核规则 CRUD(V0.2) // ═══════════════════════════════════════════════════════════════════════════ describe('考核规则 CRUD', () => { it('GET /api/rules 总部可见规则列表', async () => { const res = await api('headquarters', 'GET', '/api/rules') expect(res.status).toBe(200) expect(Array.isArray(res.body)).toBe(true) }) it('GET /api/rules 支持 status 筛选', async () => { const res = await api('headquarters', 'GET', '/api/rules?status=active') expect(res.status).toBe(200) for (const r of res.body) expect(r.status).toBe('active') }) it('GET /api/rules/:id 返回规则及指标项', async () => { const list = await api('headquarters', 'GET', '/api/rules') const rule = list.body[0] const res = await api('headquarters', 'GET', `/api/rules/${rule.id}`) expect(res.status).toBe(200) expect(res.body).toHaveProperty('items') expect(Array.isArray(res.body.items)).toBe(true) }) it('GET /api/rules/:id 不存在 -> 404', async () => { const res = await api('headquarters', 'GET', '/api/rules/99999') expect(res.status).toBe(404) }) it('POST /api/rules 总部可创建规则(含指标项)', async () => { const res = await api('headquarters', 'POST', '/api/rules', { name: '测试考核规则', description: '集成测试用规则', period_type: 'quarterly', items: [ { category: 'quantity', name: '发稿数量', metric_key: 'count_total', weight: 0.3, formula_type: 'count', formula_params: {} }, { category: 'quality', name: '审核得分', metric_key: 'avg_score', weight: 0.7, formula_type: 'avg_score', formula_params: {} }, ], }) expect(res.status).toBe(201) expect(res.body.name).toBe('测试考核规则') expect(res.body.status).toBe('draft') expect(res.body.items.length).toBe(2) }) it('POST /api/rules 非总部角色 -> 403', async () => { for (const role of ['station', 'reporter']) { const res = await api(role, 'POST', '/api/rules', { name: '非法规则', period_type: 'quarterly' }) expect(res.status).toBe(403) } }) it('POST /api/rules 必填字段校验', async () => { const res = await api('headquarters', 'POST', '/api/rules', { name: '' }) expect(res.status).toBe(400) }) it('PATCH /api/rules 总部可编辑规则', async () => { const list = await api('headquarters', 'GET', '/api/rules') const rule = list.body.find(r => r.status === 'draft') || list.body[0] const res = await api('headquarters', 'PATCH', `/api/rules/${rule.id}`, { name: '规则已更新', items: [ { category: 'quantity', name: '更新后指标', metric_key: 'count_total', weight: 0.5, formula_type: 'count', formula_params: {} }, ], }) expect(res.status).toBe(200) expect(res.body.name).toBe('规则已更新') expect(res.body.items.length).toBe(1) }) it('PATCH /api/rules 已激活规则不可直接编辑', async () => { const list = await api('headquarters', 'GET', '/api/rules') const active = list.body.find(r => r.status === 'active') if (!active) { console.warn('无激活规则,跳过'); return } const res = await api('headquarters', 'PATCH', `/api/rules/${active.id}`, { name: '非法更新' }) expect(res.status).toBe(409) }) it('POST /api/rules/:id/activate 激活规则', async () => { const list = await api('headquarters', 'GET', '/api/rules') const draft = list.body.find(r => r.status === 'draft') if (!draft) { console.warn('无草稿规则,跳过'); return } const res = await api('headquarters', 'POST', `/api/rules/${draft.id}/activate`) expect(res.status).toBe(200) expect(res.body.status).toBe('active') }) it('POST /api/rules/:id/activate 非总部 -> 403', async () => { const list = await api('headquarters', 'GET', '/api/rules') const draft = list.body.find(r => r.status === 'draft') if (!draft) { console.warn('无草稿规则,跳过'); return } const res = await api('station', 'POST', `/api/rules/${draft.id}/activate`) expect(res.status).toBe(403) }) it('激活后同周期旧规则自动归档', async () => { const list = await api('headquarters', 'GET', '/api/rules') const actives = list.body.filter(r => r.status === 'active') expect(actives.length).toBeLessThanOrEqual(1) }) }) // ═══════════════════════════════════════════════════════════════════════════ // 测试集 8:评分计算(V0.2) // ═══════════════════════════════════════════════════════════════════════════ describe('评分计算', () => { it('GET /api/scores 总部可见所有评分', async () => { const res = await api('headquarters', 'GET', '/api/scores') expect(res.status).toBe(200) expect(Array.isArray(res.body)).toBe(true) }) it('GET /api/scores 支持筛选参数', async () => { const res = await api('headquarters', 'GET', '/api/scores?period=2026-Q3&station=北京记者站') expect(res.status).toBe(200) for (const s of res.body) { expect(s.period).toBe('2026-Q3') } }) it('GET /api/scores/:id 返回评分及明细', async () => { const list = await api('headquarters', 'GET', '/api/scores') const score = list.body[0] if (!score) { console.warn('无评分记录,跳过'); return } const res = await api('headquarters', 'GET', `/api/scores/${score.id}`) expect(res.status).toBe(200) expect(res.body).toHaveProperty('items') expect(Array.isArray(res.body.items)).toBe(true) expect(res.body).toHaveProperty('totalScore') expect(res.body).toHaveProperty('qualityScore') expect(res.body).toHaveProperty('quantityScore') expect(res.body).toHaveProperty('efficiencyScore') expect(res.body).toHaveProperty('complianceScore') }) it('GET /api/scores/:id 不存在 -> 404', async () => { const res = await api('headquarters', 'GET', '/api/scores/99999') expect(res.status).toBe(404) }) it('POST /api/scores/compute 总部可触发计算', async () => { const rules = await api('headquarters', 'GET', '/api/rules') const activeRule = rules.body.find(r => r.status === 'active') || rules.body[0] const res = await api('headquarters', 'POST', '/api/scores/compute', { rule_id: activeRule.id, period: '2026-Q3', period_type: 'quarterly', }) expect(res.status).toBe(201) expect(res.body).toHaveProperty('message') expect(res.body).toHaveProperty('results') expect(Array.isArray(res.body.results)).toBe(true) }) it('POST /api/scores/compute 必填字段校验', async () => { const res = await api('headquarters', 'POST', '/api/scores/compute', {}) expect(res.status).toBe(400) }) it('POST /api/scores/compute 非总部 -> 403', async () => { const res = await api('station', 'POST', '/api/scores/compute', { rule_id: 1, period: '2026-Q3', period_type: 'quarterly', }) expect(res.status).toBe(403) }) it('评分结果 item_details 正确反序列化', async () => { const list = await api('headquarters', 'GET', '/api/scores') const score = list.body[0] if (!score) { console.warn('无评分记录,跳过'); return } expect(Array.isArray(score.items)).toBe(true) for (const item of score.items) { expect(item).toHaveProperty('raw_score') expect(item).toHaveProperty('weighted_score') expect(item).toHaveProperty('weight') } }) })