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,路由只做校验与组装"); });