chore(parent-bff): merge parent-bff module into main

Merge feat/parent-bff-ai05 into main, conflicts resolved in favor of feature branch
This commit is contained in:
SpecialX
2026-07-10 19:00:29 +08:00
89 changed files with 11177 additions and 39 deletions

View File

@@ -0,0 +1,254 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
// 使用 vi.hoisted 确保 mockDelCalls 在 mock factory 之前定义
const mockDelCalls = vi.hoisted(() => [] as string[]);
// Mock safeRedis捕获 del 调用以验证失效的缓存 key
vi.mock("../../src/shared/cache/redis.client.js", () => ({
safeRedis: vi.fn(
async (
fn: (client: unknown) => Promise<unknown>,
fallback: unknown,
): Promise<unknown> => {
const mockClient = {
del: (key: string): number => {
mockDelCalls.push(key);
return 1;
},
get: (): null => null,
set: (): string => "OK",
ping: (): string => "PONG",
};
try {
return await fn(mockClient);
} catch {
return fallback;
}
},
),
getRedisClient: vi.fn(() => null),
closeRedisClient: vi.fn(),
}));
import { CacheInvalidationHandler } from "../../src/shared/kafka/handlers/cache-invalidation.handler.js";
import type {
EventHandler,
EventContext,
} from "../../src/shared/kafka/handlers/event-handler.js";
import { safeRedis } from "../../src/shared/cache/redis.client.js";
const mockSafeRedis = vi.mocked(safeRedis);
interface TeachingEventBody {
studentId?: string;
childId?: string;
classId?: string;
parentId?: string;
}
describe("测试用例 10Kafka 缓存失效Integration", () => {
let handler: EventHandler<TeachingEventBody>;
beforeEach(() => {
vi.clearAllMocks();
mockDelCalls.length = 0;
handler = new CacheInvalidationHandler();
});
describe("edu.teaching.grade.recorded", () => {
it("失效 grades:{childId}:* + dashboard:{parentId}", async () => {
const ctx: EventContext<TeachingEventBody> = {
topic: "edu.teaching.grade.recorded",
eventId: "evt-001",
key: "parent-001",
body: {
childId: "student-001",
parentId: "parent-001",
classId: "class-001",
},
};
await handler.handle(ctx);
// 失效 grades:student-001:1 + dashboard:parent-001
expect(mockDelCalls).toContain("grades:student-001:1");
expect(mockDelCalls).toContain("dashboard:parent-001");
expect(mockDelCalls).toHaveLength(2);
});
it("使用 studentId 作为 childId fallback", async () => {
const ctx: EventContext<TeachingEventBody> = {
topic: "edu.teaching.grade.recorded",
eventId: "evt-002",
key: "parent-001",
body: {
studentId: "student-002",
parentId: "parent-001",
},
};
await handler.handle(ctx);
// childId 从 studentId fallback
expect(mockDelCalls).toContain("grades:student-002:1");
expect(mockDelCalls).toContain("dashboard:parent-001");
});
it("无 parentId 时只失效 grades 缓存", async () => {
const ctx: EventContext<TeachingEventBody> = {
topic: "edu.teaching.grade.recorded",
eventId: "evt-003",
key: "student-001",
body: {
childId: "student-001",
},
};
await handler.handle(ctx);
expect(mockDelCalls).toContain("grades:student-001:1");
expect(mockDelCalls).not.toContain("dashboard:parent-001");
expect(mockDelCalls).toHaveLength(1);
});
});
describe("edu.teaching.homework.graded", () => {
it("失效 homework:{childId}:{classId} + dashboard:{parentId}", async () => {
const ctx: EventContext<TeachingEventBody> = {
topic: "edu.teaching.homework.graded",
eventId: "evt-004",
key: "parent-001",
body: {
childId: "student-001",
classId: "class-001",
parentId: "parent-001",
},
};
await handler.handle(ctx);
expect(mockDelCalls).toContain("homework:student-001:class-001");
expect(mockDelCalls).toContain("dashboard:parent-001");
expect(mockDelCalls).toHaveLength(2);
});
it("无 classId 时只失效 dashboard", async () => {
const ctx: EventContext<TeachingEventBody> = {
topic: "edu.teaching.homework.graded",
eventId: "evt-005",
key: "parent-001",
body: {
childId: "student-001",
parentId: "parent-001",
},
};
await handler.handle(ctx);
// 无 classId 时 homework 缓存不失效条件childId && classId
expect(mockDelCalls).not.toContain("homework:student-001:undefined");
expect(mockDelCalls).toContain("dashboard:parent-001");
expect(mockDelCalls).toHaveLength(1);
});
});
describe("edu.teaching.exam.published", () => {
it("失效 exams:{childId}:{classId} + dashboard:{parentId}", async () => {
const ctx: EventContext<TeachingEventBody> = {
topic: "edu.teaching.exam.published",
eventId: "evt-006",
key: "parent-001",
body: {
childId: "student-001",
classId: "class-001",
parentId: "parent-001",
},
};
await handler.handle(ctx);
expect(mockDelCalls).toContain("exams:student-001:class-001");
expect(mockDelCalls).toContain("dashboard:parent-001");
expect(mockDelCalls).toHaveLength(2);
});
});
describe("edu.notification.read", () => {
it("失效 notifications:{parentId}:1", async () => {
const ctx: EventContext<TeachingEventBody> = {
topic: "edu.notification.read",
eventId: "evt-007",
key: "parent-001",
body: {
parentId: "parent-001",
},
};
await handler.handle(ctx);
expect(mockDelCalls).toContain("notifications:parent-001:1");
expect(mockDelCalls).toHaveLength(1);
});
});
describe("edu.notification.recalled", () => {
it("失效 notifications:{parentId}:1", async () => {
const ctx: EventContext<TeachingEventBody> = {
topic: "edu.notification.recalled",
eventId: "evt-008",
key: "parent-001",
body: {
parentId: "parent-001",
},
};
await handler.handle(ctx);
expect(mockDelCalls).toContain("notifications:parent-001:1");
expect(mockDelCalls).toHaveLength(1);
});
});
describe("未知 topic", () => {
it("不失效任何缓存", async () => {
const ctx: EventContext<TeachingEventBody> = {
topic: "edu.unknown.event",
eventId: "evt-009",
key: "parent-001",
body: {
childId: "student-001",
parentId: "parent-001",
},
};
await handler.handle(ctx);
expect(mockDelCalls).toHaveLength(0);
});
});
describe("P5 场景:成绩录入后缓存失效全链路", () => {
it("成绩事件同时失效 grades + dashboard前端刷新后重新拉取", async () => {
// 模拟真实场景:教师录入成绩 → Kafka → 缓存失效 → 前端下次查询走下游
const ctx: EventContext<TeachingEventBody> = {
topic: "edu.teaching.grade.recorded",
eventId: "evt-full-chain",
key: "parent-001",
body: {
childId: "student-001",
parentId: "parent-001",
classId: "class-001",
},
};
await handler.handle(ctx);
// 验证 safeRedis 被调用了 2 次grades del + dashboard del
expect(mockSafeRedis).toHaveBeenCalledTimes(2);
// 验证失效的 key 正确
expect(mockDelCalls).toContain("grades:student-001:1");
expect(mockDelCalls).toContain("dashboard:parent-001");
});
});
});

View File

