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
322 lines
9.5 KiB
TypeScript
322 lines
9.5 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||
import type { IamClient } from "../../src/clients/iam.client.js";
|
||
import type { CoreEduClient } from "../../src/clients/core-edu.client.js";
|
||
import type { DataAnaClient } from "../../src/clients/data-ana.client.js";
|
||
import type {
|
||
UserInfoDto,
|
||
GradeDto,
|
||
StudentWeaknessDto,
|
||
ClassInfoDto,
|
||
HomeworkDto,
|
||
ExamDto,
|
||
LearningTrendDto,
|
||
ClassPerformanceDto,
|
||
} from "../../src/clients/dtos.js";
|
||
|
||
// Mock safeRedis 以控制 Redis 探针行为
|
||
vi.mock("../../src/shared/cache/redis.client.js", () => ({
|
||
safeRedis: vi.fn().mockResolvedValue("PONG"),
|
||
getRedisClient: vi.fn(() => null),
|
||
closeRedisClient: vi.fn(),
|
||
}));
|
||
|
||
import { HealthController } from "../../src/shared/health/health.controller.js";
|
||
import { safeRedis } from "../../src/shared/cache/redis.client.js";
|
||
|
||
const mockSafeRedis = vi.mocked(safeRedis);
|
||
|
||
class UpIamClient implements IamClient {
|
||
async getUserInfo(userId: string): Promise<UserInfoDto> {
|
||
return {
|
||
id: userId,
|
||
email: "",
|
||
name: "",
|
||
roles: [],
|
||
permissions: [],
|
||
};
|
||
}
|
||
async getChildrenByParent(_parentId: string) {
|
||
return [];
|
||
}
|
||
async getViewports(_userId: string) {
|
||
return [];
|
||
}
|
||
async getEffectivePermissions(_userId: string) {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
class DownIamClient implements IamClient {
|
||
async getUserInfo(_userId: string): Promise<UserInfoDto> {
|
||
throw new Error("iam gRPC unavailable");
|
||
}
|
||
async getChildrenByParent(_parentId: string) {
|
||
return [];
|
||
}
|
||
async getViewports(_userId: string) {
|
||
return [];
|
||
}
|
||
async getEffectivePermissions(_userId: string) {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
class UpCoreEduClient implements CoreEduClient {
|
||
async listGradesByStudent(_studentId: string): Promise<GradeDto[]> {
|
||
return [];
|
||
}
|
||
async listHomeworkByClass(_classId: string): Promise<HomeworkDto[]> {
|
||
return [];
|
||
}
|
||
async listExamsByClass(_classId: string): Promise<ExamDto[]> {
|
||
return [];
|
||
}
|
||
async getClass(classId: string): Promise<ClassInfoDto> {
|
||
return { id: classId, name: "", gradeId: "" };
|
||
}
|
||
}
|
||
|
||
class DownCoreEduClient implements CoreEduClient {
|
||
async listGradesByStudent(_studentId: string): Promise<GradeDto[]> {
|
||
throw new Error("core-edu gRPC unavailable");
|
||
}
|
||
async listHomeworkByClass(_classId: string): Promise<HomeworkDto[]> {
|
||
return [];
|
||
}
|
||
async listExamsByClass(_classId: string): Promise<ExamDto[]> {
|
||
return [];
|
||
}
|
||
async getClass(classId: string): Promise<ClassInfoDto> {
|
||
return { id: classId, name: "", gradeId: "" };
|
||
}
|
||
}
|
||
|
||
class UpDataAnaClient implements DataAnaClient {
|
||
async getStudentWeakness(
|
||
studentId: string,
|
||
_subjectId: string,
|
||
): Promise<StudentWeaknessDto> {
|
||
return { studentId, weakPoints: [] };
|
||
}
|
||
async getLearningTrend(
|
||
studentId: string,
|
||
_startDate: number,
|
||
_endDate: number,
|
||
): Promise<LearningTrendDto> {
|
||
return { studentId, points: [] };
|
||
}
|
||
async getClassPerformance(
|
||
classId: string,
|
||
_subjectId: string,
|
||
_startDate: number,
|
||
_endDate: number,
|
||
): Promise<ClassPerformanceDto> {
|
||
return { classId, averageScore: 0, passRate: 0, scores: [] };
|
||
}
|
||
}
|
||
|
||
class DownDataAnaClient implements DataAnaClient {
|
||
async getStudentWeakness(
|
||
_studentId: string,
|
||
_subjectId: string,
|
||
): Promise<StudentWeaknessDto> {
|
||
throw new Error("data-ana gRPC unavailable");
|
||
}
|
||
async getLearningTrend(
|
||
studentId: string,
|
||
_startDate: number,
|
||
_endDate: number,
|
||
): Promise<LearningTrendDto> {
|
||
return { studentId, points: [] };
|
||
}
|
||
async getClassPerformance(
|
||
classId: string,
|
||
_subjectId: string,
|
||
_startDate: number,
|
||
_endDate: number,
|
||
): Promise<ClassPerformanceDto> {
|
||
return { classId, averageScore: 0, passRate: 0, scores: [] };
|
||
}
|
||
}
|
||
|
||
describe("测试用例 9:/readyz 下游探针(Integration)", () => {
|
||
beforeEach(() => {
|
||
vi.clearAllMocks();
|
||
mockSafeRedis.mockResolvedValue("PONG");
|
||
});
|
||
|
||
describe("/healthz(liveness)", () => {
|
||
it("返回 status=ok + service 名 + timestamp", () => {
|
||
const controller = new HealthController(
|
||
new UpIamClient(),
|
||
new UpCoreEduClient(),
|
||
new UpDataAnaClient(),
|
||
);
|
||
|
||
const result = controller.liveness();
|
||
|
||
expect(result.status).toBe("ok");
|
||
expect(result.service).toBe("parent-bff");
|
||
expect(result.timestamp).toBeTruthy();
|
||
|
||
// timestamp 是合法 ISO
|
||
const ts = new Date(result.timestamp);
|
||
expect(Number.isNaN(ts.getTime())).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe("/readyz(readiness)全探针正常", () => {
|
||
it("4 项探针全部 up 时返回 status=ready", async () => {
|
||
mockSafeRedis.mockResolvedValue("PONG");
|
||
|
||
const controller = new HealthController(
|
||
new UpIamClient(),
|
||
new UpCoreEduClient(),
|
||
new UpDataAnaClient(),
|
||
);
|
||
|
||
const result = await controller.readiness();
|
||
|
||
expect(result.status).toBe("ready");
|
||
expect(result.service).toBe("parent-bff");
|
||
expect(result.checks["iam"]?.status).toBe("up");
|
||
expect(result.checks["core-edu"]?.status).toBe("up");
|
||
expect(result.checks["data-ana"]?.status).toBe("up");
|
||
expect(result.checks["redis"]?.status).toBe("up");
|
||
});
|
||
});
|
||
|
||
describe("/readyz iam 故障", () => {
|
||
it("iam 探针失败时返回 status=degraded + iam.status=down", async () => {
|
||
mockSafeRedis.mockResolvedValue("PONG");
|
||
|
||
const controller = new HealthController(
|
||
new DownIamClient(),
|
||
new UpCoreEduClient(),
|
||
new UpDataAnaClient(),
|
||
);
|
||
|
||
const result = await controller.readiness();
|
||
|
||
expect(result.status).toBe("degraded");
|
||
expect(result.checks["iam"]?.status).toBe("down");
|
||
expect(result.checks["iam"]?.error).toContain("iam gRPC unavailable");
|
||
// 其他探针仍正常
|
||
expect(result.checks["core-edu"]?.status).toBe("up");
|
||
expect(result.checks["data-ana"]?.status).toBe("up");
|
||
expect(result.checks["redis"]?.status).toBe("up");
|
||
});
|
||
});
|
||
|
||
describe("/readyz core-edu 故障", () => {
|
||
it("core-edu 探针失败时返回 status=degraded", async () => {
|
||
mockSafeRedis.mockResolvedValue("PONG");
|
||
|
||
const controller = new HealthController(
|
||
new UpIamClient(),
|
||
new DownCoreEduClient(),
|
||
new UpDataAnaClient(),
|
||
);
|
||
|
||
const result = await controller.readiness();
|
||
|
||
expect(result.status).toBe("degraded");
|
||
expect(result.checks["core-edu"]?.status).toBe("down");
|
||
expect(result.checks["core-edu"]?.error).toContain("core-edu gRPC unavailable");
|
||
});
|
||
});
|
||
|
||
describe("/readyz data-ana 故障", () => {
|
||
it("data-ana 探针失败时返回 status=degraded", async () => {
|
||
mockSafeRedis.mockResolvedValue("PONG");
|
||
|
||
const controller = new HealthController(
|
||
new UpIamClient(),
|
||
new UpCoreEduClient(),
|
||
new DownDataAnaClient(),
|
||
);
|
||
|
||
const result = await controller.readiness();
|
||
|
||
expect(result.status).toBe("degraded");
|
||
expect(result.checks["data-ana"]?.status).toBe("down");
|
||
expect(result.checks["data-ana"]?.error).toContain("data-ana gRPC unavailable");
|
||
});
|
||
});
|
||
|
||
describe("/readyz Redis 故障", () => {
|
||
it("Redis 探针失败时返回 status=degraded + redis.status=down", async () => {
|
||
mockSafeRedis.mockRejectedValue(new Error("redis connection refused"));
|
||
|
||
const controller = new HealthController(
|
||
new UpIamClient(),
|
||
new UpCoreEduClient(),
|
||
new UpDataAnaClient(),
|
||
);
|
||
|
||
const result = await controller.readiness();
|
||
|
||
expect(result.status).toBe("degraded");
|
||
expect(result.checks["redis"]?.status).toBe("down");
|
||
expect(result.checks["redis"]?.error).toContain("redis connection refused");
|
||
// 其他探针仍正常
|
||
expect(result.checks["iam"]?.status).toBe("up");
|
||
});
|
||
|
||
it("Redis 返回非 PONG 时 status=down", async () => {
|
||
mockSafeRedis.mockResolvedValue("unexpected");
|
||
|
||
const controller = new HealthController(
|
||
new UpIamClient(),
|
||
new UpCoreEduClient(),
|
||
new UpDataAnaClient(),
|
||
);
|
||
|
||
const result = await controller.readiness();
|
||
|
||
expect(result.status).toBe("degraded");
|
||
expect(result.checks["redis"]?.status).toBe("down");
|
||
expect(result.checks["redis"]?.error).toContain("unexpected ping response");
|
||
});
|
||
});
|
||
|
||
describe("/readyz 全部故障", () => {
|
||
it("所有探针都失败时 status=degraded", async () => {
|
||
mockSafeRedis.mockRejectedValue(new Error("redis down"));
|
||
|
||
const controller = new HealthController(
|
||
new DownIamClient(),
|
||
new DownCoreEduClient(),
|
||
new DownDataAnaClient(),
|
||
);
|
||
|
||
const result = await controller.readiness();
|
||
|
||
expect(result.status).toBe("degraded");
|
||
expect(result.checks["iam"]?.status).toBe("down");
|
||
expect(result.checks["core-edu"]?.status).toBe("down");
|
||
expect(result.checks["data-ana"]?.status).toBe("down");
|
||
expect(result.checks["redis"]?.status).toBe("down");
|
||
});
|
||
});
|
||
|
||
describe("探针延迟记录", () => {
|
||
it("每个探针返回 latency_ms", async () => {
|
||
mockSafeRedis.mockResolvedValue("PONG");
|
||
|
||
const controller = new HealthController(
|
||
new UpIamClient(),
|
||
new UpCoreEduClient(),
|
||
new UpDataAnaClient(),
|
||
);
|
||
|
||
const result = await controller.readiness();
|
||
|
||
expect(typeof result.checks["iam"]?.latency_ms).toBe("number");
|
||
expect(typeof result.checks["core-edu"]?.latency_ms).toBe("number");
|
||
expect(typeof result.checks["data-ana"]?.latency_ms).toBe("number");
|
||
expect(typeof result.checks["redis"]?.latency_ms).toBe("number");
|
||
});
|
||
});
|
||
});
|