初始提交:全国记者站管理系统

This commit is contained in:
selfrelease
2026-08-01 23:09:49 +08:00
commit 45fbba0308
96 changed files with 21514 additions and 0 deletions
+52
View File
@@ -0,0 +1,52 @@
// 测试环境准备:每个 suite 运行前初始化干净的测试数据库
const { DatabaseSync } = require('node:sqlite')
const { mkdirSync, readFileSync, readdirSync } = require('node:fs')
const { join } = require('node:path')
const { randomUUID } = require('node:crypto')
const ROOT = join(__dirname, '../..')
const testDataDir = join(ROOT, 'data', 'test-' + randomUUID().slice(0, 8))
mkdirSync(testDataDir, { recursive: true })
const TEST_DB_PATH = join(testDataDir, 'test.db')
process.env.TEST_DB_PATH = TEST_DB_PATH
process.env.API_PORT = '0'
// 初始化 schema
const db = new DatabaseSync(TEST_DB_PATH)
db.exec(`PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;`)
const migrationFiles = readdirSync(join(ROOT, 'migrations'))
.filter(f => f.endsWith('.sql') && !f.startsWith('_'))
.sort()
for (const file of migrationFiles) {
const sql = readFileSync(join(ROOT, 'migrations', file), 'utf-8')
db.exec(sql)
}
// 公共 seed 数据
const now = new Date().toISOString().slice(0, 10)
db.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, '金融市场');
`)
global.__TEST_DB_PATH = TEST_DB_PATH
+68
View File
@@ -0,0 +1,68 @@
// 测试环境准备:每个 suite 运行前初始化干净的测试数据库
import { DatabaseSync } from 'node:sqlite'
import { mkdirSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { randomUUID } from 'crypto'
const __dirname = dirname(fileURLToPath(import.meta.url))
const root = join(__dirname, '../..')
const testDataDir = join(root, 'data', 'test-' + randomUUID().slice(0, 8))
mkdirSync(testDataDir, { recursive: true })
const TEST_DB_PATH = join(testDataDir, 'test.db')
process.env.API_PORT = '0' // 让系统分配空闲端口
export { TEST_DB_PATH }
// ── 初始化 schema ────────────────────────────────────────────────────────────
const db = new DatabaseSync(TEST_DB_PATH)
db.exec(`PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;`)
// 复用已有的 migration SQL 来初始化 schema
const migrationFiles = [
'001_create_initial_schema.sql',
'002_create_audit_logs.sql',
'003_create_people.sql',
'004_create_stations.sql',
'005_create_notices.sql',
'006_optimize_indexes.sql',
]
for (const file of migrationFiles) {
const path = join(root, 'migrations', file)
try {
const { readFileSync } = await import('node:fs')
const sql = readFileSync(path, 'utf-8')
db.exec(sql)
} catch {
// 忽略文件不存在错误
}
}
// ── 公共 seed 数据 ───────────────────────────────────────────────────────────
const now = new Date().toISOString().slice(0, 10)
db.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, '金融市场');
`)
export default db
+598
View File
@@ -0,0 +1,598 @@
// 集成测试:角色隔离验证 + 审核状态流转
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')
}
})
})