@@ -0,0 +1,475 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import type { IamClient } from "../../src/clients/iam.client.js";
import type { MsgClient } from "../../src/clients/msg.client.js";
import type { DataAnaClient } from "../../src/clients/data-ana.client.js";
import type { CoreEduClient } from "../../src/clients/core-edu.client.js";
import type {
ChildDto,
UserInfoDto,
ViewportDto,
NotificationDto,
StudentWeaknessDto,
LearningTrendDto,
ClassPerformanceDto,
} from "../../src/clients/dtos.js";
// Mock safeRedis 以控制缓存行为(集成测试聚焦 resolver 编排逻辑)
vi.mock("../../src/shared/cache/redis.client.js", () => ({
safeRedis: vi.fn().mockResolvedValue(null),
getRedisClient: vi.fn(() => null),
closeRedisClient: vi.fn(),
}));
import { buildDashboardResolver } from "../../src/graphql/resolvers/dashboard.resolver.js";
import { buildChildAnalyticsFieldResolver } from "../../src/graphql/resolvers/child.resolver.js";
import type { ResolverDeps } from "../../src/graphql/resolvers/index.js";
import type { GraphqlContext } from "../../src/graphql/context.js";
import type { ChildType } from "../../src/graphql/types.js";
import type { ChildGuard } from "../../src/aggregation/child-guard.js";
import { safeRedis } from "../../src/shared/cache/redis.client.js";
const mockSafeRedis = vi.mocked(safeRedis);
const DELAY_MS = 50;
function delay(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
/**
* 可追踪调用次数与延迟的 Mock IamClient
*/
class TrackingIamClient implements IamClient {
readonly callLog: string[] = [];
async getUserInfo(userId: string): Promise<UserInfoDto> {
this.callLog.push(`getUserInfo:${userId}`);
await delay(DELAY_MS);
return {
id: userId,
email: "parent@example.com",
name: "王家长",
roles: ["parent"],
permissions: [],
};
}
async getChildrenByParent(_parentId: string): Promise<ChildDto[]> {
this.callLog.push("getChildrenByParent");
await delay(DELAY_MS);
return [
{
id: "student-001",
name: "李同学",
grade: "三年级",
classId: "class-001",
className: "三年级1班",
gradeId: "grade-003",
},
{
id: "student-002",
name: "李妹妹",
grade: "一年级",
classId: "class-002",
className: "一年级2班",
gradeId: "grade-001",
},
{
id: "student-003",
name: "王小宝",
grade: "二年级",
classId: "class-003",
className: "二年级1班",
gradeId: "grade-002",
},
];
}
async getViewports(_userId: string): Promise<ViewportDto[]> {
this.callLog.push("getViewports");
await delay(DELAY_MS);
return [
{
key: "dashboard",
label: "首页",
route: "/parent/dashboard",
icon: "home",
sortOrder: "1",
requiredPermission: "parent:dashboard:view",
},
];
}
async getEffectivePermissions(_userId: string): Promise<string[]> {
return [];
}
}
/**
* 可追踪调用次数与延迟的 Mock MsgClient
*/
class TrackingMsgClient implements MsgClient {
readonly callLog: string[] = [];
async listNotifications(
parentId: string,
unreadOnly = false,
): Promise<NotificationDto[]> {
this.callLog.push(`listNotifications:${parentId}:${unreadOnly}`);
await delay(DELAY_MS);
return [
{
id: "notif-1",
userId: parentId,
type: "GRADE",
title: "成绩通知",
content: "测试",
channel: "APP",
isRead: false,
childId: "student-001",
createdAt: Date.now(),
},
{
id: "notif-2",
userId: parentId,
type: "HOMEWORK",
title: "作业通知",
content: "测试",
channel: "APP",
isRead: false,
childId: "student-002",
createdAt: Date.now(),
},
];
}
async markAsRead(_notificationId: string): Promise<void> {}
async getNotificationPreferences(_parentId: string) {
return {
parentId: _parentId,
channels: ["APP"],
eventTypes: {
gradeReleased: true,
homeworkGraded: true,
examPublished: true,
attendanceAlert: true,
schoolAnnouncement: true,
},
};
}
async updateNotificationPreferences(
parentId: string,
prefs: NotificationPreferencesDto,
) {
return { ...prefs, parentId };
}
}
import type { NotificationPreferencesDto } from "../../src/clients/dtos.js";
/**
* 可追踪调用次数与延迟的 Mock DataAnaClient用于多子女学情并行编排测试
*/
class TrackingDataAnaClient implements DataAnaClient {
readonly callLog: string[] = [];
async getStudentWeakness(
studentId: string,
_subjectId: string,
): Promise<StudentWeaknessDto> {
this.callLog.push(`getStudentWeakness:${studentId}`);
await delay(DELAY_MS);
return {
studentId,
weakPoints: [
{
knowledgePointId: `kp-${studentId}-1`,
title: "分数加减法",
mastery: 0.45,
},
],
};
}
async getLearningTrend(
studentId: string,
_startDate: number,
_endDate: number,
): Promise<LearningTrendDto> {
this.callLog.push(`getLearningTrend:${studentId}`);
await delay(DELAY_MS);
return { studentId, points: [{ date: Date.now(), score: 80 }] };
}
async getClassPerformance(
classId: string,
_subjectId: string,
_startDate: number,
_endDate: number,
): Promise<ClassPerformanceDto> {
this.callLog.push(`getClassPerformance:${classId}`);
await delay(DELAY_MS);
return {
classId,
averageScore: 82.5,
passRate: 0.9,
scores: [
{ studentId: "student-001", score: 85, grade: "A" },
{ studentId: "student-002", score: 78, grade: "B" },
{ studentId: "student-003", score: 92, grade: "A" },
],
};
}
}
/**
* Mock CoreEduClientResolverDeps 必需)
*/
class StubCoreEduClient implements CoreEduClient {
async listGradesByStudent(_studentId: string) {
return [];
}
async listHomeworkByClass(_classId: string) {
return [];
}
async listExamsByClass(_classId: string) {
return [];
}
async getClass(classId: string) {
return { id: classId, name: "测试班级", gradeId: "grade-001" };
}
}
function buildMockContext(parentId: string): GraphqlContext {
return {
session: {
parentId,
roles: ["parent"],
dataScope: "CHILDREN",
traceId: "trace-test-001",
},
req: {} as never,
res: {} as never,
loaders: {} as never,
};
}
describe("测试用例 4多子女仪表盘并行编排Integration", () => {
let iamClient: TrackingIamClient;
let msgClient: TrackingMsgClient;
beforeEach(() => {
vi.clearAllMocks();
mockSafeRedis.mockResolvedValue(null);
iamClient = new TrackingIamClient();
msgClient = new TrackingMsgClient();
});
describe("Dashboard 4 路并行编排", () => {
it("4 个下游调用并行执行,总耗时 < 单次耗时 + 100ms", async () => {
// safeRedis: miss → fn 执行 → set ok
mockSafeRedis
.mockResolvedValueOnce(null) // get → miss
.mockResolvedValueOnce(undefined); // set → ok
const deps: ResolverDeps = {
iamClient,
coreEduClient: new StubCoreEduClient(),
dataAnaClient: new TrackingDataAnaClient(),
msgClient,
childGuard: {} as unknown as ChildGuard,
};
const resolver = buildDashboardResolver(deps);
// 使用唯一 parentId 避免 LRU 缓存污染
const ctx = buildMockContext("parent-dash-parallel");
const start = Date.now();
const result = await resolver(null, {}, ctx);
const elapsed = Date.now() - start;
// 4 个并行调用getUserInfo + getChildrenByParent + getViewports + listNotifications
expect(iamClient.callLog).toContain("getUserInfo:parent-dash-parallel");
expect(iamClient.callLog).toContain("getChildrenByParent");
expect(iamClient.callLog).toContain("getViewports");
expect(msgClient.callLog).toContain(
"listNotifications:parent-dash-parallel:true",
);
// 并行:总耗时应接近 DELAY_MS而非 4 * DELAY_MS
// 允许 100ms 的 overhead
expect(elapsed).toBeLessThan(DELAY_MS + 100);
// 数据正确性
expect(result.parent).not.toBeNull();
expect(result.parent?.id).toBe("parent-dash-parallel");
expect(result.children).toHaveLength(3);
expect(result.viewports).toHaveLength(1);
expect(result.unreadNotifications).toBe(2);
expect(result.degraded).toBe(false);
});
it("返回 3 个子女数据", async () => {
mockSafeRedis
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(undefined);
const deps: ResolverDeps = {
iamClient,
coreEduClient: new StubCoreEduClient(),
dataAnaClient: new TrackingDataAnaClient(),
msgClient,
childGuard: {} as unknown as ChildGuard,
};
const resolver = buildDashboardResolver(deps);
// 使用唯一 parentId 避免 LRU 缓存污染
const result = await resolver(
null,
{},
buildMockContext("parent-dash-children"),
);
expect(result.children).toHaveLength(3);
expect(result.children[0]!.id).toBe("student-001");
expect(result.children[1]!.id).toBe("student-002");
expect(result.children[2]!.id).toBe("student-003");
});
});
describe("3 子女 × 3 下游 = 9 路并行 gRPC学情诊断", () => {
it("3 个子女的学情诊断并行编排,总调用 9 次,总耗时 < 单子女耗时 + 100ms", async () => {
const dataAnaClient = new TrackingDataAnaClient();
const deps: ResolverDeps = {
iamClient,
coreEduClient: new StubCoreEduClient(),
dataAnaClient,
msgClient,
childGuard: {} as unknown as ChildGuard,
};
// 3 个子女的 ChildType模拟 dashboard 返回的 children
const children: ChildType[] = [
{
id: "student-001",
name: "李同学",
grade: "三年级",
class: { id: "class-001", name: "三年级1班", gradeId: "grade-003" },
},
{
id: "student-002",
name: "李妹妹",
grade: "一年级",
class: { id: "class-002", name: "一年级2班", gradeId: "grade-001" },
},
{
id: "student-003",
name: "王小宝",
grade: "二年级",
class: { id: "class-003", name: "二年级1班", gradeId: "grade-002" },
},
];
const analyticsResolver = buildChildAnalyticsFieldResolver(deps);
const ctx = buildMockContext("parent-001");
// 并行为 3 个子女获取学情诊断
const start = Date.now();
const results = await Promise.all(
children.map((child) => analyticsResolver(child, {}, ctx)),
);
const elapsed = Date.now() - start;
// 3 子女 × 3 下游 = 9 次调用
expect(dataAnaClient.callLog).toHaveLength(9);
// 每个子女调了 3 个 data-ana 方法
const weaknessCalls = dataAnaClient.callLog.filter((c) =>
c.startsWith("getStudentWeakness:"),
);
const trendCalls = dataAnaClient.callLog.filter((c) =>
c.startsWith("getLearningTrend:"),
);
const classPerfCalls = dataAnaClient.callLog.filter((c) =>
c.startsWith("getClassPerformance:"),
);
expect(weaknessCalls).toHaveLength(3);
expect(trendCalls).toHaveLength(3);
expect(classPerfCalls).toHaveLength(3);
// 并行:总耗时接近 DELAY_MS而非 3 * DELAY_MS
expect(elapsed).toBeLessThan(DELAY_MS + 100);
// 每个子女都有学情结果
expect(results).toHaveLength(3);
for (const analytics of results) {
expect(analytics.weakness).toHaveLength(1);
expect(analytics.trend).toHaveLength(1);
expect(analytics.classRank).not.toBeNull();
}
});
});
describe("下游部分失败降级(测试用例 5 在 Integration 场景)", () => {
it("msg.listNotifications 失败时 dashboard.degraded=true其他数据正常返回", async () => {
// 使用一个 msg 失败的 client
const failingMsgClient: MsgClient = {
listNotifications: async () => {
await delay(DELAY_MS);
throw new Error("msg service unavailable");
},
markAsRead: async () => {},
getNotificationPreferences: async (parentId: string) => ({
parentId,
channels: ["APP"] as string[],
eventTypes: {
gradeReleased: true,
homeworkGraded: true,
examPublished: true,
attendanceAlert: true,
schoolAnnouncement: true,
},
}),
updateNotificationPreferences: async (
parentId: string,
prefs: NotificationPreferencesDto,
) => ({
...prefs,
parentId,
}),
};
mockSafeRedis
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(undefined);
const deps: ResolverDeps = {
iamClient,
coreEduClient: new StubCoreEduClient(),
dataAnaClient: new TrackingDataAnaClient(),
msgClient: failingMsgClient,
childGuard: {} as unknown as ChildGuard,
};
const resolver = buildDashboardResolver(deps);
// 使用唯一 parentId 避免 LRU 缓存污染
const result = await resolver(
null,
{},
buildMockContext("parent-dash-fail"),
);
// msg 失败 → degraded=true
expect(result.degraded).toBe(true);
// 其他数据仍正常返回
expect(result.parent).not.toBeNull();
expect(result.children).toHaveLength(3);
expect(result.viewports).toHaveLength(1);
expect(result.unreadNotifications).toBe(0);
});
});
});

View File

@@ -0,0 +1,329 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import type { IamClient } from "../../src/clients/iam.client.js";
import type { CoreEduClient } from "../../src/clients/core-edu.client.js";
import type { DataAnaClient } from "../../src/clients/data-ana.client.js";
import type {
UserInfoDto,
GradeDto,
StudentWeaknessDto,
ClassInfoDto,
HomeworkDto,
ExamDto,
LearningTrendDto,
ClassPerformanceDto,
} from "../../src/clients/dtos.js";
// Mock safeRedis 以控制 Redis 探针行为
vi.mock("../../src/shared/cache/redis.client.js", () => ({
safeRedis: vi.fn().mockResolvedValue("PONG"),
getRedisClient: vi.fn(() => null),
closeRedisClient: vi.fn(),
}));
import { HealthController } from "../../src/shared/health/health.controller.js";
import { safeRedis } from "../../src/shared/cache/redis.client.js";
const mockSafeRedis = vi.mocked(safeRedis);
class UpIamClient implements IamClient {
async getUserInfo(userId: string): Promise<UserInfoDto> {
return {
id: userId,
email: "",
name: "",
roles: [],
permissions: [],
};
}
async getChildrenByParent(_parentId: string) {
return [];
}
async getViewports(_userId: string) {
return [];
}
async getEffectivePermissions(_userId: string) {
return [];
}
}
class DownIamClient implements IamClient {
async getUserInfo(_userId: string): Promise<UserInfoDto> {
throw new Error("iam gRPC unavailable");
}
async getChildrenByParent(_parentId: string) {
return [];
}
async getViewports(_userId: string) {
return [];
}
async getEffectivePermissions(_userId: string) {
return [];
}
}
class UpCoreEduClient implements CoreEduClient {
async listGradesByStudent(_studentId: string): Promise<GradeDto[]> {
return [];
}
async listHomeworkByClass(_classId: string): Promise<HomeworkDto[]> {
return [];
}
async listExamsByClass(_classId: string): Promise<ExamDto[]> {
return [];
}
async getClass(classId: string): Promise<ClassInfoDto> {
return { id: classId, name: "", gradeId: "" };
}
}
class DownCoreEduClient implements CoreEduClient {
async listGradesByStudent(_studentId: string): Promise<GradeDto[]> {
throw new Error("core-edu gRPC unavailable");
}
async listHomeworkByClass(_classId: string): Promise<HomeworkDto[]> {
return [];
}
async listExamsByClass(_classId: string): Promise<ExamDto[]> {
return [];
}
async getClass(classId: string): Promise<ClassInfoDto> {
return { id: classId, name: "", gradeId: "" };
}
}
class UpDataAnaClient implements DataAnaClient {
async getStudentWeakness(
studentId: string,
_subjectId: string,
): Promise<StudentWeaknessDto> {
return { studentId, weakPoints: [] };
}
async getLearningTrend(
studentId: string,
_startDate: number,
_endDate: number,
): Promise<LearningTrendDto> {
return { studentId, points: [] };
}
async getClassPerformance(
classId: string,
_subjectId: string,
_startDate: number,
_endDate: number,
): Promise<ClassPerformanceDto> {
return { classId, averageScore: 0, passRate: 0, scores: [] };
}
}
class DownDataAnaClient implements DataAnaClient {
async getStudentWeakness(
_studentId: string,
_subjectId: string,
): Promise<StudentWeaknessDto> {
throw new Error("data-ana gRPC unavailable");
}
async getLearningTrend(
studentId: string,
_startDate: number,
_endDate: number,
): Promise<LearningTrendDto> {
return { studentId, points: [] };
}
async getClassPerformance(
classId: string,
_subjectId: string,
_startDate: number,
_endDate: number,
): Promise<ClassPerformanceDto> {
return { classId, averageScore: 0, passRate: 0, scores: [] };
}
}
describe("测试用例 9/readyz 下游探针Integration", () => {
beforeEach(() => {
vi.clearAllMocks();
mockSafeRedis.mockResolvedValue("PONG");
});
describe("/healthzliveness", () => {
it("返回 status=ok + service 名 + timestamp", () => {
const controller = new HealthController(
new UpIamClient(),
new UpCoreEduClient(),
new UpDataAnaClient(),
);
const result = controller.liveness();
expect(result.status).toBe("ok");
expect(result.service).toBe("parent-bff");
expect(result.timestamp).toBeTruthy();
// timestamp 是合法 ISO
const ts = new Date(result.timestamp);
expect(Number.isNaN(ts.getTime())).toBe(false);
});
});
describe("/readyzreadiness全探针正常", () => {
it("4 项探针全部 up 时返回 status=ready", async () => {
mockSafeRedis.mockResolvedValue("PONG");
const controller = new HealthController(
new UpIamClient(),
new UpCoreEduClient(),
new UpDataAnaClient(),
);
const result = await controller.readiness();
expect(result.status).toBe("ready");
expect(result.service).toBe("parent-bff");
expect(result.checks["iam"]?.status).toBe("up");
expect(result.checks["core-edu"]?.status).toBe("up");
expect(result.checks["data-ana"]?.status).toBe("up");
expect(result.checks["redis"]?.status).toBe("up");
});
});
describe("/readyz iam 故障", () => {
it("iam 探针失败时返回 status=degraded + iam.status=down", async () => {
mockSafeRedis.mockResolvedValue("PONG");
const controller = new HealthController(
new DownIamClient(),
new UpCoreEduClient(),
new UpDataAnaClient(),
);
const result = await controller.readiness();
expect(result.status).toBe("degraded");
expect(result.checks["iam"]?.status).toBe("down");
expect(result.checks["iam"]?.error).toContain("iam gRPC unavailable");
// 其他探针仍正常
expect(result.checks["core-edu"]?.status).toBe("up");
expect(result.checks["data-ana"]?.status).toBe("up");
expect(result.checks["redis"]?.status).toBe("up");
});
});
describe("/readyz core-edu 故障", () => {
it("core-edu 探针失败时返回 status=degraded", async () => {
mockSafeRedis.mockResolvedValue("PONG");
const controller = new HealthController(
new UpIamClient(),
new DownCoreEduClient(),
new UpDataAnaClient(),
);
const result = await controller.readiness();
expect(result.status).toBe("degraded");
expect(result.checks["core-edu"]?.status).toBe("down");
expect(result.checks["core-edu"]?.error).toContain(
"core-edu gRPC unavailable",
);
});
});
describe("/readyz data-ana 故障", () => {
it("data-ana 探针失败时返回 status=degraded", async () => {
mockSafeRedis.mockResolvedValue("PONG");
const controller = new HealthController(
new UpIamClient(),
new UpCoreEduClient(),
new DownDataAnaClient(),
);
const result = await controller.readiness();
expect(result.status).toBe("degraded");
expect(result.checks["data-ana"]?.status).toBe("down");
expect(result.checks["data-ana"]?.error).toContain(
"data-ana gRPC unavailable",
);
});
});
describe("/readyz Redis 故障", () => {
it("Redis 探针失败时返回 status=degraded + redis.status=down", async () => {
mockSafeRedis.mockRejectedValue(new Error("redis connection refused"));
const controller = new HealthController(
new UpIamClient(),
new UpCoreEduClient(),
new UpDataAnaClient(),
);
const result = await controller.readiness();
expect(result.status).toBe("degraded");
expect(result.checks["redis"]?.status).toBe("down");
expect(result.checks["redis"]?.error).toContain(
"redis connection refused",
);
// 其他探针仍正常
expect(result.checks["iam"]?.status).toBe("up");
});
it("Redis 返回非 PONG 时 status=down", async () => {
mockSafeRedis.mockResolvedValue("unexpected");
const controller = new HealthController(
new UpIamClient(),
new UpCoreEduClient(),
new UpDataAnaClient(),
);
const result = await controller.readiness();
expect(result.status).toBe("degraded");
expect(result.checks["redis"]?.status).toBe("down");
expect(result.checks["redis"]?.error).toContain(
"unexpected ping response",
);
});
});
describe("/readyz 全部故障", () => {
it("所有探针都失败时 status=degraded", async () => {
mockSafeRedis.mockRejectedValue(new Error("redis down"));
const controller = new HealthController(
new DownIamClient(),
new DownCoreEduClient(),
new DownDataAnaClient(),
);
const result = await controller.readiness();
expect(result.status).toBe("degraded");
expect(result.checks["iam"]?.status).toBe("down");
expect(result.checks["core-edu"]?.status).toBe("down");
expect(result.checks["data-ana"]?.status).toBe("down");
expect(result.checks["redis"]?.status).toBe("down");
});
});
describe("探针延迟记录", () => {
it("每个探针返回 latency_ms", async () => {
mockSafeRedis.mockResolvedValue("PONG");
const controller = new HealthController(
new UpIamClient(),
new UpCoreEduClient(),
new UpDataAnaClient(),
);
const result = await controller.readiness();
expect(typeof result.checks["iam"]?.latency_ms).toBe("number");
expect(typeof result.checks["core-edu"]?.latency_ms).toBe("number");
expect(typeof result.checks["data-ana"]?.latency_ms).toBe("number");
expect(typeof result.checks["redis"]?.latency_ms).toBe("number");
});
});
});

View File

@@ -0,0 +1,245 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import type { IamClient } from "../../src/clients/iam.client.js";
import type { MsgClient } from "../../src/clients/msg.client.js";
import type { DataAnaClient } from "../../src/clients/data-ana.client.js";
import type { CoreEduClient } from "../../src/clients/core-edu.client.js";
import type {
ChildDto,
UserInfoDto,
ViewportDto,
} from "../../src/clients/dtos.js";
// Mock safeRedis 以控制 ChildGuard 缓存行为
vi.mock("../../src/shared/cache/redis.client.js", () => ({
safeRedis: vi.fn().mockResolvedValue(null),
getRedisClient: vi.fn(() => null),
closeRedisClient: vi.fn(),
}));
import { buildSelectChildMutationResolver } from "../../src/graphql/resolvers/select-child.resolver.js";
import type { ResolverDeps } from "../../src/graphql/resolvers/index.js";
import type { GraphqlContext } from "../../src/graphql/context.js";
import { ChildGuard } from "../../src/aggregation/child-guard.js";
import { ChildNotBoundError } from "../../src/shared/errors/application-error.js";
import { logger } from "../../src/shared/observability/logger.js";
import { safeRedis } from "../../src/shared/cache/redis.client.js";
const mockSafeRedis = vi.mocked(safeRedis);
const MOCK_CHILDREN: ChildDto[] = [
{
id: "student-001",
name: "李同学",
grade: "三年级",
classId: "class-001",
className: "三年级1班",
gradeId: "grade-003",
},
{
id: "student-002",
name: "李妹妹",
grade: "一年级",
classId: "class-002",
className: "一年级2班",
gradeId: "grade-001",
},
];
class StubIamClient implements IamClient {
async getUserInfo(userId: string): Promise<UserInfoDto> {
return {
id: userId,
email: "",
name: "",
roles: ["parent"],
permissions: [],
};
}
async getChildrenByParent(_parentId: string): Promise<ChildDto[]> {
return MOCK_CHILDREN.map((c) => ({ ...c }));
}
async getViewports(_userId: string): Promise<ViewportDto[]> {
return [];
}
async getEffectivePermissions(_userId: string): Promise<string[]> {
return [];
}
}
function buildMockContext(parentId: string, traceId: string): GraphqlContext {
return {
session: {
parentId,
roles: ["parent"],
dataScope: "CHILDREN",
traceId,
},
req: {} as never,
res: {} as never,
loaders: {} as never,
};
}
describe("测试用例 8selectChild 审计日志Integration", () => {
let iamClient: StubIamClient;
let childGuard: ChildGuard;
let loggerInfoSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.clearAllMocks();
mockSafeRedis.mockResolvedValue(null);
iamClient = new StubIamClient();
childGuard = new ChildGuard(iamClient);
loggerInfoSpy = vi.spyOn(logger, "info").mockImplementation(() => logger);
});
it("合法 childId记录审计日志traceId + parentId + childId + timestamp", async () => {
// safeRedis: miss → set okChildGuard.getBoundChildren
mockSafeRedis
.mockResolvedValueOnce(null) // get → miss
.mockResolvedValueOnce(undefined); // set → ok
const deps: ResolverDeps = {
iamClient,
coreEduClient: {} as unknown as CoreEduClient,
dataAnaClient: {} as unknown as DataAnaClient,
msgClient: {} as unknown as MsgClient,
childGuard,
};
const resolver = buildSelectChildMutationResolver(deps);
const ctx = buildMockContext("parent-001", "trace-audit-001");
const result = await resolver(null, { childId: "student-001" }, ctx);
// 返回值正确
expect(result.childId).toBe("student-001");
expect(result.audited).toBe(true);
expect(result.selectedAt).toBeTruthy();
// selectedAt 是合法 ISO 时间
const selectedAtDate = new Date(result.selectedAt);
expect(Number.isNaN(selectedAtDate.getTime())).toBe(false);
// 审计日志被调用
expect(loggerInfoSpy).toHaveBeenCalled();
// 查找 "Child selected (audit log)" 的调用
const auditCall = loggerInfoSpy.mock.calls.find(
(call) => typeof call[1] === "string" && call[1].includes("audit log"),
);
expect(auditCall).toBeDefined();
// 审计日志包含 traceId + parentId + childId + selectedAt
const logPayload = auditCall![0] as Record<string, unknown>;
expect(logPayload.parentId).toBe("parent-001");
expect(logPayload.childId).toBe("student-001");
expect(logPayload.traceId).toBe("trace-audit-001");
expect(logPayload.selectedAt).toBe(result.selectedAt);
});
it("越权 childId先抛 ChildNotBoundError不记录审计日志", async () => {
// safeRedis: miss → set okChildGuard.getBoundChildren 第一次调用)
mockSafeRedis
.mockResolvedValueOnce(null) // get → miss
.mockResolvedValueOnce(undefined); // set → ok
const deps: ResolverDeps = {
iamClient,
coreEduClient: {} as unknown as CoreEduClient,
dataAnaClient: {} as unknown as DataAnaClient,
msgClient: {} as unknown as MsgClient,
childGuard,
};
const resolver = buildSelectChildMutationResolver(deps);
const ctx = buildMockContext("parent-001", "trace-audit-002");
// 越权 childId 抛 ChildNotBoundError
await expect(
resolver(null, { childId: "student-999" }, ctx),
).rejects.toThrow(ChildNotBoundError);
// 不应记录审计日志(只可能有 ChildGuard 的 warn 日志)
const auditCall = loggerInfoSpy.mock.calls.find(
(call) => typeof call[1] === "string" && call[1].includes("audit log"),
);
expect(auditCall).toBeUndefined();
});
it("审计日志的 selectedAt 与返回值一致", async () => {
mockSafeRedis.mockResolvedValueOnce(null).mockResolvedValueOnce(undefined);
const deps: ResolverDeps = {
iamClient,
coreEduClient: {} as unknown as CoreEduClient,
dataAnaClient: {} as unknown as DataAnaClient,
msgClient: {} as unknown as MsgClient,
childGuard,
};
const resolver = buildSelectChildMutationResolver(deps);
const ctx = buildMockContext("parent-001", "trace-audit-003");
const result = await resolver(null, { childId: "student-002" }, ctx);
const auditCall = loggerInfoSpy.mock.calls.find(
(call) => typeof call[1] === "string" && call[1].includes("audit log"),
);
expect(auditCall).toBeDefined();
const logPayload = auditCall![0] as Record<string, unknown>;
// 审计日志的 selectedAt 与返回值完全一致
expect(logPayload.selectedAt).toBe(result.selectedAt);
// childId 也一致
expect(logPayload.childId).toBe(result.childId);
});
it("不同 traceId 生成不同审计日志", async () => {
// 第一次调用
mockSafeRedis.mockResolvedValueOnce(null).mockResolvedValueOnce(undefined);
const deps: ResolverDeps = {
iamClient,
coreEduClient: {} as unknown as CoreEduClient,
dataAnaClient: {} as unknown as DataAnaClient,
msgClient: {} as unknown as MsgClient,
childGuard,
};
const resolver = buildSelectChildMutationResolver(deps);
// 第一次traceId-A
const result1 = await resolver(
null,
{ childId: "student-001" },
buildMockContext("parent-001", "traceId-A"),
);
// 等待 2ms 确保 selectedAt 时间戳不同
await new Promise((r) => setTimeout(r, 2));
// 第二次traceId-B缓存已写入直接命中或重新调
mockSafeRedis.mockResolvedValueOnce(null).mockResolvedValueOnce(undefined);
const result2 = await resolver(
null,
{ childId: "student-002" },
buildMockContext("parent-002", "traceId-B"),
);
// 两次审计日志的 traceId 不同
const auditCalls = loggerInfoSpy.mock.calls.filter(
(call) => typeof call[1] === "string" && call[1].includes("audit log"),
);
expect(auditCalls).toHaveLength(2);
const payload1 = auditCalls[0]![0] as Record<string, unknown>;
const payload2 = auditCalls[1]![0] as Record<string, unknown>;
expect(payload1.traceId).toBe("traceId-A");
expect(payload2.traceId).toBe("traceId-B");
expect(payload1.childId).toBe("student-001");
expect(payload2.childId).toBe("student-002");
expect(result1.selectedAt).not.toBe(result2.selectedAt);
});
});

