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
459 lines
14 KiB
TypeScript
459 lines
14 KiB
TypeScript
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 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);
|
||
});
|
||
});
|
||
});
|