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
234 lines
7.5 KiB
TypeScript
234 lines
7.5 KiB
TypeScript
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);
|
||
});
|
||
});
|
||
});
|