View File

@@ -0,0 +1,177 @@
import { describe, it, expect } from "vitest";
import {
ApplicationError,
ValidationError,
UnauthorizedError,
ChildNotBoundError,
NotFoundError,
ConflictError,
BusinessError,
BadGatewayError,
GatewayTimeoutError,
ServiceUnavailableError,
InternalError,
} from "../../src/shared/errors/application-error.js";
describe("ApplicationError 体系", () => {
describe("ValidationError", () => {
it("statusCode 为 400", () => {
const err = new ValidationError("参数错误");
expect(err.statusCode).toBe(400);
expect(err.code).toBe("BFF_PARENT_VALIDATION_ERROR");
expect(err.type).toBe("validation");
expect(err.message).toBe("参数错误");
});
it("携带 details", () => {
const err = new ValidationError("参数错误", { field: "name" });
expect(err.details).toEqual({ field: "name" });
});
});
describe("UnauthorizedError", () => {
it("statusCode 为 401", () => {
const err = new UnauthorizedError("未授权");
expect(err.statusCode).toBe(401);
expect(err.code).toBe("BFF_PARENT_UNAUTHORIZED");
});
});
describe("ChildNotBoundError", () => {
it("statusCode 为 403", () => {
const err = new ChildNotBoundError("parent-001", "student-999", [
"student-001",
"student-002",
]);
expect(err.statusCode).toBe(403);
expect(err.code).toBe("BFF_PARENT_CHILD_NOT_BOUND");
expect(err.type).toBe("child_not_bound");
expect(err.details).toEqual({
parentId: "parent-001",
requestedChildId: "student-999",
boundChildren: ["student-001", "student-002"],
});
});
it("message 包含 parentId 和 childId", () => {
const err = new ChildNotBoundError("parent-001", "student-999", []);
expect(err.message).toContain("student-999");
expect(err.message).toContain("parent-001");
});
});
describe("NotFoundError", () => {
it("statusCode 为 404", () => {
const err = new NotFoundError("Parent", "parent-999");
expect(err.statusCode).toBe(404);
expect(err.code).toBe("BFF_PARENT_NOT_FOUND");
expect(err.message).toContain("Parent");
expect(err.message).toContain("parent-999");
});
});
describe("ConflictError", () => {
it("statusCode 为 409", () => {
const err = new ConflictError("冲突");
expect(err.statusCode).toBe(409);
expect(err.code).toBe("BFF_PARENT_CONFLICT");
});
});
describe("BusinessError", () => {
it("statusCode 为 422", () => {
const err = new BusinessError("业务错误");
expect(err.statusCode).toBe(422);
expect(err.code).toBe("BFF_PARENT_BUSINESS_ERROR");
});
});
describe("BadGatewayError", () => {
it("statusCode 为 502", () => {
const err = new BadGatewayError("下游错误");
expect(err.statusCode).toBe(502);
expect(err.code).toBe("BFF_PARENT_BAD_GATEWAY");
});
});
describe("GatewayTimeoutError", () => {
it("statusCode 为 504", () => {
const err = new GatewayTimeoutError("超时");
expect(err.statusCode).toBe(504);
expect(err.code).toBe("BFF_PARENT_GATEWAY_TIMEOUT");
});
});
describe("ServiceUnavailableError", () => {
it("statusCode 为 503", () => {
const err = new ServiceUnavailableError("不可用");
expect(err.statusCode).toBe(503);
expect(err.code).toBe("BFF_PARENT_SERVICE_UNAVAILABLE");
});
});
describe("InternalError", () => {
it("statusCode 为 500", () => {
const err = new InternalError("内部错误");
expect(err.statusCode).toBe(500);
expect(err.code).toBe("BFF_PARENT_INTERNAL_ERROR");
});
});
describe("toJSON", () => {
it("序列化为 ActionState 信封格式", () => {
const err = new ValidationError("参数错误", { field: "name" });
err.traceId = "trace-001";
const json = err.toJSON();
expect(json.success).toBe(false);
// 从 unknown 转换toJSON 返回 Record<string, unknown>
const error = json.error as {
code: string;
message: string;
details: unknown;
traceId?: string;
};
expect(error.code).toBe("BFF_PARENT_VALIDATION_ERROR");
expect(error.message).toBe("参数错误");
expect(error.details).toEqual({ field: "name" });
expect(error.traceId).toBe("trace-001");
});
it("无 traceId 时序列化正常", () => {
const err = new NotFoundError("Child", "child-999");
const json = err.toJSON();
expect(json.success).toBe(false);
// 从 unknown 转换toJSON 返回 Record<string, unknown>
const error = json.error as { traceId?: string };
expect(error.traceId).toBeUndefined();
});
});
describe("继承关系", () => {
it("所有错误继承自 ApplicationError", () => {
const errors = [
new ValidationError(""),
new UnauthorizedError(""),
new ChildNotBoundError("", "", []),
new NotFoundError("", ""),
new ConflictError(""),
new BusinessError(""),
new BadGatewayError(""),
new GatewayTimeoutError(""),
new ServiceUnavailableError(""),
new InternalError(""),
];
for (const err of errors) {
expect(err).toBeInstanceOf(ApplicationError);
expect(err).toBeInstanceOf(Error);
}
});
it("name 属性为构造函数名", () => {
expect(new ValidationError("").name).toBe("ValidationError");
expect(new ChildNotBoundError("", "", []).name).toBe(
"ChildNotBoundError",
);
});
});
});

