提交 f69f0b6f authored 作者: 王鹏飞's avatar 王鹏飞

test: 补充 node:test 测试套件(48 个)

- test/unit:鉴权规则、zod 契约、缓存去重(cached-fetch) - test/routes:路由清单、错误格式、CORS、请求头、OpenAPI、docs 开关 - 全部无需数据库、无需 .env,可直接在 CI 运行
上级 9623d948
import Fastify from 'fastify'
import app from '#src/app.js'
/**
* 构建一个用于测试的 app 实例(不监听端口,用 app.inject 发请求)。
* 传入 t(node:test 的 TestContext)时会在测试结束后自动关闭。
*/
export const build = async (t) => {
const fastify = Fastify({ logger: false, trustProxy: true, bodyLimit: 10 * 1024 * 1024 })
await fastify.register(app)
await fastify.ready()
if (t) t.after(() => fastify.close())
return fastify
}
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { build } from '../helper.js'
const DMS_ROUTES = [
['GET', '/api/dms/auth/me'], ['GET', '/api/dms/system/users'],
['GET', '/api/dms/products'], ['POST', '/api/dms/products'],
['PUT', '/api/dms/products/:id'], ['PUT', '/api/dms/products/:id/status'], ['DELETE', '/api/dms/products/:id'],
['GET', '/api/dms/cases'], ['POST', '/api/dms/cases/sync'], ['POST', '/api/dms/cases'],
['GET', '/api/dms/cases/:id'], ['PUT', '/api/dms/cases/:id'], ['DELETE', '/api/dms/cases/:id'],
['POST', '/api/dms/projects'], ['GET', '/api/dms/projects'],
['GET', '/api/dms/projects/:projectCode'], ['PUT', '/api/dms/projects/:projectCode'],
['DELETE', '/api/dms/projects/:projectCode'],
['PUT', '/api/dms/projects/:projectCode/stage'],
['PUT', '/api/dms/projects/:projectCode/stage/rollback'],
['PUT', '/api/dms/projects/:projectCode/solution'],
['GET', '/api/dms/projects/:projectCode/team'], ['PUT', '/api/dms/projects/:projectCode/team'],
['PUT', '/api/dms/projects/:projectCode/initiation'],
['PUT', '/api/dms/projects/:projectCode/procurement'],
['PUT', '/api/dms/projects/:projectCode/contract'],
['PUT', '/api/dms/projects/:projectCode/delivery'],
['PUT', '/api/dms/projects/:projectCode/acceptance'],
]
test('app boots and exposes the full route table', async (t) => {
const app = await build(t)
assert.equal(app.hasRoute({ method: 'GET', url: '/health' }), true, 'health')
assert.equal(app.hasRoute({ method: 'POST', url: '/share/getsignature' }), true, 'wechat')
assert.equal(app.hasRoute({ method: 'POST', url: '/share/token' }), true, 'wechat')
assert.equal(app.hasRoute({ method: 'POST', url: '/getInfo' }), true, 'wechat')
assert.equal(app.hasRoute({ method: 'GET', url: '/get/wx-chart/*' }), true, 'wx-chart get')
assert.equal(app.hasRoute({ method: 'GET', url: '/set/wx-chart/*' }), true, 'wx-chart set')
assert.equal(app.hasRoute({ method: 'POST', url: '/api/logs' }), true, 'logs create')
assert.equal(app.hasRoute({ method: 'POST', url: '/api/logs/batch' }), true, 'logs batch')
assert.equal(app.hasRoute({ method: 'GET', url: '/api/logs' }), true, 'logs query')
assert.equal(app.hasRoute({ method: 'GET', url: '/api/logs/stats' }), true, 'logs stats')
for (const [method, url] of DMS_ROUTES) {
assert.equal(app.hasRoute({ method, url }), true, `missing ${method} ${url}`)
}
})
test('health check', async (t) => {
const app = await build(t)
const res = await app.inject({ url: '/health' })
assert.equal(res.statusCode, 200)
assert.equal(res.json().status, 'ok')
})
test('unknown route uses the unified error shape', async (t) => {
const app = await build(t)
const res = await app.inject({ url: '/nope' })
assert.equal(res.statusCode, 404)
assert.deepEqual(res.json(), {
success: false,
error: { message: '接口未定义', path: '/nope', method: 'GET' },
})
})
test('zod schema failure returns 400 in the unified shape', async (t) => {
const app = await build(t)
const res = await app.inject({ method: 'POST', url: '/share/token', payload: {} })
assert.equal(res.statusCode, 400)
const body = res.json()
assert.equal(body.success, false)
assert.match(body.error.message, /appId/)
assert.ok(Array.isArray(body.error.details))
})
test('dms routes require authentication before validation', async (t) => {
const app = await build(t)
const me = await app.inject({ url: '/api/dms/auth/me' })
assert.equal(me.statusCode, 401)
assert.equal(me.json().error.message, '登录状态已失效')
// invalid params + no cookie -> 401 (auth runs in onRequest, before schema validation)
const del = await app.inject({ method: 'DELETE', url: '/api/dms/projects/X' })
assert.equal(del.statusCode, 401)
})
test('wx-chart wildcard routing', async (t) => {
const app = await build(t)
const empty = await app.inject({ url: '/get/wx-chart/' })
assert.equal(empty.statusCode, 400)
const set = await app.inject({ url: '/set/wx-chart/test-key?val=7' })
assert.equal(set.statusCode, 200)
assert.equal(set.json().data['test-key'], 7)
const get = await app.inject({ url: '/get/wx-chart/test-key' })
assert.equal(get.statusCode, 200)
assert.equal(get.json().data.code, 7)
})
test('logs query validation rejects page=0', async (t) => {
const app = await build(t)
const res = await app.inject({ url: '/api/logs?page=0' })
assert.equal(res.statusCode, 400)
assert.equal(res.json().success, false)
})
test('urlencoded bodies are parsed', async (t) => {
const app = await build(t)
const res = await app.inject({
method: 'POST',
url: '/api/logs',
payload: 'message=hello&level=info',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
})
assert.notEqual(res.statusCode, 415)
})
test('body limit allows multi-megabyte log batches', async (t) => {
const app = await build(t)
const payload = JSON.stringify({ logs: [{ message: 'x'.repeat(2 * 1024 * 1024) }] })
const res = await app.inject({
method: 'POST',
url: '/api/logs/batch',
payload,
headers: { 'content-type': 'application/json' },
})
assert.notEqual(res.statusCode, 413)
})
test('usercenter proxy is registered and forwards upstream', async (t) => {
const app = await build(t)
assert.equal(
app.hasRoute({ method: 'GET', url: '/api/usercenter/*' }) || app.hasRoute({ method: 'GET', url: '/api/usercenter/x' }),
true,
)
const res = await app.inject({ url: '/api/usercenter/v2/frontend/user/get-user-info' })
assert.doesNotMatch(String(res.body), /接口未定义/)
})
test('cors preflight allows credentials', async (t) => {
const app = await build(t)
const res = await app.inject({
method: 'OPTIONS',
url: '/health',
headers: { origin: 'http://example.test', 'access-control-request-method': 'GET' },
})
assert.equal(res.statusCode, 204)
assert.equal(res.headers['access-control-allow-credentials'], 'true')
})
test('openapi document is served', async (t) => {
const app = await build(t)
const res = await app.inject({ url: '/docs/json' })
assert.equal(res.statusCode, 200)
assert.equal(res.json().info.title, 'ezijing-node-server')
})
// 生产默认关闭 /docs:这里用 ENABLE_DOCS=false 模拟生产行为
// 注意必须在 import helper(会加载 config)之前设置环境变量
process.env.ENABLE_DOCS = 'false'
import { test } from 'node:test'
import assert from 'node:assert/strict'
const { build } = await import('../helper.js')
test('ENABLE_DOCS=false 时不注册 /docs 路由', async (t) => {
const app = await build(t)
assert.equal(app.hasRoute({ method: 'GET', url: '/docs/json' }), false)
const res = await app.inject({ url: '/docs/json' })
assert.equal(res.statusCode, 404)
assert.equal(res.json().error.message, '接口未定义')
})
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { build } from '../helper.js'
test('响应头回传 x-request-id', async (t) => {
const app = await build(t)
const ok = await app.inject({ url: '/health' })
assert.match(ok.headers['x-request-id'], /^req-\d+$/)
// 出错时也要有,便于把前端报错和服务端日志对上
const bad = await app.inject({ url: '/nope' })
assert.match(bad.headers['x-request-id'], /^req-\d+$/)
})
test('CORS 反射任意来源且允许凭据', async (t) => {
const app = await build(t)
const preflight = await app.inject({
method: 'OPTIONS',
url: '/health',
headers: { origin: 'https://any.example', 'access-control-request-method': 'GET' },
})
assert.equal(preflight.statusCode, 204)
assert.equal(preflight.headers['access-control-allow-origin'], 'https://any.example')
assert.equal(preflight.headers['access-control-allow-credentials'], 'true')
const actual = await app.inject({ url: '/health', headers: { origin: 'https://any.example' } })
assert.equal(actual.headers['access-control-allow-origin'], 'https://any.example')
assert.match(actual.headers['access-control-expose-headers'], /x-request-id/i)
})
import { test } from 'node:test'
import assert from 'node:assert/strict'
// 固定探测前置条件:不配置任何数据库(本机若有 MySQL 在默认地址上运行,
// 也无法让就绪探针通过;这里显式断掉默认地址,保证测试与机器环境无关)。
process.env.MYSQL_PORT = '1'
process.env.MONGODB_URI = ''
const { build } = await import('../helper.js')
test('GET /health returns ok with a numeric timestamp', async (t) => {
const app = await build(t)
const res = await app.inject({ url: '/health' })
assert.equal(res.statusCode, 200)
const body = res.json()
assert.deepEqual(Object.keys(body).sort(), ['status', 'timestamp'])
assert.equal(body.status, 'ok')
assert.equal(typeof body.timestamp, 'number')
})
test('GET /health/ready without databases returns 503 degraded with both checks down', async (t) => {
const app = await build(t)
const res = await app.inject({ url: '/health/ready' })
assert.equal(res.statusCode, 503)
assert.deepEqual(res.json(), { status: 'degraded', checks: { mysql: false, mongo: false } })
})
test('responses larger than 1KB are gzip-compressed when accepted', async (t) => {
const app = await build(t)
const res = await app.inject({
url: '/docs/json',
headers: { 'accept-encoding': 'gzip' },
})
assert.equal(res.statusCode, 200)
assert.ok(res.rawPayload.length > 1024, 'docs/json should exceed the 1KB compression threshold')
assert.equal(res.headers['content-encoding'], 'gzip')
})
test('cors preflight still works on /health/ready', async (t) => {
const app = await build(t)
const res = await app.inject({
method: 'OPTIONS',
url: '/health/ready',
headers: { origin: 'http://example.test', 'access-control-request-method': 'GET' },
})
assert.equal(res.statusCode, 204)
assert.equal(res.headers['access-control-allow-credentials'], 'true')
})
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createCachedFetcher } from '#src/lib/cached-fetch.js'
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
test('同一 key 的并发调用只执行一次 fetcher', async () => {
const get = createCachedFetcher({ ttlMs: 1000 })
let calls = 0
const fetcher = async () => {
calls++
await sleep(20)
return `v${calls}`
}
const results = await Promise.all([get('k', fetcher), get('k', fetcher), get('k', fetcher)])
assert.equal(calls, 1)
assert.deepEqual(results, ['v1', 'v1', 'v1'])
})
test('缓存命中后不再执行 fetcher', async () => {
const get = createCachedFetcher({ ttlMs: 1000 })
let calls = 0
const fetcher = async () => { calls++; return 'x' }
assert.equal(await get('k', fetcher), 'x')
assert.equal(await get('k', fetcher), 'x')
assert.equal(calls, 1)
})
test('TTL 过期后重新取数', async () => {
const get = createCachedFetcher({ ttlMs: 20 })
let calls = 0
const fetcher = async () => { calls++; return calls }
assert.equal(await get('k', fetcher), 1)
await sleep(40)
assert.equal(await get('k', fetcher), 2)
})
test('不同 key 互不影响', async () => {
const get = createCachedFetcher({ ttlMs: 1000 })
const fetcher = async (value) => value
assert.equal(await get('a', () => fetcher('A')), 'A')
assert.equal(await get('b', () => fetcher('B')), 'B')
assert.equal(await get('a', () => fetcher('changed')), 'A')
})
test('fetcher 抛错时不写入缓存,且能从在途表清理', async () => {
const get = createCachedFetcher({ ttlMs: 1000 })
let calls = 0
const failing = async () => { calls++; throw new Error('boom') }
await assert.rejects(() => get('k', failing))
await assert.rejects(() => get('k', failing))
assert.equal(calls, 2) // 失败不缓存,下次重试
})
import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
authenticate,
hasRole,
requireAnyDmsRouteAccess,
requireProjectAccess,
requireProjectRole,
requireRole,
requireRouteAccess,
} from '#src/services/dms/hooks.js'
// 纯授权规则测试:用 fake request,不连数据库、不发 HTTP。
test('authenticate: missing TGC rejects with 401', async () => {
await assert.rejects(authenticate({ headers: {} }), (err) => err.statusCode === 401)
})
test('hasRole: admin passes any role, exact role matches, other roles do not', () => {
assert.equal(hasRole({ roles: ['admin'] }, 'viewer'), true)
assert.equal(hasRole({ roles: ['viewer'] }, 'viewer'), true)
assert.equal(hasRole({ roles: ['viewer'] }, 'editor'), false)
assert.equal(hasRole({ roles: [] }, 'admin'), false)
// 无用户对象时返回 falsy(undefined),仍应视为不通过
assert.ok(!hasRole(undefined, 'admin'))
})
test('requireRouteAccess: user with the route in the list passes', async () => {
const request = { user: { roles: ['viewer'], routes: ['/dms/cases'] } }
await assert.doesNotReject(requireRouteAccess('/dms/cases')(request))
})
test('requireRouteAccess: route not in the list rejects with 403', async () => {
const request = { user: { roles: ['viewer'], routes: ['/dms/products'] } }
await assert.rejects(
requireRouteAccess('/dms/cases')(request),
(err) => err.statusCode === 403,
)
})
test('requireRouteAccess: admin bypasses even with no routes', async () => {
const request = { user: { roles: ['admin'], routes: [] } }
await assert.doesNotReject(requireRouteAccess('/dms/cases')(request))
})
test('requireAnyDmsRouteAccess: any /dms/* route passes', async () => {
const request = { user: { roles: ['viewer'], routes: ['/dms/cases'] } }
await assert.doesNotReject(requireAnyDmsRouteAccess(request))
})
test('requireAnyDmsRouteAccess: admin passes with no /dms routes', async () => {
const request = { user: { roles: ['admin'], routes: [] } }
await assert.doesNotReject(requireAnyDmsRouteAccess(request))
})
test('requireAnyDmsRouteAccess: only non-dms routes reject with 403', async () => {
const request = { user: { roles: ['viewer'], routes: ['/api/logs'] } }
await assert.rejects(
requireAnyDmsRouteAccess(request),
(err) => err.statusCode === 403,
)
})
test('requireRole: admin passes, viewer rejected with 403', async () => {
await assert.doesNotReject(requireRole('admin')({ user: { roles: ['admin'] } }))
await assert.rejects(
requireRole('admin')({ user: { roles: ['viewer'] } }),
(err) => err.statusCode === 403,
)
})
test('requireRole: exact role match passes', async () => {
await assert.doesNotReject(requireRole('operator')({ user: { roles: ['operator'] } }))
})
test('requireProjectRole: admin bypasses before any DB query (no projectCode needed)', async () => {
// params 里没有 projectCode:若绕过发生在查询之前,此调用不会触碰数据库
const request = { user: { roles: ['admin'] }, params: {} }
await assert.doesNotReject(requireProjectRole('project_manager')(request))
})
test('requireProjectAccess: admin bypasses before any DB query (no projectCode needed)', async () => {
const request = { user: { roles: ['admin'] }, params: {} }
await assert.doesNotReject(requireProjectAccess(request))
})
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { idParam } from '#src/schemas/dms/common.js'
import { productCreateBody, productUpdateBody } from '#src/schemas/dms/products.js'
import { caseCreateBody } from '#src/schemas/dms/cases.js'
import {
initiationUpsertBody,
moveStageBody,
projectCodeParam,
projectCreateBody,
projectUpdateBody,
} from '#src/schemas/dms/projects.js'
import { createLogBody, queryLogsQuery, statsQuery } from '#src/schemas/logs.js'
import { shareBody } from '#src/schemas/wechat.js'
test('productCreateBody: trims name, requires it, caps length at 120', () => {
assert.deepEqual(productCreateBody.parse({ name: ' x ' }), { name: 'x' })
assert.throws(() => productCreateBody.parse({}))
assert.throws(() => productCreateBody.parse({ name: 'x'.repeat(121) }))
})
test('productUpdateBody shares the same contract as create', () => {
assert.deepEqual(productUpdateBody.parse({ name: ' y ' }), { name: 'y' })
})
test('idParam: coerces numeric strings, rejects 0 and non-numeric ids', () => {
assert.deepEqual(idParam.parse({ id: '5' }), { id: 5 })
assert.throws(() => idParam.parse({ id: '0' }))
assert.throws(() => idParam.parse({ id: 'abc' }))
})
test('projectCodeParam: passes a non-empty code, throws when missing', () => {
assert.deepEqual(projectCodeParam.parse({ projectCode: 'ABC' }), { projectCode: 'ABC' })
assert.throws(() => projectCodeParam.parse({}))
})
test('moveStageBody: coerces toStage to a number, requires it', () => {
assert.deepEqual(moveStageBody.parse({ to_stage: '3' }), { to_stage: 3 })
assert.throws(() => moveStageBody.parse({}))
})
test('initiationUpsertBody: empty projectAmount becomes undefined, numeric string coerces', () => {
const empty = initiationUpsertBody.parse({ project_amount: '' })
assert.equal(empty.project_amount, undefined)
const filled = initiationUpsertBody.parse({ project_amount: '123.45' })
assert.equal(filled.project_amount, 123.45)
})
test('createLogBody: passthrough keeps arbitrary keys', () => {
assert.deepEqual(createLogBody.parse({ anything: 1 }), { anything: 1 })
})
test('queryLogsQuery: coerces page/limit, rejects page=0', () => {
assert.deepEqual(queryLogsQuery.parse({ page: '2', limit: '10' }), { page: 2, limit: 10 })
assert.throws(() => queryLogsQuery.parse({ page: '0' }))
})
test('statsQuery: accepts an empty object', () => {
assert.deepEqual(statsQuery.parse({}), {})
})
test('shareBody: requires appId', () => {
assert.deepEqual(shareBody.parse({ appId: 'x' }), { appId: 'x' })
assert.throws(() => shareBody.parse({}))
})
test('可选文本字段接受 null(前端清空字段时传 null)', () => {
// products
assert.deepEqual(productCreateBody.parse({ name: 'x', description: null }), { name: 'x', description: null })
// cases
const c = caseCreateBody.parse({ name: 'x', description: null, product_name: null, files: null })
assert.equal(c.description, null)
assert.equal(c.product_name, null)
assert.equal(c.files, null)
// projects 创建
const created = projectCreateBody.parse({
name: 'n', province: 'p', city: 'c', school_name: 's', product_id: '1',
contact_name: null, contact_title: null, contact_phone: null, description: null, department_name: null,
})
assert.equal(created.contact_name, null)
assert.equal(created.contact_title, null)
assert.equal(created.contact_phone, null)
assert.equal(created.description, null)
assert.equal(created.department_name, null)
assert.equal(created.product_id, 1)
// projects 更新:null 必须保留(service 用 pickDefined 判断,null 表示清空该列)
const updated = projectUpdateBody.parse({ contact_name: null, description: null })
assert.deepEqual(updated, { contact_name: null, description: null })
// 必填字段仍然拒绝 null
assert.throws(() => productCreateBody.parse({ name: null }))
assert.throws(() => caseCreateBody.parse({ name: null }))
})
Markdown 格式
0% 或
您添加了 0 人 到此讨论。请谨慎行事。
请先完成此评论的编辑!
请 注册 或者 后发表评论