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
This commit is contained in:
@@ -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("测试用例 10:Kafka 缓存失效(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");
|
||||
});
|
||||
});
|
||||
});
|
||||
458
services/parent-bff/test/integration/dashboard.resolver.test.ts
Normal file
458
services/parent-bff/test/integration/dashboard.resolver.test.ts
Normal file
@@ -0,0 +1,458 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
321
services/parent-bff/test/integration/health.controller.test.ts
Normal file
321
services/parent-bff/test/integration/health.controller.test.ts
Normal file
@@ -0,0 +1,321 @@
|
||||
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("/healthz(liveness)", () => {
|
||||
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("/readyz(readiness)全探针正常", () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,251 @@
|
||||
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("测试用例 8:selectChild 审计日志(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 ok(ChildGuard.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 ok(ChildGuard.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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user