View File

@@ -0,0 +1,85 @@
import { describe, it, expect } from "vitest";
import { CacheKeys } from "../../src/shared/cache/cache-key.builder.js";
describe("CacheKeys", () => {
describe("dashboard", () => {
it("应该为 dashboard 构建正确的 key", () => {
expect(CacheKeys.dashboard("parent-001")).toBe("dashboard:parent-001");
});
});
describe("children", () => {
it("应该为 children 构建正确的 key", () => {
expect(CacheKeys.children("parent-001")).toBe("children:parent-001");
});
});
describe("grades", () => {
it("应该为 grades 构建带分页的 key", () => {
expect(CacheKeys.grades("student-001", 1)).toBe("grades:student-001:1");
expect(CacheKeys.grades("student-002", 3)).toBe("grades:student-002:3");
});
});
describe("homework", () => {
it("应该为 homework 构建带 classId 的 key", () => {
expect(CacheKeys.homework("student-001", "class-001")).toBe(
"homework:student-001:class-001",
);
});
});
describe("exams", () => {
it("应该为 exams 构建带 classId 的 key", () => {
expect(CacheKeys.exams("student-001", "class-001")).toBe(
"exams:student-001:class-001",
);
});
});
describe("weakness", () => {
it("应该为 weakness 构建正确的 key", () => {
expect(CacheKeys.weakness("student-001")).toBe(
"analytics:weakness:student-001",
);
});
});
describe("trend", () => {
it("应该为 trend 构建带 range 的 key", () => {
expect(CacheKeys.trend("student-001", "7d")).toBe(
"analytics:trend:student-001:7d",
);
});
});
describe("notifications", () => {
it("应该为 notifications 构建带分页的 key", () => {
expect(CacheKeys.notifications("parent-001", 1)).toBe(
"notifications:parent-001:1",
);
});
});
describe("notificationPrefs", () => {
it("应该为 notificationPrefs 构建正确的 key", () => {
expect(CacheKeys.notificationPrefs("parent-001")).toBe(
"notification-prefs:parent-001",
);
});
});
describe("childBindings", () => {
it("应该为 childBindings 构建正确的 key", () => {
expect(CacheKeys.childBindings("parent-001")).toBe(
"child-bindings:parent-001",
);
});
});
it("不同 parentId 应该产生不同 key", () => {
expect(CacheKeys.dashboard("parent-001")).not.toBe(
CacheKeys.dashboard("parent-002"),
);
});
});

