169 lines
7.9 KiB
JavaScript
169 lines
7.9 KiB
JavaScript
|
|
'use strict';
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* backend 最小可跑单测集(P1 ㉚a)
|
|||
|
|
* ================================================================
|
|||
|
|
* 背景:`backend/package.json` 声明了 `test-local: egg-bin test`,但 `backend/test/`
|
|||
|
|
* 长期是**空目录** ⇒ 「配置存在、用例为零」——跑 `npm run test-local` 恒绿(0 个用例),
|
|||
|
|
* 属 R95 根因①「最危险假绿族」。本文件填充**真实、无外部依赖(无 DB / 无网络 /
|
|||
|
|
* 无 egg 启动)、有判别力**的最小单测集,使该 script 不再空转。
|
|||
|
|
*
|
|||
|
|
* 取材原则(为什么选这三个纯函数模块):
|
|||
|
|
* ① 业务核心:aggregationKey = 铁律13/14 归集键 SoT;idCard = 导入通道判据 SoT;
|
|||
|
|
* filename = R108g 上传落盘名清洗。
|
|||
|
|
* ② **判别力**:三者的关键判据都有「退化后必然出错」的形态——
|
|||
|
|
* · aggregationKey 若退回「无条件双键归集」(历史 bug)⇒ 跨编号空间重叠用例必红;
|
|||
|
|
* · idCard 若校验位算法改动 ⇒ 已知公开有效号 `11010519491231002X` 用例必红;
|
|||
|
|
* · filename 若漏控制字符 / 路径分隔符清洗 ⇒ 穿越与 ENOENT 用例必红。
|
|||
|
|
* ③ 零副作用:全部为纯函数,不触盘、不连库。
|
|||
|
|
*
|
|||
|
|
* ⚠️ 运行:`cd backend && node_modules/.bin/egg-bin test`(或裸 mocha 直跑本文件)。
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
const assert = require('assert');
|
|||
|
|
const aggKey = require('../app/lib/aggregationKey');
|
|||
|
|
const idCard = require('../app/lib/idCard');
|
|||
|
|
const { sanitizeUploadFileName } = require('../app/utils/filename');
|
|||
|
|
|
|||
|
|
describe('app/lib/aggregationKey —— 归集键 SoT(铁律13/14)', () => {
|
|||
|
|
it('考试组:主键 = contestant.id,兜底键 = 真实聚合 uid', () => {
|
|||
|
|
assert.strictEqual(aggKey.primaryKeyOf('gaozhi', 5, 9), '5');
|
|||
|
|
assert.strictEqual(aggKey.primaryKeyOf('zhongzhi', 5, 9), '5');
|
|||
|
|
assert.strictEqual(aggKey.fallbackKeyOf('gaozhi', 5, 9), '9');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('考试组:缺真实聚合 uid ⇒ 无兜底键(null),而非拼一个假键', () => {
|
|||
|
|
assert.strictEqual(aggKey.fallbackKeyOf('gaozhi', 5, null), null);
|
|||
|
|
assert.strictEqual(aggKey.fallbackKeyOf('gaozhi', 5, ''), null);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('作品组:主键 = 真实聚合 uid,兜底键 = cid', () => {
|
|||
|
|
assert.strictEqual(aggKey.primaryKeyOf('benke', 5, 9), '9');
|
|||
|
|
assert.strictEqual(aggKey.fallbackKeyOf('benke', 5, 9), '5');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('作品组:缺真实聚合 uid ⇒ 主键退化为 cid(不再引入第二个键)', () => {
|
|||
|
|
assert.strictEqual(aggKey.primaryKeyOf('benke', 5, null), '5');
|
|||
|
|
assert.strictEqual(aggKey.primaryKeyOf('benke', 5, ''), '5');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('isExamGroup 只认 zhongzhi / gaozhi', () => {
|
|||
|
|
assert.strictEqual(aggKey.isExamGroup('zhongzhi'), true);
|
|||
|
|
assert.strictEqual(aggKey.isExamGroup('gaozhi'), true);
|
|||
|
|
assert.strictEqual(aggKey.isExamGroup('benke'), false);
|
|||
|
|
assert.strictEqual(aggKey.isExamGroup('jiaoshi'), false);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// ---- 判别力核心:跨编号空间重叠 ----
|
|||
|
|
// 生产实测(2026-10-08)283 对碰撞的根因就是「无条件双键归集」。
|
|||
|
|
it('buildAggregationKeyMap:兜底键撞上同组他人主键 ⇒ 必须被丢弃', () => {
|
|||
|
|
// 考试组:cid=1 的真实聚合 uid = 2,而 cid=2 的主键恰为 '2' ⇒ 二者重叠
|
|||
|
|
const contestants = [{ id: 1 }, { id: 2 }];
|
|||
|
|
const realAgg = { 1: 2 };
|
|||
|
|
const map = aggKey.buildAggregationKeyMap('gaozhi', contestants, realAgg);
|
|||
|
|
assert.deepStrictEqual(map['1'], ['1'], 'cid=1 的兜底键 2 会命中 cid=2 ⇒ 必须丢弃');
|
|||
|
|
assert.deepStrictEqual(map['2'], ['2']);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('buildAggregationKeyMap:安全兜底键须保留(不因噎废食)', () => {
|
|||
|
|
const contestants = [{ id: 1 }, { id: 2 }];
|
|||
|
|
const realAgg = { 1: 7 }; // 7 未被任何人认领主键
|
|||
|
|
const map = aggKey.buildAggregationKeyMap('gaozhi', contestants, realAgg);
|
|||
|
|
assert.deepStrictEqual(map['1'], ['1', '7']);
|
|||
|
|
assert.deepStrictEqual(map['2'], ['2']);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('buildAggregationKeyMap:空输入 / 非数组不抛错', () => {
|
|||
|
|
assert.deepStrictEqual(aggKey.buildAggregationKeyMap('gaozhi', null, null), {});
|
|||
|
|
assert.deepStrictEqual(aggKey.buildAggregationKeyMap('gaozhi', [], {}), {});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('buildAggregationKeyMap:首元素恒为主键且键不重复', () => {
|
|||
|
|
const contestants = [{ id: 3 }];
|
|||
|
|
const map = aggKey.buildAggregationKeyMap('benke', contestants, { 3: 3 }); // 兜底=cid=主键
|
|||
|
|
assert.deepStrictEqual(map['3'], ['3'], '主键与兜底相等时不得重复');
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
describe('app/lib/idCard —— 导入通道判据 SoT', () => {
|
|||
|
|
it('normalizeIdNumber:trim + 末位 x 归一为大写 X', () => {
|
|||
|
|
assert.strictEqual(idCard.normalizeIdNumber(' 11010519491231002x '), '11010519491231002X');
|
|||
|
|
assert.strictEqual(idCard.normalizeIdNumber(null), '');
|
|||
|
|
assert.strictEqual(idCard.normalizeIdNumber(undefined), '');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('expectedCheckCode:对 GB11643 公开样例号算出正确校验位(独立已知答案)', () => {
|
|||
|
|
// `11010519491231002X` 为公开引用的有效样例;校验位算法若被改动此处必红。
|
|||
|
|
assert.strictEqual(idCard.expectedCheckCode('11010519491231002'), 'X');
|
|||
|
|
// 入参不足 17 位或含非数字 ⇒ 返回 ''
|
|||
|
|
assert.strictEqual(idCard.expectedCheckCode('123'), '');
|
|||
|
|
assert.strictEqual(idCard.expectedCheckCode('1101051949123100A'), '');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('inspectIdNumber:合法号 ⇒ importable=true 且 level=null', () => {
|
|||
|
|
const r = idCard.inspectIdNumber('11010519491231002X');
|
|||
|
|
assert.strictEqual(r.importable, true);
|
|||
|
|
assert.strictEqual(r.level, null);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('inspectIdNumber:空 ⇒ error 且不可导入', () => {
|
|||
|
|
const r = idCard.inspectIdNumber('');
|
|||
|
|
assert.strictEqual(r.importable, false);
|
|||
|
|
assert.strictEqual(r.level, 'error');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('inspectIdNumber:长度≠18 ⇒ error 且不可导入(模型层硬约束前置为可读错误)', () => {
|
|||
|
|
const r = idCard.inspectIdNumber('11010519491231002'); // 17 位
|
|||
|
|
assert.strictEqual(r.importable, false);
|
|||
|
|
assert.strictEqual(r.level, 'error');
|
|||
|
|
assert.ok(r.message.includes('18'));
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('inspectIdNumber:18 位但校验位不符 ⇒ warn(不阻断,防误伤在册考生)', () => {
|
|||
|
|
const r = idCard.inspectIdNumber('110105194912310020'); // 末位应为 X
|
|||
|
|
assert.strictEqual(r.importable, true);
|
|||
|
|
assert.strictEqual(r.level, 'warn');
|
|||
|
|
assert.ok(r.message.includes('校验位'));
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('inspectIdNumber:18 位但含非法字符 ⇒ warn(模型只查长度)', () => {
|
|||
|
|
const r = idCard.inspectIdNumber('1101051949123100AX');
|
|||
|
|
assert.strictEqual(r.importable, true);
|
|||
|
|
assert.strictEqual(r.level, 'warn');
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
describe('app/utils/filename —— 上传落盘名清洗(R108g)', () => {
|
|||
|
|
it('去除控制字符(防 stat 失败 / ENOENT)', () => {
|
|||
|
|
assert.strictEqual(sanitizeUploadFileName('a\x00b\x1fc.txt'), 'abc.txt');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('路径分隔符替换为下划线(防目录穿越)', () => {
|
|||
|
|
assert.strictEqual(sanitizeUploadFileName('a/b\\c.txt'), 'a_b_c.txt');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('折叠多余空白并 trim(修「前导空格 + 乱码」类 ENOENT)', () => {
|
|||
|
|
assert.strictEqual(sanitizeUploadFileName(' a b .txt'), 'a b .txt');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('去除首尾点(防 .name / .. 类)', () => {
|
|||
|
|
assert.strictEqual(sanitizeUploadFileName('.hidden'), 'hidden');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('超长名保留扩展名并截断到上限(120)', () => {
|
|||
|
|
const out = sanitizeUploadFileName('a'.repeat(200) + '.txt');
|
|||
|
|
assert.strictEqual(out.length, 120);
|
|||
|
|
assert.ok(out.endsWith('.txt'), '扩展名必须保留');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('客户端预编码(%XX)先解码', () => {
|
|||
|
|
assert.strictEqual(sanitizeUploadFileName('%E5%89%8D.txt'), '前.txt');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('空值 / 非字符串 ⇒ 返回空串(调用方回退时间戳名)', () => {
|
|||
|
|
assert.strictEqual(sanitizeUploadFileName(''), '');
|
|||
|
|
assert.strictEqual(sanitizeUploadFileName(null), '');
|
|||
|
|
assert.strictEqual(sanitizeUploadFileName(123), '');
|
|||
|
|
});
|
|||
|
|
});
|