Files
Edu/services/parent-bff/test/integration/dashboard.resolver.test.ts
SpecialX 2229309a1e feat: initialize parent-bff service with full core features
add complete parent-bff implementation including:
- GraphQL endpoint with depth/cost validation
- ChildGuard越权校验 with redis cache and singleflight
- parallel orchestration with partial failure fallback
- three-level cache fallback strategy (Redis + LRU + downstream)
- Kafka consumer for cache invalidation and notification push
- opossum circuit breaker for downstream services
- Prometheus metrics and SLO alerts
- Helm chart for k8s deployment with multi-environment configs
- Grafana dashboard for observability
- complete unit and integration tests
2026-07-10 18:49:06 +08:00

459 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});
});
});