View File

@@ -0,0 +1,237 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import type { IamClient } from "../../src/clients/iam.client.js";
import type {
ChildDto,
UserInfoDto,
ViewportDto,
} from "../../src/clients/dtos.js";
// Mock safeRedis 以控制缓存行为
vi.mock("../../src/shared/cache/redis.client.js", () => ({
safeRedis: vi.fn().mockResolvedValue(null),
getRedisClient: vi.fn(() => null),
closeRedisClient: vi.fn(),
}));
import { ChildGuard } from "../../src/aggregation/child-guard.js";
import { ChildNotBoundError } from "../../src/shared/errors/application-error.js";
import { safeRedis } from "../../src/shared/cache/redis.client.js";
const mockSafeRedis = vi.mocked(safeRedis);
/**
* 可追踪调用次数的 Mock IamClient
*/
class TrackingIamClient implements IamClient {
getChildrenCallCount = 0;
private readonly children: ChildDto[];
constructor(children: ChildDto[]) {
this.children = children;
}
async getUserInfo(userId: string): Promise<UserInfoDto> {
return {
id: userId,
email: "test@example.com",
name: "测试家长",
roles: ["parent"],
permissions: [],
};
}
async getChildrenByParent(_parentId: string): Promise<ChildDto[]> {
this.getChildrenCallCount++;
// 小延迟确保并发请求重叠
await new Promise((r) => setTimeout(r, 10));
return this.children.map((c) => ({ ...c }));
}
async getViewports(_userId: string): Promise<ViewportDto[]> {
return [];
}
async getEffectivePermissions(_userId: string): Promise<string[]> {
return [];
}
}
const MOCK_CHILDREN: ChildDto[] = [
{
id: "student-001",
name: "李同学",
grade: "三年级",
classId: "class-001",
className: "三年级1班",
gradeId: "grade-003",
},
{
id: "student-002",
name: "李妹妹",
grade: "一年级",
classId: "class-002",
className: "一年级2班",
gradeId: "grade-001",
},
];
describe("ChildGuard", () => {
let iamClient: TrackingIamClient;
let childGuard: ChildGuard;
beforeEach(() => {
vi.clearAllMocks();
mockSafeRedis.mockResolvedValue(null);
iamClient = new TrackingIamClient(MOCK_CHILDREN);
childGuard = new ChildGuard(iamClient);
});
// ============ 测试用例 1ChildGuard 拦截越权 childId ============
describe("测试用例 1拦截越权 childId", () => {
it("childId 不在绑定列表时抛 ChildNotBoundError(403)", async () => {
// safeRedis miss → 调 iam
mockSafeRedis
.mockResolvedValueOnce(null) // get → miss
.mockResolvedValueOnce(undefined); // set → ok
await expect(
childGuard.validateChildAccess("parent-001", "student-999"),
).rejects.toThrow(ChildNotBoundError);
try {
await childGuard.validateChildAccess("parent-001", "student-999");
} catch (err) {
const error = err as ChildNotBoundError;
expect(error.statusCode).toBe(403);
expect(error.code).toBe("BFF_PARENT_CHILD_NOT_BOUND");
expect(error.details?.parentId).toBe("parent-001");
expect(error.details?.requestedChildId).toBe("student-999");
}
});
it("childId 在绑定列表时不抛异常", async () => {
mockSafeRedis
.mockResolvedValueOnce(null) // get → miss
.mockResolvedValueOnce(undefined); // set → ok
await expect(
childGuard.validateChildAccess("parent-001", "student-001"),
).resolves.toBeUndefined();
});
it("支持多个绑定的 childId", async () => {
mockSafeRedis
.mockResolvedValueOnce(null) // get → miss
.mockResolvedValueOnce(undefined); // set → ok
await expect(
childGuard.validateChildAccess("parent-001", "student-002"),
).resolves.toBeUndefined();
});
});
// ============ 测试用例 2缓存命中 ============
describe("测试用例 2缓存命中30s 内第二次不调 iam", () => {
it("Redis 缓存命中时不调用 iam.GetChildrenByParent", async () => {
const cachedChildren = JSON.stringify(MOCK_CHILDREN);
// 第一次调用:缓存未命中 → 调 iam → 写缓存
mockSafeRedis
.mockResolvedValueOnce(null) // get → miss
.mockResolvedValueOnce(undefined); // set → ok
await childGuard.getBoundChildren("parent-001");
expect(iamClient.getChildrenCallCount).toBe(1);
// 第二次调用:缓存命中 → 不调 iam
mockSafeRedis.mockResolvedValueOnce(cachedChildren); // get → hit
const children = await childGuard.getBoundChildren("parent-001");
expect(iamClient.getChildrenCallCount).toBe(1); // 仍然只有 1 次
expect(children).toHaveLength(2);
expect(children[0]!.id).toBe("student-001");
});
it("缓存返回的数据与原始数据一致", async () => {
const cachedChildren = JSON.stringify(MOCK_CHILDREN);
mockSafeRedis.mockResolvedValueOnce(cachedChildren);
const children = await childGuard.getBoundChildren("parent-001");
expect(children).toHaveLength(2);
expect(children[0]!.id).toBe("student-001");
expect(children[0]!.name).toBe("李同学");
expect(children[1]!.id).toBe("student-002");
});
});
// ============ 测试用例 3缓存击穿保护singleflight============
describe("测试用例 3缓存击穿保护singleflight", () => {
it("并发 100 请求只调 iam 1 次", async () => {
// safeRedis 始终返回 null缓存未命中
mockSafeRedis.mockResolvedValue(null);
// 并发发起 100 个请求
const promises: Promise<unknown>[] = [];
for (let i = 0; i < 100; i++) {
promises.push(childGuard.getBoundChildren("parent-001"));
}
const results = await Promise.all(promises);
// 所有请求都成功返回
expect(results).toHaveLength(100);
// iam 只被调用了 1 次singleflight 保护)
expect(iamClient.getChildrenCallCount).toBe(1);
// 所有结果一致
const firstResult = results[0] as ChildDto[];
expect(firstResult).toHaveLength(2);
for (const result of results) {
expect(result).toEqual(firstResult);
}
});
it("不同 parentId 的请求各自独立调用 iam", async () => {
mockSafeRedis.mockResolvedValue(null);
await Promise.all([
childGuard.getBoundChildren("parent-001"),
childGuard.getBoundChildren("parent-002"),
]);
// 不同 parentId 各调 1 次
expect(iamClient.getChildrenCallCount).toBe(2);
});
it("singleflight 完成后 inflight map 被清理", async () => {
mockSafeRedis
.mockResolvedValueOnce(null) // get → miss
.mockResolvedValueOnce(undefined); // set → ok
await childGuard.getBoundChildren("parent-001");
// 再次请求应该走缓存或重新调 iaminflight 已清理)
mockSafeRedis
.mockResolvedValueOnce(null) // get → miss
.mockResolvedValueOnce(undefined); // set → ok
await childGuard.getBoundChildren("parent-001");
expect(iamClient.getChildrenCallCount).toBe(2);
});
});
describe("缓存 JSON 解析失败降级", () => {
it("Redis 返回非法 JSON 时降级到 iam 调用", async () => {
mockSafeRedis
.mockResolvedValueOnce("invalid-json") // get → 返回非法 JSON
.mockResolvedValueOnce(undefined); // set → ok
const children = await childGuard.getBoundChildren("parent-001");
expect(children).toHaveLength(2);
expect(iamClient.getChildrenCallCount).toBe(1);
});
});
});

