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:
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
475
services/parent-bff/test/integration/dashboard.resolver.test.ts
Normal file
475
services/parent-bff/test/integration/dashboard.resolver.test.ts
Normal 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 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
329
services/parent-bff/test/integration/health.controller.test.ts
Normal file
329
services/parent-bff/test/integration/health.controller.test.ts
Normal 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("/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,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("测试用例 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);
|
||||
});
|
||||
});
|
||||
177
services/parent-bff/test/unit/application-error.test.ts
Normal file
177
services/parent-bff/test/unit/application-error.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
85
services/parent-bff/test/unit/cache-key.builder.test.ts
Normal file
85
services/parent-bff/test/unit/cache-key.builder.test.ts
Normal 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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
237
services/parent-bff/test/unit/child-guard.test.ts
Normal file
237
services/parent-bff/test/unit/child-guard.test.ts
Normal 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);
|
||||
});
|
||||
|
||||
// ============ 测试用例 1:ChildGuard 拦截越权 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");
|
||||
|
||||
// 再次请求应该走缓存或重新调 iam(inflight 已清理)
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
163
services/parent-bff/test/unit/fallback-strategy.test.ts
Normal file
163
services/parent-bff/test/unit/fallback-strategy.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
239
services/parent-bff/test/unit/graphql-validation.test.ts
Normal file
239
services/parent-bff/test/unit/graphql-validation.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
99
services/parent-bff/test/unit/lru.cache.test.ts
Normal file
99
services/parent-bff/test/unit/lru.cache.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
121
services/parent-bff/test/unit/orchestrator.test.ts
Normal file
121
services/parent-bff/test/unit/orchestrator.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
110
services/parent-bff/test/unit/parent-inputs.dto.test.ts
Normal file
110
services/parent-bff/test/unit/parent-inputs.dto.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
402
services/parent-bff/test/unit/response-mapper.test.ts
Normal file
402
services/parent-bff/test/unit/response-mapper.test.ts
Normal 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 → GradeType(score 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user