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 { return new Promise((r) => setTimeout(r, ms)); } /** * 可追踪调用次数与延迟的 Mock IamClient */ class TrackingIamClient implements IamClient { readonly callLog: string[] = []; async getUserInfo(userId: string): Promise { 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 { 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 { 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 { return []; } } /** * 可追踪调用次数与延迟的 Mock MsgClient */ class TrackingMsgClient implements MsgClient { readonly callLog: string[] = []; async listNotifications( parentId: string, unreadOnly = false, ): Promise { 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 {} 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 { 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 { 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 { 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 CoreEduClient(ResolverDeps 必需) */ 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); }); }); });