docs(admin-portal): 新增 nextstep-v2.md 记录下游核查结果
v1 声称完成的下游工作经核查实际未完成: - api-gateway: /api/admin/graphql 路由未注册,go vet 编译失败 - teacher-bff: resolver 已完成但 schema 未同步(命名空间 vs 扁平) - iam: proto 缺 BatchGetUsers rpc 声明 v2 记录详细核查证据和修复要求
This commit is contained in:
196
apps/teacher-portal/src/mocks/__tests__/handlers-p4.test.ts
Normal file
196
apps/teacher-portal/src/mocks/__tests__/handlers-p4.test.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* MSW P4 Handlers 单元测试
|
||||
*
|
||||
* 测试 handleP4GraphQL 函数:
|
||||
* - KnowledgeGraph 操作返回 nodes + edges
|
||||
* - ClassAnalytics 操作返回班级统计
|
||||
* - StudentAnalytics 操作返回单生学情
|
||||
* - 未覆盖的 operationName 返回 null(fallthrough)
|
||||
*
|
||||
* 直接测试 handleP4GraphQL 函数返回值(HttpResponse 或 null)。
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { handleP4GraphQL } from "../handlers-p4";
|
||||
import {
|
||||
mockKnowledgeGraph,
|
||||
filterKnowledgeGraph,
|
||||
} from "../fixtures/knowledge-graph";
|
||||
import {
|
||||
mockClassAnalytics,
|
||||
findStudentAnalytics,
|
||||
} from "../fixtures/analytics";
|
||||
|
||||
/** 从 HttpResponse 中提取 JSON 数据 */
|
||||
async function extractJson(res: Response): Promise<Record<string, unknown>> {
|
||||
return (await res.json()) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
describe("handleP4GraphQL: KnowledgeGraph", () => {
|
||||
it("subject 为 null 时返回全量图谱(13 节点 + 10 边)", async () => {
|
||||
const res = handleP4GraphQL("KnowledgeGraph", { subject: null });
|
||||
expect(res).not.toBeNull();
|
||||
const body = await extractJson(res!);
|
||||
const data = body.data as {
|
||||
knowledgeGraph: typeof mockKnowledgeGraph;
|
||||
};
|
||||
expect(data.knowledgeGraph.nodes.length).toBe(
|
||||
mockKnowledgeGraph.nodes.length,
|
||||
);
|
||||
expect(data.knowledgeGraph.edges.length).toBe(
|
||||
mockKnowledgeGraph.edges.length,
|
||||
);
|
||||
});
|
||||
|
||||
it("subject 为 '数学' 时仅返回数学节点(5 节点)", async () => {
|
||||
const res = handleP4GraphQL("KnowledgeGraph", { subject: "数学" });
|
||||
expect(res).not.toBeNull();
|
||||
const body = await extractJson(res!);
|
||||
const data = body.data as {
|
||||
knowledgeGraph: { nodes: Array<{ subject: string }>; edges: unknown[] };
|
||||
};
|
||||
const expected = filterKnowledgeGraph("数学");
|
||||
expect(data.knowledgeGraph.nodes.length).toBe(expected.nodes.length);
|
||||
for (const node of data.knowledgeGraph.nodes) {
|
||||
expect(node.subject).toBe("数学");
|
||||
}
|
||||
});
|
||||
|
||||
it("subject 为 undefined 时返回全量(兜底)", async () => {
|
||||
const res = handleP4GraphQL("KnowledgeGraph", {});
|
||||
expect(res).not.toBeNull();
|
||||
const body = await extractJson(res!);
|
||||
const data = body.data as {
|
||||
knowledgeGraph: typeof mockKnowledgeGraph;
|
||||
};
|
||||
expect(data.knowledgeGraph.nodes.length).toBe(
|
||||
mockKnowledgeGraph.nodes.length,
|
||||
);
|
||||
});
|
||||
|
||||
it("每条 edge 引用的节点存在于 nodes 列表中", async () => {
|
||||
const res = handleP4GraphQL("KnowledgeGraph", { subject: "数学" });
|
||||
expect(res).not.toBeNull();
|
||||
const body = await extractJson(res!);
|
||||
const data = body.data as {
|
||||
knowledgeGraph: {
|
||||
nodes: Array<{ id: string }>;
|
||||
edges: Array<{ from: string; to: string }>;
|
||||
};
|
||||
};
|
||||
const nodeIds = new Set(data.knowledgeGraph.nodes.map((n) => n.id));
|
||||
for (const edge of data.knowledgeGraph.edges) {
|
||||
expect(nodeIds.has(edge.from)).toBe(true);
|
||||
expect(nodeIds.has(edge.to)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleP4GraphQL: ClassAnalytics", () => {
|
||||
it("返回班级学情分析数据(含 avgScore / passRate / topStudents)", async () => {
|
||||
const classId = "class-test-001";
|
||||
const res = handleP4GraphQL("ClassAnalytics", { classId });
|
||||
expect(res).not.toBeNull();
|
||||
const body = await extractJson(res!);
|
||||
const data = body.data as {
|
||||
classAnalytics: {
|
||||
classId: string;
|
||||
className: string;
|
||||
avgScore: number;
|
||||
passRate: number;
|
||||
avgTrend: number[];
|
||||
topStudents: unknown[];
|
||||
weakPoints: string[];
|
||||
};
|
||||
};
|
||||
expect(data.classAnalytics.classId).toBe(classId);
|
||||
expect(data.classAnalytics.className).toBe(mockClassAnalytics.className);
|
||||
expect(typeof data.classAnalytics.avgScore).toBe("number");
|
||||
expect(typeof data.classAnalytics.passRate).toBe("number");
|
||||
expect(Array.isArray(data.classAnalytics.avgTrend)).toBe(true);
|
||||
expect(data.classAnalytics.avgTrend.length).toBeGreaterThan(0);
|
||||
expect(Array.isArray(data.classAnalytics.topStudents)).toBe(true);
|
||||
expect(data.classAnalytics.topStudents.length).toBeGreaterThan(0);
|
||||
expect(Array.isArray(data.classAnalytics.weakPoints)).toBe(true);
|
||||
});
|
||||
|
||||
it("classId 为 undefined 时回退到 mockClassAnalytics.classId", async () => {
|
||||
const res = handleP4GraphQL("ClassAnalytics", {});
|
||||
expect(res).not.toBeNull();
|
||||
const body = await extractJson(res!);
|
||||
const data = body.data as { classAnalytics: { classId: string } };
|
||||
expect(data.classAnalytics.classId).toBe(mockClassAnalytics.classId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleP4GraphQL: StudentAnalytics", () => {
|
||||
it("返回指定学生的学情数据(命中 stu-001)", async () => {
|
||||
const studentId = "stu-001";
|
||||
const res = handleP4GraphQL("StudentAnalytics", { studentId });
|
||||
expect(res).not.toBeNull();
|
||||
const body = await extractJson(res!);
|
||||
const data = body.data as {
|
||||
studentAnalytics: {
|
||||
studentId: string;
|
||||
studentName: string;
|
||||
avgScore: number;
|
||||
trend: number[];
|
||||
weakPoints: string[];
|
||||
strongPoints: string[];
|
||||
masteryRate: number;
|
||||
};
|
||||
};
|
||||
const expected = findStudentAnalytics(studentId);
|
||||
expect(data.studentAnalytics.studentId).toBe(studentId);
|
||||
expect(data.studentAnalytics.studentName).toBe(expected.studentName);
|
||||
expect(data.studentAnalytics.avgScore).toBe(expected.avgScore);
|
||||
expect(data.studentAnalytics.trend).toEqual(expected.trend);
|
||||
expect(data.studentAnalytics.weakPoints).toEqual(expected.weakPoints);
|
||||
expect(data.studentAnalytics.strongPoints).toEqual(expected.strongPoints);
|
||||
expect(data.studentAnalytics.masteryRate).toBe(expected.masteryRate);
|
||||
});
|
||||
|
||||
it("未命中的 studentId 返回兜底数据(非空)", async () => {
|
||||
const studentId = "stu-unknown-999";
|
||||
const res = handleP4GraphQL("StudentAnalytics", { studentId });
|
||||
expect(res).not.toBeNull();
|
||||
const body = await extractJson(res!);
|
||||
const data = body.data as {
|
||||
studentAnalytics: { studentId: string; avgScore: number };
|
||||
};
|
||||
expect(data.studentAnalytics.studentId).toBe(studentId);
|
||||
expect(typeof data.studentAnalytics.avgScore).toBe("number");
|
||||
});
|
||||
|
||||
it("studentId 为 undefined 时回退到 stu-001", async () => {
|
||||
const res = handleP4GraphQL("StudentAnalytics", {});
|
||||
expect(res).not.toBeNull();
|
||||
const body = await extractJson(res!);
|
||||
const data = body.data as {
|
||||
studentAnalytics: { studentId: string };
|
||||
};
|
||||
expect(data.studentAnalytics.studentId).toBe("stu-001");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleP4GraphQL: 未覆盖的 operationName", () => {
|
||||
it("非 P4 operation 返回 null(fallthrough)", () => {
|
||||
const res = handleP4GraphQL("Dashboard", {});
|
||||
expect(res).toBeNull();
|
||||
});
|
||||
|
||||
it("Me operation 返回 null", () => {
|
||||
const res = handleP4GraphQL("Me", {});
|
||||
expect(res).toBeNull();
|
||||
});
|
||||
|
||||
it("空字符串 operationName 返回 null", () => {
|
||||
const res = handleP4GraphQL("", {});
|
||||
expect(res).toBeNull();
|
||||
});
|
||||
|
||||
it("Unknown operationName 返回 null", () => {
|
||||
const res = handleP4GraphQL("SomeFutureOperation", {});
|
||||
expect(res).toBeNull();
|
||||
});
|
||||
});
|
||||
192
apps/teacher-portal/src/mocks/__tests__/handlers.test.ts
Normal file
192
apps/teacher-portal/src/mocks/__tests__/handlers.test.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* MSW Handlers 单元测试
|
||||
*
|
||||
* 测试 handlers.ts 拦截的关键 operationName:
|
||||
* - POST /api/auth/login → 登录返回 token
|
||||
* - POST /api/v1/teacher/graphql → Me / Viewports / CreateExam / 未知 operation
|
||||
* - GET /api/health、GET /api/ready → 健康检查
|
||||
*
|
||||
* 使用 msw/node server(在 vitest.setup.ts 中启动)+ fetch 调用验证。
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import { server } from "../server";
|
||||
import { handlers } from "../handlers";
|
||||
import { mockUser } from "../fixtures/user";
|
||||
import { mockViewports } from "../fixtures/viewports";
|
||||
|
||||
const GRAPHQL_URL = "http://localhost/api/v1/teacher/graphql";
|
||||
const LOGIN_URL = "http://localhost/api/auth/login";
|
||||
const HEALTH_URL = "http://localhost/api/health";
|
||||
const READY_URL = "http://localhost/api/ready";
|
||||
|
||||
// 替换 MSW handlers 为仅 handlers.ts(避免 p7/p4/p5 抢占同一 endpoint
|
||||
// 导致 request body 被多次读取的 "Body is unusable" 错误)
|
||||
beforeAll(() => {
|
||||
server.resetHandlers(...handlers);
|
||||
});
|
||||
afterAll(() => {
|
||||
server.restoreHandlers();
|
||||
});
|
||||
|
||||
async function graphql(
|
||||
operationName: string,
|
||||
variables: Record<string, unknown> = {},
|
||||
): Promise<Record<string, unknown>> {
|
||||
const res = await fetch(GRAPHQL_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ operationName, variables }),
|
||||
});
|
||||
return (await res.json()) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
describe("handlers: POST /api/auth/login", () => {
|
||||
it("邮箱密码齐全时返回 envelope 包含 user + tokens", async () => {
|
||||
const res = await fetch(LOGIN_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: "teacher@edu.test", password: "pass" }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
success: boolean;
|
||||
data: { user: typeof mockUser; tokens: { accessToken: string } };
|
||||
};
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.user.id).toBe(mockUser.id);
|
||||
expect(body.data.user.email).toBe(mockUser.email);
|
||||
expect(typeof body.data.tokens.accessToken).toBe("string");
|
||||
expect(body.data.tokens.accessToken.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("缺少 email 时返回 400", async () => {
|
||||
const res = await fetch(LOGIN_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ password: "pass" }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as {
|
||||
success: boolean;
|
||||
error: { code: string };
|
||||
};
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.error.code).toBe("GW_UNAUTHORIZED");
|
||||
});
|
||||
|
||||
it("缺少 password 时返回 400", async () => {
|
||||
const res = await fetch(LOGIN_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: "teacher@edu.test" }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handlers: POST /api/v1/teacher/graphql Me", () => {
|
||||
it("Me 操作返回 mockUser", async () => {
|
||||
const body = await graphql("Me");
|
||||
const data = body.data as { me: typeof mockUser };
|
||||
expect(data.me).toBeDefined();
|
||||
expect(data.me.id).toBe(mockUser.id);
|
||||
expect(data.me.email).toBe(mockUser.email);
|
||||
expect(data.me.name).toBe(mockUser.name);
|
||||
expect(data.me.roles).toEqual(mockUser.roles);
|
||||
expect(data.me.dataScope).toBe(mockUser.dataScope);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handlers: POST /api/v1/teacher/graphql Viewports", () => {
|
||||
it("Viewports 操作返回 mockViewports(实际 23 项)", async () => {
|
||||
const body = await graphql("Viewports");
|
||||
const data = body.data as { viewports: typeof mockViewports };
|
||||
expect(Array.isArray(data.viewports)).toBe(true);
|
||||
expect(data.viewports.length).toBe(mockViewports.length);
|
||||
// 抽样校验首项
|
||||
const first = data.viewports[0];
|
||||
expect(first).toBeDefined();
|
||||
expect(first?.key).toBe("dashboard");
|
||||
expect(first?.label).toBe("仪表盘");
|
||||
expect(first?.route).toBe("/dashboard");
|
||||
expect(first?.sortOrder).toBe("01");
|
||||
});
|
||||
|
||||
it("每项视口包含必需字段", async () => {
|
||||
const body = await graphql("Viewports");
|
||||
const data = body.data as { viewports: typeof mockViewports };
|
||||
for (const v of data.viewports) {
|
||||
expect(typeof v.key).toBe("string");
|
||||
expect(typeof v.label).toBe("string");
|
||||
expect(typeof v.route).toBe("string");
|
||||
expect(typeof v.sortOrder).toBe("string");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("handlers: POST /api/v1/teacher/graphql CreateExam", () => {
|
||||
it("CreateExam 操作返回 examId(含 classId 前缀)", async () => {
|
||||
const input = {
|
||||
classId: "class-test-001",
|
||||
title: "单元测试一",
|
||||
description: "覆盖函数与导数",
|
||||
examDate: new Date().toISOString(),
|
||||
duration: 90,
|
||||
totalScore: 100,
|
||||
};
|
||||
const body = await graphql("CreateExam", { input });
|
||||
const data = body.data as {
|
||||
createExam: {
|
||||
id: string;
|
||||
classId: string;
|
||||
title: string;
|
||||
status: string;
|
||||
};
|
||||
};
|
||||
expect(data.createExam).toBeDefined();
|
||||
expect(data.createExam.id).toContain(input.classId);
|
||||
expect(data.createExam.classId).toBe(input.classId);
|
||||
expect(data.createExam.title).toBe(input.title);
|
||||
expect(data.createExam.status).toBe("DRAFT");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handlers: POST /api/v1/teacher/graphql 未知 operation", () => {
|
||||
it("未覆盖的 operationName 返回 GraphQL errors", async () => {
|
||||
const body = await graphql("NotARealOperation");
|
||||
expect(body.data).toBeUndefined();
|
||||
const errors = body.errors as Array<{
|
||||
message: string;
|
||||
extensions: { code: string };
|
||||
}>;
|
||||
expect(Array.isArray(errors)).toBe(true);
|
||||
expect(errors[0]?.extensions.code).toBe("BFF_TEACHER_NOT_IMPLEMENTED");
|
||||
expect(errors[0]?.message).toContain("NotARealOperation");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handlers: GET /api/health", () => {
|
||||
it("返回 ok 状态", async () => {
|
||||
const res = await fetch(HEALTH_URL);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { status: string; service: string };
|
||||
expect(body.status).toBe("ok");
|
||||
expect(body.service).toBe("teacher-portal");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handlers: GET /api/ready", () => {
|
||||
it("返回 ok 状态 + checks", async () => {
|
||||
const res = await fetch(READY_URL);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
status: string;
|
||||
service: string;
|
||||
checks: { msw: boolean };
|
||||
};
|
||||
expect(body.status).toBe("ok");
|
||||
expect(body.service).toBe("teacher-portal");
|
||||
expect(body.checks.msw).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user