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:
177
services/parent-bff/test/unit/application-error.test.ts
Normal file
177
services/parent-bff/test/unit/application-error.test.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
ApplicationError,
|
||||
ValidationError,
|
||||
UnauthorizedError,
|
||||
ChildNotBoundError,
|
||||
NotFoundError,
|
||||
ConflictError,
|
||||
BusinessError,
|
||||
BadGatewayError,
|
||||
GatewayTimeoutError,
|
||||
ServiceUnavailableError,
|
||||
InternalError,
|
||||
} from "../../src/shared/errors/application-error.js";
|
||||
|
||||
describe("ApplicationError 体系", () => {
|
||||
describe("ValidationError", () => {
|
||||
it("statusCode 为 400", () => {
|
||||
const err = new ValidationError("参数错误");
|
||||
expect(err.statusCode).toBe(400);
|
||||
expect(err.code).toBe("BFF_PARENT_VALIDATION_ERROR");
|
||||
expect(err.type).toBe("validation");
|
||||
expect(err.message).toBe("参数错误");
|
||||
});
|
||||
|
||||
it("携带 details", () => {
|
||||
const err = new ValidationError("参数错误", { field: "name" });
|
||||
expect(err.details).toEqual({ field: "name" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("UnauthorizedError", () => {
|
||||
it("statusCode 为 401", () => {
|
||||
const err = new UnauthorizedError("未授权");
|
||||
expect(err.statusCode).toBe(401);
|
||||
expect(err.code).toBe("BFF_PARENT_UNAUTHORIZED");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChildNotBoundError", () => {
|
||||
it("statusCode 为 403", () => {
|
||||
const err = new ChildNotBoundError("parent-001", "student-999", [
|
||||
"student-001",
|
||||
"student-002",
|
||||
]);
|
||||
expect(err.statusCode).toBe(403);
|
||||
expect(err.code).toBe("BFF_PARENT_CHILD_NOT_BOUND");
|
||||
expect(err.type).toBe("child_not_bound");
|
||||
expect(err.details).toEqual({
|
||||
parentId: "parent-001",
|
||||
requestedChildId: "student-999",
|
||||
boundChildren: ["student-001", "student-002"],
|
||||
});
|
||||
});
|
||||
|
||||
it("message 包含 parentId 和 childId", () => {
|
||||
const err = new ChildNotBoundError("parent-001", "student-999", []);
|
||||
expect(err.message).toContain("student-999");
|
||||
expect(err.message).toContain("parent-001");
|
||||
});
|
||||
});
|
||||
|
||||
describe("NotFoundError", () => {
|
||||
it("statusCode 为 404", () => {
|
||||
const err = new NotFoundError("Parent", "parent-999");
|
||||
expect(err.statusCode).toBe(404);
|
||||
expect(err.code).toBe("BFF_PARENT_NOT_FOUND");
|
||||
expect(err.message).toContain("Parent");
|
||||
expect(err.message).toContain("parent-999");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ConflictError", () => {
|
||||
it("statusCode 为 409", () => {
|
||||
const err = new ConflictError("冲突");
|
||||
expect(err.statusCode).toBe(409);
|
||||
expect(err.code).toBe("BFF_PARENT_CONFLICT");
|
||||
});
|
||||
});
|
||||
|
||||
describe("BusinessError", () => {
|
||||
it("statusCode 为 422", () => {
|
||||
const err = new BusinessError("业务错误");
|
||||
expect(err.statusCode).toBe(422);
|
||||
expect(err.code).toBe("BFF_PARENT_BUSINESS_ERROR");
|
||||
});
|
||||
});
|
||||
|
||||
describe("BadGatewayError", () => {
|
||||
it("statusCode 为 502", () => {
|
||||
const err = new BadGatewayError("下游错误");
|
||||
expect(err.statusCode).toBe(502);
|
||||
expect(err.code).toBe("BFF_PARENT_BAD_GATEWAY");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GatewayTimeoutError", () => {
|
||||
it("statusCode 为 504", () => {
|
||||
const err = new GatewayTimeoutError("超时");
|
||||
expect(err.statusCode).toBe(504);
|
||||
expect(err.code).toBe("BFF_PARENT_GATEWAY_TIMEOUT");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ServiceUnavailableError", () => {
|
||||
it("statusCode 为 503", () => {
|
||||
const err = new ServiceUnavailableError("不可用");
|
||||
expect(err.statusCode).toBe(503);
|
||||
expect(err.code).toBe("BFF_PARENT_SERVICE_UNAVAILABLE");
|
||||
});
|
||||
});
|
||||
|
||||
describe("InternalError", () => {
|
||||
it("statusCode 为 500", () => {
|
||||
const err = new InternalError("内部错误");
|
||||
expect(err.statusCode).toBe(500);
|
||||
expect(err.code).toBe("BFF_PARENT_INTERNAL_ERROR");
|
||||
});
|
||||
});
|
||||
|
||||
describe("toJSON", () => {
|
||||
it("序列化为 ActionState 信封格式", () => {
|
||||
const err = new ValidationError("参数错误", { field: "name" });
|
||||
err.traceId = "trace-001";
|
||||
const json = err.toJSON();
|
||||
expect(json.success).toBe(false);
|
||||
// 从 unknown 转换:toJSON 返回 Record<string, unknown>
|
||||
const error = json.error as {
|
||||
code: string;
|
||||
message: string;
|
||||
details: unknown;
|
||||
traceId?: string;
|
||||
};
|
||||
expect(error.code).toBe("BFF_PARENT_VALIDATION_ERROR");
|
||||
expect(error.message).toBe("参数错误");
|
||||
expect(error.details).toEqual({ field: "name" });
|
||||
expect(error.traceId).toBe("trace-001");
|
||||
});
|
||||
|
||||
it("无 traceId 时序列化正常", () => {
|
||||
const err = new NotFoundError("Child", "child-999");
|
||||
const json = err.toJSON();
|
||||
expect(json.success).toBe(false);
|
||||
// 从 unknown 转换:toJSON 返回 Record<string, unknown>
|
||||
const error = json.error as { traceId?: string };
|
||||
expect(error.traceId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("继承关系", () => {
|
||||
it("所有错误继承自 ApplicationError", () => {
|
||||
const errors = [
|
||||
new ValidationError(""),
|
||||
new UnauthorizedError(""),
|
||||
new ChildNotBoundError("", "", []),
|
||||
new NotFoundError("", ""),
|
||||
new ConflictError(""),
|
||||
new BusinessError(""),
|
||||
new BadGatewayError(""),
|
||||
new GatewayTimeoutError(""),
|
||||
new ServiceUnavailableError(""),
|
||||
new InternalError(""),
|
||||
];
|
||||
for (const err of errors) {
|
||||
expect(err).toBeInstanceOf(ApplicationError);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
}
|
||||
});
|
||||
|
||||
it("name 属性为构造函数名", () => {
|
||||
expect(new ValidationError("").name).toBe("ValidationError");
|
||||
expect(new ChildNotBoundError("", "", []).name).toBe(
|
||||
"ChildNotBoundError",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
85
services/parent-bff/test/unit/cache-key.builder.test.ts
Normal file
85
services/parent-bff/test/unit/cache-key.builder.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { CacheKeys } from "../../src/shared/cache/cache-key.builder.js";
|
||||
|
||||
describe("CacheKeys", () => {
|
||||
describe("dashboard", () => {
|
||||
it("应该为 dashboard 构建正确的 key", () => {
|
||||
expect(CacheKeys.dashboard("parent-001")).toBe("dashboard:parent-001");
|
||||
});
|
||||
});
|
||||
|
||||
describe("children", () => {
|
||||
it("应该为 children 构建正确的 key", () => {
|
||||
expect(CacheKeys.children("parent-001")).toBe("children:parent-001");
|
||||
});
|
||||
});
|
||||
|
||||
describe("grades", () => {
|
||||
it("应该为 grades 构建带分页的 key", () => {
|
||||
expect(CacheKeys.grades("student-001", 1)).toBe("grades:student-001:1");
|
||||
expect(CacheKeys.grades("student-002", 3)).toBe("grades:student-002:3");
|
||||
});
|
||||
});
|
||||
|
||||
describe("homework", () => {
|
||||
it("应该为 homework 构建带 classId 的 key", () => {
|
||||
expect(CacheKeys.homework("student-001", "class-001")).toBe(
|
||||
"homework:student-001:class-001",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("exams", () => {
|
||||
it("应该为 exams 构建带 classId 的 key", () => {
|
||||
expect(CacheKeys.exams("student-001", "class-001")).toBe(
|
||||
"exams:student-001:class-001",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("weakness", () => {
|
||||
it("应该为 weakness 构建正确的 key", () => {
|
||||
expect(CacheKeys.weakness("student-001")).toBe(
|
||||
"analytics:weakness:student-001",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("trend", () => {
|
||||
it("应该为 trend 构建带 range 的 key", () => {
|
||||
expect(CacheKeys.trend("student-001", "7d")).toBe(
|
||||
"analytics:trend:student-001:7d",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("notifications", () => {
|
||||
it("应该为 notifications 构建带分页的 key", () => {
|
||||
expect(CacheKeys.notifications("parent-001", 1)).toBe(
|
||||
"notifications:parent-001:1",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("notificationPrefs", () => {
|
||||
it("应该为 notificationPrefs 构建正确的 key", () => {
|
||||
expect(CacheKeys.notificationPrefs("parent-001")).toBe(
|
||||
"notification-prefs:parent-001",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("childBindings", () => {
|
||||
it("应该为 childBindings 构建正确的 key", () => {
|
||||
expect(CacheKeys.childBindings("parent-001")).toBe(
|
||||
"child-bindings:parent-001",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("不同 parentId 应该产生不同 key", () => {
|
||||
expect(CacheKeys.dashboard("parent-001")).not.toBe(
|
||||
CacheKeys.dashboard("parent-002"),
|
||||
);
|
||||
});
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
163
services/parent-bff/test/unit/fallback-strategy.test.ts
Normal file
163
services/parent-bff/test/unit/fallback-strategy.test.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
239
services/parent-bff/test/unit/graphql-validation.test.ts
Normal file
239
services/parent-bff/test/unit/graphql-validation.test.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
validate,
|
||||
parse,
|
||||
GraphQLSchema,
|
||||
GraphQLObjectType,
|
||||
GraphQLString,
|
||||
GraphQLBoolean,
|
||||
GraphQLID,
|
||||
GraphQLList,
|
||||
GraphQLNonNull,
|
||||
} from "graphql";
|
||||
import depthLimit from "graphql-depth-limit";
|
||||
import {
|
||||
createComplexityRule,
|
||||
simpleEstimator,
|
||||
} from "graphql-query-complexity";
|
||||
|
||||
const DEPTH_LIMIT = 7;
|
||||
const COST_LIMIT = 1000;
|
||||
|
||||
/**
|
||||
* 测试用 schema:包含递归类型以测试深度限制。
|
||||
*
|
||||
* 直接使用 graphql 包的 GraphQLSchema 构造,避免
|
||||
* @graphql-tools/schema 引入不同 graphql 实例导致
|
||||
* "Cannot use GraphQLSchema from another module or realm" 错误。
|
||||
*/
|
||||
const typeA: GraphQLObjectType = new GraphQLObjectType({
|
||||
name: "A",
|
||||
fields: () => ({
|
||||
a: { type: typeA },
|
||||
value: { type: GraphQLString },
|
||||
}),
|
||||
});
|
||||
|
||||
const parentType = new GraphQLObjectType({
|
||||
name: "Parent",
|
||||
fields: {
|
||||
id: { type: new GraphQLNonNull(GraphQLID) },
|
||||
name: { type: new GraphQLNonNull(GraphQLString) },
|
||||
},
|
||||
});
|
||||
|
||||
const childType = new GraphQLObjectType({
|
||||
name: "Child",
|
||||
fields: {
|
||||
id: { type: new GraphQLNonNull(GraphQLID) },
|
||||
name: { type: new GraphQLNonNull(GraphQLString) },
|
||||
},
|
||||
});
|
||||
|
||||
const dashboardDataType = new GraphQLObjectType({
|
||||
name: "DashboardData",
|
||||
fields: {
|
||||
parent: { type: parentType },
|
||||
children: {
|
||||
type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(childType))),
|
||||
},
|
||||
degraded: { type: new GraphQLNonNull(GraphQLBoolean) },
|
||||
},
|
||||
});
|
||||
|
||||
const queryType = new GraphQLObjectType({
|
||||
name: "Query",
|
||||
fields: {
|
||||
a: { type: typeA },
|
||||
dashboard: { type: dashboardDataType },
|
||||
},
|
||||
});
|
||||
|
||||
const testSchema = new GraphQLSchema({ query: queryType });
|
||||
|
||||
function validateQuery(
|
||||
query: string,
|
||||
opts: { depth?: number; cost?: number } = {},
|
||||
): ReturnType<typeof validate> {
|
||||
const rules: ReturnType<typeof depthLimit>[] = [];
|
||||
if (opts.depth !== undefined) {
|
||||
rules.push(depthLimit(opts.depth));
|
||||
}
|
||||
if (opts.cost !== undefined) {
|
||||
rules.push(
|
||||
createComplexityRule({
|
||||
maximumComplexity: opts.cost,
|
||||
estimators: [simpleEstimator({ defaultComplexity: 1 })],
|
||||
onComplete: () => {},
|
||||
}),
|
||||
);
|
||||
}
|
||||
return validate(testSchema, parse(query), rules);
|
||||
}
|
||||
|
||||
describe("GraphQL 查询复杂度限制(测试用例 6)", () => {
|
||||
describe("深度限制", () => {
|
||||
it("depth=7 查询通过", () => {
|
||||
const query = `query {
|
||||
a { # 1
|
||||
a { # 2
|
||||
a { # 3
|
||||
a { # 4
|
||||
a { # 5
|
||||
a { # 6
|
||||
a { # 7
|
||||
value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`;
|
||||
const errors = validateQuery(query, { depth: DEPTH_LIMIT });
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("depth=8 查询被拒", () => {
|
||||
const query = `query {
|
||||
a { # 1
|
||||
a { # 2
|
||||
a { # 3
|
||||
a { # 4
|
||||
a { # 5
|
||||
a { # 6
|
||||
a { # 7
|
||||
a { # 8
|
||||
value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`;
|
||||
const errors = validateQuery(query, { depth: DEPTH_LIMIT });
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors[0]!.message).toContain("depth");
|
||||
});
|
||||
|
||||
it("depth=1 查询通过", () => {
|
||||
const query = `query { a { value } }`;
|
||||
const errors = validateQuery(query, { depth: DEPTH_LIMIT });
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("复杂度限制", () => {
|
||||
it("低复杂度查询通过", () => {
|
||||
const query = `query {
|
||||
dashboard {
|
||||
degraded
|
||||
parent { id name }
|
||||
children { id name }
|
||||
}
|
||||
}`;
|
||||
const errors = validateQuery(query, { cost: COST_LIMIT });
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("cost > 1000 查询被拒", () => {
|
||||
const aliases: string[] = [];
|
||||
for (let i = 0; i < 501; i++) {
|
||||
aliases.push(`a${i}: dashboard { degraded }`);
|
||||
}
|
||||
const query = `query { ${aliases.join(" ")} }`;
|
||||
|
||||
const errors = validateQuery(query, { cost: COST_LIMIT });
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors[0]!.message).toContain("complexity");
|
||||
});
|
||||
|
||||
it("cost = 1000 查询通过(边界值)", () => {
|
||||
const aliases: string[] = [];
|
||||
for (let i = 0; i < 500; i++) {
|
||||
aliases.push(`a${i}: dashboard { degraded }`);
|
||||
}
|
||||
const query = `query { ${aliases.join(" ")} }`;
|
||||
|
||||
const errors = validateQuery(query, { cost: COST_LIMIT });
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("组合限制", () => {
|
||||
it("同时满足深度和复杂度限制的查询通过", () => {
|
||||
const query = `query {
|
||||
a { # 1
|
||||
a { # 2
|
||||
a { # 3
|
||||
value
|
||||
}
|
||||
}
|
||||
}
|
||||
dashboard {
|
||||
degraded
|
||||
parent { id }
|
||||
}
|
||||
}`;
|
||||
const errors = validateQuery(query, {
|
||||
depth: DEPTH_LIMIT,
|
||||
cost: COST_LIMIT,
|
||||
});
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("深度超限但复杂度未超限时被深度规则拒绝", () => {
|
||||
const query = `query {
|
||||
a { # 1
|
||||
a { # 2
|
||||
a { # 3
|
||||
a { # 4
|
||||
a { # 5
|
||||
a { # 6
|
||||
a { # 7
|
||||
a { # 8
|
||||
value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`;
|
||||
const errors = validateQuery(query, {
|
||||
depth: DEPTH_LIMIT,
|
||||
cost: COST_LIMIT,
|
||||
});
|
||||
const depthErrors = errors.filter((e) =>
|
||||
e.message.toLowerCase().includes("depth"),
|
||||
);
|
||||
expect(depthErrors.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
99
services/parent-bff/test/unit/lru.cache.test.ts
Normal file
99
services/parent-bff/test/unit/lru.cache.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { LruCache } from "../../src/shared/cache/lru.cache.js";
|
||||
|
||||
describe("LruCache", () => {
|
||||
let cache: LruCache;
|
||||
|
||||
beforeEach(() => {
|
||||
cache = new LruCache(3);
|
||||
});
|
||||
|
||||
describe("set / get", () => {
|
||||
it("应该存取值", () => {
|
||||
cache.set("key1", "value1", 60);
|
||||
expect(cache.get("key1")).toBe("value1");
|
||||
});
|
||||
|
||||
it("未设置的 key 返回 null", () => {
|
||||
expect(cache.get("nonexistent")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TTL 过期", () => {
|
||||
it("过期后返回 null", async () => {
|
||||
cache.set("key1", "value1", 0.01);
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(cache.get("key1")).toBeNull();
|
||||
});
|
||||
|
||||
it("未过期时返回值", () => {
|
||||
cache.set("key1", "value1", 60);
|
||||
expect(cache.get("key1")).toBe("value1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("LRU 淘汰", () => {
|
||||
it("容量满时淘汰最久未使用的条目", () => {
|
||||
cache.set("a", "1", 60);
|
||||
cache.set("b", "2", 60);
|
||||
cache.set("c", "3", 60);
|
||||
cache.set("d", "4", 60);
|
||||
|
||||
expect(cache.get("a")).toBeNull();
|
||||
expect(cache.get("b")).toBe("2");
|
||||
expect(cache.get("c")).toBe("3");
|
||||
expect(cache.get("d")).toBe("4");
|
||||
});
|
||||
|
||||
it("访问后更新 LRU 顺序", () => {
|
||||
cache.set("a", "1", 60);
|
||||
cache.set("b", "2", 60);
|
||||
cache.set("c", "3", 60);
|
||||
|
||||
cache.get("a");
|
||||
|
||||
cache.set("d", "4", 60);
|
||||
|
||||
expect(cache.get("a")).toBe("1");
|
||||
expect(cache.get("b")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("delete", () => {
|
||||
it("删除后返回 null", () => {
|
||||
cache.set("key1", "value1", 60);
|
||||
cache.delete("key1");
|
||||
expect(cache.get("key1")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("clear", () => {
|
||||
it("清空所有条目", () => {
|
||||
cache.set("a", "1", 60);
|
||||
cache.set("b", "2", 60);
|
||||
cache.clear();
|
||||
expect(cache.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("size", () => {
|
||||
it("返回当前条目数", () => {
|
||||
expect(cache.size).toBe(0);
|
||||
cache.set("a", "1", 60);
|
||||
expect(cache.size).toBe(1);
|
||||
cache.set("b", "2", 60);
|
||||
expect(cache.size).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("默认 maxSize 为 100", () => {
|
||||
const defaultCache = new LruCache();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
defaultCache.set(`key${i}`, `val${i}`, 60);
|
||||
}
|
||||
expect(defaultCache.size).toBe(100);
|
||||
defaultCache.set("key100", "val100", 60);
|
||||
expect(defaultCache.size).toBe(100);
|
||||
expect(defaultCache.get("key0")).toBeNull();
|
||||
});
|
||||
});
|
||||
121
services/parent-bff/test/unit/orchestrator.test.ts
Normal file
121
services/parent-bff/test/unit/orchestrator.test.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { orchestrate } from "../../src/aggregation/orchestrator.js";
|
||||
|
||||
describe("Orchestrator", () => {
|
||||
describe("全部成功", () => {
|
||||
it("返回所有结果且 partial=false", async () => {
|
||||
const result = await orchestrate({
|
||||
a: Promise.resolve("value-a"),
|
||||
b: Promise.resolve(42),
|
||||
c: Promise.resolve({ key: "val" }),
|
||||
});
|
||||
|
||||
expect(result.partial).toBe(false);
|
||||
expect(result.failures).toHaveLength(0);
|
||||
expect(result.results.a).toBe("value-a");
|
||||
expect(result.results.b).toBe(42);
|
||||
expect(result.results.c).toEqual({ key: "val" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("部分失败降级(测试用例 5)", () => {
|
||||
it("data-ana 失败时该字段为 null,其他字段正常", async () => {
|
||||
const result = await orchestrate({
|
||||
parent: Promise.resolve({ id: "parent-001", name: "王家长" }),
|
||||
children: Promise.resolve([{ id: "student-001", name: "李同学" }]),
|
||||
analytics: Promise.reject(new Error("data-ana connection refused")),
|
||||
});
|
||||
|
||||
expect(result.partial).toBe(true);
|
||||
expect(result.results.parent).toEqual({
|
||||
id: "parent-001",
|
||||
name: "王家长",
|
||||
});
|
||||
expect(result.results.children).toEqual([
|
||||
{ id: "student-001", name: "李同学" },
|
||||
]);
|
||||
expect(result.results.analytics).toBeNull();
|
||||
expect(result.failures).toHaveLength(1);
|
||||
expect(result.failures[0]!.key).toBe("analytics");
|
||||
});
|
||||
|
||||
it("多个失败都记录在 failures 中", async () => {
|
||||
const result = await orchestrate({
|
||||
ok: Promise.resolve("ok"),
|
||||
fail1: Promise.reject(new Error("err1")),
|
||||
fail2: Promise.reject(new Error("err2")),
|
||||
});
|
||||
|
||||
expect(result.partial).toBe(true);
|
||||
expect(result.failures).toHaveLength(2);
|
||||
expect(result.failures.map((f) => f.key)).toContain("fail1");
|
||||
expect(result.failures.map((f) => f.key)).toContain("fail2");
|
||||
expect(result.results.ok).toBe("ok");
|
||||
expect(result.results.fail1).toBeNull();
|
||||
expect(result.results.fail2).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("全部失败", () => {
|
||||
it("所有字段为 null 且 partial=true", async () => {
|
||||
const result = await orchestrate({
|
||||
a: Promise.reject(new Error("err-a")),
|
||||
b: Promise.reject(new Error("err-b")),
|
||||
});
|
||||
|
||||
expect(result.partial).toBe(true);
|
||||
expect(result.results.a).toBeNull();
|
||||
expect(result.results.b).toBeNull();
|
||||
expect(result.failures).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("空任务", () => {
|
||||
it("返回空结果且 partial=false", async () => {
|
||||
const result = await orchestrate({});
|
||||
|
||||
expect(result.partial).toBe(false);
|
||||
expect(result.failures).toHaveLength(0);
|
||||
expect(Object.keys(result.results)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("并行执行", () => {
|
||||
it("任务并行执行而非串行", async () => {
|
||||
const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
const start = Date.now();
|
||||
|
||||
await orchestrate({
|
||||
a: delay(100).then(() => "a"),
|
||||
b: delay(100).then(() => "b"),
|
||||
c: delay(100).then(() => "c"),
|
||||
});
|
||||
|
||||
const elapsed = Date.now() - start;
|
||||
// 并行:总耗时约 100ms(而非 300ms)
|
||||
expect(elapsed).toBeLessThan(250);
|
||||
});
|
||||
});
|
||||
|
||||
describe("failures 结构", () => {
|
||||
it("failure 包含 key 和 error", async () => {
|
||||
const error = new Error("test error");
|
||||
const result = await orchestrate({
|
||||
failing: Promise.reject(error),
|
||||
});
|
||||
|
||||
expect(result.failures).toHaveLength(1);
|
||||
expect(result.failures[0]!.key).toBe("failing");
|
||||
expect(result.failures[0]!.error).toBe(error);
|
||||
});
|
||||
|
||||
it("非 Error 类型的 rejection 也记录", async () => {
|
||||
const result = await orchestrate({
|
||||
failing: Promise.reject("string error"),
|
||||
});
|
||||
|
||||
expect(result.failures).toHaveLength(1);
|
||||
expect(result.failures[0]!.error).toBe("string error");
|
||||
});
|
||||
});
|
||||
});
|
||||
110
services/parent-bff/test/unit/parent-inputs.dto.test.ts
Normal file
110
services/parent-bff/test/unit/parent-inputs.dto.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
UpdateNotificationPreferencesSchema,
|
||||
NotificationChannelSchema,
|
||||
NotificationEventTypesInputSchema,
|
||||
} from "../../src/parent/dto/parent-inputs.dto.js";
|
||||
|
||||
describe("NotificationChannelSchema", () => {
|
||||
it("接受有效渠道", () => {
|
||||
expect(NotificationChannelSchema.parse("APP")).toBe("APP");
|
||||
expect(NotificationChannelSchema.parse("SMS")).toBe("SMS");
|
||||
expect(NotificationChannelSchema.parse("EMAIL")).toBe("EMAIL");
|
||||
expect(NotificationChannelSchema.parse("WECHAT")).toBe("WECHAT");
|
||||
});
|
||||
|
||||
it("拒绝无效渠道", () => {
|
||||
expect(() => NotificationChannelSchema.parse("PUSH")).toThrow();
|
||||
expect(() => NotificationChannelSchema.parse("")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("NotificationEventTypesInputSchema", () => {
|
||||
it("接受所有可选布尔字段", () => {
|
||||
const result = NotificationEventTypesInputSchema.parse({
|
||||
gradeReleased: true,
|
||||
homeworkGraded: false,
|
||||
examPublished: true,
|
||||
attendanceAlert: false,
|
||||
schoolAnnouncement: true,
|
||||
});
|
||||
expect(result.gradeReleased).toBe(true);
|
||||
expect(result.homeworkGraded).toBe(false);
|
||||
});
|
||||
|
||||
it("允许部分字段(未传为 undefined)", () => {
|
||||
const result = NotificationEventTypesInputSchema.parse({
|
||||
gradeReleased: true,
|
||||
});
|
||||
expect(result.gradeReleased).toBe(true);
|
||||
expect(result.homeworkGraded).toBeUndefined();
|
||||
});
|
||||
|
||||
it("接受空对象", () => {
|
||||
const result = NotificationEventTypesInputSchema.parse({});
|
||||
expect(result.gradeReleased).toBeUndefined();
|
||||
});
|
||||
|
||||
it("拒绝非布尔值", () => {
|
||||
expect(() =>
|
||||
NotificationEventTypesInputSchema.parse({ gradeReleased: "yes" }),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("UpdateNotificationPreferencesSchema", () => {
|
||||
it("接受完整有效输入", () => {
|
||||
const input = {
|
||||
channels: ["APP", "WECHAT"],
|
||||
eventTypes: {
|
||||
gradeReleased: true,
|
||||
homeworkGraded: false,
|
||||
},
|
||||
};
|
||||
const result = UpdateNotificationPreferencesSchema.parse(input);
|
||||
expect(result.channels).toEqual(["APP", "WECHAT"]);
|
||||
expect(result.eventTypes.gradeReleased).toBe(true);
|
||||
});
|
||||
|
||||
it("拒绝空 channels 数组", () => {
|
||||
expect(() =>
|
||||
UpdateNotificationPreferencesSchema.parse({
|
||||
channels: [],
|
||||
eventTypes: {},
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("拒绝无效 channel 值", () => {
|
||||
expect(() =>
|
||||
UpdateNotificationPreferencesSchema.parse({
|
||||
channels: ["INVALID"],
|
||||
eventTypes: {},
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("拒绝缺少 channels", () => {
|
||||
expect(() =>
|
||||
UpdateNotificationPreferencesSchema.parse({
|
||||
eventTypes: {},
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("拒绝缺少 eventTypes", () => {
|
||||
expect(() =>
|
||||
UpdateNotificationPreferencesSchema.parse({
|
||||
channels: ["APP"],
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("接受单个 channel", () => {
|
||||
const result = UpdateNotificationPreferencesSchema.parse({
|
||||
channels: ["APP"],
|
||||
eventTypes: {},
|
||||
});
|
||||
expect(result.channels).toEqual(["APP"]);
|
||||
});
|
||||
});
|
||||
402
services/parent-bff/test/unit/response-mapper.test.ts
Normal file
402
services/parent-bff/test/unit/response-mapper.test.ts
Normal file
@@ -0,0 +1,402 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
mapParent,
|
||||
mapChild,
|
||||
mapClassInfo,
|
||||
mapGrade,
|
||||
mapHomework,
|
||||
mapExam,
|
||||
mapViewport,
|
||||
mapWeakness,
|
||||
mapTrend,
|
||||
mapAnalytics,
|
||||
} from "../../src/aggregation/response-mapper.js";
|
||||
import type {
|
||||
UserInfoDto,
|
||||
ChildDto,
|
||||
GradeDto,
|
||||
HomeworkDto,
|
||||
ExamDto,
|
||||
ViewportDto,
|
||||
StudentWeaknessDto,
|
||||
LearningTrendDto,
|
||||
ClassPerformanceDto,
|
||||
} from "../../src/clients/dtos.js";
|
||||
|
||||
describe("Response Mapper", () => {
|
||||
describe("mapParent", () => {
|
||||
it("正确映射 UserInfoDto → ParentType", () => {
|
||||
const dto: UserInfoDto = {
|
||||
id: "parent-001",
|
||||
email: "parent@example.com",
|
||||
name: "王家长",
|
||||
roles: ["parent"],
|
||||
permissions: [],
|
||||
};
|
||||
const result = mapParent(dto);
|
||||
expect(result.id).toBe("parent-001");
|
||||
expect(result.email).toBe("parent@example.com");
|
||||
expect(result.name).toBe("王家长");
|
||||
expect(result.avatar).toBeNull();
|
||||
expect(result.roles).toEqual(["parent"]);
|
||||
expect(result.dataScope).toBe("CHILDREN");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapChild", () => {
|
||||
it("正确映射 ChildDto → ChildType", () => {
|
||||
const dto: ChildDto = {
|
||||
id: "student-001",
|
||||
name: "李同学",
|
||||
grade: "三年级",
|
||||
classId: "class-001",
|
||||
className: "三年级1班",
|
||||
gradeId: "grade-003",
|
||||
};
|
||||
const result = mapChild(dto);
|
||||
expect(result.id).toBe("student-001");
|
||||
expect(result.name).toBe("李同学");
|
||||
expect(result.grade).toBe("三年级");
|
||||
expect(result.class.id).toBe("class-001");
|
||||
expect(result.class.name).toBe("三年级1班");
|
||||
expect(result.class.gradeId).toBe("grade-003");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapClassInfo", () => {
|
||||
it("正确映射 ClassInfo", () => {
|
||||
const result = mapClassInfo({
|
||||
id: "class-001",
|
||||
name: "三年级1班",
|
||||
gradeId: "grade-003",
|
||||
});
|
||||
expect(result.id).toBe("class-001");
|
||||
expect(result.name).toBe("三年级1班");
|
||||
expect(result.gradeId).toBe("grade-003");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapGrade", () => {
|
||||
it("正确映射 GradeDto → GradeType(score string → number)", () => {
|
||||
const dto: GradeDto = {
|
||||
id: "grade-001",
|
||||
studentId: "student-001",
|
||||
examId: "exam-001",
|
||||
homeworkId: "",
|
||||
score: "85.5",
|
||||
feedback: "数学表现良好",
|
||||
gradedBy: "teacher-001",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-02T00:00:00.000Z",
|
||||
};
|
||||
const result = mapGrade(dto);
|
||||
expect(result.id).toBe("grade-001");
|
||||
expect(result.examId).toBe("exam-001");
|
||||
expect(result.score).toBe(85.5);
|
||||
expect(result.subject).toBe("数学");
|
||||
expect(result.examTitle).toBe("数学表现良好");
|
||||
expect(result.rank).toBeNull();
|
||||
expect(result.gradedAt).toBe("2026-01-02T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("feedback 为空时 examTitle 为默认值", () => {
|
||||
const dto: GradeDto = {
|
||||
id: "grade-001",
|
||||
studentId: "student-001",
|
||||
examId: "exam-001",
|
||||
homeworkId: "",
|
||||
score: "90",
|
||||
feedback: "",
|
||||
gradedBy: "teacher-001",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-02T00:00:00.000Z",
|
||||
};
|
||||
const result = mapGrade(dto);
|
||||
expect(result.examTitle).toBe("未命名考试");
|
||||
expect(result.subject).toBe("未知科目");
|
||||
});
|
||||
|
||||
it("score 非数字时返回 0", () => {
|
||||
const dto: GradeDto = {
|
||||
id: "grade-001",
|
||||
studentId: "student-001",
|
||||
examId: "exam-001",
|
||||
homeworkId: "",
|
||||
score: "invalid",
|
||||
feedback: "语文表现良好",
|
||||
gradedBy: "teacher-001",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-02T00:00:00.000Z",
|
||||
};
|
||||
const result = mapGrade(dto);
|
||||
expect(result.score).toBe(0);
|
||||
expect(result.subject).toBe("语文");
|
||||
});
|
||||
|
||||
it("feedback 无科目前缀时 subject 为综合", () => {
|
||||
const dto: GradeDto = {
|
||||
id: "grade-001",
|
||||
studentId: "student-001",
|
||||
examId: "exam-001",
|
||||
homeworkId: "",
|
||||
score: "80",
|
||||
feedback: "表现不错",
|
||||
gradedBy: "teacher-001",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-02T00:00:00.000Z",
|
||||
};
|
||||
const result = mapGrade(dto);
|
||||
expect(result.subject).toBe("综合");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapHomework", () => {
|
||||
it("SUBMITTED 状态返回 submittedAt", () => {
|
||||
const dto: HomeworkDto = {
|
||||
id: "hw-001",
|
||||
classId: "class-001",
|
||||
title: "数学练习",
|
||||
description: "",
|
||||
dueDate: "2026-01-05T00:00:00.000Z",
|
||||
status: "SUBMITTED",
|
||||
createdBy: "teacher-001",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-02T00:00:00.000Z",
|
||||
};
|
||||
const result = mapHomework(dto);
|
||||
expect(result.status).toBe("SUBMITTED");
|
||||
expect(result.submittedAt).toBe("2026-01-02T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("GRADED 状态返回 submittedAt", () => {
|
||||
const dto: HomeworkDto = {
|
||||
id: "hw-001",
|
||||
classId: "class-001",
|
||||
title: "数学练习",
|
||||
description: "",
|
||||
dueDate: "2026-01-05T00:00:00.000Z",
|
||||
status: "GRADED",
|
||||
createdBy: "teacher-001",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-03T00:00:00.000Z",
|
||||
};
|
||||
const result = mapHomework(dto);
|
||||
expect(result.submittedAt).toBe("2026-01-03T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("NOT_SUBMITTED 状态 submittedAt 为 null", () => {
|
||||
const dto: HomeworkDto = {
|
||||
id: "hw-001",
|
||||
classId: "class-001",
|
||||
title: "数学练习",
|
||||
description: "",
|
||||
dueDate: "2026-01-05T00:00:00.000Z",
|
||||
status: "NOT_SUBMITTED",
|
||||
createdBy: "teacher-001",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-02T00:00:00.000Z",
|
||||
};
|
||||
const result = mapHomework(dto);
|
||||
expect(result.submittedAt).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapExam", () => {
|
||||
it("PUBLISHED 状态返回 publishedAt", () => {
|
||||
const dto: ExamDto = {
|
||||
id: "exam-001",
|
||||
classId: "class-001",
|
||||
title: "数学期中考试",
|
||||
description: "",
|
||||
examDate: "2026-02-01T00:00:00.000Z",
|
||||
duration: "90",
|
||||
totalScore: "100",
|
||||
status: "PUBLISHED",
|
||||
createdBy: "teacher-001",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-15T00:00:00.000Z",
|
||||
};
|
||||
const result = mapExam(dto);
|
||||
expect(result.status).toBe("PUBLISHED");
|
||||
expect(result.publishedAt).toBe("2026-01-15T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("DRAFT 状态 publishedAt 为 null", () => {
|
||||
const dto: ExamDto = {
|
||||
id: "exam-001",
|
||||
classId: "class-001",
|
||||
title: "数学期中考试",
|
||||
description: "",
|
||||
examDate: "2026-02-01T00:00:00.000Z",
|
||||
duration: "90",
|
||||
totalScore: "100",
|
||||
status: "DRAFT",
|
||||
createdBy: "teacher-001",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-15T00:00:00.000Z",
|
||||
};
|
||||
const result = mapExam(dto);
|
||||
expect(result.publishedAt).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapViewport", () => {
|
||||
it("正确映射 ViewportDto", () => {
|
||||
const dto: ViewportDto = {
|
||||
key: "dashboard",
|
||||
label: "首页",
|
||||
route: "/parent/dashboard",
|
||||
icon: "home",
|
||||
sortOrder: "1",
|
||||
requiredPermission: "parent:dashboard:view",
|
||||
};
|
||||
const result = mapViewport(dto);
|
||||
expect(result.key).toBe("dashboard");
|
||||
expect(result.label).toBe("首页");
|
||||
expect(result.icon).toBe("home");
|
||||
expect(result.requiredPermission).toBe("parent:dashboard:view");
|
||||
});
|
||||
|
||||
it("icon 和 requiredPermission 可选", () => {
|
||||
const dto: ViewportDto = {
|
||||
key: "settings",
|
||||
label: "设置",
|
||||
route: "/parent/settings",
|
||||
sortOrder: "5",
|
||||
};
|
||||
const result = mapViewport(dto);
|
||||
expect(result.icon).toBeNull();
|
||||
expect(result.requiredPermission).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapWeakness", () => {
|
||||
it("正确映射薄弱点列表", () => {
|
||||
const dto: StudentWeaknessDto = {
|
||||
studentId: "student-001",
|
||||
weakPoints: [
|
||||
{ knowledgePointId: "kp-1", title: "分数加减法", mastery: 0.45 },
|
||||
{ knowledgePointId: "kp-2", title: "阅读理解", mastery: 0.62 },
|
||||
],
|
||||
};
|
||||
const result = mapWeakness(dto);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]!.knowledgePointId).toBe("kp-1");
|
||||
expect(result[0]!.name).toBe("分数加减法");
|
||||
expect(result[0]!.masteryRate).toBe(0.45);
|
||||
expect(result[0]!.subject).toBe("综合");
|
||||
});
|
||||
|
||||
it("空薄弱点列表", () => {
|
||||
const dto: StudentWeaknessDto = {
|
||||
studentId: "student-001",
|
||||
weakPoints: [],
|
||||
};
|
||||
const result = mapWeakness(dto);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapTrend", () => {
|
||||
it("正确映射趋势点(int64 ms → ISO string)", () => {
|
||||
const dto: LearningTrendDto = {
|
||||
studentId: "student-001",
|
||||
points: [
|
||||
{ date: 1735689600000, score: 80 },
|
||||
{ date: 1735776000000, score: 85 },
|
||||
],
|
||||
};
|
||||
const result = mapTrend(dto);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]!.score).toBe(80);
|
||||
expect(typeof result[0]!.date).toBe("string");
|
||||
expect(new Date(result[0]!.date).getTime()).toBe(1735689600000);
|
||||
expect(result[0]!.subject).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapAnalytics", () => {
|
||||
it("正确计算 classRank(降序排名)", () => {
|
||||
const weakness: StudentWeaknessDto = {
|
||||
studentId: "student-001",
|
||||
weakPoints: [],
|
||||
};
|
||||
const trend: LearningTrendDto = {
|
||||
studentId: "student-001",
|
||||
points: [],
|
||||
};
|
||||
const classPerf: ClassPerformanceDto = {
|
||||
classId: "class-001",
|
||||
averageScore: 82.5,
|
||||
passRate: 0.9,
|
||||
scores: [
|
||||
{ studentId: "student-001", score: 85, grade: "A" },
|
||||
{ studentId: "student-002", score: 92, grade: "A" },
|
||||
{ studentId: "student-003", score: 65, grade: "C" },
|
||||
],
|
||||
};
|
||||
const result = mapAnalytics("student-001", weakness, trend, classPerf);
|
||||
expect(result.childId).toBe("student-001");
|
||||
expect(result.classAverage).toBe(82.5);
|
||||
const sorted = [...classPerf.scores].sort((a, b) => b.score - a.score);
|
||||
const idx = sorted.findIndex((s) => s.studentId === "student-001");
|
||||
expect(result.classRank).toBe(idx + 1);
|
||||
});
|
||||
|
||||
it("classPerf 为 null 时 classRank 和 classAverage 为 null", () => {
|
||||
const weakness: StudentWeaknessDto = {
|
||||
studentId: "student-001",
|
||||
weakPoints: [],
|
||||
};
|
||||
const trend: LearningTrendDto = {
|
||||
studentId: "student-001",
|
||||
points: [],
|
||||
};
|
||||
const result = mapAnalytics("student-001", weakness, trend, null);
|
||||
expect(result.classRank).toBeNull();
|
||||
expect(result.classAverage).toBeNull();
|
||||
});
|
||||
|
||||
it("childId 不在 scores 中时 classRank 为 null", () => {
|
||||
const weakness: StudentWeaknessDto = {
|
||||
studentId: "student-001",
|
||||
weakPoints: [],
|
||||
};
|
||||
const trend: LearningTrendDto = {
|
||||
studentId: "student-001",
|
||||
points: [],
|
||||
};
|
||||
const classPerf: ClassPerformanceDto = {
|
||||
classId: "class-001",
|
||||
averageScore: 80,
|
||||
passRate: 0.9,
|
||||
scores: [
|
||||
{ studentId: "student-002", score: 90, grade: "A" },
|
||||
{ studentId: "student-003", score: 70, grade: "B" },
|
||||
],
|
||||
};
|
||||
const result = mapAnalytics("student-001", weakness, trend, classPerf);
|
||||
expect(result.classRank).toBeNull();
|
||||
expect(result.classAverage).toBe(80);
|
||||
});
|
||||
|
||||
it("scores 为空数组时 classRank 为 null", () => {
|
||||
const weakness: StudentWeaknessDto = {
|
||||
studentId: "student-001",
|
||||
weakPoints: [],
|
||||
};
|
||||
const trend: LearningTrendDto = {
|
||||
studentId: "student-001",
|
||||
points: [],
|
||||
};
|
||||
const classPerf: ClassPerformanceDto = {
|
||||
classId: "class-001",
|
||||
averageScore: 0,
|
||||
passRate: 0,
|
||||
scores: [],
|
||||
};
|
||||
const result = mapAnalytics("student-001", weakness, trend, classPerf);
|
||||
expect(result.classRank).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user