View File

@@ -0,0 +1,163 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
// Mock safeRedis 以控制 Redis 行为(单元测试不依赖真实 Redis
vi.mock("../../src/shared/cache/redis.client.js", () => ({
safeRedis: vi.fn().mockResolvedValue(null),
getRedisClient: vi.fn(() => null),
closeRedisClient: vi.fn(),
}));
import {
withCacheFallback,
invalidateCache,
} from "../../src/aggregation/fallback-strategy.js";
import { safeRedis } from "../../src/shared/cache/redis.client.js";
const mockSafeRedis = vi.mocked(safeRedis);
describe("FallbackStrategy三级降级", () => {
beforeEach(() => {
vi.clearAllMocks();
mockSafeRedis.mockResolvedValue(null);
});
describe("Redis 缓存命中(第一级)", () => {
it("Redis 命中时返回缓存数据fromCache=true", async () => {
const cachedData = { id: "test-1", name: "测试" };
mockSafeRedis.mockResolvedValueOnce(JSON.stringify(cachedData));
const result = await withCacheFallback(
"test-redis-hit",
() => Promise.resolve({ id: "test-1", name: "不应该被调用" }),
30,
"test",
);
expect(result.data).toEqual(cachedData);
expect(result.fromCache).toBe(true);
expect(result.stale).toBe(false);
});
});
describe("Redis 未命中 → fn 成功(第三级直连)", () => {
it("Redis miss 后调 fn 获取数据并写入缓存", async () => {
const fnData = { id: "test-2", value: 42 };
mockSafeRedis
.mockResolvedValueOnce(null) // get → miss
.mockResolvedValueOnce(undefined); // set → ok
const result = await withCacheFallback(
"test-redis-miss-fn-success",
() => Promise.resolve(fnData),
30,
"test",
);
expect(result.data).toEqual(fnData);
expect(result.fromCache).toBe(false);
expect(result.stale).toBe(false);
// safeRedis 被调用 2 次get + set
expect(mockSafeRedis).toHaveBeenCalledTimes(2);
});
});
describe("LRU 缓存命中(第二级降级)", () => {
it("Redis 不可用时从 LRU 缓存返回数据", async () => {
// 第一次调用miss Redis + miss LRU → 调 fn → 写入 LRU
const fnData = { id: "test-3", label: "LRU测试" };
mockSafeRedis
.mockResolvedValueOnce(null) // get → miss
.mockResolvedValueOnce(undefined); // set → ok
await withCacheFallback(
"test-lru-hit-key",
() => Promise.resolve(fnData),
60,
"test",
);
// 第二次调用miss Redis → hit LRU
mockSafeRedis.mockResolvedValueOnce(null); // get → miss
const result = await withCacheFallback(
"test-lru-hit-key",
() => Promise.resolve({ id: "test-3", label: "不应该被调用" }),
60,
"test",
);
expect(result.data).toEqual(fnData);
expect(result.fromCache).toBe(true);
expect(result.stale).toBe(false);
});
});
describe("fn 失败降级", () => {
it("fn 抛错时返回 null", async () => {
mockSafeRedis.mockResolvedValueOnce(null); // get → miss
const result = await withCacheFallback(
"test-fn-fail",
() => Promise.reject(new Error("downstream error")),
30,
"test",
);
expect(result.data).toBeNull();
expect(result.fromCache).toBe(false);
expect(result.stale).toBe(false);
});
});
describe("Redis JSON 解析失败", () => {
it("Redis 返回非法 JSON 时降级到 fn", async () => {
mockSafeRedis
.mockResolvedValueOnce("not-valid-json") // get → 返回非法 JSON
.mockResolvedValueOnce(null) // LRU miss
.mockResolvedValueOnce(undefined); // set → ok
const result = await withCacheFallback(
"test-bad-json",
() => Promise.resolve({ data: "from-fn" }),
30,
"test",
);
expect(result.data).toEqual({ data: "from-fn" });
expect(result.fromCache).toBe(false);
});
});
describe("invalidateCache", () => {
it("调用 safeRedis del + LRU delete", async () => {
// 先写入 LRU
mockSafeRedis
.mockResolvedValueOnce(null) // get → miss
.mockResolvedValueOnce(undefined); // set → ok
await withCacheFallback(
"test-invalidate",
() => Promise.resolve({ val: 1 }),
30,
"test",
);
// 失效缓存
mockSafeRedis.mockResolvedValueOnce(undefined); // del → ok
await invalidateCache("test-invalidate");
// 再次查询,应该 miss LRU
mockSafeRedis
.mockResolvedValueOnce(null) // get → miss
.mockResolvedValueOnce(undefined); // set → ok
const result = await withCacheFallback(
"test-invalidate",
() => Promise.resolve({ val: 2 }),
30,
"test",
);
expect(result.data).toEqual({ val: 2 });
expect(result.fromCache).toBe(false);
});
});
});

View File

@@ -0,0 +1,239 @@
import { describe, it, expect } from "vitest";
import {
validate,
parse,
GraphQLSchema,
GraphQLObjectType,
GraphQLString,
GraphQLBoolean,
GraphQLID,
GraphQLList,
GraphQLNonNull,
} from "graphql";
import depthLimit from "graphql-depth-limit";
import {
createComplexityRule,
simpleEstimator,
} from "graphql-query-complexity";
const DEPTH_LIMIT = 7;
const COST_LIMIT = 1000;
/**
* 测试用 schema包含递归类型以测试深度限制。
*
* 直接使用 graphql 包的 GraphQLSchema 构造,避免
* @graphql-tools/schema 引入不同 graphql 实例导致
* "Cannot use GraphQLSchema from another module or realm" 错误。
*/
const typeA: GraphQLObjectType = new GraphQLObjectType({
name: "A",
fields: () => ({
a: { type: typeA },
value: { type: GraphQLString },
}),
});
const parentType = new GraphQLObjectType({
name: "Parent",
fields: {
id: { type: new GraphQLNonNull(GraphQLID) },
name: { type: new GraphQLNonNull(GraphQLString) },
},
});
const childType = new GraphQLObjectType({
name: "Child",
fields: {
id: { type: new GraphQLNonNull(GraphQLID) },
name: { type: new GraphQLNonNull(GraphQLString) },
},
});
const dashboardDataType = new GraphQLObjectType({
name: "DashboardData",
fields: {
parent: { type: parentType },
children: {
type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(childType))),
},
degraded: { type: new GraphQLNonNull(GraphQLBoolean) },
},
});
const queryType = new GraphQLObjectType({
name: "Query",
fields: {
a: { type: typeA },
dashboard: { type: dashboardDataType },
},
});
const testSchema = new GraphQLSchema({ query: queryType });
function validateQuery(
query: string,
opts: { depth?: number; cost?: number } = {},
): ReturnType<typeof validate> {
const rules: ReturnType<typeof depthLimit>[] = [];
if (opts.depth !== undefined) {
rules.push(depthLimit(opts.depth));
}
if (opts.cost !== undefined) {
rules.push(
createComplexityRule({
maximumComplexity: opts.cost,
estimators: [simpleEstimator({ defaultComplexity: 1 })],
onComplete: () => {},
}),
);
}
return validate(testSchema, parse(query), rules);
}
describe("GraphQL 查询复杂度限制(测试用例 6", () => {
describe("深度限制", () => {
it("depth=7 查询通过", () => {
const query = `query {
a { # 1
a { # 2
a { # 3
a { # 4
a { # 5
a { # 6
a { # 7
value
}
}
}
}
}
}
}
}`;
const errors = validateQuery(query, { depth: DEPTH_LIMIT });
expect(errors).toHaveLength(0);
});
it("depth=8 查询被拒", () => {
const query = `query {
a { # 1
a { # 2
a { # 3
a { # 4
a { # 5
a { # 6
a { # 7
a { # 8
value
}
}
}
}
}
}
}
}
}`;
const errors = validateQuery(query, { depth: DEPTH_LIMIT });
expect(errors.length).toBeGreaterThan(0);
expect(errors[0]!.message).toContain("depth");
});
it("depth=1 查询通过", () => {
const query = `query { a { value } }`;
const errors = validateQuery(query, { depth: DEPTH_LIMIT });
expect(errors).toHaveLength(0);
});
});
describe("复杂度限制", () => {
it("低复杂度查询通过", () => {
const query = `query {
dashboard {
degraded
parent { id name }
children { id name }
}
}`;
const errors = validateQuery(query, { cost: COST_LIMIT });
expect(errors).toHaveLength(0);
});
it("cost > 1000 查询被拒", () => {
const aliases: string[] = [];
for (let i = 0; i < 501; i++) {
aliases.push(`a${i}: dashboard { degraded }`);
}
const query = `query { ${aliases.join(" ")} }`;
const errors = validateQuery(query, { cost: COST_LIMIT });
expect(errors.length).toBeGreaterThan(0);
expect(errors[0]!.message).toContain("complexity");
});
it("cost = 1000 查询通过(边界值)", () => {
const aliases: string[] = [];
for (let i = 0; i < 500; i++) {
aliases.push(`a${i}: dashboard { degraded }`);
}
const query = `query { ${aliases.join(" ")} }`;
const errors = validateQuery(query, { cost: COST_LIMIT });
expect(errors).toHaveLength(0);
});
});
describe("组合限制", () => {
it("同时满足深度和复杂度限制的查询通过", () => {
const query = `query {
a { # 1
a { # 2
a { # 3
value
}
}
}
dashboard {
degraded
parent { id }
}
}`;
const errors = validateQuery(query, {
depth: DEPTH_LIMIT,
cost: COST_LIMIT,
});
expect(errors).toHaveLength(0);
});
it("深度超限但复杂度未超限时被深度规则拒绝", () => {
const query = `query {
a { # 1
a { # 2
a { # 3
a { # 4
a { # 5
a { # 6
a { # 7
a { # 8
value
}
}
}
}
}
}
}
}
}`;
const errors = validateQuery(query, {
depth: DEPTH_LIMIT,
cost: COST_LIMIT,
});
const depthErrors = errors.filter((e) =>
e.message.toLowerCase().includes("depth"),
);
expect(depthErrors.length).toBeGreaterThan(0);
});
});
});

