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
164 lines
4.9 KiB
TypeScript
164 lines
4.9 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||
|
||
// Mock safeRedis 以控制 Redis 行为(单元测试不依赖真实 Redis)
|
||
vi.mock("../../src/shared/cache/redis.client.js", () => ({
|
||
safeRedis: vi.fn().mockResolvedValue(null),
|
||
getRedisClient: vi.fn(() => null),
|
||
closeRedisClient: vi.fn(),
|
||
}));
|
||
|
||
import {
|
||
withCacheFallback,
|
||
invalidateCache,
|
||
} from "../../src/aggregation/fallback-strategy.js";
|
||
import { safeRedis } from "../../src/shared/cache/redis.client.js";
|
||
|
||
const mockSafeRedis = vi.mocked(safeRedis);
|
||
|
||
describe("FallbackStrategy(三级降级)", () => {
|
||
beforeEach(() => {
|
||
vi.clearAllMocks();
|
||
mockSafeRedis.mockResolvedValue(null);
|
||
});
|
||
|
||
describe("Redis 缓存命中(第一级)", () => {
|
||
it("Redis 命中时返回缓存数据,fromCache=true", async () => {
|
||
const cachedData = { id: "test-1", name: "测试" };
|
||
mockSafeRedis.mockResolvedValueOnce(JSON.stringify(cachedData));
|
||
|
||
const result = await withCacheFallback(
|
||
"test-redis-hit",
|
||
() => Promise.resolve({ id: "test-1", name: "不应该被调用" }),
|
||
30,
|
||
"test",
|
||
);
|
||
|
||
expect(result.data).toEqual(cachedData);
|
||
expect(result.fromCache).toBe(true);
|
||
expect(result.stale).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe("Redis 未命中 → fn 成功(第三级直连)", () => {
|
||
it("Redis miss 后调 fn 获取数据并写入缓存", async () => {
|
||
const fnData = { id: "test-2", value: 42 };
|
||
mockSafeRedis
|
||
.mockResolvedValueOnce(null) // get → miss
|
||
.mockResolvedValueOnce(undefined); // set → ok
|
||
|
||
const result = await withCacheFallback(
|
||
"test-redis-miss-fn-success",
|
||
() => Promise.resolve(fnData),
|
||
30,
|
||
"test",
|
||
);
|
||
|
||
expect(result.data).toEqual(fnData);
|
||
expect(result.fromCache).toBe(false);
|
||
expect(result.stale).toBe(false);
|
||
// safeRedis 被调用 2 次:get + set
|
||
expect(mockSafeRedis).toHaveBeenCalledTimes(2);
|
||
});
|
||
});
|
||
|
||
describe("LRU 缓存命中(第二级降级)", () => {
|
||
it("Redis 不可用时从 LRU 缓存返回数据", async () => {
|
||
// 第一次调用:miss Redis + miss LRU → 调 fn → 写入 LRU
|
||
const fnData = { id: "test-3", label: "LRU测试" };
|
||
mockSafeRedis
|
||
.mockResolvedValueOnce(null) // get → miss
|
||
.mockResolvedValueOnce(undefined); // set → ok
|
||
|
||
await withCacheFallback(
|
||
"test-lru-hit-key",
|
||
() => Promise.resolve(fnData),
|
||
60,
|
||
"test",
|
||
);
|
||
|
||
// 第二次调用:miss Redis → hit LRU
|
||
mockSafeRedis.mockResolvedValueOnce(null); // get → miss
|
||
|
||
const result = await withCacheFallback(
|
||
"test-lru-hit-key",
|
||
() => Promise.resolve({ id: "test-3", label: "不应该被调用" }),
|
||
60,
|
||
"test",
|
||
);
|
||
|
||
expect(result.data).toEqual(fnData);
|
||
expect(result.fromCache).toBe(true);
|
||
expect(result.stale).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe("fn 失败降级", () => {
|
||
it("fn 抛错时返回 null", async () => {
|
||
mockSafeRedis.mockResolvedValueOnce(null); // get → miss
|
||
|
||
const result = await withCacheFallback(
|
||
"test-fn-fail",
|
||
() => Promise.reject(new Error("downstream error")),
|
||
30,
|
||
"test",
|
||
);
|
||
|
||
expect(result.data).toBeNull();
|
||
expect(result.fromCache).toBe(false);
|
||
expect(result.stale).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe("Redis JSON 解析失败", () => {
|
||
it("Redis 返回非法 JSON 时降级到 fn", async () => {
|
||
mockSafeRedis
|
||
.mockResolvedValueOnce("not-valid-json") // get → 返回非法 JSON
|
||
.mockResolvedValueOnce(null) // LRU miss
|
||
.mockResolvedValueOnce(undefined); // set → ok
|
||
|
||
const result = await withCacheFallback(
|
||
"test-bad-json",
|
||
() => Promise.resolve({ data: "from-fn" }),
|
||
30,
|
||
"test",
|
||
);
|
||
|
||
expect(result.data).toEqual({ data: "from-fn" });
|
||
expect(result.fromCache).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe("invalidateCache", () => {
|
||
it("调用 safeRedis del + LRU delete", async () => {
|
||
// 先写入 LRU
|
||
mockSafeRedis
|
||
.mockResolvedValueOnce(null) // get → miss
|
||
.mockResolvedValueOnce(undefined); // set → ok
|
||
await withCacheFallback(
|
||
"test-invalidate",
|
||
() => Promise.resolve({ val: 1 }),
|
||
30,
|
||
"test",
|
||
);
|
||
|
||
// 失效缓存
|
||
mockSafeRedis.mockResolvedValueOnce(undefined); // del → ok
|
||
await invalidateCache("test-invalidate");
|
||
|
||
// 再次查询,应该 miss LRU
|
||
mockSafeRedis
|
||
.mockResolvedValueOnce(null) // get → miss
|
||
.mockResolvedValueOnce(undefined); // set → ok
|
||
const result = await withCacheFallback(
|
||
"test-invalidate",
|
||
() => Promise.resolve({ val: 2 }),
|
||
30,
|
||
"test",
|
||
);
|
||
|
||
expect(result.data).toEqual({ val: 2 });
|
||
expect(result.fromCache).toBe(false);
|
||
});
|
||
});
|
||
});
|