add complete parent-bff implementation including: - GraphQL endpoint with depth/cost validation - ChildGuard越权校验 with redis cache and singleflight - parallel orchestration with partial failure fallback - three-level cache fallback strategy (Redis + LRU + downstream) - Kafka consumer for cache invalidation and notification push - opossum circuit breaker for downstream services - Prometheus metrics and SLO alerts - Helm chart for k8s deployment with multi-environment configs - Grafana dashboard for observability - complete unit and integration tests
255 lines
7.4 KiB
TypeScript
255 lines
7.4 KiB
TypeScript
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");
|
||
});
|
||
});
|
||
});
|