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:
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user