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:
SpecialX
2026-07-10 19:00:29 +08:00
89 changed files with 11177 additions and 39 deletions

View 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",
);
});
});
});

View 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"),
);
});
});

View 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);
});
// ============ 测试用例 1ChildGuard 拦截越权 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");
// 再次请求应该走缓存或重新调 iaminflight 已清理)
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);
});
});
});

View 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);
});
});
});

View 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);
});
});
});

View 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();
});
});

View 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");
});
});
});

View 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"]);
});
});

View 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 → GradeTypescore 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();
});
});
});