Files
Edu/services/parent-bff/test/integration/select-child.resolver.test.ts
SpecialX 2229309a1e feat: initialize parent-bff service with full core features
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
2026-07-10 18:49:06 +08:00

252 lines
8.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, beforeEach, vi } from "vitest";
import type { IamClient } from "../../src/clients/iam.client.js";
import type { MsgClient } from "../../src/clients/msg.client.js";
import type { DataAnaClient } from "../../src/clients/data-ana.client.js";
import type { CoreEduClient } from "../../src/clients/core-edu.client.js";
import type {
ChildDto,
UserInfoDto,
ViewportDto,
} from "../../src/clients/dtos.js";
// Mock safeRedis 以控制 ChildGuard 缓存行为
vi.mock("../../src/shared/cache/redis.client.js", () => ({
safeRedis: vi.fn().mockResolvedValue(null),
getRedisClient: vi.fn(() => null),
closeRedisClient: vi.fn(),
}));
import { buildSelectChildMutationResolver } from "../../src/graphql/resolvers/select-child.resolver.js";
import type { ResolverDeps } from "../../src/graphql/resolvers/index.js";
import type { GraphqlContext } from "../../src/graphql/context.js";
import { ChildGuard } from "../../src/aggregation/child-guard.js";
import { ChildNotBoundError } from "../../src/shared/errors/application-error.js";
import { logger } from "../../src/shared/observability/logger.js";
import { safeRedis } from "../../src/shared/cache/redis.client.js";
const mockSafeRedis = vi.mocked(safeRedis);
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",
},
];
class StubIamClient implements IamClient {
async getUserInfo(userId: string): Promise<UserInfoDto> {
return {
id: userId,
email: "",
name: "",
roles: ["parent"],
permissions: [],
};
}
async getChildrenByParent(_parentId: string): Promise<ChildDto[]> {
return MOCK_CHILDREN.map((c) => ({ ...c }));
}
async getViewports(_userId: string): Promise<ViewportDto[]> {
return [];
}
async getEffectivePermissions(_userId: string): Promise<string[]> {
return [];
}
}
function buildMockContext(parentId: string, traceId: string): GraphqlContext {
return {
session: {
parentId,
roles: ["parent"],
dataScope: "CHILDREN",
traceId,
},
req: {} as never,
res: {} as never,
loaders: {} as never,
};
}
describe("测试用例 8selectChild 审计日志Integration", () => {
let iamClient: StubIamClient;
let childGuard: ChildGuard;
let loggerInfoSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.clearAllMocks();
mockSafeRedis.mockResolvedValue(null);
iamClient = new StubIamClient();
childGuard = new ChildGuard(iamClient);
loggerInfoSpy = vi.spyOn(logger, "info").mockImplementation(() => logger);
});
it("合法 childId记录审计日志traceId + parentId + childId + timestamp", async () => {
// safeRedis: miss → set okChildGuard.getBoundChildren
mockSafeRedis
.mockResolvedValueOnce(null) // get → miss
.mockResolvedValueOnce(undefined); // set → ok
const deps: ResolverDeps = {
iamClient,
coreEduClient: {} as unknown as CoreEduClient,
dataAnaClient: {} as unknown as DataAnaClient,
msgClient: {} as unknown as MsgClient,
childGuard,
};
const resolver = buildSelectChildMutationResolver(deps);
const ctx = buildMockContext("parent-001", "trace-audit-001");
const result = await resolver(null, { childId: "student-001" }, ctx);
// 返回值正确
expect(result.childId).toBe("student-001");
expect(result.audited).toBe(true);
expect(result.selectedAt).toBeTruthy();
// selectedAt 是合法 ISO 时间
const selectedAtDate = new Date(result.selectedAt);
expect(Number.isNaN(selectedAtDate.getTime())).toBe(false);
// 审计日志被调用
expect(loggerInfoSpy).toHaveBeenCalled();
// 查找 "Child selected (audit log)" 的调用
const auditCall = loggerInfoSpy.mock.calls.find(
(call) => typeof call[1] === "string" && call[1].includes("audit log"),
);
expect(auditCall).toBeDefined();
// 审计日志包含 traceId + parentId + childId + selectedAt
const logPayload = auditCall![0] as Record<string, unknown>;
expect(logPayload.parentId).toBe("parent-001");
expect(logPayload.childId).toBe("student-001");
expect(logPayload.traceId).toBe("trace-audit-001");
expect(logPayload.selectedAt).toBe(result.selectedAt);
});
it("越权 childId先抛 ChildNotBoundError不记录审计日志", async () => {
// safeRedis: miss → set okChildGuard.getBoundChildren 第一次调用)
mockSafeRedis
.mockResolvedValueOnce(null) // get → miss
.mockResolvedValueOnce(undefined); // set → ok
const deps: ResolverDeps = {
iamClient,
coreEduClient: {} as unknown as CoreEduClient,
dataAnaClient: {} as unknown as DataAnaClient,
msgClient: {} as unknown as MsgClient,
childGuard,
};
const resolver = buildSelectChildMutationResolver(deps);
const ctx = buildMockContext("parent-001", "trace-audit-002");
// 越权 childId 抛 ChildNotBoundError
await expect(
resolver(null, { childId: "student-999" }, ctx),
).rejects.toThrow(ChildNotBoundError);
// 不应记录审计日志(只可能有 ChildGuard 的 warn 日志)
const auditCall = loggerInfoSpy.mock.calls.find(
(call) => typeof call[1] === "string" && call[1].includes("audit log"),
);
expect(auditCall).toBeUndefined();
});
it("审计日志的 selectedAt 与返回值一致", async () => {
mockSafeRedis
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(undefined);
const deps: ResolverDeps = {
iamClient,
coreEduClient: {} as unknown as CoreEduClient,
dataAnaClient: {} as unknown as DataAnaClient,
msgClient: {} as unknown as MsgClient,
childGuard,
};
const resolver = buildSelectChildMutationResolver(deps);
const ctx = buildMockContext("parent-001", "trace-audit-003");
const result = await resolver(null, { childId: "student-002" }, ctx);
const auditCall = loggerInfoSpy.mock.calls.find(
(call) => typeof call[1] === "string" && call[1].includes("audit log"),
);
expect(auditCall).toBeDefined();
const logPayload = auditCall![0] as Record<string, unknown>;
// 审计日志的 selectedAt 与返回值完全一致
expect(logPayload.selectedAt).toBe(result.selectedAt);
// childId 也一致
expect(logPayload.childId).toBe(result.childId);
});
it("不同 traceId 生成不同审计日志", async () => {
// 第一次调用
mockSafeRedis
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(undefined);
const deps: ResolverDeps = {
iamClient,
coreEduClient: {} as unknown as CoreEduClient,
dataAnaClient: {} as unknown as DataAnaClient,
msgClient: {} as unknown as MsgClient,
childGuard,
};
const resolver = buildSelectChildMutationResolver(deps);
// 第一次traceId-A
const result1 = await resolver(
null,
{ childId: "student-001" },
buildMockContext("parent-001", "traceId-A"),
);
// 等待 2ms 确保 selectedAt 时间戳不同
await new Promise((r) => setTimeout(r, 2));
// 第二次traceId-B缓存已写入直接命中或重新调
mockSafeRedis
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(undefined);
const result2 = await resolver(
null,
{ childId: "student-002" },
buildMockContext("parent-002", "traceId-B"),
);
// 两次审计日志的 traceId 不同
const auditCalls = loggerInfoSpy.mock.calls.filter(
(call) => typeof call[1] === "string" && call[1].includes("audit log"),
);
expect(auditCalls).toHaveLength(2);
const payload1 = auditCalls[0]![0] as Record<string, unknown>;
const payload2 = auditCalls[1]![0] as Record<string, unknown>;
expect(payload1.traceId).toBe("traceId-A");
expect(payload2.traceId).toBe("traceId-B");
expect(payload1.childId).toBe("student-001");
expect(payload2.childId).toBe("student-002");
expect(result1.selectedAt).not.toBe(result2.selectedAt);
});
});