Files
2026-08-01 23:09:49 +08:00

598 lines
27 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 集成测试:角色隔离验证 + 审核状态流转
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:考核规则 CRUDV0.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')
}
})
})