改动 - 新增 scheduling/repository.ts:通知/干预与建议列表的全部 SQL(13 条)。 - notify.ts / suggestions.ts 改为 register*(app, deps) 可注入,保留默认导出与 create*Router 工厂。 - suggestions.ts 的两处跨域依赖(里程车辆信息、OneOS 里程)改为依赖注入: 既让本域可独立测试,也让"建议依赖里程数据"在类型上可见,而不是藏在 import 里。 - 跨域引用修正:mapRegion 原本从 '../vehicles/routes.js' 引入(为一个纯函数把整个 车辆路由拖进依赖图),改为直接从 '../vehicles/model.js' 引入。 契约测试(新增 10 个用例) - 逐条锁定 SQL 与参数顺序:干预登记的三步(查重 → INSERT → 回读)、409 阻断、 批量循环、历史列表的状态过滤与 limit 上限(500/默认 200)、状态更新的 UPDATE 形状、 400/404 分支、活跃映射与近 7 天计数、建议列表五条基础查询的内容与顺序。 - 架构守护升级:不再只看 routes.ts,而是要求该域**除 repository.ts 外的任何非测试文件 都不得含 SQL**;匹配用"语句形状"正则(如 update <table> set)而非裸关键字, 避免把 UpdateNotification 或日志 "update error" 误判。已验证 6/6 个 repository 被识别、 其余文件零误判。 等价性验证 - 把 notify.ts / suggestions.ts 改造前的实现从 git 取出,与改造后跑同一批请求 (9 个场景,含建议列表整条链路 8 次查询),对比落库 SQL、参数、HTTP 状态与响应体: 完全一致。 - 期间修正了两处**验证工具自身**的缺陷(旧文件误引用新 notify;跨域默认实现的调用 被记到另一侧),修正后结论可信。 lint / test(191) / build 全绿,可达性 0 未引用文件。
240 lines
9.4 KiB
TypeScript
240 lines
9.4 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 { registerNotifyRoutes, fetchActiveNotificationMap, fetchRecentInterventionCount } from "./notify.js";
|
||
import { registerSuggestionsRoutes } from "./suggestions.js";
|
||
import type { Database } from "./repository.js";
|
||
|
||
interface Call {
|
||
via: "query" | "execute";
|
||
sql: string;
|
||
params: unknown[];
|
||
}
|
||
|
||
const normalize = (sql: string) => sql.replace(/\s+/g, " ").trim();
|
||
|
||
/** 依次弹出预设结果;未预设则返回空数组。 */
|
||
function createMockDb(resultSets: unknown[] = []): { db: Database; calls: Call[] } {
|
||
const calls: Call[] = [];
|
||
const queue = [...resultSets];
|
||
const mk = (via: "query" | "execute") => async (sql: string, params?: any[]) => {
|
||
calls.push({ via, sql: normalize(sql), params: (params ?? []) as unknown[] });
|
||
return [queue.shift() ?? [], []] as never;
|
||
};
|
||
return { db: { query: mk("query"), execute: mk("execute") }, calls };
|
||
}
|
||
|
||
const USER: AuthUser = {
|
||
userId: "u-1", userName: "张三", loginName: "zhangsan", depCode: "", depName: "",
|
||
permissionLevel: "full", roles: ["BI-SCHEDULE-OPT"],
|
||
};
|
||
|
||
function appWith(register: (app: any, deps: any) => void, db: Database, extra: Record<string, unknown> = {}) {
|
||
const app = new Hono<{ Variables: { user: AuthUser } }>();
|
||
app.use("*", async (c, next) => {
|
||
c.set("user", USER);
|
||
await next();
|
||
});
|
||
register(app, { db, ...extra });
|
||
return app;
|
||
}
|
||
|
||
/** 建议列表依赖的跨域数据源:测试中替成空实现,避免触达真实库/外部 API。 */
|
||
const STUB_DEPS = {
|
||
loadVehicleInfoMap: async () => new Map(),
|
||
loadOneOsMileageDates: async () => new Map(),
|
||
};
|
||
|
||
const NOTIFICATION_ROW = {
|
||
id: 5,
|
||
suggestion_id: "sug-1",
|
||
current_plate: "粤A1",
|
||
candidate_plate: "粤B2",
|
||
operator_id: "u-1",
|
||
operator_name: "张三",
|
||
status: "sent",
|
||
created_at: "2026-07-01 10:00:00",
|
||
updated_at: "2026-07-01 10:00:00",
|
||
executed_at: null,
|
||
notes: null,
|
||
before_mileage: null,
|
||
after_mileage: null,
|
||
};
|
||
|
||
test("干预登记:先查是否已有生效记录,再 INSERT 并回读", async () => {
|
||
const { db, calls } = createMockDb([[], { insertId: 5 }, [NOTIFICATION_ROW]]);
|
||
const app = appWith(registerNotifyRoutes, db);
|
||
|
||
const res = await app.request("/", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ suggestionId: "sug-1", currentPlate: "粤A1", candidatePlate: "粤B2" }),
|
||
});
|
||
|
||
assert.equal(res.status, 200);
|
||
const payload = await res.json() as { success: boolean; record: Record<string, unknown> };
|
||
assert.equal(payload.success, true);
|
||
assert.equal(payload.record.candidatePlate, "粤B2");
|
||
|
||
assert.equal(calls.length, 3);
|
||
assert.equal(
|
||
calls[0].sql,
|
||
"SELECT id, candidate_plate FROM tab_scheduling_notifications WHERE suggestion_id = ? AND status != 'cancelled' LIMIT 1",
|
||
);
|
||
assert.deepEqual(calls[0].params, ["sug-1"]);
|
||
assert.equal(calls[0].via, "execute");
|
||
assert.equal(
|
||
calls[1].sql,
|
||
"INSERT INTO tab_scheduling_notifications (suggestion_id, current_plate, candidate_plate, operator_id, operator_name, status) VALUES (?, ?, ?, ?, ?, 'sent')",
|
||
);
|
||
assert.deepEqual(calls[1].params, ["sug-1", "粤A1", "粤B2", "u-1", "张三"]);
|
||
assert.equal(calls[2].sql, "SELECT * FROM tab_scheduling_notifications WHERE id = ?");
|
||
assert.deepEqual(calls[2].params, [5]);
|
||
});
|
||
|
||
test("干预登记:已有生效记录返回 409 且不写库", async () => {
|
||
const { db, calls } = createMockDb([[{ id: 9, candidate_plate: "粤C3" }]]);
|
||
const app = appWith(registerNotifyRoutes, db);
|
||
|
||
const res = await app.request("/", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ suggestionId: "sug-1", currentPlate: "粤A1", candidatePlate: "粤B2" }),
|
||
});
|
||
|
||
assert.equal(res.status, 409);
|
||
assert.equal(calls.length, 1, "被阻断后不应再 INSERT");
|
||
const payload = await res.json() as { message: string };
|
||
assert.equal(payload.message.includes("粤C3"), true);
|
||
});
|
||
|
||
test("干预登记:缺少必要参数返回 400 且不查库", async () => {
|
||
const { db, calls } = createMockDb();
|
||
const app = appWith(registerNotifyRoutes, db);
|
||
const res = await app.request("/", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ suggestionId: "sug-1" }),
|
||
});
|
||
assert.equal(res.status, 400);
|
||
assert.equal(calls.length, 0);
|
||
});
|
||
|
||
test("历史列表:状态过滤与条数上限,SQL 与参数顺序锁定", async () => {
|
||
const { db, calls } = createMockDb([[]]);
|
||
const app = appWith(registerNotifyRoutes, db);
|
||
|
||
await app.request("/?status=sent&limit=9999");
|
||
assert.equal(calls.length, 1);
|
||
assert.equal(calls[0].via, "query", "列表沿用 query(非 execute)");
|
||
assert.equal(
|
||
calls[0].sql,
|
||
"SELECT * FROM tab_scheduling_notifications WHERE status = ? ORDER BY created_at DESC LIMIT ?",
|
||
);
|
||
assert.deepEqual(calls[0].params, ["sent", 500], "limit 上限 500");
|
||
|
||
calls.length = 0;
|
||
await app.request("/");
|
||
assert.equal(calls[0].sql, "SELECT * FROM tab_scheduling_notifications ORDER BY created_at DESC LIMIT ?");
|
||
assert.deepEqual(calls[0].params, [200], "无过滤时默认 200");
|
||
});
|
||
|
||
test("状态更新:UPDATE 字段顺序与回读 SQL 保持", async () => {
|
||
const { db, calls } = createMockDb([{}, [NOTIFICATION_ROW]]);
|
||
const app = appWith(registerNotifyRoutes, db);
|
||
|
||
const res = await app.request("/5", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ status: "executed" }),
|
||
});
|
||
|
||
assert.equal(res.status, 200);
|
||
assert.equal(calls[0].via, "execute");
|
||
assert.equal(calls[0].sql.startsWith("UPDATE tab_scheduling_notifications SET "), true);
|
||
assert.equal(calls[0].sql.endsWith("WHERE id = ?"), true);
|
||
assert.equal(calls[0].params[calls[0].params.length - 1], 5);
|
||
assert.equal(calls[1].sql, "SELECT * FROM tab_scheduling_notifications WHERE id = ?");
|
||
});
|
||
|
||
test("状态更新:非法 id / 缺 status / 非法 status 一律 400 且不查库", async () => {
|
||
const { db, calls } = createMockDb();
|
||
const app = appWith(registerNotifyRoutes, db);
|
||
const cases: Array<[string, unknown]> = [
|
||
["/0", { status: "executed" }],
|
||
["/5", {}],
|
||
["/5", { status: "nope" }],
|
||
];
|
||
for (const [url, body] of cases) {
|
||
const res = await app.request(url, {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(body),
|
||
});
|
||
assert.equal(res.status, 400, `${url} ${JSON.stringify(body)}`);
|
||
}
|
||
assert.equal(calls.length, 0);
|
||
});
|
||
|
||
test("状态更新:记录不存在返回 404", async () => {
|
||
const { db } = createMockDb([{}, []]);
|
||
const app = appWith(registerNotifyRoutes, db);
|
||
const res = await app.request("/5", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ status: "cancelled" }),
|
||
});
|
||
assert.equal(res.status, 404);
|
||
});
|
||
|
||
test("活跃映射与近 7 天计数:SQL 与映射键保持", async () => {
|
||
const { db, calls } = createMockDb([
|
||
[{ id: 1, suggestion_id: "sug-1", candidate_plate: "粤B2", status: "sent", created_at: "2026-07-01 10:00:00" }],
|
||
[{ cnt: "4" }],
|
||
]);
|
||
|
||
const map = await fetchActiveNotificationMap(db);
|
||
assert.equal(map.get("sug-1::粤B2")?.id, 1);
|
||
assert.equal(
|
||
calls[0].sql,
|
||
"SELECT id, suggestion_id, candidate_plate, status, created_at FROM tab_scheduling_notifications WHERE status != 'cancelled' ORDER BY created_at DESC",
|
||
);
|
||
|
||
assert.equal(await fetchRecentInterventionCount(db), 4);
|
||
assert.equal(
|
||
calls[1].sql,
|
||
"SELECT COUNT(*) AS cnt FROM tab_scheduling_notifications WHERE status != 'cancelled' AND created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)",
|
||
);
|
||
});
|
||
|
||
test("建议列表:五条基础查询的 SQL 与顺序保持(不查外部 API 的路径)", async () => {
|
||
const { db, calls } = createMockDb([[], [], [], [], []]);
|
||
const app = appWith(registerSuggestionsRoutes, db, STUB_DEPS);
|
||
|
||
// targetId 让响应可预测;OneOS 段在无车牌时不会触发
|
||
const res = await app.request("/?targetId=1");
|
||
assert.equal(res.status, 200);
|
||
|
||
const sqls = calls.map(c => c.sql);
|
||
assert.equal(sqls[0], "SELECT id, target_name, annual_mileage_per_vehicle FROM lingniu_prod.tab_mileage_assessment_target WHERE is_deleted = 0 ORDER BY id");
|
||
assert.equal(sqls[1].startsWith("SELECT target_id, plate_number, today_mileage, vehicle_total_mileage,"), true);
|
||
assert.equal(sqls[1].includes("FROM lingniu_prod.tab_mileage_assessment_vehicle WHERE is_deleted = 0"), true);
|
||
assert.equal(sqls[2].includes("FROM vehicle_info vi LEFT JOIN vehicle_status vs"), true);
|
||
assert.equal(sqls[2].includes("COALESCE(vs.operation_status, '') <> '5'"), true);
|
||
assert.equal(sqls[3], "SELECT plate_number, province, city FROM tab_truck_remote_sync_realtime_info WHERE is_deleted = 0 AND plate_number IS NOT NULL");
|
||
assert.equal(sqls[4].includes("COALESCE(vs.operation_status, '') IN ('3','4')"), true, "库存车辆条件保持");
|
||
});
|
||
|
||
test("分层:调度域的路由文件不再直接书写 SQL", () => {
|
||
const dir = path.dirname(fileURLToPath(import.meta.url));
|
||
for (const file of ["notify.ts", "suggestions.ts"]) {
|
||
const source = readFileSync(path.join(dir, file), "utf8");
|
||
const offenders = source.match(/\b(SELECT|INSERT INTO|UPDATE |DELETE FROM)\b/g) ?? [];
|
||
assert.deepEqual(offenders, [], `${file} 的 SQL 必须留在 repository.ts`);
|
||
}
|
||
});
|