198 lines
7.3 KiB
TypeScript
198 lines
7.3 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
import { Hono } from "hono";
|
|
import { assetResult, validatePlan, visibleVehicles } from "./model.js";
|
|
import router from "./index.js";
|
|
import { vehicleRepository } from "../vehicles/repository.js";
|
|
import type { Vehicle } from "../../types.js";
|
|
import type { AuthUser } from "../../auth/types.js";
|
|
|
|
const user: AuthUser = {
|
|
userId: "u1",
|
|
userName: "测试",
|
|
loginName: "test",
|
|
depName: "甲部门",
|
|
depCode: "A",
|
|
permissionLevel: "personal",
|
|
roles: [],
|
|
};
|
|
const plan = {
|
|
kind: "assets",
|
|
category: "inventory",
|
|
groupBy: "region",
|
|
view: "chart",
|
|
filters: {},
|
|
};
|
|
const vehicles = ["Inventory", "Abnormal", "Pending", "Operating"].map(
|
|
(status, index) =>
|
|
({
|
|
id: String(index),
|
|
status,
|
|
operationStatus: index === 3 ? "1" : "3",
|
|
managerId: index === 1 ? "u2" : "u1",
|
|
departmentName: "甲部门",
|
|
subjectOrg: index === 2 ? "乙公司" : "甲公司",
|
|
province: "浙江省",
|
|
city: "嘉兴市",
|
|
plateNumber: `测试${index}`,
|
|
type: "4.5T",
|
|
model: "测试型号",
|
|
}) as Vehicle,
|
|
);
|
|
|
|
test("public inventory and company filters apply before aggregation and detail generation", () => {
|
|
const visible = visibleVehicles(vehicles, user, "甲公司");
|
|
const result = assetResult(visible, validatePlan(plan));
|
|
assert.equal(result.metrics[0].value, 2);
|
|
assert.equal(result.groups[0].count, result.rows.length);
|
|
assert.deepEqual(
|
|
result.rows.map((v) => v.id),
|
|
["0", "1"],
|
|
);
|
|
});
|
|
test("Shanghai counts include districts and preserve permission and company scope", () => {
|
|
const input = vehicles.map((v, index) => ({ ...v, province: "上海市", city: ["松江区", "嘉定区", "奉贤区", "上海市"][index] }));
|
|
const query = validatePlan({ ...plan, category: "all", groupBy: "city", filters: { city: ["上海市"] } });
|
|
const all = assetResult(input, query);
|
|
assert.equal(all.metrics[0].value, 4);
|
|
assert.equal(all.rows.length, 4);
|
|
assert.equal(all.groups.length, 1);
|
|
assert.equal(all.groups[0].count, 4);
|
|
const scoped = assetResult(visibleVehicles(input, user, "甲公司"), query);
|
|
assert.equal(scoped.metrics[0].value, 3);
|
|
assert.deepEqual(scoped.rows.map(v => v.id).sort(), ["0", "1", "3"]);
|
|
assert.ok(scoped.rows.every(v => v.city === "上海市"));
|
|
});
|
|
|
|
test("inventory includes abnormal and pending; group totals reconcile with detail", () => {
|
|
const result = assetResult(vehicles, validatePlan(plan));
|
|
assert.equal(result.rows.length, 3);
|
|
assert.equal(
|
|
result.groups.reduce((n, g) => n + g.count, 0),
|
|
3,
|
|
);
|
|
assert.equal(result.metrics[1].value, 3);
|
|
});
|
|
test("unsafe or unsupported query plans, impossible dates and long ranges are rejected", () => {
|
|
assert.throws(() => validatePlan({ ...plan, filters: { sql: "SELECT *" } }));
|
|
assert.throws(() => validatePlan({ ...plan, category: "delete" }));
|
|
assert.throws(() => validatePlan({ ...plan, start: "2026-01-01" }));
|
|
assert.throws(() =>
|
|
validatePlan({
|
|
...plan,
|
|
kind: "flow",
|
|
start: "2026-02-30",
|
|
end: "2026-03-05",
|
|
}),
|
|
);
|
|
assert.throws(() =>
|
|
validatePlan({
|
|
...plan,
|
|
kind: "flow",
|
|
start: "2026-01-01",
|
|
end: "2026-09-01",
|
|
}),
|
|
);
|
|
});
|
|
test("assistant rejects unauthenticated direct calls before contacting a model", async () => {
|
|
const response = await router.request("/ask", { method: "POST", body: "{}" });
|
|
assert.equal(response.status, 401);
|
|
});
|
|
test("presentation receives authorized aggregates while vehicle rows stay in the internal response", async (t) => {
|
|
const oldKey = process.env.DEEPSEEK_API_KEY;
|
|
process.env.DEEPSEEK_API_KEY = "test-only";
|
|
const oldEnabled = process.env.ASSET_AI_EXTERNAL_ENABLED;
|
|
process.env.ASSET_AI_EXTERNAL_ENABLED = "1";
|
|
t.after(() => {
|
|
if (oldEnabled === undefined) delete process.env.ASSET_AI_EXTERNAL_ENABLED;
|
|
else process.env.ASSET_AI_EXTERNAL_ENABLED = oldEnabled;
|
|
if (oldKey === undefined) delete process.env.DEEPSEEK_API_KEY;
|
|
else process.env.DEEPSEEK_API_KEY = oldKey;
|
|
});
|
|
t.mock.method(vehicleRepository, "getVehicles", async () => vehicles);
|
|
const prompts: string[] = [];
|
|
t.mock.method(
|
|
globalThis,
|
|
"fetch",
|
|
async (_url: unknown, init: RequestInit) => {
|
|
prompts.push(String(init.body));
|
|
return new Response(
|
|
JSON.stringify({
|
|
choices: [
|
|
{
|
|
message: {
|
|
content:
|
|
prompts.length === 1
|
|
? JSON.stringify({
|
|
kind: "query",
|
|
plan,
|
|
})
|
|
: JSON.stringify({results:[{id:"main",blocks:[{id:"summary",kind:"summary"}],interactions:[]}]}),
|
|
},
|
|
finish_reason: "stop",
|
|
},
|
|
],
|
|
}),
|
|
{ status: 200 },
|
|
);
|
|
},
|
|
);
|
|
const app = new Hono<{ Variables: { user: AuthUser } }>();
|
|
app.use("*", async (c, next) => {
|
|
c.set("user", user);
|
|
await next();
|
|
});
|
|
app.route("/assistant", router);
|
|
const response = await app.request("/assistant/ask", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ message: "统计库存" }),
|
|
});
|
|
const events = (await response.text())
|
|
.split("\n")
|
|
.filter((s) => s.startsWith("data:"))
|
|
.map((s) => JSON.parse(s.slice(5)));
|
|
const result = events.find((e) => e.type === "result").result;
|
|
assert.deepEqual(
|
|
result.rows.map((r: { id: string }) => r.id),
|
|
["0", "1", "2"],
|
|
);
|
|
assert.equal(prompts.length, 2);
|
|
const presentationInput = JSON.parse(JSON.parse(prompts[1]).messages[1].content);
|
|
assert.equal(presentationInput.stage, "presentation");
|
|
assert.equal(presentationInput.results[0].metrics[0].value, 3);
|
|
assert.equal(presentationInput.results[0].rows, undefined);
|
|
assert.ok(
|
|
prompts.every(
|
|
(p) =>
|
|
!p.includes("测试0") &&
|
|
!p.includes("测试2") &&
|
|
!p.includes("plateNumber"),
|
|
),
|
|
);
|
|
assert.equal(events.filter((e) => e.type === "step").length, 4);
|
|
});
|
|
|
|
test("inventory subsets do not inherit parent inventory metric cards", () => {
|
|
for (const category of ["abnormal", "pending", "available", "awaiting"] as const) {
|
|
const r = assetResult(vehicles, {kind:"assets", category, groupBy:"none", view:"chart", filters:{}});
|
|
assert.equal(r.metrics.length, 1, category);
|
|
assert.doesNotMatch(r.summary, /其中异动.*待交车/);
|
|
}
|
|
});
|
|
|
|
test("personal and department scopes include all current stock subsets but keep operating vehicles restricted", () => {
|
|
const stock = vehicles.slice(0, 3).map(v => ({ ...v, subjectOrg: "甲公司", managerId: "other", departmentName: "乙部门" }));
|
|
const ownOperating = vehicles[3];
|
|
const foreignOperating = { ...ownOperating, id: "foreign-operating", managerId: "other", departmentName: "乙部门" };
|
|
const input = [...stock, ownOperating, foreignOperating];
|
|
for (const permissionLevel of ["personal", "department"] as const) {
|
|
const visible = visibleVehicles(input, { ...user, permissionLevel }, "甲公司");
|
|
assert.deepEqual(visible.map(v => v.id), ["0", "1", "2", "3"]);
|
|
for (const [category, expected] of [["inventory", ["0", "1", "2"]], ["abnormal", ["1"]], ["pending", ["2"]], ["operating", ["3"]]] as const) {
|
|
const result = assetResult(visible, validatePlan({ ...plan, category }));
|
|
assert.deepEqual(result.rows.map(v => v.id), expected);
|
|
}
|
|
}
|
|
});
|