背景
- 这两个域把 SQL 与处理器放在同一文件,是全仓仅有的两个零接口测试的后端域。
直接拆会改动 SQL 与参数顺序,而硬约束要求不得变更统计口径 —— 因此按
"可注入化 -> 补契约测试 -> 再提取" 的顺序做。
改动
- ele:拆为 routes.ts(校验与组装)/ repository.ts(全部 SQL)/ model.ts(xlsx 解析、
取值清洗、筛选片段、插入值组装等纯逻辑),并改为 registerEleRoutes(app, deps) 可注入。
- feedback:拆为 routes.ts / repository.ts,同样可注入;建表与截图上传也作为依赖注入。
- 两域各补测试:ele 7 个接口契约 + 6 个模型用例,feedback 9 个接口契约。
mock pool 逐条断言 SQL 文本与参数顺序(含分页、状态白名单、批量插入的 30 列顺序)。
- 架构测试新增一条:已完整分层的三个域(vehicles / ele / feedback)必须有 repository.ts
且 routes.ts 不得出现 SQL。
等价性验证(关键)
- 把改造前的实现从 git 取出,与改造后的实现跑同一批请求(含真实 xlsx 解析路径),
对比每一步落库 SQL、参数、HTTP 状态与响应体:
feedback:6 个场景,SQL + 参数 + 状态完全一致(差异仅 DDL 已移交 db/schema 层)
ele :5 个场景,SQL + 参数 + 状态 + 响应体完全一致(仅随机 batchId/时间戳做掩码)
- 期间的修正:曾把 /mine 与 /list 的列集合统一,二者实际不同(管理列表多 user_id/user_name),
已按原样保留;测试同时锁定了这一差异。
lint / test(161) / build 全绿,可达性 0 未引用文件。
212 lines
7.3 KiB
TypeScript
212 lines
7.3 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import { fileURLToPath } from "node:url";
|
|
import path from "node:path";
|
|
import test from "node:test";
|
|
import { Hono } from "hono";
|
|
import type { AuthUser } from "../../auth/types.js";
|
|
import { registerFeedbackRoutes, type FeedbackDependencies } from "./routes.js";
|
|
import type { Database } from "./repository.js";
|
|
|
|
interface Call {
|
|
sql: string;
|
|
params: unknown[];
|
|
}
|
|
|
|
/** 规范化空格,便于对 SQL 文本做稳定断言(不改变语义)。 */
|
|
const normalize = (sql: string) => sql.replace(/\s+/g, " ").trim();
|
|
|
|
function createMockDb(resultSets: unknown[] = []): { db: Database; calls: Call[] } {
|
|
const calls: Call[] = [];
|
|
const db: Database = {
|
|
async query(sql: string, params?: unknown[]) {
|
|
calls.push({ sql: normalize(sql), params: (params ?? []) as unknown[] });
|
|
return [resultSets.shift() ?? [], []] as never;
|
|
},
|
|
};
|
|
return { db, calls };
|
|
}
|
|
|
|
const ADMIN: AuthUser = {
|
|
userId: "u-admin",
|
|
userName: "管理员",
|
|
loginName: "admin",
|
|
depCode: "",
|
|
depName: "",
|
|
permissionLevel: "full",
|
|
roles: ["BI-ADMIN-FEEDBACK"],
|
|
};
|
|
|
|
const NORMAL: AuthUser = {
|
|
userId: "u-1",
|
|
userName: "普通用户",
|
|
loginName: "user1",
|
|
depCode: "",
|
|
depName: "",
|
|
permissionLevel: "personal",
|
|
roles: [],
|
|
};
|
|
|
|
function makeApp(user: AuthUser | undefined, deps: FeedbackDependencies): Hono<{ Variables: { user: AuthUser } }> {
|
|
const app = new Hono<{ Variables: { user: AuthUser } }>();
|
|
app.use("*", async (c, next) => {
|
|
if (user) c.set("user", user);
|
|
await next();
|
|
});
|
|
registerFeedbackRoutes(app as never, deps);
|
|
return app;
|
|
}
|
|
|
|
test("提交反馈:INSERT 语句与参数顺序保持不变", async () => {
|
|
const { db, calls } = createMockDb([{ insertId: 42 }]);
|
|
const app = makeApp(NORMAL, { db, ensureTable: async () => {} });
|
|
|
|
const res = await app.request("/submit", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
type: "bug",
|
|
module: "mileage",
|
|
content: " 车牌筛选失效 ",
|
|
contact: "13800000000",
|
|
screenshots: ["https://oss.example.com/a.png", "javascript:alert(1)", "not-a-url"],
|
|
userAgent: "jest",
|
|
}),
|
|
});
|
|
|
|
assert.equal(res.status, 200);
|
|
assert.deepEqual(await res.json(), { ok: true, id: 42 });
|
|
assert.equal(calls.length, 1);
|
|
assert.equal(
|
|
calls[0].sql,
|
|
"INSERT INTO bi_user_feedback (type, module, content, contact, screenshots, user_id, user_name, user_agent, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, DATE_ADD(UTC_TIMESTAMP(), INTERVAL 8 HOUR))",
|
|
);
|
|
assert.deepEqual(calls[0].params, [
|
|
"bug",
|
|
"mileage",
|
|
"车牌筛选失效",
|
|
"13800000000",
|
|
JSON.stringify(["https://oss.example.com/a.png"]),
|
|
"u-1",
|
|
"普通用户",
|
|
"jest",
|
|
]);
|
|
});
|
|
|
|
test("提交反馈:类型与内容长度校验拦截", async () => {
|
|
const { db, calls } = createMockDb();
|
|
const app = makeApp(NORMAL, { db, ensureTable: async () => {} });
|
|
|
|
const badType = await app.request("/submit", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ type: "nope", content: "x" }),
|
|
});
|
|
assert.equal(badType.status, 400);
|
|
|
|
const empty = await app.request("/submit", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ type: "bug", content: " " }),
|
|
});
|
|
assert.equal(empty.status, 400);
|
|
assert.equal(calls.length, 0, "校验失败不应触达数据库");
|
|
});
|
|
|
|
test("我的反馈:列集合不含 user_id/user_name,按当前用户过滤", async () => {
|
|
const { db, calls } = createMockDb([[]]);
|
|
const app = makeApp(NORMAL, { db, ensureTable: async () => {} });
|
|
|
|
const res = await app.request("/mine");
|
|
assert.equal(res.status, 200);
|
|
assert.equal(calls.length, 1);
|
|
assert.equal(
|
|
calls[0].sql,
|
|
"SELECT id, type, module, content, contact, screenshots, status, reply_content, reply_user, reply_at, created_at FROM bi_user_feedback WHERE user_id = ? ORDER BY created_at DESC LIMIT 100",
|
|
);
|
|
assert.deepEqual(calls[0].params, ["u-1"]);
|
|
});
|
|
|
|
test("我的反馈:无登录用户时直接返回空列表且不查库", async () => {
|
|
const { db, calls } = createMockDb();
|
|
const app = makeApp(undefined, { db, ensureTable: async () => {} });
|
|
|
|
const res = await app.request("/mine");
|
|
assert.equal(res.status, 200);
|
|
assert.deepEqual(await res.json(), { items: [] });
|
|
assert.equal(calls.length, 0);
|
|
});
|
|
|
|
test("管理列表:无管理角色返回 403 且不查库", async () => {
|
|
const { db, calls } = createMockDb();
|
|
const app = makeApp(NORMAL, { db, ensureTable: async () => {} });
|
|
|
|
const res = await app.request("/list");
|
|
assert.equal(res.status, 403);
|
|
assert.equal(calls.length, 0);
|
|
});
|
|
|
|
test("管理列表:列集合含 user_id/user_name,状态白名单与条数上限生效", async () => {
|
|
const { db, calls } = createMockDb([[]]);
|
|
const app = makeApp(ADMIN, { db, ensureTable: async () => {} });
|
|
|
|
await app.request("/list?status=done&limit=9999");
|
|
assert.equal(
|
|
calls[0].sql,
|
|
"SELECT id, type, module, content, contact, screenshots, user_id, user_name, status, reply_content, reply_user, reply_at, created_at FROM bi_user_feedback WHERE 1=1 AND status = ? ORDER BY created_at DESC LIMIT ?",
|
|
);
|
|
assert.deepEqual(calls[0].params, ["done", 500], "limit 上限为 500");
|
|
|
|
calls.length = 0;
|
|
await app.request("/list?status=not-a-status&limit=abc");
|
|
assert.equal(
|
|
calls[0].sql,
|
|
"SELECT id, type, module, content, contact, screenshots, user_id, user_name, status, reply_content, reply_user, reply_at, created_at FROM bi_user_feedback WHERE 1=1 ORDER BY created_at DESC LIMIT ?",
|
|
);
|
|
assert.deepEqual(calls[0].params, [100], "非法状态被丢弃,limit 回退默认值");
|
|
});
|
|
|
|
test("管理更新:状态与回复的字段顺序与参数顺序保持不变", async () => {
|
|
const { db, calls } = createMockDb([{ affectedRows: 1 }]);
|
|
const app = makeApp(ADMIN, { db, ensureTable: async () => {} });
|
|
|
|
const res = await app.request("/7", {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ status: "done", reply: " 已修复 " }),
|
|
});
|
|
|
|
assert.equal(res.status, 200);
|
|
assert.equal(
|
|
calls[0].sql,
|
|
"UPDATE bi_user_feedback SET status = ?, reply_content = ?, reply_user = ?, reply_at = DATE_ADD(UTC_TIMESTAMP(), INTERVAL 8 HOUR) WHERE id = ?",
|
|
);
|
|
assert.deepEqual(calls[0].params, ["done", "已修复", "管理员", 7]);
|
|
});
|
|
|
|
test("管理更新:无可用字段或非法 id 返回 400", async () => {
|
|
const { db, calls } = createMockDb();
|
|
const app = makeApp(ADMIN, { db, ensureTable: async () => {} });
|
|
|
|
const none = await app.request("/7", {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({}),
|
|
});
|
|
assert.equal(none.status, 400);
|
|
|
|
const badId = await app.request("/0", {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ status: "done" }),
|
|
});
|
|
assert.equal(badId.status, 400);
|
|
assert.equal(calls.length, 0);
|
|
});
|
|
|
|
test("分层:路由文件不再直接书写 SQL", () => {
|
|
const source = readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "routes.ts"), "utf8");
|
|
const offenders = source.match(/\b(SELECT|INSERT INTO|UPDATE |DELETE FROM)\b/g) ?? [];
|
|
assert.deepEqual(offenders, [], "SQL 必须留在 repository.ts,路由只做校验与组装");
|
|
});
|