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 { return { id: userId, email: "test@example.com", name: "测试家长", roles: ["parent"], permissions: [], }; } async getChildrenByParent(_parentId: string): Promise { this.getChildrenCallCount++; // 小延迟确保并发请求重叠 await new Promise((r) => setTimeout(r, 10)); return this.children.map((c) => ({ ...c })); } async getViewports(_userId: string): Promise { return []; } async getEffectivePermissions(_userId: string): Promise { 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[] = []; 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); }); }); });