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:
SpecialX
2026-07-10 19:00:29 +08:00
89 changed files with 11177 additions and 39 deletions

View 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);
});
// ============ 测试用例 1ChildGuard 拦截越权 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");
// 再次请求应该走缓存或重新调 iaminflight 已清理)
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);
});
});
});