View File

@@ -0,0 +1,99 @@
import { describe, it, expect, beforeEach } from "vitest";
import { LruCache } from "../../src/shared/cache/lru.cache.js";
describe("LruCache", () => {
let cache: LruCache;
beforeEach(() => {
cache = new LruCache(3);
});
describe("set / get", () => {
it("应该存取值", () => {
cache.set("key1", "value1", 60);
expect(cache.get("key1")).toBe("value1");
});
it("未设置的 key 返回 null", () => {
expect(cache.get("nonexistent")).toBeNull();
});
});
describe("TTL 过期", () => {
it("过期后返回 null", async () => {
cache.set("key1", "value1", 0.01);
await new Promise((resolve) => setTimeout(resolve, 20));
expect(cache.get("key1")).toBeNull();
});
it("未过期时返回值", () => {
cache.set("key1", "value1", 60);
expect(cache.get("key1")).toBe("value1");
});
});
describe("LRU 淘汰", () => {
it("容量满时淘汰最久未使用的条目", () => {
cache.set("a", "1", 60);
cache.set("b", "2", 60);
cache.set("c", "3", 60);
cache.set("d", "4", 60);
expect(cache.get("a")).toBeNull();
expect(cache.get("b")).toBe("2");
expect(cache.get("c")).toBe("3");
expect(cache.get("d")).toBe("4");
});
it("访问后更新 LRU 顺序", () => {
cache.set("a", "1", 60);
cache.set("b", "2", 60);
cache.set("c", "3", 60);
cache.get("a");
cache.set("d", "4", 60);
expect(cache.get("a")).toBe("1");
expect(cache.get("b")).toBeNull();
});
});
describe("delete", () => {
it("删除后返回 null", () => {
cache.set("key1", "value1", 60);
cache.delete("key1");
expect(cache.get("key1")).toBeNull();
});
});
describe("clear", () => {
it("清空所有条目", () => {
cache.set("a", "1", 60);
cache.set("b", "2", 60);
cache.clear();
expect(cache.size).toBe(0);
});
});
describe("size", () => {
it("返回当前条目数", () => {
expect(cache.size).toBe(0);
cache.set("a", "1", 60);
expect(cache.size).toBe(1);
cache.set("b", "2", 60);
expect(cache.size).toBe(2);
});
});
it("默认 maxSize 为 100", () => {
const defaultCache = new LruCache();
for (let i = 0; i < 100; i++) {
defaultCache.set(`key${i}`, `val${i}`, 60);
}
expect(defaultCache.size).toBe(100);
defaultCache.set("key100", "val100", 60);
expect(defaultCache.size).toBe(100);
expect(defaultCache.get("key0")).toBeNull();
});
});

View File

@@ -0,0 +1,121 @@
import { describe, it, expect } from "vitest";
import { orchestrate } from "../../src/aggregation/orchestrator.js";
describe("Orchestrator", () => {
describe("全部成功", () => {
it("返回所有结果且 partial=false", async () => {
const result = await orchestrate({
a: Promise.resolve("value-a"),
b: Promise.resolve(42),
c: Promise.resolve({ key: "val" }),
});
expect(result.partial).toBe(false);
expect(result.failures).toHaveLength(0);
expect(result.results.a).toBe("value-a");
expect(result.results.b).toBe(42);
expect(result.results.c).toEqual({ key: "val" });
});
});
describe("部分失败降级(测试用例 5", () => {
it("data-ana 失败时该字段为 null其他字段正常", async () => {
const result = await orchestrate({
parent: Promise.resolve({ id: "parent-001", name: "王家长" }),
children: Promise.resolve([{ id: "student-001", name: "李同学" }]),
analytics: Promise.reject(new Error("data-ana connection refused")),
});
expect(result.partial).toBe(true);
expect(result.results.parent).toEqual({
id: "parent-001",
name: "王家长",
});
expect(result.results.children).toEqual([
{ id: "student-001", name: "李同学" },
]);
expect(result.results.analytics).toBeNull();
expect(result.failures).toHaveLength(1);
expect(result.failures[0]!.key).toBe("analytics");
});
it("多个失败都记录在 failures 中", async () => {
const result = await orchestrate({
ok: Promise.resolve("ok"),
fail1: Promise.reject(new Error("err1")),
fail2: Promise.reject(new Error("err2")),
});
expect(result.partial).toBe(true);
expect(result.failures).toHaveLength(2);
expect(result.failures.map((f) => f.key)).toContain("fail1");
expect(result.failures.map((f) => f.key)).toContain("fail2");
expect(result.results.ok).toBe("ok");
expect(result.results.fail1).toBeNull();
expect(result.results.fail2).toBeNull();
});
});
describe("全部失败", () => {
it("所有字段为 null 且 partial=true", async () => {
const result = await orchestrate({
a: Promise.reject(new Error("err-a")),
b: Promise.reject(new Error("err-b")),
});
expect(result.partial).toBe(true);
expect(result.results.a).toBeNull();
expect(result.results.b).toBeNull();
expect(result.failures).toHaveLength(2);
});
});
describe("空任务", () => {
it("返回空结果且 partial=false", async () => {
const result = await orchestrate({});
expect(result.partial).toBe(false);
expect(result.failures).toHaveLength(0);
expect(Object.keys(result.results)).toHaveLength(0);
});
});
describe("并行执行", () => {
it("任务并行执行而非串行", async () => {
const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
const start = Date.now();
await orchestrate({
a: delay(100).then(() => "a"),
b: delay(100).then(() => "b"),
c: delay(100).then(() => "c"),
});
const elapsed = Date.now() - start;
// 并行:总耗时约 100ms而非 300ms
expect(elapsed).toBeLessThan(250);
});
});
describe("failures 结构", () => {
it("failure 包含 key 和 error", async () => {
const error = new Error("test error");
const result = await orchestrate({
failing: Promise.reject(error),
});
expect(result.failures).toHaveLength(1);
expect(result.failures[0]!.key).toBe("failing");
expect(result.failures[0]!.error).toBe(error);
});
it("非 Error 类型的 rejection 也记录", async () => {
const result = await orchestrate({
failing: Promise.reject("string error"),
});
expect(result.failures).toHaveLength(1);
expect(result.failures[0]!.error).toBe("string error");
});
});
});

View File

@@ -0,0 +1,110 @@
import { describe, it, expect } from "vitest";
import {
UpdateNotificationPreferencesSchema,
NotificationChannelSchema,
NotificationEventTypesInputSchema,
} from "../../src/parent/dto/parent-inputs.dto.js";
describe("NotificationChannelSchema", () => {
it("接受有效渠道", () => {
expect(NotificationChannelSchema.parse("APP")).toBe("APP");
expect(NotificationChannelSchema.parse("SMS")).toBe("SMS");
expect(NotificationChannelSchema.parse("EMAIL")).toBe("EMAIL");
expect(NotificationChannelSchema.parse("WECHAT")).toBe("WECHAT");
});
it("拒绝无效渠道", () => {
expect(() => NotificationChannelSchema.parse("PUSH")).toThrow();
expect(() => NotificationChannelSchema.parse("")).toThrow();
});
});
describe("NotificationEventTypesInputSchema", () => {
it("接受所有可选布尔字段", () => {
const result = NotificationEventTypesInputSchema.parse({
gradeReleased: true,
homeworkGraded: false,
examPublished: true,
attendanceAlert: false,
schoolAnnouncement: true,
});
expect(result.gradeReleased).toBe(true);
expect(result.homeworkGraded).toBe(false);
});
it("允许部分字段(未传为 undefined", () => {
const result = NotificationEventTypesInputSchema.parse({
gradeReleased: true,
});
expect(result.gradeReleased).toBe(true);
expect(result.homeworkGraded).toBeUndefined();
});
it("接受空对象", () => {
const result = NotificationEventTypesInputSchema.parse({});
expect(result.gradeReleased).toBeUndefined();
});
it("拒绝非布尔值", () => {
expect(() =>
NotificationEventTypesInputSchema.parse({ gradeReleased: "yes" }),
).toThrow();
});
});
describe("UpdateNotificationPreferencesSchema", () => {
it("接受完整有效输入", () => {
const input = {
channels: ["APP", "WECHAT"],
eventTypes: {
gradeReleased: true,
homeworkGraded: false,
},
};
const result = UpdateNotificationPreferencesSchema.parse(input);
expect(result.channels).toEqual(["APP", "WECHAT"]);
expect(result.eventTypes.gradeReleased).toBe(true);
});
it("拒绝空 channels 数组", () => {
expect(() =>
UpdateNotificationPreferencesSchema.parse({
channels: [],
eventTypes: {},
}),
).toThrow();
});
it("拒绝无效 channel 值", () => {
expect(() =>
UpdateNotificationPreferencesSchema.parse({
channels: ["INVALID"],
eventTypes: {},
}),
).toThrow();
});
it("拒绝缺少 channels", () => {
expect(() =>
UpdateNotificationPreferencesSchema.parse({
eventTypes: {},
}),
).toThrow();
});
it("拒绝缺少 eventTypes", () => {
expect(() =>
UpdateNotificationPreferencesSchema.parse({
channels: ["APP"],
}),
).toThrow();
});
it("接受单个 channel", () => {
const result = UpdateNotificationPreferencesSchema.parse({
channels: ["APP"],
eventTypes: {},
});
expect(result.channels).toEqual(["APP"]);
});
});

View File

