初始提交:全国记者站管理系统
This commit is contained in:
+1426
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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
|
||||
@@ -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:考核规则 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')
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user