@@ -0,0 +1,402 @@
import { describe, it, expect } from "vitest";
import {
mapParent,
mapChild,
mapClassInfo,
mapGrade,
mapHomework,
mapExam,
mapViewport,
mapWeakness,
mapTrend,
mapAnalytics,
} from "../../src/aggregation/response-mapper.js";
import type {
UserInfoDto,
ChildDto,
GradeDto,
HomeworkDto,
ExamDto,
ViewportDto,
StudentWeaknessDto,
LearningTrendDto,
ClassPerformanceDto,
} from "../../src/clients/dtos.js";
describe("Response Mapper", () => {
describe("mapParent", () => {
it("正确映射 UserInfoDto → ParentType", () => {
const dto: UserInfoDto = {
id: "parent-001",
email: "parent@example.com",
name: "王家长",
roles: ["parent"],
permissions: [],
};
const result = mapParent(dto);
expect(result.id).toBe("parent-001");
expect(result.email).toBe("parent@example.com");
expect(result.name).toBe("王家长");
expect(result.avatar).toBeNull();
expect(result.roles).toEqual(["parent"]);
expect(result.dataScope).toBe("CHILDREN");
});
});
describe("mapChild", () => {
it("正确映射 ChildDto → ChildType", () => {
const dto: ChildDto = {
id: "student-001",
name: "李同学",
grade: "三年级",
classId: "class-001",
className: "三年级1班",
gradeId: "grade-003",
};
const result = mapChild(dto);
expect(result.id).toBe("student-001");
expect(result.name).toBe("李同学");
expect(result.grade).toBe("三年级");
expect(result.class.id).toBe("class-001");
expect(result.class.name).toBe("三年级1班");
expect(result.class.gradeId).toBe("grade-003");
});
});
describe("mapClassInfo", () => {
it("正确映射 ClassInfo", () => {
const result = mapClassInfo({
id: "class-001",
name: "三年级1班",
gradeId: "grade-003",
});
expect(result.id).toBe("class-001");
expect(result.name).toBe("三年级1班");
expect(result.gradeId).toBe("grade-003");
});
});
describe("mapGrade", () => {
it("正确映射 GradeDto → GradeTypescore string → number", () => {
const dto: GradeDto = {
id: "grade-001",
studentId: "student-001",
examId: "exam-001",
homeworkId: "",
score: "85.5",
feedback: "数学表现良好",
gradedBy: "teacher-001",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-02T00:00:00.000Z",
};
const result = mapGrade(dto);
expect(result.id).toBe("grade-001");
expect(result.examId).toBe("exam-001");
expect(result.score).toBe(85.5);
expect(result.subject).toBe("数学");
expect(result.examTitle).toBe("数学表现良好");
expect(result.rank).toBeNull();
expect(result.gradedAt).toBe("2026-01-02T00:00:00.000Z");
});
it("feedback 为空时 examTitle 为默认值", () => {
const dto: GradeDto = {
id: "grade-001",
studentId: "student-001",
examId: "exam-001",
homeworkId: "",
score: "90",
feedback: "",
gradedBy: "teacher-001",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-02T00:00:00.000Z",
};
const result = mapGrade(dto);
expect(result.examTitle).toBe("未命名考试");
expect(result.subject).toBe("未知科目");
});
it("score 非数字时返回 0", () => {
const dto: GradeDto = {
id: "grade-001",
studentId: "student-001",
examId: "exam-001",
homeworkId: "",
score: "invalid",
feedback: "语文表现良好",
gradedBy: "teacher-001",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-02T00:00:00.000Z",
};
const result = mapGrade(dto);
expect(result.score).toBe(0);
expect(result.subject).toBe("语文");
});
it("feedback 无科目前缀时 subject 为综合", () => {
const dto: GradeDto = {
id: "grade-001",
studentId: "student-001",
examId: "exam-001",
homeworkId: "",
score: "80",
feedback: "表现不错",
gradedBy: "teacher-001",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-02T00:00:00.000Z",
};
const result = mapGrade(dto);
expect(result.subject).toBe("综合");
});
});
describe("mapHomework", () => {
it("SUBMITTED 状态返回 submittedAt", () => {
const dto: HomeworkDto = {
id: "hw-001",
classId: "class-001",
title: "数学练习",
description: "",
dueDate: "2026-01-05T00:00:00.000Z",
status: "SUBMITTED",
createdBy: "teacher-001",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-02T00:00:00.000Z",
};
const result = mapHomework(dto);
expect(result.status).toBe("SUBMITTED");
expect(result.submittedAt).toBe("2026-01-02T00:00:00.000Z");
});
it("GRADED 状态返回 submittedAt", () => {
const dto: HomeworkDto = {
id: "hw-001",
classId: "class-001",
title: "数学练习",
description: "",
dueDate: "2026-01-05T00:00:00.000Z",
status: "GRADED",
createdBy: "teacher-001",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-03T00:00:00.000Z",
};
const result = mapHomework(dto);
expect(result.submittedAt).toBe("2026-01-03T00:00:00.000Z");
});
it("NOT_SUBMITTED 状态 submittedAt 为 null", () => {
const dto: HomeworkDto = {
id: "hw-001",
classId: "class-001",
title: "数学练习",
description: "",
dueDate: "2026-01-05T00:00:00.000Z",
status: "NOT_SUBMITTED",
createdBy: "teacher-001",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-02T00:00:00.000Z",
};
const result = mapHomework(dto);
expect(result.submittedAt).toBeNull();
});
});
describe("mapExam", () => {
it("PUBLISHED 状态返回 publishedAt", () => {
const dto: ExamDto = {
id: "exam-001",
classId: "class-001",
title: "数学期中考试",
description: "",
examDate: "2026-02-01T00:00:00.000Z",
duration: "90",
totalScore: "100",
status: "PUBLISHED",
createdBy: "teacher-001",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-15T00:00:00.000Z",
};
const result = mapExam(dto);
expect(result.status).toBe("PUBLISHED");
expect(result.publishedAt).toBe("2026-01-15T00:00:00.000Z");
});
it("DRAFT 状态 publishedAt 为 null", () => {
const dto: ExamDto = {
id: "exam-001",
classId: "class-001",
title: "数学期中考试",
description: "",
examDate: "2026-02-01T00:00:00.000Z",
duration: "90",
totalScore: "100",
status: "DRAFT",
createdBy: "teacher-001",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-15T00:00:00.000Z",
};
const result = mapExam(dto);
expect(result.publishedAt).toBeNull();
});
});
describe("mapViewport", () => {
it("正确映射 ViewportDto", () => {
const dto: ViewportDto = {
key: "dashboard",
label: "首页",
route: "/parent/dashboard",
icon: "home",
sortOrder: "1",
requiredPermission: "parent:dashboard:view",
};
const result = mapViewport(dto);
expect(result.key).toBe("dashboard");
expect(result.label).toBe("首页");
expect(result.icon).toBe("home");
expect(result.requiredPermission).toBe("parent:dashboard:view");
});
it("icon 和 requiredPermission 可选", () => {
const dto: ViewportDto = {
key: "settings",
label: "设置",
route: "/parent/settings",
sortOrder: "5",
};
const result = mapViewport(dto);
expect(result.icon).toBeNull();
expect(result.requiredPermission).toBeNull();
});
});
describe("mapWeakness", () => {
it("正确映射薄弱点列表", () => {
const dto: StudentWeaknessDto = {
studentId: "student-001",
weakPoints: [
{ knowledgePointId: "kp-1", title: "分数加减法", mastery: 0.45 },
{ knowledgePointId: "kp-2", title: "阅读理解", mastery: 0.62 },
],
};
const result = mapWeakness(dto);
expect(result).toHaveLength(2);
expect(result[0]!.knowledgePointId).toBe("kp-1");
expect(result[0]!.name).toBe("分数加减法");
expect(result[0]!.masteryRate).toBe(0.45);
expect(result[0]!.subject).toBe("综合");
});
it("空薄弱点列表", () => {
const dto: StudentWeaknessDto = {
studentId: "student-001",
weakPoints: [],
};
const result = mapWeakness(dto);
expect(result).toHaveLength(0);
});
});
describe("mapTrend", () => {
it("正确映射趋势点int64 ms → ISO string", () => {
const dto: LearningTrendDto = {
studentId: "student-001",
points: [
{ date: 1735689600000, score: 80 },
{ date: 1735776000000, score: 85 },
],
};
const result = mapTrend(dto);
expect(result).toHaveLength(2);
expect(result[0]!.score).toBe(80);
expect(typeof result[0]!.date).toBe("string");
expect(new Date(result[0]!.date).getTime()).toBe(1735689600000);
expect(result[0]!.subject).toBeNull();
});
});
describe("mapAnalytics", () => {
it("正确计算 classRank降序排名", () => {
const weakness: StudentWeaknessDto = {
studentId: "student-001",
weakPoints: [],
};
const trend: LearningTrendDto = {
studentId: "student-001",
points: [],
};
const classPerf: ClassPerformanceDto = {
classId: "class-001",
averageScore: 82.5,
passRate: 0.9,
scores: [
{ studentId: "student-001", score: 85, grade: "A" },
{ studentId: "student-002", score: 92, grade: "A" },
{ studentId: "student-003", score: 65, grade: "C" },
],
};
const result = mapAnalytics("student-001", weakness, trend, classPerf);
expect(result.childId).toBe("student-001");
expect(result.classAverage).toBe(82.5);
const sorted = [...classPerf.scores].sort((a, b) => b.score - a.score);
const idx = sorted.findIndex((s) => s.studentId === "student-001");
expect(result.classRank).toBe(idx + 1);
});
it("classPerf 为 null 时 classRank 和 classAverage 为 null", () => {
const weakness: StudentWeaknessDto = {
studentId: "student-001",
weakPoints: [],
};
const trend: LearningTrendDto = {
studentId: "student-001",
points: [],
};
const result = mapAnalytics("student-001", weakness, trend, null);
expect(result.classRank).toBeNull();
expect(result.classAverage).toBeNull();
});
it("childId 不在 scores 中时 classRank 为 null", () => {
const weakness: StudentWeaknessDto = {
studentId: "student-001",
weakPoints: [],
};
const trend: LearningTrendDto = {
studentId: "student-001",
points: [],
};
const classPerf: ClassPerformanceDto = {
classId: "class-001",
averageScore: 80,
passRate: 0.9,
scores: [
{ studentId: "student-002", score: 90, grade: "A" },
{ studentId: "student-003", score: 70, grade: "B" },
],
};
const result = mapAnalytics("student-001", weakness, trend, classPerf);
expect(result.classRank).toBeNull();
expect(result.classAverage).toBe(80);
});
it("scores 为空数组时 classRank 为 null", () => {
const weakness: StudentWeaknessDto = {
studentId: "student-001",
weakPoints: [],
};
const trend: LearningTrendDto = {
studentId: "student-001",
points: [],
};
const classPerf: ClassPerformanceDto = {
classId: "class-001",
averageScore: 0,
passRate: 0,
scores: [],
};
const result = mapAnalytics("student-001", weakness, trend, classPerf);
expect(result.classRank).toBeNull();
});
});
});