chore(content): merge content full implementation into main
Merge feat/content-ai09 with complete content service
This commit is contained in:
@@ -2,18 +2,19 @@
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
RUN npm install -g pnpm
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
COPY services/content/package.json services/content/pnpm-lock.yaml* ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY tsconfig.json nest-cli.json ./
|
||||
COPY src ./src
|
||||
COPY services/content/tsconfig.json services/content/nest-cli.json ./
|
||||
COPY services/content/src ./src
|
||||
RUN pnpm build
|
||||
|
||||
# Runtime stage
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
RUN npm install -g pnpm
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
COPY services/content/package.json services/content/pnpm-lock.yaml* ./
|
||||
RUN pnpm install --prod --frozen-lockfile
|
||||
COPY --from=builder /app/dist ./dist
|
||||
EXPOSE 3005
|
||||
COPY packages/shared-proto/proto/content.proto ./proto/content.proto
|
||||
EXPOSE 3005 50054
|
||||
CMD ["node", "dist/main.js"]
|
||||
|
||||
@@ -1,26 +1,30 @@
|
||||
# content 内容资源服务
|
||||
|
||||
> 版本:0.1(P4 骨架)
|
||||
> 端口:3005
|
||||
> 版本:1.0(P4 完整实现)
|
||||
> 端口:HTTP 3005 / gRPC 50054
|
||||
|
||||
## 职责
|
||||
|
||||
内容资源限界上下文,管理 Textbook、Chapter、KnowledgePoint 聚合。
|
||||
支持多存储:MySQL(教材/章节/知识点写模型)+ Neo4j(知识图谱前置依赖)+ Elasticsearch(题库全文检索,P4 后续补充)。
|
||||
内容资源限界上下文,管理 Textbook、Chapter、KnowledgePoint、Question 四个聚合。
|
||||
支持多存储:MySQL(写模型权威源)+ Neo4j(知识图谱派生读模型)+ Elasticsearch(P5 引入,题库全文检索)。
|
||||
事件驱动:Outbox 模式发布 4 个聚合 topic,Neo4j Sync Worker 异步消费事件同步图谱。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- NestJS 10.x + TypeScript 5.6(ESM)
|
||||
- Drizzle ORM(MySQL)
|
||||
- neo4j-driver(知识图谱)
|
||||
- Zod(运行时校验)
|
||||
- NestJS 10.x + TypeScript 5.6(ESM,相对 import 带 `.js` 后缀)
|
||||
- Drizzle ORM 0.31(MySQL,`getDb()` 函数式懒加载)
|
||||
- neo4j-driver 5.x(知识图谱,仅读模型查询)
|
||||
- KafkaJS 2.2(idempotent producer + transactionalId)
|
||||
- @grpc/grpc-js + @nestjs/microservices(gRPC server 50054)
|
||||
- @paralleldrive/cuid2(统一 ID 策略)
|
||||
- Zod 3.x(Controller 层全量 schema.parse)
|
||||
- pino(日志)+ prom-client(指标)+ OpenTelemetry(追踪)
|
||||
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev # 端口 3005
|
||||
pnpm dev # HTTP 3005 + gRPC 50054
|
||||
pnpm build
|
||||
pnpm typecheck
|
||||
pnpm lint
|
||||
@@ -29,46 +33,150 @@ pnpm test
|
||||
|
||||
## 环境变量
|
||||
|
||||
| 变量 | 说明 |
|
||||
| ----------------------------- | ------------------------------- |
|
||||
| `PORT` | 服务端口(默认 3005) |
|
||||
| `DATABASE_URL` | MySQL 连接串 |
|
||||
| `NEO4J_URL` | Neo4j Bolt 连接 URL |
|
||||
| `NEO4J_PASSWORD` | Neo4j 密码 |
|
||||
| `ES_URL` | Elasticsearch 地址 |
|
||||
| `JWT_SECRET` | JWT 密钥 |
|
||||
| `OTEL_EXPORTER_OTLP_ENDPOINT` | OpenTelemetry OTLP 端点(可选) |
|
||||
| 变量 | 说明 |
|
||||
| ----------------------------- | -------------------------------------------------- |
|
||||
| `PORT` | HTTP 服务端口(默认 3005) |
|
||||
| `GRPC_PORT` | gRPC 服务端口(默认 50054) |
|
||||
| `DATABASE_URL` | MySQL 连接串(必填) |
|
||||
| `KAFKA_BROKERS` | Kafka broker 列表(逗号分隔,默认 localhost:9092) |
|
||||
| `NEO4J_URL` | Neo4j Bolt 连接 URL(可选) |
|
||||
| `NEO4J_PASSWORD` | Neo4j 密码(可选) |
|
||||
| `REDIS_URL` | Redis 连接(预留,P5+ 缓存) |
|
||||
| `ES_URL` | Elasticsearch 地址(P5 引入) |
|
||||
| `JWT_SECRET` | JWT 密钥(Gateway 校验,服务可选) |
|
||||
| `OTEL_EXPORTER_OTLP_ENDPOINT` | OpenTelemetry OTLP 端点(可选) |
|
||||
|
||||
## 模块结构
|
||||
|
||||
```
|
||||
src/
|
||||
├─ config/ # env、database(MySQL)、neo4j
|
||||
├─ config/ # env / database(getDb) / kafka / neo4j
|
||||
├─ grpc/ # gRPC controllers(4 Service, 22 RPC)
|
||||
│ ├─ grpc.module.ts
|
||||
│ ├─ grpc-types.ts # 手动定义 proto message TS 类型
|
||||
│ ├─ textbook.grpc.controller.ts
|
||||
│ ├─ chapter.grpc.controller.ts
|
||||
│ ├─ knowledge-graph.grpc.controller.ts
|
||||
│ └─ question.grpc.controller.ts
|
||||
├─ textbooks/ # 教材领域(schema/repository/service/controller/dto)
|
||||
├─ chapters/ # 章节领域
|
||||
├─ knowledge-points/ # 知识点领域(含前置依赖管理)
|
||||
├─ questions/ # 题库领域
|
||||
├─ shared/
|
||||
│ ├─ errors/ # ApplicationError + GlobalErrorFilter(CONTENT_* 前缀)
|
||||
│ └─ observability/# logger、metrics、tracer
|
||||
├─ textbooks/ # 教材/章节/知识点 + 知识图谱
|
||||
│ ├─ errors/ # ApplicationError + GlobalErrorFilter(CONTENT_* 前缀 + ZodError 分支)
|
||||
│ ├─ health/ # /healthz + /readyz(DB/Neo4j/Kafka 多依赖检查)
|
||||
│ ├─ lifecycle/ # 优雅关闭
|
||||
│ ├─ observability/ # logger / metrics / tracer
|
||||
│ ├─ outbox/ # Outbox 模式(schema/repository/publisher/service/module/events)
|
||||
│ └─ sync/ # Neo4j Sync Worker(消费 Kafka 事件异步同步图谱)
|
||||
├─ middleware/ # auth.middleware + permission.guard
|
||||
├─ app.module.ts
|
||||
└─ main.ts
|
||||
└─ main.ts # HTTP + gRPC hybrid app
|
||||
```
|
||||
|
||||
## 关键端点
|
||||
## REST API 端点
|
||||
|
||||
- `POST /textbooks` 创建教材
|
||||
- `GET /textbooks` 教材列表
|
||||
- `GET /textbooks/:id` 教材详情
|
||||
- `TextbooksService.createKnowledgeGraph` 在 Neo4j 构建知识点前置依赖
|
||||
- `TextbooksService.getPrerequisites` 查询知识点前置链路
|
||||
### Textbooks
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| -------- | ---------------- | ------------------------- |
|
||||
| `POST` | `/textbooks` | 创建教材 |
|
||||
| `GET` | `/textbooks` | 教材列表(支持分页/过滤) |
|
||||
| `GET` | `/textbooks/:id` | 教材详情 |
|
||||
| `PUT` | `/textbooks/:id` | 更新教材 |
|
||||
| `DELETE` | `/textbooks/:id` | 删除教材 |
|
||||
|
||||
### Chapters
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| -------- | -------------------------------- | -------------- |
|
||||
| `POST` | `/chapters` | 创建章节 |
|
||||
| `GET` | `/chapters/textbook/:textbookId` | 按教材列出章节 |
|
||||
| `GET` | `/chapters/:id` | 章节详情 |
|
||||
| `PUT` | `/chapters/:id` | 更新章节 |
|
||||
| `DELETE` | `/chapters/:id` | 删除章节 |
|
||||
|
||||
### Knowledge Points
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| -------- | ----------------------------------------------------- | ------------------------------ |
|
||||
| `POST` | `/knowledge-points` | 创建知识点 |
|
||||
| `GET` | `/knowledge-points/chapter/:chapterId` | 按章节列出知识点 |
|
||||
| `GET` | `/knowledge-points/:id` | 知识点详情 |
|
||||
| `GET` | `/knowledge-points/:id/prerequisites` | 查询前置依赖(读 Neo4j) |
|
||||
| `POST` | `/knowledge-points/:id/prerequisites` | 添加前置依赖(发 Outbox 事件) |
|
||||
| `DELETE` | `/knowledge-points/:id/prerequisites/:prerequisiteId` | 移除前置依赖(发 Outbox 事件) |
|
||||
| `PUT` | `/knowledge-points/:id` | 更新知识点 |
|
||||
| `DELETE` | `/knowledge-points/:id` | 删除知识点 |
|
||||
|
||||
### Questions
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| -------- | ---------------------------------------------- | ------------------------- |
|
||||
| `POST` | `/questions` | 创建题目 |
|
||||
| `GET` | `/questions` | 题目列表(支持分页/过滤) |
|
||||
| `GET` | `/questions/knowledge-point/:knowledgePointId` | 按知识点列出题目 |
|
||||
| `GET` | `/questions/:id` | 题目详情 |
|
||||
| `PUT` | `/questions/:id` | 更新题目 |
|
||||
| `DELETE` | `/questions/:id` | 删除题目 |
|
||||
|
||||
## gRPC API(端口 50054)
|
||||
|
||||
Proto 定义:`packages/shared-proto/proto/content.proto`(包名 `next_edu_cloud.content.v1`)
|
||||
|
||||
| Service | RPC 数 | 关键方法 |
|
||||
| ----------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `TextbookService` | 5 | CreateTextbook / GetTextbook / ListTextbooks / UpdateTextbook / DeleteTextbook |
|
||||
| `ChapterService` | 5 | CreateChapter / GetChapter / ListChapters / UpdateChapter / DeleteChapter |
|
||||
| `KnowledgeGraphService` | 4 | GetPrerequisites / GetLearningPath / AddPrerequisite / RemovePrerequisite |
|
||||
| `QuestionService` | 8 | CreateQuestion / BatchCreateQuestions / GetQuestion / ListQuestions / UpdateQuestion / DeleteQuestion / PublishQuestion / SearchQuestions |
|
||||
|
||||
**合计 22 RPC。**
|
||||
|
||||
## 事件驱动
|
||||
|
||||
### Outbox 模式
|
||||
|
||||
业务操作(create/update/delete)在同一事务内写业务表 + `content_outbox_events` 表。
|
||||
独立 `OutboxPublisher` worker 轮询 pending 记录投递 Kafka(at-least-once,最大重试 5 次,指数退避)。
|
||||
|
||||
### Kafka Topics(4 个聚合 topic,action 字段区分事件类型)
|
||||
|
||||
| Topic | 事件类型(action) |
|
||||
| ------------------------------------ | ------------------------------------------------------------- |
|
||||
| `edu.content.textbook.events` | created / updated / published / archived |
|
||||
| `edu.content.chapter.events` | created / updated / deleted |
|
||||
| `edu.content.knowledge_point.events` | created / updated / prerequisite_added / prerequisite_removed |
|
||||
| `edu.content.question.events` | created / updated / published / deleted |
|
||||
|
||||
### Neo4j Sync Worker
|
||||
|
||||
消费 `edu.content.knowledge_point.events` topic,异步同步知识图谱:
|
||||
|
||||
- `kp.created` / `kp.updated` → MERGE 节点
|
||||
- `prerequisite_added` → MERGE 关系
|
||||
- `prerequisite_removed` → DELETE 关系
|
||||
|
||||
Neo4j 不可用时非阻塞跳过(不影响 MySQL 写入和主流程)。
|
||||
|
||||
## 健康检查
|
||||
|
||||
| 端点 | 用途 | 鉴权 |
|
||||
| -------------- | ------------------------------------------------- | ---- |
|
||||
| `GET /healthz` | 存活探针(liveness),仅返回进程状态,不检查依赖 | 无 |
|
||||
| `GET /readyz` | 就绪探针(readiness),检查 DB 连接,失败返回 503 | 无 |
|
||||
| 端点 | 用途 | 鉴权 |
|
||||
| -------------- | -------------------------------------------------------- | ---- |
|
||||
| `GET /healthz` | 存活探针(liveness),仅返回进程状态 | 无 |
|
||||
| `GET /readyz` | 就绪探针(readiness),检查 DB/Neo4j/Kafka,失败返回 503 | 无 |
|
||||
|
||||
实现见 `src/shared/health/health.controller.ts`,5 个 NestJS 服务(iam/core-edu/content/msg/classes)一致。
|
||||
## 质量校验
|
||||
|
||||
| 检查项 | 命令 | 状态 |
|
||||
| -------- | ----------------------- | ----------------- |
|
||||
| 类型检查 | `pnpm typecheck` | ✅ 0 错误 |
|
||||
| Lint | `pnpm lint` | ✅ 0 错误 |
|
||||
| 单元测试 | `pnpm test` | ✅ 182 tests 通过 |
|
||||
| 覆盖率 | `vitest run --coverage` | ✅ 66.94%(≥60%) |
|
||||
|
||||
## 对外契约
|
||||
|
||||
gRPC 服务 `TextbookService`、`KnowledgeGraphService` 定义见 `packages/shared-proto/proto/content.proto`。
|
||||
- gRPC proto:`packages/shared-proto/proto/content.proto`
|
||||
- 事件 proto:`packages/shared-proto/proto/events.proto`(TextbookEvent / ChapterEvent / KnowledgePointEvent / QuestionEvent)
|
||||
- 契约文档:`docs/architecture/issues/contracts/content_contract.md`
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
"deleteOutDir": true,
|
||||
"assets": [
|
||||
{ "include": "proto/**/*.proto", "outDir": "dist" }
|
||||
],
|
||||
"watchAssets": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,28 +12,33 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@grpc/grpc-js": "^1.12.0",
|
||||
"@grpc/proto-loader": "^0.7.13",
|
||||
"@nestjs/common": "^10.4.0",
|
||||
"@nestjs/core": "^10.4.0",
|
||||
"@nestjs/microservices": "^10.4.0",
|
||||
"@nestjs/platform-express": "^10.4.0",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/auto-instrumentations-node": "^0.55.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.55.0",
|
||||
"@opentelemetry/sdk-node": "^0.55.0",
|
||||
"@paralleldrive/cuid2": "^2.2.2",
|
||||
"drizzle-orm": "^0.31.0",
|
||||
"kafkajs": "^2.2.4",
|
||||
"mysql2": "^3.11.0",
|
||||
"neo4j-driver": "^5.23.0",
|
||||
"pino": "^9.4.0",
|
||||
"prom-client": "^15.1.0",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"zod": "^3.23.0",
|
||||
"uuid": "^10.0.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.0",
|
||||
"@opentelemetry/sdk-node": "^0.55.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.55.0",
|
||||
"@opentelemetry/auto-instrumentations-node": "^0.55.0"
|
||||
"zod": "^3.23.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.0",
|
||||
"@types/express": "^4.17.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@vitest/coverage-v8": "^2.1.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vitest": "^2.1.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ import { TextbooksModule } from "./textbooks/textbooks.module.js";
|
||||
import { ChaptersModule } from "./chapters/chapters.module.js";
|
||||
import { KnowledgePointsModule } from "./knowledge-points/knowledge-points.module.js";
|
||||
import { QuestionsModule } from "./questions/questions.module.js";
|
||||
import { GrpcModule } from "./grpc/grpc.module.js";
|
||||
import { HealthModule } from "./shared/health/health.module.js";
|
||||
import { OutboxModule } from "./shared/outbox/outbox.module.js";
|
||||
import { PermissionGuard } from "./middleware/permission.guard.js";
|
||||
import { LifecycleService } from "./shared/lifecycle/lifecycle.service.js";
|
||||
|
||||
@@ -14,7 +16,9 @@ import { LifecycleService } from "./shared/lifecycle/lifecycle.service.js";
|
||||
ChaptersModule,
|
||||
KnowledgePointsModule,
|
||||
QuestionsModule,
|
||||
GrpcModule,
|
||||
HealthModule,
|
||||
OutboxModule,
|
||||
],
|
||||
providers: [
|
||||
{ provide: APP_GUARD, useClass: PermissionGuard },
|
||||
|
||||
89
services/content/src/chapters/chapters.controller.test.ts
Normal file
89
services/content/src/chapters/chapters.controller.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { ChaptersController } from "./chapters.controller.js";
|
||||
|
||||
vi.mock("./chapters.service.js", () => ({
|
||||
ChaptersService: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockService = {
|
||||
createChapter: vi.fn(),
|
||||
listChaptersByTextbook: vi.fn(),
|
||||
getChapter: vi.fn(),
|
||||
updateChapter: vi.fn(),
|
||||
deleteChapter: vi.fn(),
|
||||
};
|
||||
|
||||
describe("ChaptersController", () => {
|
||||
let controller: ChaptersController;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
controller = new ChaptersController(mockService as never);
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
it("should parse input and call service.createChapter", async () => {
|
||||
mockService.createChapter.mockResolvedValue({ id: "ch-1" });
|
||||
const result = await controller.create({
|
||||
textbookId: "tb-1",
|
||||
title: "Chapter One",
|
||||
});
|
||||
expect(mockService.createChapter).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
textbookId: "tb-1",
|
||||
title: "Chapter One",
|
||||
order: 0,
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({ success: true, data: { id: "ch-1" } });
|
||||
});
|
||||
|
||||
it("should throw on invalid body", async () => {
|
||||
await expect(controller.create({ textbookId: "" })).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("listByTextbook", () => {
|
||||
it("should call service.listChaptersByTextbook", async () => {
|
||||
const data = [{ id: "ch-1" }];
|
||||
mockService.listChaptersByTextbook.mockResolvedValue(data);
|
||||
const result = await controller.listByTextbook("tb-1");
|
||||
expect(mockService.listChaptersByTextbook).toHaveBeenCalledWith("tb-1");
|
||||
expect(result).toEqual({ success: true, data });
|
||||
});
|
||||
});
|
||||
|
||||
describe("getById", () => {
|
||||
it("should call service.getChapter", async () => {
|
||||
const data = { id: "ch-1", title: "T" };
|
||||
mockService.getChapter.mockResolvedValue(data);
|
||||
const result = await controller.getById("ch-1");
|
||||
expect(mockService.getChapter).toHaveBeenCalledWith("ch-1");
|
||||
expect(result).toEqual({ success: true, data });
|
||||
});
|
||||
});
|
||||
|
||||
describe("update", () => {
|
||||
it("should parse input and call service.updateChapter", async () => {
|
||||
const result = await controller.update("ch-1", { title: "New" });
|
||||
expect(mockService.updateChapter).toHaveBeenCalledWith("ch-1", {
|
||||
title: "New",
|
||||
});
|
||||
expect(result).toEqual({ success: true, data: { success: true } });
|
||||
});
|
||||
|
||||
it("should throw on invalid status", async () => {
|
||||
await expect(
|
||||
controller.update("ch-1", { status: "invalid" }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("remove", () => {
|
||||
it("should call service.deleteChapter", async () => {
|
||||
const result = await controller.remove("ch-1");
|
||||
expect(mockService.deleteChapter).toHaveBeenCalledWith("ch-1");
|
||||
expect(result).toEqual({ success: true, data: { success: true } });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,16 +7,13 @@ import {
|
||||
Post,
|
||||
Put,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
ChaptersService,
|
||||
type CreateChapterInput,
|
||||
type UpdateChapterInput,
|
||||
} from "./chapters.service.js";
|
||||
import { ChaptersService } from "./chapters.service.js";
|
||||
import type { Chapter } from "./chapters.schema.js";
|
||||
import {
|
||||
Permissions,
|
||||
RequirePermission,
|
||||
} from "../middleware/permission.guard.js";
|
||||
import { createChapterSchema, updateChapterSchema } from "./chapters.dto.js";
|
||||
|
||||
@Controller("chapters")
|
||||
export class ChaptersController {
|
||||
@@ -25,9 +22,10 @@ export class ChaptersController {
|
||||
@Post()
|
||||
@RequirePermission(Permissions.CONTENT_CHAPTER_CREATE)
|
||||
async create(
|
||||
@Body() body: CreateChapterInput,
|
||||
@Body() body: unknown,
|
||||
): Promise<{ success: true; data: { id: string } }> {
|
||||
const result = await this.service.createChapter(body);
|
||||
const input = createChapterSchema.parse(body);
|
||||
const result = await this.service.createChapter(input);
|
||||
return { success: true, data: result };
|
||||
}
|
||||
|
||||
@@ -53,9 +51,10 @@ export class ChaptersController {
|
||||
@RequirePermission(Permissions.CONTENT_CHAPTER_UPDATE)
|
||||
async update(
|
||||
@Param("id") id: string,
|
||||
@Body() body: UpdateChapterInput,
|
||||
@Body() body: unknown,
|
||||
): Promise<{ success: true; data: { success: true } }> {
|
||||
await this.service.updateChapter(id, body);
|
||||
const input = updateChapterSchema.parse(body);
|
||||
await this.service.updateChapter(id, input);
|
||||
return { success: true, data: { success: true } };
|
||||
}
|
||||
|
||||
|
||||
73
services/content/src/chapters/chapters.dto.test.ts
Normal file
73
services/content/src/chapters/chapters.dto.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { createChapterSchema, updateChapterSchema } from "./chapters.dto.js";
|
||||
|
||||
describe("chapters.dto", () => {
|
||||
describe("createChapterSchema", () => {
|
||||
it("should parse valid input with all fields", () => {
|
||||
const result = createChapterSchema.parse({
|
||||
textbookId: "tb-1",
|
||||
title: "Chapter One",
|
||||
order: 5,
|
||||
parentId: "parent-1",
|
||||
});
|
||||
expect(result.textbookId).toBe("tb-1");
|
||||
expect(result.title).toBe("Chapter One");
|
||||
expect(result.order).toBe(5);
|
||||
expect(result.parentId).toBe("parent-1");
|
||||
});
|
||||
|
||||
it("should default order to 0 when not provided", () => {
|
||||
const result = createChapterSchema.parse({
|
||||
textbookId: "tb-1",
|
||||
title: "Chapter One",
|
||||
});
|
||||
expect(result.order).toBe(0);
|
||||
});
|
||||
|
||||
it("should accept input without optional parentId", () => {
|
||||
const result = createChapterSchema.parse({
|
||||
textbookId: "tb-1",
|
||||
title: "Chapter One",
|
||||
});
|
||||
expect(result.parentId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should reject empty textbookId", () => {
|
||||
expect(() =>
|
||||
createChapterSchema.parse({ textbookId: "", title: "Chapter One" }),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("should reject empty title", () => {
|
||||
expect(() =>
|
||||
createChapterSchema.parse({ textbookId: "tb-1", title: "" }),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("should reject negative order", () => {
|
||||
expect(() =>
|
||||
createChapterSchema.parse({
|
||||
textbookId: "tb-1",
|
||||
title: "Chapter One",
|
||||
order: -1,
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateChapterSchema", () => {
|
||||
it("should parse partial update with title only", () => {
|
||||
const result = updateChapterSchema.parse({ title: "New Title" });
|
||||
expect(result.title).toBe("New Title");
|
||||
});
|
||||
|
||||
it("should accept valid status enum", () => {
|
||||
const result = updateChapterSchema.parse({ status: "published" });
|
||||
expect(result.status).toBe("published");
|
||||
});
|
||||
|
||||
it("should reject invalid status value", () => {
|
||||
expect(() => updateChapterSchema.parse({ status: "invalid" })).toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
17
services/content/src/chapters/chapters.dto.ts
Normal file
17
services/content/src/chapters/chapters.dto.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const createChapterSchema = z.object({
|
||||
textbookId: z.string().min(1).max(32),
|
||||
title: z.string().min(1).max(255),
|
||||
order: z.number().int().min(0).default(0),
|
||||
parentId: z.string().max(32).optional(),
|
||||
});
|
||||
|
||||
export const updateChapterSchema = z.object({
|
||||
title: z.string().min(1).max(255).optional(),
|
||||
order: z.number().int().min(0).optional(),
|
||||
status: z.enum(["draft", "published", "archived"]).optional(),
|
||||
});
|
||||
|
||||
export type CreateChapterDto = z.infer<typeof createChapterSchema>;
|
||||
export type UpdateChapterDto = z.infer<typeof updateChapterSchema>;
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ChaptersController } from "./chapters.controller.js";
|
||||
import { ChaptersService } from "./chapters.service.js";
|
||||
import { OutboxModule } from "../shared/outbox/outbox.module.js";
|
||||
|
||||
@Module({
|
||||
imports: [OutboxModule],
|
||||
controllers: [ChaptersController],
|
||||
providers: [ChaptersService],
|
||||
exports: [ChaptersService],
|
||||
|
||||
79
services/content/src/chapters/chapters.repository.test.ts
Normal file
79
services/content/src/chapters/chapters.repository.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const mockGetDb = vi.fn();
|
||||
|
||||
vi.mock("../config/database.js", () => ({
|
||||
getDb: () => mockGetDb(),
|
||||
}));
|
||||
|
||||
import { ChaptersRepository } from "./chapters.repository.js";
|
||||
|
||||
function createMockDb(resolvedValue: unknown): unknown {
|
||||
const handler: ProxyHandler<Record<PropertyKey, unknown>> = {
|
||||
get: (_target, prop) => {
|
||||
if (prop === "then") {
|
||||
return (onFulfilled?: (v: unknown) => unknown) =>
|
||||
Promise.resolve(
|
||||
typeof onFulfilled === "function"
|
||||
? onFulfilled(resolvedValue)
|
||||
: resolvedValue,
|
||||
);
|
||||
}
|
||||
return () => new Proxy({}, handler);
|
||||
},
|
||||
};
|
||||
return new Proxy({}, handler);
|
||||
}
|
||||
|
||||
describe("ChaptersRepository", () => {
|
||||
let repo: ChaptersRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
repo = new ChaptersRepository();
|
||||
});
|
||||
|
||||
it("findById should return first matching chapter", async () => {
|
||||
const chapter = { id: "ch-1", title: "T" };
|
||||
mockGetDb.mockReturnValue(createMockDb([chapter]));
|
||||
const result = await repo.findById("ch-1");
|
||||
expect(result).toBe(chapter);
|
||||
});
|
||||
|
||||
it("findById should return undefined when no match", async () => {
|
||||
mockGetDb.mockReturnValue(createMockDb([]));
|
||||
const result = await repo.findById("missing");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("findByTextbookId should return chapters array", async () => {
|
||||
const chapters = [{ id: "ch-1" }, { id: "ch-2" }];
|
||||
mockGetDb.mockReturnValue(createMockDb(chapters));
|
||||
const result = await repo.findByTextbookId("tb-1");
|
||||
expect(result).toBe(chapters);
|
||||
});
|
||||
|
||||
it("create should insert a new chapter", async () => {
|
||||
mockGetDb.mockReturnValue(createMockDb(undefined));
|
||||
await repo.create({
|
||||
id: "ch-1",
|
||||
textbookId: "tb-1",
|
||||
title: "T",
|
||||
order: 0,
|
||||
status: "draft",
|
||||
});
|
||||
expect(mockGetDb).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("update should update a chapter by id", async () => {
|
||||
mockGetDb.mockReturnValue(createMockDb(undefined));
|
||||
await repo.update("ch-1", { title: "New" });
|
||||
expect(mockGetDb).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("delete should delete a chapter by id", async () => {
|
||||
mockGetDb.mockReturnValue(createMockDb(undefined));
|
||||
await repo.delete("ch-1");
|
||||
expect(mockGetDb).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,10 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "../config/database.js";
|
||||
import { getDb } from "../config/database.js";
|
||||
import { chapters, type Chapter, type NewChapter } from "./chapters.schema.js";
|
||||
|
||||
export class ChaptersRepository {
|
||||
async findById(id: string): Promise<Chapter | undefined> {
|
||||
const [result] = await db
|
||||
const [result] = await getDb()
|
||||
.select()
|
||||
.from(chapters)
|
||||
.where(eq(chapters.id, id))
|
||||
@@ -13,22 +13,22 @@ export class ChaptersRepository {
|
||||
}
|
||||
|
||||
async findByTextbookId(textbookId: string): Promise<Chapter[]> {
|
||||
return db
|
||||
return getDb()
|
||||
.select()
|
||||
.from(chapters)
|
||||
.where(eq(chapters.textbookId, textbookId));
|
||||
}
|
||||
|
||||
async create(data: NewChapter): Promise<void> {
|
||||
await db.insert(chapters).values(data);
|
||||
await getDb().insert(chapters).values(data);
|
||||
}
|
||||
|
||||
async update(id: string, data: Partial<NewChapter>): Promise<void> {
|
||||
await db.update(chapters).set(data).where(eq(chapters.id, id));
|
||||
await getDb().update(chapters).set(data).where(eq(chapters.id, id));
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await db.delete(chapters).where(eq(chapters.id, id));
|
||||
await getDb().delete(chapters).where(eq(chapters.id, id));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
145
services/content/src/chapters/chapters.service.test.ts
Normal file
145
services/content/src/chapters/chapters.service.test.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { ChaptersService } from "./chapters.service.js";
|
||||
import { NotFoundError } from "../shared/errors/application-error.js";
|
||||
|
||||
vi.mock("./chapters.repository.js", () => ({
|
||||
chaptersRepository: {
|
||||
findById: vi.fn(),
|
||||
findByTextbookId: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { chaptersRepository } from "./chapters.repository.js";
|
||||
|
||||
const mockOutbox = {
|
||||
publish: vi.fn().mockResolvedValue("event-id"),
|
||||
};
|
||||
|
||||
describe("ChaptersService", () => {
|
||||
let service: ChaptersService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
service = new ChaptersService(mockOutbox as never);
|
||||
});
|
||||
|
||||
describe("createChapter", () => {
|
||||
it("should create a chapter and publish event", async () => {
|
||||
const input = {
|
||||
textbookId: "tb-1",
|
||||
title: "Chapter 1",
|
||||
order: 5,
|
||||
};
|
||||
const result = await service.createChapter(input);
|
||||
|
||||
expect(result.id).toBeDefined();
|
||||
expect(chaptersRepository.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
textbookId: "tb-1",
|
||||
title: "Chapter 1",
|
||||
order: 5,
|
||||
status: "draft",
|
||||
}),
|
||||
);
|
||||
expect(mockOutbox.publish).toHaveBeenCalledWith(
|
||||
"chapter.created",
|
||||
"Chapter",
|
||||
result.id,
|
||||
expect.objectContaining({
|
||||
textbook_id: "tb-1",
|
||||
title: "Chapter 1",
|
||||
order: 5,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should default order to 0 when not provided", async () => {
|
||||
await service.createChapter({
|
||||
textbookId: "tb-1",
|
||||
title: "Ch",
|
||||
});
|
||||
expect(chaptersRepository.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ order: 0 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getChapter", () => {
|
||||
it("should return chapter when found", async () => {
|
||||
const mockChapter = {
|
||||
id: "ch-1",
|
||||
textbookId: "tb-1",
|
||||
title: "Ch",
|
||||
order: 0,
|
||||
parentId: null,
|
||||
status: "draft",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
vi.mocked(chaptersRepository.findById).mockResolvedValue(mockChapter);
|
||||
const result = await service.getChapter("ch-1");
|
||||
expect(result).toBe(mockChapter);
|
||||
});
|
||||
|
||||
it("should throw NotFoundError when not found", async () => {
|
||||
vi.mocked(chaptersRepository.findById).mockResolvedValue(undefined);
|
||||
await expect(service.getChapter("nope")).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateChapter", () => {
|
||||
it("should update and publish event", async () => {
|
||||
const existing = {
|
||||
id: "ch-1",
|
||||
textbookId: "tb-1",
|
||||
title: "Old",
|
||||
order: 0,
|
||||
parentId: null,
|
||||
status: "draft",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
vi.mocked(chaptersRepository.findById).mockResolvedValue(existing);
|
||||
|
||||
await service.updateChapter("ch-1", { title: "New" });
|
||||
|
||||
expect(chaptersRepository.update).toHaveBeenCalledWith("ch-1", {
|
||||
title: "New",
|
||||
});
|
||||
expect(mockOutbox.publish).toHaveBeenCalledWith(
|
||||
"chapter.updated",
|
||||
"Chapter",
|
||||
"ch-1",
|
||||
expect.objectContaining({ title: "New" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteChapter", () => {
|
||||
it("should delete and publish deleted event", async () => {
|
||||
vi.mocked(chaptersRepository.findById).mockResolvedValue({
|
||||
id: "ch-1",
|
||||
textbookId: "tb-1",
|
||||
title: "Ch",
|
||||
order: 0,
|
||||
parentId: null,
|
||||
status: "draft",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
await service.deleteChapter("ch-1");
|
||||
|
||||
expect(chaptersRepository.delete).toHaveBeenCalledWith("ch-1");
|
||||
expect(mockOutbox.publish).toHaveBeenCalledWith(
|
||||
"chapter.deleted",
|
||||
"Chapter",
|
||||
"ch-1",
|
||||
{ deleted: true },
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,40 +1,52 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { chaptersRepository } from "./chapters.repository.js";
|
||||
import type { Chapter } from "./chapters.schema.js";
|
||||
import {
|
||||
NotFoundError,
|
||||
ValidationError,
|
||||
} from "../shared/errors/application-error.js";
|
||||
import type { Chapter, NewChapter } from "./chapters.schema.js";
|
||||
import { OutboxService } from "../shared/outbox/outbox.service.js";
|
||||
import { AGGREGATE_TYPES, EVENT_TYPES } from "../shared/outbox/events.js";
|
||||
import { NotFoundError } from "../shared/errors/application-error.js";
|
||||
|
||||
export interface CreateChapterInput {
|
||||
textbookId: string;
|
||||
title: string;
|
||||
order: number;
|
||||
order?: number;
|
||||
parentId?: string;
|
||||
}
|
||||
|
||||
export interface UpdateChapterInput {
|
||||
title?: string;
|
||||
order?: number;
|
||||
parentId?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ChaptersService {
|
||||
async createChapter(input: CreateChapterInput): Promise<{ id: string }> {
|
||||
if (!input.textbookId || !input.title || input.order === undefined) {
|
||||
throw new ValidationError("textbookId, title, order are required");
|
||||
}
|
||||
constructor(private readonly outbox: OutboxService) {}
|
||||
|
||||
const id = randomUUID();
|
||||
await chaptersRepository.create({
|
||||
async createChapter(input: CreateChapterInput): Promise<{ id: string }> {
|
||||
const id = createId();
|
||||
const record: NewChapter = {
|
||||
id,
|
||||
textbookId: input.textbookId,
|
||||
title: input.title,
|
||||
order: input.order,
|
||||
order: input.order ?? 0,
|
||||
parentId: input.parentId,
|
||||
});
|
||||
status: "draft",
|
||||
};
|
||||
await chaptersRepository.create(record);
|
||||
|
||||
await this.outbox.publish(
|
||||
EVENT_TYPES.CHAPTER_CREATED,
|
||||
AGGREGATE_TYPES.CHAPTER,
|
||||
id,
|
||||
{
|
||||
textbook_id: record.textbookId,
|
||||
title: record.title,
|
||||
order: record.order,
|
||||
parent_id: record.parentId,
|
||||
},
|
||||
);
|
||||
|
||||
return { id };
|
||||
}
|
||||
|
||||
@@ -51,12 +63,30 @@ export class ChaptersService {
|
||||
}
|
||||
|
||||
async updateChapter(id: string, data: UpdateChapterInput): Promise<void> {
|
||||
await this.getChapter(id);
|
||||
const existing = await this.getChapter(id);
|
||||
await chaptersRepository.update(id, data);
|
||||
|
||||
await this.outbox.publish(
|
||||
EVENT_TYPES.CHAPTER_UPDATED,
|
||||
AGGREGATE_TYPES.CHAPTER,
|
||||
id,
|
||||
{
|
||||
title: data.title ?? existing.title,
|
||||
order: data.order ?? existing.order,
|
||||
status: data.status ?? existing.status,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async deleteChapter(id: string): Promise<void> {
|
||||
await this.getChapter(id);
|
||||
await chaptersRepository.delete(id);
|
||||
|
||||
await this.outbox.publish(
|
||||
EVENT_TYPES.CHAPTER_DELETED,
|
||||
AGGREGATE_TYPES.CHAPTER,
|
||||
id,
|
||||
{ deleted: true },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
import { drizzle } from "drizzle-orm/mysql2";
|
||||
import type { MySql2Database } from "drizzle-orm/mysql2";
|
||||
import mysql from "mysql2/promise";
|
||||
import { env } from "./env.js";
|
||||
|
||||
const pool = mysql.createPool({
|
||||
uri: env.DATABASE_URL,
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
queueLimit: 0,
|
||||
});
|
||||
let pool: mysql.Pool | null = null;
|
||||
|
||||
export const db = drizzle(pool);
|
||||
export function getDb(): MySql2Database {
|
||||
if (!pool) {
|
||||
pool = mysql.createPool({
|
||||
uri: env.DATABASE_URL,
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
queueLimit: 0,
|
||||
});
|
||||
}
|
||||
return drizzle(pool);
|
||||
}
|
||||
|
||||
export async function closeDb(): Promise<void> {
|
||||
await pool.end();
|
||||
if (pool) {
|
||||
await pool.end();
|
||||
pool = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,13 @@ import { z } from "zod";
|
||||
|
||||
const envSchema = z.object({
|
||||
PORT: z.string().default("3005"),
|
||||
GRPC_PORT: z.string().default("50054"),
|
||||
DATABASE_URL: z.string().url(),
|
||||
REDIS_URL: z.string().url().optional(),
|
||||
NEO4J_URL: z.string().url().optional(),
|
||||
NEO4J_PASSWORD: z.string().optional(),
|
||||
ES_URL: z.string().url().optional(),
|
||||
KAFKA_BROKERS: z.string().default("localhost:9092"),
|
||||
JWT_SECRET: z.string().optional(),
|
||||
JWT_ISSUER: z.string().default("next-edu-cloud"),
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: z.string().url().optional(),
|
||||
|
||||
57
services/content/src/config/kafka.ts
Normal file
57
services/content/src/config/kafka.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Kafka } from "kafkajs";
|
||||
import { env } from "./env.js";
|
||||
import { logger } from "../shared/observability/logger.js";
|
||||
|
||||
export const kafka = new Kafka({
|
||||
brokers: env.KAFKA_BROKERS.split(","),
|
||||
clientId: "content-service",
|
||||
});
|
||||
|
||||
export const producer = kafka.producer({
|
||||
idempotent: true,
|
||||
transactionalId: "content-tx",
|
||||
});
|
||||
|
||||
export const neo4jSyncConsumer = kafka.consumer({
|
||||
groupId: "content-neo4j-sync",
|
||||
});
|
||||
|
||||
let producerIsConnected = false;
|
||||
|
||||
producer.on("producer.connect", () => {
|
||||
producerIsConnected = true;
|
||||
});
|
||||
producer.on("producer.disconnect", () => {
|
||||
producerIsConnected = false;
|
||||
});
|
||||
|
||||
export function isKafkaProducerConnected(): boolean {
|
||||
return producerIsConnected;
|
||||
}
|
||||
|
||||
export async function connectKafka(): Promise<void> {
|
||||
try {
|
||||
await producer.connect();
|
||||
logger.info("Kafka producer connected");
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
"Kafka connect failed, running without Kafka (outbox will retry)",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function disconnectKafka(): Promise<void> {
|
||||
try {
|
||||
await producer.disconnect();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
await neo4jSyncConsumer.disconnect();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
75
services/content/src/grpc/chapter.grpc.controller.ts
Normal file
75
services/content/src/grpc/chapter.grpc.controller.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { Controller } from "@nestjs/common";
|
||||
import { GrpcMethod } from "@nestjs/microservices";
|
||||
import { ChaptersService } from "../chapters/chapters.service.js";
|
||||
import type { Chapter } from "../chapters/chapters.schema.js";
|
||||
import type {
|
||||
CreateChapterRequest,
|
||||
GetChapterRequest,
|
||||
ListChaptersRequest,
|
||||
ListChaptersResponse,
|
||||
UpdateChapterRequest,
|
||||
DeleteChapterRequest,
|
||||
GrpcChapter,
|
||||
Empty,
|
||||
} from "./grpc-types.js";
|
||||
|
||||
function toGrpcChapter(c: Chapter): GrpcChapter {
|
||||
return {
|
||||
id: c.id,
|
||||
textbook_id: c.textbookId,
|
||||
title: c.title,
|
||||
order: c.order,
|
||||
parent_id: c.parentId ?? "",
|
||||
status: c.status,
|
||||
created_at: c.createdAt.getTime(),
|
||||
updated_at: c.updatedAt.getTime(),
|
||||
};
|
||||
}
|
||||
|
||||
@Controller()
|
||||
export class ChapterGrpcController {
|
||||
constructor(private readonly service: ChaptersService) {}
|
||||
|
||||
@GrpcMethod("ChapterService", "CreateChapter")
|
||||
async createChapter(data: CreateChapterRequest): Promise<GrpcChapter> {
|
||||
const { id } = await this.service.createChapter({
|
||||
textbookId: data.textbook_id,
|
||||
title: data.title,
|
||||
order: data.order,
|
||||
parentId: data.parent_id,
|
||||
});
|
||||
const chapter = await this.service.getChapter(id);
|
||||
return toGrpcChapter(chapter);
|
||||
}
|
||||
|
||||
@GrpcMethod("ChapterService", "GetChapter")
|
||||
async getChapter(data: GetChapterRequest): Promise<GrpcChapter> {
|
||||
const chapter = await this.service.getChapter(data.id);
|
||||
return toGrpcChapter(chapter);
|
||||
}
|
||||
|
||||
@GrpcMethod("ChapterService", "ListChapters")
|
||||
async listChapters(data: ListChaptersRequest): Promise<ListChaptersResponse> {
|
||||
const chapters = await this.service.listChaptersByTextbook(
|
||||
data.textbook_id,
|
||||
);
|
||||
return { chapters: chapters.map(toGrpcChapter) };
|
||||
}
|
||||
|
||||
@GrpcMethod("ChapterService", "UpdateChapter")
|
||||
async updateChapter(data: UpdateChapterRequest): Promise<GrpcChapter> {
|
||||
await this.service.updateChapter(data.id, {
|
||||
title: data.title,
|
||||
order: data.order ?? undefined,
|
||||
status: data.status,
|
||||
});
|
||||
const chapter = await this.service.getChapter(data.id);
|
||||
return toGrpcChapter(chapter);
|
||||
}
|
||||
|
||||
@GrpcMethod("ChapterService", "DeleteChapter")
|
||||
async deleteChapter(data: DeleteChapterRequest): Promise<Empty> {
|
||||
await this.service.deleteChapter(data.id);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
235
services/content/src/grpc/grpc-types.ts
Normal file
235
services/content/src/grpc/grpc-types.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* Content gRPC message types — 手动定义,对齐 content.proto。
|
||||
* 当 buf generate 生成 TS 类型后,可替换为生成类型。
|
||||
*/
|
||||
|
||||
export type Empty = Record<string, never>;
|
||||
|
||||
export interface GrpcTextbook {
|
||||
id: string;
|
||||
title: string;
|
||||
subject_id: string;
|
||||
grade_id: string;
|
||||
version: string;
|
||||
status: string;
|
||||
tenant_id: string;
|
||||
metadata: Record<string, unknown> | null;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface GrpcChapter {
|
||||
id: string;
|
||||
textbook_id: string;
|
||||
title: string;
|
||||
order: number;
|
||||
parent_id: string;
|
||||
status: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface GrpcKnowledgePoint {
|
||||
id: string;
|
||||
chapter_id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
difficulty: number;
|
||||
metadata: Record<string, unknown> | null;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface GrpcQuestion {
|
||||
id: string;
|
||||
knowledge_point_id: string;
|
||||
type: string;
|
||||
content: string;
|
||||
options: Record<string, unknown> | null;
|
||||
answer: string;
|
||||
explanation: string;
|
||||
difficulty: number;
|
||||
status: string;
|
||||
source: string;
|
||||
created_by: string;
|
||||
metadata: Record<string, unknown> | null;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
// TextbookService requests
|
||||
export interface CreateTextbookRequest {
|
||||
title: string;
|
||||
subject_id: string;
|
||||
grade_id: string;
|
||||
version?: string;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface GetTextbookRequest {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface ListTextbooksRequest {
|
||||
subject_id?: string;
|
||||
grade_id?: string;
|
||||
page_token?: string;
|
||||
page_size?: number;
|
||||
}
|
||||
|
||||
export interface ListTextbooksResponse {
|
||||
textbooks: GrpcTextbook[];
|
||||
next_page_token: string;
|
||||
}
|
||||
|
||||
export interface UpdateTextbookRequest {
|
||||
id: string;
|
||||
title?: string;
|
||||
status?: string;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface DeleteTextbookRequest {
|
||||
id: string;
|
||||
}
|
||||
|
||||
// ChapterService requests
|
||||
export interface CreateChapterRequest {
|
||||
textbook_id: string;
|
||||
title: string;
|
||||
order?: number;
|
||||
parent_id?: string;
|
||||
}
|
||||
|
||||
export interface GetChapterRequest {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface ListChaptersRequest {
|
||||
textbook_id: string;
|
||||
parent_id?: string;
|
||||
}
|
||||
|
||||
export interface ListChaptersResponse {
|
||||
chapters: GrpcChapter[];
|
||||
}
|
||||
|
||||
export interface UpdateChapterRequest {
|
||||
id: string;
|
||||
title?: string;
|
||||
order?: number;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface DeleteChapterRequest {
|
||||
id: string;
|
||||
}
|
||||
|
||||
// KnowledgeGraphService requests
|
||||
export interface GetPrerequisitesRequest {
|
||||
knowledge_point_id: string;
|
||||
depth?: number;
|
||||
}
|
||||
|
||||
export interface KnowledgePointsResponse {
|
||||
points: GrpcKnowledgePoint[];
|
||||
}
|
||||
|
||||
export interface GetLearningPathRequest {
|
||||
student_id: string;
|
||||
subject_id: string;
|
||||
}
|
||||
|
||||
export interface LearningPath {
|
||||
points: GrpcKnowledgePoint[];
|
||||
recommended_order: string[];
|
||||
}
|
||||
|
||||
export interface AddPrerequisiteRequest {
|
||||
kp_id: string;
|
||||
prerequisite_id: string;
|
||||
}
|
||||
|
||||
export interface RemovePrerequisiteRequest {
|
||||
kp_id: string;
|
||||
prerequisite_id: string;
|
||||
}
|
||||
|
||||
// QuestionService requests
|
||||
export interface CreateQuestionRequest {
|
||||
knowledge_point_id: string;
|
||||
type: string;
|
||||
content: string;
|
||||
options?: Record<string, unknown> | null;
|
||||
answer: string;
|
||||
explanation?: string;
|
||||
difficulty?: number;
|
||||
source?: string;
|
||||
created_by: string;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface BatchCreateQuestionsRequest {
|
||||
questions: CreateQuestionRequest[];
|
||||
}
|
||||
|
||||
export interface BatchCreateQuestionsResponse {
|
||||
ids: string[];
|
||||
failed: BatchCreateFailure[];
|
||||
}
|
||||
|
||||
export interface BatchCreateFailure {
|
||||
index: number;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface GetQuestionRequest {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface ListQuestionsRequest {
|
||||
knowledge_point_id?: string;
|
||||
type?: string;
|
||||
difficulty?: number;
|
||||
status?: string;
|
||||
page_token?: string;
|
||||
page_size?: number;
|
||||
}
|
||||
|
||||
export interface ListQuestionsResponse {
|
||||
questions: GrpcQuestion[];
|
||||
next_page_token: string;
|
||||
}
|
||||
|
||||
export interface UpdateQuestionRequest {
|
||||
id: string;
|
||||
content?: string;
|
||||
answer?: string;
|
||||
status?: string;
|
||||
options?: Record<string, unknown> | null;
|
||||
explanation?: string;
|
||||
difficulty?: number;
|
||||
}
|
||||
|
||||
export interface DeleteQuestionRequest {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface PublishQuestionRequest {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface SearchQuestionsRequest {
|
||||
q: string;
|
||||
type?: string;
|
||||
difficulty?: number;
|
||||
knowledge_point_id?: string;
|
||||
page_token?: string;
|
||||
page_size?: number;
|
||||
}
|
||||
|
||||
export interface SearchQuestionsResponse {
|
||||
questions: GrpcQuestion[];
|
||||
total: number;
|
||||
next_page_token: string;
|
||||
}
|
||||
25
services/content/src/grpc/grpc.module.ts
Normal file
25
services/content/src/grpc/grpc.module.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TextbooksModule } from "../textbooks/textbooks.module.js";
|
||||
import { ChaptersModule } from "../chapters/chapters.module.js";
|
||||
import { KnowledgePointsModule } from "../knowledge-points/knowledge-points.module.js";
|
||||
import { QuestionsModule } from "../questions/questions.module.js";
|
||||
import { TextbookGrpcController } from "./textbook.grpc.controller.js";
|
||||
import { ChapterGrpcController } from "./chapter.grpc.controller.js";
|
||||
import { KnowledgeGraphGrpcController } from "./knowledge-graph.grpc.controller.js";
|
||||
import { QuestionGrpcController } from "./question.grpc.controller.js";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TextbooksModule,
|
||||
ChaptersModule,
|
||||
KnowledgePointsModule,
|
||||
QuestionsModule,
|
||||
],
|
||||
controllers: [
|
||||
TextbookGrpcController,
|
||||
ChapterGrpcController,
|
||||
KnowledgeGraphGrpcController,
|
||||
QuestionGrpcController,
|
||||
],
|
||||
})
|
||||
export class GrpcModule {}
|
||||
72
services/content/src/grpc/knowledge-graph.grpc.controller.ts
Normal file
72
services/content/src/grpc/knowledge-graph.grpc.controller.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Controller } from "@nestjs/common";
|
||||
import { GrpcMethod } from "@nestjs/microservices";
|
||||
import { KnowledgePointsService } from "../knowledge-points/knowledge-points.service.js";
|
||||
import type { KnowledgePoint } from "../knowledge-points/knowledge-points.schema.js";
|
||||
import type {
|
||||
GetPrerequisitesRequest,
|
||||
KnowledgePointsResponse,
|
||||
GetLearningPathRequest,
|
||||
LearningPath,
|
||||
AddPrerequisiteRequest,
|
||||
RemovePrerequisiteRequest,
|
||||
GrpcKnowledgePoint,
|
||||
Empty,
|
||||
} from "./grpc-types.js";
|
||||
|
||||
function toGrpcKp(kp: KnowledgePoint): GrpcKnowledgePoint {
|
||||
return {
|
||||
id: kp.id,
|
||||
chapter_id: kp.chapterId,
|
||||
title: kp.title,
|
||||
description: kp.description ?? "",
|
||||
difficulty: kp.difficulty,
|
||||
metadata: kp.metadata ?? null,
|
||||
created_at: kp.createdAt.getTime(),
|
||||
updated_at: kp.updatedAt.getTime(),
|
||||
};
|
||||
}
|
||||
|
||||
@Controller()
|
||||
export class KnowledgeGraphGrpcController {
|
||||
constructor(private readonly service: KnowledgePointsService) {}
|
||||
|
||||
@GrpcMethod("KnowledgeGraphService", "GetPrerequisites")
|
||||
async getPrerequisites(
|
||||
data: GetPrerequisitesRequest,
|
||||
): Promise<KnowledgePointsResponse> {
|
||||
// 直接使用 service.getPrerequisites(读 Neo4j 派生数据)
|
||||
const prereqs = await this.service.getPrerequisites(
|
||||
data.knowledge_point_id,
|
||||
);
|
||||
// PrerequisiteNode 只含 id + title,需要查 MySQL 补全信息
|
||||
const points: GrpcKnowledgePoint[] = [];
|
||||
for (const p of prereqs) {
|
||||
try {
|
||||
const kp = await this.service.getKnowledgePoint(p.id);
|
||||
points.push(toGrpcKp(kp));
|
||||
} catch {
|
||||
// 节点可能已删除,跳过
|
||||
}
|
||||
}
|
||||
return { points };
|
||||
}
|
||||
|
||||
@GrpcMethod("KnowledgeGraphService", "GetLearningPath")
|
||||
async getLearningPath(_data: GetLearningPathRequest): Promise<LearningPath> {
|
||||
// P4 阶段未实现学习路径推荐算法,返回空路径
|
||||
// P5+ 引入 AI 服务后实现
|
||||
return { points: [], recommended_order: [] };
|
||||
}
|
||||
|
||||
@GrpcMethod("KnowledgeGraphService", "AddPrerequisite")
|
||||
async addPrerequisite(data: AddPrerequisiteRequest): Promise<Empty> {
|
||||
await this.service.addPrerequisite(data.kp_id, data.prerequisite_id);
|
||||
return {};
|
||||
}
|
||||
|
||||
@GrpcMethod("KnowledgeGraphService", "RemovePrerequisite")
|
||||
async removePrerequisite(data: RemovePrerequisiteRequest): Promise<Empty> {
|
||||
await this.service.removePrerequisite(data.kp_id, data.prerequisite_id);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
169
services/content/src/grpc/question.grpc.controller.ts
Normal file
169
services/content/src/grpc/question.grpc.controller.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { Controller } from "@nestjs/common";
|
||||
import { GrpcMethod } from "@nestjs/microservices";
|
||||
import { QuestionsService } from "../questions/questions.service.js";
|
||||
import type { Question } from "../questions/questions.schema.js";
|
||||
import type {
|
||||
CreateQuestionRequest,
|
||||
BatchCreateQuestionsRequest,
|
||||
BatchCreateQuestionsResponse,
|
||||
GetQuestionRequest,
|
||||
ListQuestionsRequest,
|
||||
ListQuestionsResponse,
|
||||
UpdateQuestionRequest,
|
||||
DeleteQuestionRequest,
|
||||
PublishQuestionRequest,
|
||||
SearchQuestionsRequest,
|
||||
SearchQuestionsResponse,
|
||||
GrpcQuestion,
|
||||
Empty,
|
||||
} from "./grpc-types.js";
|
||||
|
||||
function toGrpcQuestion(q: Question): GrpcQuestion {
|
||||
return {
|
||||
id: q.id,
|
||||
knowledge_point_id: q.knowledgePointId,
|
||||
type: q.type,
|
||||
content: q.content,
|
||||
options: q.options ?? null,
|
||||
answer: q.answer,
|
||||
explanation: q.explanation ?? "",
|
||||
difficulty: q.difficulty,
|
||||
status: q.status,
|
||||
source: q.source,
|
||||
created_by: q.createdBy,
|
||||
metadata: q.metadata ?? null,
|
||||
created_at: q.createdAt.getTime(),
|
||||
updated_at: q.updatedAt.getTime(),
|
||||
};
|
||||
}
|
||||
|
||||
@Controller()
|
||||
export class QuestionGrpcController {
|
||||
constructor(private readonly service: QuestionsService) {}
|
||||
|
||||
@GrpcMethod("QuestionService", "CreateQuestion")
|
||||
async createQuestion(data: CreateQuestionRequest): Promise<GrpcQuestion> {
|
||||
const { id } = await this.service.createQuestion({
|
||||
knowledgePointId: data.knowledge_point_id,
|
||||
type: data.type,
|
||||
content: data.content,
|
||||
options: data.options ?? null,
|
||||
answer: data.answer,
|
||||
explanation: data.explanation,
|
||||
difficulty: data.difficulty,
|
||||
source: data.source,
|
||||
createdBy: data.created_by,
|
||||
metadata: data.metadata ?? null,
|
||||
});
|
||||
const question = await this.service.getQuestion(id);
|
||||
return toGrpcQuestion(question);
|
||||
}
|
||||
|
||||
@GrpcMethod("QuestionService", "BatchCreateQuestions")
|
||||
async batchCreateQuestions(
|
||||
data: BatchCreateQuestionsRequest,
|
||||
): Promise<BatchCreateQuestionsResponse> {
|
||||
const ids: string[] = [];
|
||||
const failed: { index: number; error: string }[] = [];
|
||||
|
||||
for (let i = 0; i < (data.questions ?? []).length; i++) {
|
||||
const q = data.questions?.[i];
|
||||
if (!q) continue;
|
||||
try {
|
||||
const { id } = await this.service.createQuestion({
|
||||
knowledgePointId: q.knowledge_point_id,
|
||||
type: q.type,
|
||||
content: q.content,
|
||||
options: q.options ?? null,
|
||||
answer: q.answer,
|
||||
explanation: q.explanation,
|
||||
difficulty: q.difficulty,
|
||||
source: q.source,
|
||||
createdBy: q.created_by,
|
||||
metadata: q.metadata ?? null,
|
||||
});
|
||||
ids.push(id);
|
||||
} catch (err) {
|
||||
failed.push({
|
||||
index: i,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { ids, failed };
|
||||
}
|
||||
|
||||
@GrpcMethod("QuestionService", "GetQuestion")
|
||||
async getQuestion(data: GetQuestionRequest): Promise<GrpcQuestion> {
|
||||
const question = await this.service.getQuestion(data.id);
|
||||
return toGrpcQuestion(question);
|
||||
}
|
||||
|
||||
@GrpcMethod("QuestionService", "ListQuestions")
|
||||
async listQuestions(
|
||||
data: ListQuestionsRequest,
|
||||
): Promise<ListQuestionsResponse> {
|
||||
const pageSize = data.page_size ?? 20;
|
||||
const page = data.page_token ? Number(data.page_token) : 1;
|
||||
const questions = await this.service.list({
|
||||
knowledgePointId: data.knowledge_point_id,
|
||||
type: data.type,
|
||||
difficulty: data.difficulty,
|
||||
status: data.status,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
return {
|
||||
questions: questions.map(toGrpcQuestion),
|
||||
next_page_token: questions.length === pageSize ? String(page + 1) : "",
|
||||
};
|
||||
}
|
||||
|
||||
@GrpcMethod("QuestionService", "UpdateQuestion")
|
||||
async updateQuestion(data: UpdateQuestionRequest): Promise<GrpcQuestion> {
|
||||
await this.service.updateQuestion(data.id, {
|
||||
content: data.content,
|
||||
answer: data.answer,
|
||||
status: data.status,
|
||||
options: data.options,
|
||||
explanation: data.explanation,
|
||||
difficulty: data.difficulty ?? undefined,
|
||||
});
|
||||
const question = await this.service.getQuestion(data.id);
|
||||
return toGrpcQuestion(question);
|
||||
}
|
||||
|
||||
@GrpcMethod("QuestionService", "DeleteQuestion")
|
||||
async deleteQuestion(data: DeleteQuestionRequest): Promise<Empty> {
|
||||
await this.service.deleteQuestion(data.id);
|
||||
return {};
|
||||
}
|
||||
|
||||
@GrpcMethod("QuestionService", "PublishQuestion")
|
||||
async publishQuestion(data: PublishQuestionRequest): Promise<Empty> {
|
||||
await this.service.updateQuestion(data.id, { status: "published" });
|
||||
return {};
|
||||
}
|
||||
|
||||
@GrpcMethod("QuestionService", "SearchQuestions")
|
||||
async searchQuestions(
|
||||
data: SearchQuestionsRequest,
|
||||
): Promise<SearchQuestionsResponse> {
|
||||
// P4 阶段:使用 MySQL LIKE 搜索;P5 引入 ES 后替换为全文检索
|
||||
const pageSize = data.page_size ?? 20;
|
||||
const page = data.page_token ? Number(data.page_token) : 1;
|
||||
const questions = await this.service.list({
|
||||
knowledgePointId: data.knowledge_point_id,
|
||||
type: data.type,
|
||||
difficulty: data.difficulty,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
return {
|
||||
questions: questions.map(toGrpcQuestion),
|
||||
total: questions.length,
|
||||
next_page_token: questions.length === pageSize ? String(page + 1) : "",
|
||||
};
|
||||
}
|
||||
}
|
||||
88
services/content/src/grpc/textbook.grpc.controller.ts
Normal file
88
services/content/src/grpc/textbook.grpc.controller.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { Controller } from "@nestjs/common";
|
||||
import { GrpcMethod } from "@nestjs/microservices";
|
||||
import { TextbooksService } from "../textbooks/textbooks.service.js";
|
||||
import type { Textbook } from "../textbooks/textbooks.schema.js";
|
||||
import type {
|
||||
CreateTextbookRequest,
|
||||
GetTextbookRequest,
|
||||
ListTextbooksRequest,
|
||||
ListTextbooksResponse,
|
||||
UpdateTextbookRequest,
|
||||
DeleteTextbookRequest,
|
||||
GrpcTextbook,
|
||||
Empty,
|
||||
} from "./grpc-types.js";
|
||||
|
||||
function toGrpcTextbook(t: Textbook): GrpcTextbook {
|
||||
return {
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
subject_id: t.subjectId,
|
||||
grade_id: t.gradeId,
|
||||
version: t.version,
|
||||
status: t.status,
|
||||
tenant_id: t.tenantId ?? "",
|
||||
metadata: t.metadata ?? null,
|
||||
created_at: t.createdAt.getTime(),
|
||||
updated_at: t.updatedAt.getTime(),
|
||||
};
|
||||
}
|
||||
|
||||
@Controller()
|
||||
export class TextbookGrpcController {
|
||||
constructor(private readonly service: TextbooksService) {}
|
||||
|
||||
@GrpcMethod("TextbookService", "CreateTextbook")
|
||||
async createTextbook(data: CreateTextbookRequest): Promise<GrpcTextbook> {
|
||||
const { id } = await this.service.create({
|
||||
title: data.title,
|
||||
subjectId: data.subject_id,
|
||||
gradeId: data.grade_id,
|
||||
version: data.version,
|
||||
metadata: data.metadata ?? null,
|
||||
});
|
||||
const textbook = await this.service.getById(id);
|
||||
return toGrpcTextbook(textbook);
|
||||
}
|
||||
|
||||
@GrpcMethod("TextbookService", "GetTextbook")
|
||||
async getTextbook(data: GetTextbookRequest): Promise<GrpcTextbook> {
|
||||
const textbook = await this.service.getById(data.id);
|
||||
return toGrpcTextbook(textbook);
|
||||
}
|
||||
|
||||
@GrpcMethod("TextbookService", "ListTextbooks")
|
||||
async listTextbooks(
|
||||
data: ListTextbooksRequest,
|
||||
): Promise<ListTextbooksResponse> {
|
||||
const pageSize = data.page_size ?? 20;
|
||||
const page = data.page_token ? Number(data.page_token) : 1;
|
||||
const textbooks = await this.service.list({
|
||||
subjectId: data.subject_id,
|
||||
gradeId: data.grade_id,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
return {
|
||||
textbooks: textbooks.map(toGrpcTextbook),
|
||||
next_page_token: textbooks.length === pageSize ? String(page + 1) : "",
|
||||
};
|
||||
}
|
||||
|
||||
@GrpcMethod("TextbookService", "UpdateTextbook")
|
||||
async updateTextbook(data: UpdateTextbookRequest): Promise<GrpcTextbook> {
|
||||
await this.service.update(data.id, {
|
||||
title: data.title,
|
||||
status: data.status,
|
||||
metadata: data.metadata,
|
||||
});
|
||||
const textbook = await this.service.getById(data.id);
|
||||
return toGrpcTextbook(textbook);
|
||||
}
|
||||
|
||||
@GrpcMethod("TextbookService", "DeleteTextbook")
|
||||
async deleteTextbook(data: DeleteTextbookRequest): Promise<Empty> {
|
||||
await this.service.delete(data.id);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { KnowledgePointsController } from "./knowledge-points.controller.js";
|
||||
|
||||
vi.mock("./knowledge-points.service.js", () => ({
|
||||
KnowledgePointsService: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockService = {
|
||||
createKnowledgePoint: vi.fn(),
|
||||
listByChapter: vi.fn(),
|
||||
getPrerequisites: vi.fn(),
|
||||
getKnowledgePoint: vi.fn(),
|
||||
addPrerequisite: vi.fn(),
|
||||
removePrerequisite: vi.fn(),
|
||||
updateKnowledgePoint: vi.fn(),
|
||||
deleteKnowledgePoint: vi.fn(),
|
||||
};
|
||||
|
||||
describe("KnowledgePointsController", () => {
|
||||
let controller: KnowledgePointsController;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
controller = new KnowledgePointsController(mockService as never);
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
it("should parse input and call service.createKnowledgePoint", async () => {
|
||||
mockService.createKnowledgePoint.mockResolvedValue({ id: "kp-1" });
|
||||
const result = await controller.create({
|
||||
chapterId: "ch-1",
|
||||
title: "Algebra Basics",
|
||||
});
|
||||
expect(mockService.createKnowledgePoint).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
chapterId: "ch-1",
|
||||
title: "Algebra Basics",
|
||||
difficulty: 3,
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({ success: true, data: { id: "kp-1" } });
|
||||
});
|
||||
|
||||
it("should throw on invalid body", async () => {
|
||||
await expect(controller.create({ chapterId: "" })).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("listByChapter", () => {
|
||||
it("should call service.listByChapter", async () => {
|
||||
const data = [{ id: "kp-1" }];
|
||||
mockService.listByChapter.mockResolvedValue(data);
|
||||
const result = await controller.listByChapter("ch-1");
|
||||
expect(mockService.listByChapter).toHaveBeenCalledWith("ch-1");
|
||||
expect(result).toEqual({ success: true, data });
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPrerequisites", () => {
|
||||
it("should call service.getPrerequisites", async () => {
|
||||
const data = [{ id: "kp-pre", title: "Pre" }];
|
||||
mockService.getPrerequisites.mockResolvedValue(data);
|
||||
const result = await controller.getPrerequisites("kp-1");
|
||||
expect(mockService.getPrerequisites).toHaveBeenCalledWith("kp-1");
|
||||
expect(result).toEqual({ success: true, data });
|
||||
});
|
||||
});
|
||||
|
||||
describe("getById", () => {
|
||||
it("should call service.getKnowledgePoint", async () => {
|
||||
const data = { id: "kp-1", title: "T" };
|
||||
mockService.getKnowledgePoint.mockResolvedValue(data);
|
||||
const result = await controller.getById("kp-1");
|
||||
expect(mockService.getKnowledgePoint).toHaveBeenCalledWith("kp-1");
|
||||
expect(result).toEqual({ success: true, data });
|
||||
});
|
||||
});
|
||||
|
||||
describe("addPrerequisite", () => {
|
||||
it("should parse input and call service.addPrerequisite", async () => {
|
||||
const result = await controller.addPrerequisite("kp-1", {
|
||||
prerequisiteId: "kp-pre",
|
||||
});
|
||||
expect(mockService.addPrerequisite).toHaveBeenCalledWith(
|
||||
"kp-1",
|
||||
"kp-pre",
|
||||
);
|
||||
expect(result).toEqual({ success: true, data: { success: true } });
|
||||
});
|
||||
|
||||
it("should throw on invalid body", async () => {
|
||||
await expect(
|
||||
controller.addPrerequisite("kp-1", { prerequisiteId: "" }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("removePrerequisite", () => {
|
||||
it("should call service.removePrerequisite", async () => {
|
||||
const result = await controller.removePrerequisite("kp-1", "kp-pre");
|
||||
expect(mockService.removePrerequisite).toHaveBeenCalledWith(
|
||||
"kp-1",
|
||||
"kp-pre",
|
||||
);
|
||||
expect(result).toEqual({ success: true, data: { success: true } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("update", () => {
|
||||
it("should parse input and call service.updateKnowledgePoint", async () => {
|
||||
const result = await controller.update("kp-1", { title: "New" });
|
||||
expect(mockService.updateKnowledgePoint).toHaveBeenCalledWith("kp-1", {
|
||||
title: "New",
|
||||
});
|
||||
expect(result).toEqual({ success: true, data: { success: true } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("remove", () => {
|
||||
it("should call service.deleteKnowledgePoint", async () => {
|
||||
const result = await controller.remove("kp-1");
|
||||
expect(mockService.deleteKnowledgePoint).toHaveBeenCalledWith("kp-1");
|
||||
expect(result).toEqual({ success: true, data: { success: true } });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,8 +9,6 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
KnowledgePointsService,
|
||||
type CreateKnowledgePointInput,
|
||||
type UpdateKnowledgePointInput,
|
||||
type PrerequisiteNode,
|
||||
} from "./knowledge-points.service.js";
|
||||
import type { KnowledgePoint } from "./knowledge-points.schema.js";
|
||||
@@ -18,6 +16,11 @@ import {
|
||||
Permissions,
|
||||
RequirePermission,
|
||||
} from "../middleware/permission.guard.js";
|
||||
import {
|
||||
createKnowledgePointSchema,
|
||||
updateKnowledgePointSchema,
|
||||
addPrerequisiteSchema,
|
||||
} from "./knowledge-points.dto.js";
|
||||
|
||||
@Controller("knowledge-points")
|
||||
export class KnowledgePointsController {
|
||||
@@ -26,9 +29,10 @@ export class KnowledgePointsController {
|
||||
@Post()
|
||||
@RequirePermission(Permissions.CONTENT_KNOWLEDGE_POINT_CREATE)
|
||||
async create(
|
||||
@Body() body: CreateKnowledgePointInput,
|
||||
@Body() body: unknown,
|
||||
): Promise<{ success: true; data: { id: string } }> {
|
||||
const result = await this.service.createKnowledgePoint(body);
|
||||
const input = createKnowledgePointSchema.parse(body);
|
||||
const result = await this.service.createKnowledgePoint(input);
|
||||
return { success: true, data: result };
|
||||
}
|
||||
|
||||
@@ -59,13 +63,24 @@ export class KnowledgePointsController {
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
@Post(":id/prerequisites/:prerequisiteId")
|
||||
@Post(":id/prerequisites")
|
||||
@RequirePermission(Permissions.CONTENT_KNOWLEDGE_POINT_UPDATE)
|
||||
async addPrerequisite(
|
||||
@Param("id") id: string,
|
||||
@Body() body: unknown,
|
||||
): Promise<{ success: true; data: { success: true } }> {
|
||||
const { prerequisiteId } = addPrerequisiteSchema.parse(body);
|
||||
await this.service.addPrerequisite(id, prerequisiteId);
|
||||
return { success: true, data: { success: true } };
|
||||
}
|
||||
|
||||
@Delete(":id/prerequisites/:prerequisiteId")
|
||||
@RequirePermission(Permissions.CONTENT_KNOWLEDGE_POINT_UPDATE)
|
||||
async removePrerequisite(
|
||||
@Param("id") id: string,
|
||||
@Param("prerequisiteId") prerequisiteId: string,
|
||||
): Promise<{ success: true; data: { success: true } }> {
|
||||
await this.service.addPrerequisite(id, prerequisiteId);
|
||||
await this.service.removePrerequisite(id, prerequisiteId);
|
||||
return { success: true, data: { success: true } };
|
||||
}
|
||||
|
||||
@@ -73,9 +88,10 @@ export class KnowledgePointsController {
|
||||
@RequirePermission(Permissions.CONTENT_KNOWLEDGE_POINT_UPDATE)
|
||||
async update(
|
||||
@Param("id") id: string,
|
||||
@Body() body: UpdateKnowledgePointInput,
|
||||
@Body() body: unknown,
|
||||
): Promise<{ success: true; data: { success: true } }> {
|
||||
await this.service.updateKnowledgePoint(id, body);
|
||||
const input = updateKnowledgePointSchema.parse(body);
|
||||
await this.service.updateKnowledgePoint(id, input);
|
||||
return { success: true, data: { success: true } };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
createKnowledgePointSchema,
|
||||
updateKnowledgePointSchema,
|
||||
addPrerequisiteSchema,
|
||||
} from "./knowledge-points.dto.js";
|
||||
|
||||
describe("knowledge-points.dto", () => {
|
||||
describe("createKnowledgePointSchema", () => {
|
||||
it("should parse valid input with all fields", () => {
|
||||
const result = createKnowledgePointSchema.parse({
|
||||
chapterId: "ch-1",
|
||||
title: "Algebra Basics",
|
||||
description: "Intro to algebra",
|
||||
difficulty: 4,
|
||||
metadata: { tags: ["math"] },
|
||||
});
|
||||
expect(result.chapterId).toBe("ch-1");
|
||||
expect(result.title).toBe("Algebra Basics");
|
||||
expect(result.description).toBe("Intro to algebra");
|
||||
expect(result.difficulty).toBe(4);
|
||||
expect(result.metadata).toEqual({ tags: ["math"] });
|
||||
});
|
||||
|
||||
it("should default difficulty to 3 when not provided", () => {
|
||||
const result = createKnowledgePointSchema.parse({
|
||||
chapterId: "ch-1",
|
||||
title: "Algebra Basics",
|
||||
});
|
||||
expect(result.difficulty).toBe(3);
|
||||
});
|
||||
|
||||
it("should accept null metadata", () => {
|
||||
const result = createKnowledgePointSchema.parse({
|
||||
chapterId: "ch-1",
|
||||
title: "Algebra Basics",
|
||||
metadata: null,
|
||||
});
|
||||
expect(result.metadata).toBeNull();
|
||||
});
|
||||
|
||||
it("should reject empty chapterId", () => {
|
||||
expect(() =>
|
||||
createKnowledgePointSchema.parse({ chapterId: "", title: "Title" }),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("should reject difficulty greater than 5", () => {
|
||||
expect(() =>
|
||||
createKnowledgePointSchema.parse({
|
||||
chapterId: "ch-1",
|
||||
title: "Title",
|
||||
difficulty: 6,
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateKnowledgePointSchema", () => {
|
||||
it("should parse partial update with title only", () => {
|
||||
const result = updateKnowledgePointSchema.parse({ title: "New Title" });
|
||||
expect(result.title).toBe("New Title");
|
||||
});
|
||||
|
||||
it("should accept null metadata in update", () => {
|
||||
const result = updateKnowledgePointSchema.parse({ metadata: null });
|
||||
expect(result.metadata).toBeNull();
|
||||
});
|
||||
|
||||
it("should accept difficulty update within valid range", () => {
|
||||
const result = updateKnowledgePointSchema.parse({ difficulty: 5 });
|
||||
expect(result.difficulty).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("addPrerequisiteSchema", () => {
|
||||
it("should parse valid prerequisiteId", () => {
|
||||
const result = addPrerequisiteSchema.parse({
|
||||
prerequisiteId: "kp-1",
|
||||
});
|
||||
expect(result.prerequisiteId).toBe("kp-1");
|
||||
});
|
||||
|
||||
it("should reject empty prerequisiteId", () => {
|
||||
expect(() =>
|
||||
addPrerequisiteSchema.parse({ prerequisiteId: "" }),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const createKnowledgePointSchema = z.object({
|
||||
chapterId: z.string().min(1).max(32),
|
||||
title: z.string().min(1).max(255),
|
||||
description: z.string().optional(),
|
||||
difficulty: z.number().int().min(1).max(5).optional().default(3),
|
||||
metadata: z.record(z.unknown()).nullish(),
|
||||
});
|
||||
|
||||
export const updateKnowledgePointSchema = z.object({
|
||||
title: z.string().min(1).max(255).optional(),
|
||||
description: z.string().optional(),
|
||||
difficulty: z.number().int().min(1).max(5).optional(),
|
||||
metadata: z.record(z.unknown()).nullish(),
|
||||
});
|
||||
|
||||
export const addPrerequisiteSchema = z.object({
|
||||
prerequisiteId: z.string().min(1).max(32),
|
||||
});
|
||||
|
||||
export type CreateKnowledgePointDto = z.infer<
|
||||
typeof createKnowledgePointSchema
|
||||
>;
|
||||
export type UpdateKnowledgePointDto = z.infer<
|
||||
typeof updateKnowledgePointSchema
|
||||
>;
|
||||
export type AddPrerequisiteDto = z.infer<typeof addPrerequisiteSchema>;
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { KnowledgePointsController } from "./knowledge-points.controller.js";
|
||||
import { KnowledgePointsService } from "./knowledge-points.service.js";
|
||||
import { OutboxModule } from "../shared/outbox/outbox.module.js";
|
||||
|
||||
@Module({
|
||||
imports: [OutboxModule],
|
||||
controllers: [KnowledgePointsController],
|
||||
providers: [KnowledgePointsService],
|
||||
exports: [KnowledgePointsService],
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const mockGetDb = vi.fn();
|
||||
|
||||
vi.mock("../config/database.js", () => ({
|
||||
getDb: () => mockGetDb(),
|
||||
}));
|
||||
|
||||
import { KnowledgePointsRepository } from "./knowledge-points.repository.js";
|
||||
|
||||
function createMockDb(resolvedValue: unknown): unknown {
|
||||
const handler: ProxyHandler<Record<PropertyKey, unknown>> = {
|
||||
get: (_target, prop) => {
|
||||
if (prop === "then") {
|
||||
return (onFulfilled?: (v: unknown) => unknown) =>
|
||||
Promise.resolve(
|
||||
typeof onFulfilled === "function"
|
||||
? onFulfilled(resolvedValue)
|
||||
: resolvedValue,
|
||||
);
|
||||
}
|
||||
return () => new Proxy({}, handler);
|
||||
},
|
||||
};
|
||||
return new Proxy({}, handler);
|
||||
}
|
||||
|
||||
describe("KnowledgePointsRepository", () => {
|
||||
let repo: KnowledgePointsRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
repo = new KnowledgePointsRepository();
|
||||
});
|
||||
|
||||
it("findById should return first matching knowledge point", async () => {
|
||||
const kp = { id: "kp-1", title: "T" };
|
||||
mockGetDb.mockReturnValue(createMockDb([kp]));
|
||||
const result = await repo.findById("kp-1");
|
||||
expect(result).toBe(kp);
|
||||
});
|
||||
|
||||
it("findById should return undefined when no match", async () => {
|
||||
mockGetDb.mockReturnValue(createMockDb([]));
|
||||
const result = await repo.findById("missing");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("findByChapterId should return knowledge points array", async () => {
|
||||
const kps = [{ id: "kp-1" }];
|
||||
mockGetDb.mockReturnValue(createMockDb(kps));
|
||||
const result = await repo.findByChapterId("ch-1");
|
||||
expect(result).toBe(kps);
|
||||
});
|
||||
|
||||
it("create should insert a new knowledge point", async () => {
|
||||
mockGetDb.mockReturnValue(createMockDb(undefined));
|
||||
await repo.create({
|
||||
id: "kp-1",
|
||||
chapterId: "ch-1",
|
||||
title: "T",
|
||||
difficulty: 3,
|
||||
});
|
||||
expect(mockGetDb).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("update should update a knowledge point by id", async () => {
|
||||
mockGetDb.mockReturnValue(createMockDb(undefined));
|
||||
await repo.update("kp-1", { title: "New" });
|
||||
expect(mockGetDb).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("delete should delete a knowledge point by id", async () => {
|
||||
mockGetDb.mockReturnValue(createMockDb(undefined));
|
||||
await repo.delete("kp-1");
|
||||
expect(mockGetDb).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "../config/database.js";
|
||||
import { getDb } from "../config/database.js";
|
||||
import {
|
||||
knowledgePoints,
|
||||
type KnowledgePoint,
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
|
||||
export class KnowledgePointsRepository {
|
||||
async findById(id: string): Promise<KnowledgePoint | undefined> {
|
||||
const [result] = await db
|
||||
const [result] = await getDb()
|
||||
.select()
|
||||
.from(knowledgePoints)
|
||||
.where(eq(knowledgePoints.id, id))
|
||||
@@ -17,25 +17,25 @@ export class KnowledgePointsRepository {
|
||||
}
|
||||
|
||||
async findByChapterId(chapterId: string): Promise<KnowledgePoint[]> {
|
||||
return db
|
||||
return getDb()
|
||||
.select()
|
||||
.from(knowledgePoints)
|
||||
.where(eq(knowledgePoints.chapterId, chapterId));
|
||||
}
|
||||
|
||||
async create(data: NewKnowledgePoint): Promise<void> {
|
||||
await db.insert(knowledgePoints).values(data);
|
||||
await getDb().insert(knowledgePoints).values(data);
|
||||
}
|
||||
|
||||
async update(id: string, data: Partial<NewKnowledgePoint>): Promise<void> {
|
||||
await db
|
||||
await getDb()
|
||||
.update(knowledgePoints)
|
||||
.set(data)
|
||||
.where(eq(knowledgePoints.id, id));
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await db.delete(knowledgePoints).where(eq(knowledgePoints.id, id));
|
||||
await getDb().delete(knowledgePoints).where(eq(knowledgePoints.id, id));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { KnowledgePointsService } from "./knowledge-points.service.js";
|
||||
import {
|
||||
NotFoundError,
|
||||
ValidationError,
|
||||
} from "../shared/errors/application-error.js";
|
||||
|
||||
vi.mock("./knowledge-points.repository.js", () => ({
|
||||
knowledgePointsRepository: {
|
||||
findById: vi.fn(),
|
||||
findByChapterId: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../config/neo4j.js", () => ({
|
||||
getNeo4jSession: vi.fn().mockReturnValue(null),
|
||||
}));
|
||||
|
||||
import { knowledgePointsRepository } from "./knowledge-points.repository.js";
|
||||
|
||||
const mockOutbox = {
|
||||
publish: vi.fn().mockResolvedValue("event-id"),
|
||||
};
|
||||
|
||||
describe("KnowledgePointsService", () => {
|
||||
let service: KnowledgePointsService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
service = new KnowledgePointsService(mockOutbox as never);
|
||||
});
|
||||
|
||||
describe("createKnowledgePoint", () => {
|
||||
it("should create a KP and publish event (no direct Neo4j write)", async () => {
|
||||
const input = {
|
||||
chapterId: "ch-1",
|
||||
title: "Algebra Basics",
|
||||
description: "Intro to algebra",
|
||||
difficulty: 3,
|
||||
};
|
||||
const result = await service.createKnowledgePoint(input);
|
||||
|
||||
expect(result.id).toBeDefined();
|
||||
expect(knowledgePointsRepository.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
chapterId: "ch-1",
|
||||
title: "Algebra Basics",
|
||||
difficulty: 3,
|
||||
}),
|
||||
);
|
||||
expect(mockOutbox.publish).toHaveBeenCalledWith(
|
||||
"knowledge_point.created",
|
||||
"KnowledgePoint",
|
||||
result.id,
|
||||
expect.objectContaining({
|
||||
chapter_id: "ch-1",
|
||||
title: "Algebra Basics",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should default difficulty to 3", async () => {
|
||||
await service.createKnowledgePoint({
|
||||
chapterId: "ch-1",
|
||||
title: "Test KP",
|
||||
});
|
||||
expect(knowledgePointsRepository.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ difficulty: 3 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getKnowledgePoint", () => {
|
||||
it("should throw NotFoundError when not found", async () => {
|
||||
vi.mocked(knowledgePointsRepository.findById).mockResolvedValue(
|
||||
undefined,
|
||||
);
|
||||
await expect(service.getKnowledgePoint("nope")).rejects.toThrow(
|
||||
NotFoundError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("addPrerequisite", () => {
|
||||
it("should throw ValidationError when id === prerequisiteId", async () => {
|
||||
await expect(service.addPrerequisite("kp-1", "kp-1")).rejects.toThrow(
|
||||
ValidationError,
|
||||
);
|
||||
});
|
||||
|
||||
it("should publish prerequisite_added event", async () => {
|
||||
const mockKp = {
|
||||
id: "kp-1",
|
||||
chapterId: "ch-1",
|
||||
title: "KP1",
|
||||
description: null,
|
||||
difficulty: 3,
|
||||
metadata: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
vi.mocked(knowledgePointsRepository.findById).mockResolvedValue(mockKp);
|
||||
|
||||
await service.addPrerequisite("kp-1", "kp-2");
|
||||
|
||||
expect(mockOutbox.publish).toHaveBeenCalledWith(
|
||||
"knowledge_point.prerequisite_added",
|
||||
"KnowledgePoint",
|
||||
"kp-1",
|
||||
{ prerequisite_id: "kp-2" },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removePrerequisite", () => {
|
||||
it("should publish prerequisite_removed event", async () => {
|
||||
const mockKp = {
|
||||
id: "kp-1",
|
||||
chapterId: "ch-1",
|
||||
title: "KP1",
|
||||
description: null,
|
||||
difficulty: 3,
|
||||
metadata: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
vi.mocked(knowledgePointsRepository.findById).mockResolvedValue(mockKp);
|
||||
|
||||
await service.removePrerequisite("kp-1", "kp-2");
|
||||
|
||||
expect(mockOutbox.publish).toHaveBeenCalledWith(
|
||||
"knowledge_point.prerequisite_removed",
|
||||
"KnowledgePoint",
|
||||
"kp-1",
|
||||
{ prerequisite_id: "kp-2" },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPrerequisites", () => {
|
||||
it("should return empty array when Neo4j not available", async () => {
|
||||
const result = await service.getPrerequisites("kp-1");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,14 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { knowledgePointsRepository } from "./knowledge-points.repository.js";
|
||||
import type { KnowledgePoint } from "./knowledge-points.schema.js";
|
||||
import type {
|
||||
KnowledgePoint,
|
||||
NewKnowledgePoint,
|
||||
} from "./knowledge-points.schema.js";
|
||||
import { OutboxService } from "../shared/outbox/outbox.service.js";
|
||||
import { AGGREGATE_TYPES, EVENT_TYPES } from "../shared/outbox/events.js";
|
||||
import { getNeo4jSession } from "../config/neo4j.js";
|
||||
import {
|
||||
InternalError,
|
||||
NotFoundError,
|
||||
ValidationError,
|
||||
} from "../shared/errors/application-error.js";
|
||||
@@ -13,11 +17,15 @@ export interface CreateKnowledgePointInput {
|
||||
chapterId: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
difficulty?: number;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface UpdateKnowledgePointInput {
|
||||
title?: string;
|
||||
description?: string;
|
||||
difficulty?: number;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface PrerequisiteNode {
|
||||
@@ -29,23 +37,35 @@ export interface PrerequisiteNode {
|
||||
export class KnowledgePointsService {
|
||||
private readonly logger = new Logger(KnowledgePointsService.name);
|
||||
|
||||
constructor(private readonly outbox: OutboxService) {}
|
||||
|
||||
async createKnowledgePoint(
|
||||
input: CreateKnowledgePointInput,
|
||||
): Promise<{ id: string }> {
|
||||
if (!input.chapterId || !input.title) {
|
||||
throw new ValidationError("chapterId, title are required");
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
await knowledgePointsRepository.create({
|
||||
const id = createId();
|
||||
const record: NewKnowledgePoint = {
|
||||
id,
|
||||
chapterId: input.chapterId,
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
});
|
||||
difficulty: input.difficulty ?? 3,
|
||||
metadata: input.metadata ?? null,
|
||||
};
|
||||
await knowledgePointsRepository.create(record);
|
||||
|
||||
// Neo4j:创建知识点节点。非阻塞——失败仅记录日志,不影响 MySQL 写入。
|
||||
await this.safeCreateNode(id, input.title);
|
||||
// 发 Outbox 事件,由 Neo4j Sync Worker 异步创建图谱节点。
|
||||
// 禁止业务事务内同步双写 Neo4j(架构原则 §0.1 第 3 条)。
|
||||
await this.outbox.publish(
|
||||
EVENT_TYPES.KP_CREATED,
|
||||
AGGREGATE_TYPES.KNOWLEDGE_POINT,
|
||||
id,
|
||||
{
|
||||
chapter_id: record.chapterId,
|
||||
title: record.title,
|
||||
description: record.description,
|
||||
difficulty: record.difficulty,
|
||||
},
|
||||
);
|
||||
|
||||
return { id };
|
||||
}
|
||||
@@ -66,15 +86,37 @@ export class KnowledgePointsService {
|
||||
id: string,
|
||||
data: UpdateKnowledgePointInput,
|
||||
): Promise<void> {
|
||||
await this.getKnowledgePoint(id);
|
||||
const existing = await this.getKnowledgePoint(id);
|
||||
await knowledgePointsRepository.update(id, data);
|
||||
|
||||
await this.outbox.publish(
|
||||
EVENT_TYPES.KP_UPDATED,
|
||||
AGGREGATE_TYPES.KNOWLEDGE_POINT,
|
||||
id,
|
||||
{
|
||||
title: data.title ?? existing.title,
|
||||
description: data.description ?? existing.description,
|
||||
difficulty: data.difficulty ?? existing.difficulty,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async deleteKnowledgePoint(id: string): Promise<void> {
|
||||
await this.getKnowledgePoint(id);
|
||||
await knowledgePointsRepository.delete(id);
|
||||
// 知识点删除事件也走 kp.updated(下游可标记节点为失效)
|
||||
await this.outbox.publish(
|
||||
EVENT_TYPES.KP_UPDATED,
|
||||
AGGREGATE_TYPES.KNOWLEDGE_POINT,
|
||||
id,
|
||||
{ deleted: true },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询知识点前置依赖——读模型查询,直接读 Neo4j。
|
||||
* Neo4j 不可用时返回空数组,不阻塞主流程。
|
||||
*/
|
||||
async getPrerequisites(
|
||||
knowledgePointId: string,
|
||||
): Promise<PrerequisiteNode[]> {
|
||||
@@ -109,6 +151,10 @@ export class KnowledgePointsService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加前置依赖——仅写 Outbox 事件,由 Neo4j Sync Worker 异步建立关系。
|
||||
* 不再直接写 Neo4j。
|
||||
*/
|
||||
async addPrerequisite(id: string, prerequisiteId: string): Promise<void> {
|
||||
if (id === prerequisiteId) {
|
||||
throw new ValidationError(
|
||||
@@ -120,45 +166,26 @@ export class KnowledgePointsService {
|
||||
await this.getKnowledgePoint(id);
|
||||
await this.getKnowledgePoint(prerequisiteId);
|
||||
|
||||
const session = getNeo4jSession();
|
||||
if (!session) {
|
||||
throw new InternalError(
|
||||
"Neo4j is not available, cannot add prerequisite",
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await session.executeWrite((tx) =>
|
||||
tx.run(
|
||||
`MATCH (kp:KnowledgePoint {id: $kpId}), (prereq:KnowledgePoint {id: $prereqId})
|
||||
MERGE (prereq)-[:PREREQUISITE_OF]->(kp)`,
|
||||
{ kpId: id, prereqId: prerequisiteId },
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
await this.outbox.publish(
|
||||
EVENT_TYPES.KP_PREREQUISITE_ADDED,
|
||||
AGGREGATE_TYPES.KNOWLEDGE_POINT,
|
||||
id,
|
||||
{ prerequisite_id: prerequisiteId },
|
||||
);
|
||||
}
|
||||
|
||||
private async safeCreateNode(id: string, title: string): Promise<void> {
|
||||
const session = getNeo4jSession();
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
/**
|
||||
* 移除前置依赖——仅写 Outbox 事件。
|
||||
*/
|
||||
async removePrerequisite(id: string, prerequisiteId: string): Promise<void> {
|
||||
await this.getKnowledgePoint(id);
|
||||
await this.getKnowledgePoint(prerequisiteId);
|
||||
|
||||
try {
|
||||
await session.executeWrite((tx) =>
|
||||
tx.run("MERGE (kp:KnowledgePoint {id: $id, title: $title})", {
|
||||
id,
|
||||
title,
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Neo4j createNode failed (non-blocking): ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
await this.outbox.publish(
|
||||
EVENT_TYPES.KP_PREREQUISITE_REMOVED,
|
||||
AGGREGATE_TYPES.KNOWLEDGE_POINT,
|
||||
id,
|
||||
{ prerequisite_id: prerequisiteId },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,46 @@
|
||||
import "reflect-metadata";
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import { Transport, MicroserviceOptions } from "@nestjs/microservices";
|
||||
import { join } from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import { AppModule } from "./app.module.js";
|
||||
import { GlobalErrorFilter } from "./shared/errors/global-error.filter.js";
|
||||
import { initTracer, shutdownTracer } from "./shared/observability/tracer.js";
|
||||
import { env } from "./config/env.js";
|
||||
import { closeDb } from "./config/database.js";
|
||||
import { closeNeo4j } from "./config/neo4j.js";
|
||||
import { connectKafka, disconnectKafka } from "./config/kafka.js";
|
||||
import { logger } from "./shared/observability/logger.js";
|
||||
import { metricsRegistry } from "./shared/observability/metrics.js";
|
||||
import { outboxPublisher } from "./shared/outbox/outbox.publisher.js";
|
||||
import { neo4jSyncWorker } from "./shared/sync/neo4j-sync.worker.js";
|
||||
import type { Request, Response } from "express";
|
||||
|
||||
/**
|
||||
* 解析 proto 文件路径。
|
||||
* 开发环境:从 monorepo 根目录的 packages/shared-proto/proto/ 加载
|
||||
* 生产环境(Docker):从服务本地的 ./proto/ 加载(Dockerfile COPY)
|
||||
*/
|
||||
function resolveProtoPath(): string {
|
||||
const monorepoPath = join(
|
||||
process.cwd(),
|
||||
"packages",
|
||||
"shared-proto",
|
||||
"proto",
|
||||
"content.proto",
|
||||
);
|
||||
const localPath = join(process.cwd(), "proto", "content.proto");
|
||||
if (existsSync(monorepoPath)) return monorepoPath;
|
||||
if (existsSync(localPath)) return localPath;
|
||||
return monorepoPath;
|
||||
}
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
initTracer();
|
||||
|
||||
// 连接 Kafka producer(非阻塞——失败不阻止启动,Outbox 会重试)
|
||||
await connectKafka();
|
||||
|
||||
const app = await NestFactory.create(AppModule, {
|
||||
logger: ["log", "error", "warn"],
|
||||
});
|
||||
@@ -20,30 +48,57 @@ async function bootstrap(): Promise<void> {
|
||||
app.useGlobalFilters(new GlobalErrorFilter());
|
||||
app.enableShutdownHooks();
|
||||
|
||||
// gRPC 微服务:端口 50054,加载 content.proto
|
||||
app.connectMicroservice<MicroserviceOptions>({
|
||||
transport: Transport.GRPC,
|
||||
options: {
|
||||
package: "next_edu_cloud.content.v1",
|
||||
protoPath: resolveProtoPath(),
|
||||
url: `0.0.0.0:${env.GRPC_PORT}`,
|
||||
},
|
||||
});
|
||||
|
||||
// Prometheus 指标端点:不鉴权,供 Prometheus 抓取。
|
||||
app.getHttpAdapter().get("/metrics", async (_req: Request, res: Response) => {
|
||||
res.set("Content-Type", metricsRegistry.contentType);
|
||||
res.end(await metricsRegistry.metrics());
|
||||
});
|
||||
|
||||
// 启动 HTTP + gRPC
|
||||
await app.startAllMicroservices();
|
||||
await app.listen(env.PORT);
|
||||
logger.info({ port: env.PORT }, "Content service started");
|
||||
logger.info(
|
||||
{ port: env.PORT, grpcPort: env.GRPC_PORT },
|
||||
"Content service started (HTTP + gRPC)",
|
||||
);
|
||||
|
||||
// 启动 Outbox Publisher(轮询 pending 事件投递 Kafka)
|
||||
await outboxPublisher.start();
|
||||
|
||||
// 启动 Neo4j Sync Worker(消费 Kafka 事件异步同步图谱)
|
||||
await neo4jSyncWorker.start();
|
||||
|
||||
process.on("SIGTERM", async () => {
|
||||
logger.info("SIGTERM received, shutting down gracefully...");
|
||||
await neo4jSyncWorker.stop();
|
||||
await outboxPublisher.stop();
|
||||
await disconnectKafka();
|
||||
await app.close();
|
||||
await closeNeo4j();
|
||||
await closeDb();
|
||||
await shutdownTracer();
|
||||
await app.close();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on("SIGINT", async () => {
|
||||
logger.info("SIGINT received, shutting down gracefully...");
|
||||
await neo4jSyncWorker.stop();
|
||||
await outboxPublisher.stop();
|
||||
await disconnectKafka();
|
||||
await app.close();
|
||||
await closeNeo4j();
|
||||
await closeDb();
|
||||
await shutdownTracer();
|
||||
await app.close();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
59
services/content/src/middleware/auth.middleware.test.ts
Normal file
59
services/content/src/middleware/auth.middleware.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { AuthMiddleware } from "./auth.middleware.js";
|
||||
import type { AuthenticatedRequest } from "./auth.middleware.js";
|
||||
import { UnauthorizedException } from "@nestjs/common";
|
||||
import type { Response, NextFunction } from "express";
|
||||
|
||||
describe("AuthMiddleware", () => {
|
||||
let middleware: AuthMiddleware;
|
||||
const next = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
middleware = new AuthMiddleware();
|
||||
});
|
||||
|
||||
function createReq(headers: Record<string, unknown>): AuthenticatedRequest {
|
||||
return { headers } as unknown as AuthenticatedRequest;
|
||||
}
|
||||
|
||||
it("should set userId and userRoles from headers", () => {
|
||||
const req = createReq({
|
||||
"x-user-id": "user-1",
|
||||
"x-user-roles": "admin,teacher",
|
||||
});
|
||||
middleware.use(req, {} as Response, next as NextFunction);
|
||||
expect(req.userId).toBe("user-1");
|
||||
expect(req.userRoles).toEqual(["admin", "teacher"]);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should set empty roles array when x-user-roles missing", () => {
|
||||
const req = createReq({ "x-user-id": "user-1" });
|
||||
middleware.use(req, {} as Response, next as NextFunction);
|
||||
expect(req.userId).toBe("user-1");
|
||||
expect(req.userRoles).toEqual([]);
|
||||
});
|
||||
|
||||
it("should throw UnauthorizedException when x-user-id missing", () => {
|
||||
const req = createReq({});
|
||||
expect(() =>
|
||||
middleware.use(req, {} as Response, next as NextFunction),
|
||||
).toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it("should throw when x-user-id is not a string", () => {
|
||||
const req = createReq({ "x-user-id": ["not-a-string"] });
|
||||
expect(() =>
|
||||
middleware.use(req, {} as Response, next as NextFunction),
|
||||
).toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it("should not call next when unauthorized", () => {
|
||||
const req = createReq({});
|
||||
expect(() =>
|
||||
middleware.use(req, {} as Response, next as NextFunction),
|
||||
).toThrow();
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
116
services/content/src/middleware/permission.guard.test.ts
Normal file
116
services/content/src/middleware/permission.guard.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { ExecutionContext } from "@nestjs/common";
|
||||
import {
|
||||
PermissionGuard,
|
||||
Permissions,
|
||||
PERMISSIONS_KEY,
|
||||
} from "./permission.guard.js";
|
||||
import type { AuthenticatedRequest } from "./auth.middleware.js";
|
||||
import { PermissionDeniedError } from "../shared/errors/application-error.js";
|
||||
|
||||
describe("PermissionGuard", () => {
|
||||
let guard: PermissionGuard;
|
||||
let reflector: { getAllAndOverride: ReturnType<typeof vi.fn> };
|
||||
|
||||
function createMockContext(
|
||||
request: Partial<AuthenticatedRequest>,
|
||||
): ExecutionContext {
|
||||
return {
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => request as unknown as AuthenticatedRequest,
|
||||
}),
|
||||
getHandler: () => ({}),
|
||||
getClass: () => ({}),
|
||||
} as unknown as ExecutionContext;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
reflector = {
|
||||
getAllAndOverride: vi.fn(),
|
||||
};
|
||||
guard = new PermissionGuard(reflector as never);
|
||||
});
|
||||
|
||||
it("should allow access when DEV_MODE is true", () => {
|
||||
const original = process.env.DEV_MODE;
|
||||
process.env.DEV_MODE = "true";
|
||||
const ctx = createMockContext({ userRoles: [] });
|
||||
expect(guard.canActivate(ctx)).toBe(true);
|
||||
process.env.DEV_MODE = original;
|
||||
});
|
||||
|
||||
it("should allow access when no required permissions", () => {
|
||||
process.env.DEV_MODE = "false";
|
||||
reflector.getAllAndOverride.mockReturnValue(undefined);
|
||||
const ctx = createMockContext({ userRoles: [] });
|
||||
expect(guard.canActivate(ctx)).toBe(true);
|
||||
});
|
||||
|
||||
it("should allow access when no required permissions (empty array)", () => {
|
||||
reflector.getAllAndOverride.mockReturnValue([]);
|
||||
const ctx = createMockContext({ userRoles: [] });
|
||||
expect(guard.canActivate(ctx)).toBe(true);
|
||||
});
|
||||
|
||||
it("should allow access when user role has required permission", () => {
|
||||
reflector.getAllAndOverride.mockReturnValue([
|
||||
Permissions.CONTENT_TEXTBOOK_READ,
|
||||
]);
|
||||
const ctx = createMockContext({
|
||||
userRoles: ["admin"],
|
||||
});
|
||||
expect(guard.canActivate(ctx)).toBe(true);
|
||||
});
|
||||
|
||||
it("should allow access when teacher role has matching permission", () => {
|
||||
reflector.getAllAndOverride.mockReturnValue([
|
||||
Permissions.CONTENT_CHAPTER_CREATE,
|
||||
]);
|
||||
const ctx = createMockContext({
|
||||
userRoles: ["teacher"],
|
||||
});
|
||||
expect(guard.canActivate(ctx)).toBe(true);
|
||||
});
|
||||
|
||||
it("should allow access when at least one role matches", () => {
|
||||
reflector.getAllAndOverride.mockReturnValue([
|
||||
Permissions.CONTENT_TEXTBOOK_READ,
|
||||
]);
|
||||
const ctx = createMockContext({
|
||||
userRoles: ["student", "unknown-role"],
|
||||
});
|
||||
expect(guard.canActivate(ctx)).toBe(true);
|
||||
});
|
||||
|
||||
it("should throw PermissionDeniedError when user lacks permission", () => {
|
||||
reflector.getAllAndOverride.mockReturnValue([
|
||||
Permissions.CONTENT_TEXTBOOK_DELETE,
|
||||
]);
|
||||
const ctx = createMockContext({
|
||||
userRoles: ["student"],
|
||||
});
|
||||
expect(() => guard.canActivate(ctx)).toThrow(PermissionDeniedError);
|
||||
});
|
||||
|
||||
it("should throw PermissionDeniedError when user has no roles", () => {
|
||||
reflector.getAllAndOverride.mockReturnValue([
|
||||
Permissions.CONTENT_TEXTBOOK_READ,
|
||||
]);
|
||||
const ctx = createMockContext({
|
||||
userRoles: [],
|
||||
});
|
||||
expect(() => guard.canActivate(ctx)).toThrow(PermissionDeniedError);
|
||||
});
|
||||
|
||||
it("should use empty roles array when userRoles is undefined", () => {
|
||||
reflector.getAllAndOverride.mockReturnValue([
|
||||
Permissions.CONTENT_TEXTBOOK_READ,
|
||||
]);
|
||||
const ctx = createMockContext({});
|
||||
expect(() => guard.canActivate(ctx)).toThrow(PermissionDeniedError);
|
||||
});
|
||||
|
||||
it("should expose PERMISSIONS_KEY constant", () => {
|
||||
expect(PERMISSIONS_KEY).toBe("permissions");
|
||||
});
|
||||
});
|
||||
107
services/content/src/questions/questions.controller.test.ts
Normal file
107
services/content/src/questions/questions.controller.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { QuestionsController } from "./questions.controller.js";
|
||||
|
||||
vi.mock("./questions.service.js", () => ({
|
||||
QuestionsService: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockService = {
|
||||
createQuestion: vi.fn(),
|
||||
list: vi.fn(),
|
||||
listByKnowledgePoint: vi.fn(),
|
||||
getQuestion: vi.fn(),
|
||||
updateQuestion: vi.fn(),
|
||||
deleteQuestion: vi.fn(),
|
||||
};
|
||||
|
||||
describe("QuestionsController", () => {
|
||||
let controller: QuestionsController;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
controller = new QuestionsController(mockService as never);
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
it("should parse input and call service.createQuestion", async () => {
|
||||
mockService.createQuestion.mockResolvedValue({ id: "q-1" });
|
||||
const result = await controller.create({
|
||||
knowledgePointId: "kp-1",
|
||||
type: "single_choice",
|
||||
content: "What is 2+2?",
|
||||
answer: "4",
|
||||
createdBy: "user-1",
|
||||
});
|
||||
expect(mockService.createQuestion).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
knowledgePointId: "kp-1",
|
||||
type: "single_choice",
|
||||
answer: "4",
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({ success: true, data: { id: "q-1" } });
|
||||
});
|
||||
|
||||
it("should throw on invalid body", async () => {
|
||||
await expect(
|
||||
controller.create({ knowledgePointId: "kp-1", type: "invalid" }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("list", () => {
|
||||
it("should parse query and call service.list", async () => {
|
||||
const data = [{ id: "q-1" }];
|
||||
mockService.list.mockResolvedValue(data);
|
||||
const result = await controller.list({ page: "1", pageSize: "20" });
|
||||
expect(mockService.list).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ page: 1, pageSize: 20 }),
|
||||
);
|
||||
expect(result).toEqual({ success: true, data });
|
||||
});
|
||||
});
|
||||
|
||||
describe("listByKnowledgePoint", () => {
|
||||
it("should call service.listByKnowledgePoint", async () => {
|
||||
const data = [{ id: "q-1" }];
|
||||
mockService.listByKnowledgePoint.mockResolvedValue(data);
|
||||
const result = await controller.listByKnowledgePoint("kp-1");
|
||||
expect(mockService.listByKnowledgePoint).toHaveBeenCalledWith("kp-1");
|
||||
expect(result).toEqual({ success: true, data });
|
||||
});
|
||||
});
|
||||
|
||||
describe("getById", () => {
|
||||
it("should call service.getQuestion", async () => {
|
||||
const data = { id: "q-1", content: "Q" };
|
||||
mockService.getQuestion.mockResolvedValue(data);
|
||||
const result = await controller.getById("q-1");
|
||||
expect(mockService.getQuestion).toHaveBeenCalledWith("q-1");
|
||||
expect(result).toEqual({ success: true, data });
|
||||
});
|
||||
});
|
||||
|
||||
describe("update", () => {
|
||||
it("should parse input and call service.updateQuestion", async () => {
|
||||
const result = await controller.update("q-1", { content: "New content" });
|
||||
expect(mockService.updateQuestion).toHaveBeenCalledWith("q-1", {
|
||||
content: "New content",
|
||||
});
|
||||
expect(result).toEqual({ success: true, data: { success: true } });
|
||||
});
|
||||
|
||||
it("should throw on invalid status", async () => {
|
||||
await expect(
|
||||
controller.update("q-1", { status: "invalid" }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("remove", () => {
|
||||
it("should call service.deleteQuestion", async () => {
|
||||
const result = await controller.remove("q-1");
|
||||
expect(mockService.deleteQuestion).toHaveBeenCalledWith("q-1");
|
||||
expect(result).toEqual({ success: true, data: { success: true } });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,17 +6,19 @@ import {
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
QuestionsService,
|
||||
type CreateQuestionInput,
|
||||
type UpdateQuestionInput,
|
||||
} from "./questions.service.js";
|
||||
import { QuestionsService } from "./questions.service.js";
|
||||
import type { Question } from "./questions.schema.js";
|
||||
import {
|
||||
Permissions,
|
||||
RequirePermission,
|
||||
} from "../middleware/permission.guard.js";
|
||||
import {
|
||||
createQuestionSchema,
|
||||
updateQuestionSchema,
|
||||
listQuestionsSchema,
|
||||
} from "./questions.dto.js";
|
||||
|
||||
@Controller("questions")
|
||||
export class QuestionsController {
|
||||
@@ -25,12 +27,23 @@ export class QuestionsController {
|
||||
@Post()
|
||||
@RequirePermission(Permissions.CONTENT_QUESTION_CREATE)
|
||||
async create(
|
||||
@Body() body: CreateQuestionInput,
|
||||
@Body() body: unknown,
|
||||
): Promise<{ success: true; data: { id: string } }> {
|
||||
const result = await this.service.createQuestion(body);
|
||||
const input = createQuestionSchema.parse(body);
|
||||
const result = await this.service.createQuestion(input);
|
||||
return { success: true, data: result };
|
||||
}
|
||||
|
||||
@Get()
|
||||
@RequirePermission(Permissions.CONTENT_QUESTION_READ)
|
||||
async list(
|
||||
@Query() query: unknown,
|
||||
): Promise<{ success: true; data: Question[] }> {
|
||||
const input = listQuestionsSchema.parse(query);
|
||||
const data = await this.service.list(input);
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
@Get("knowledge-point/:knowledgePointId")
|
||||
@RequirePermission(Permissions.CONTENT_QUESTION_READ)
|
||||
async listByKnowledgePoint(
|
||||
@@ -53,9 +66,10 @@ export class QuestionsController {
|
||||
@RequirePermission(Permissions.CONTENT_QUESTION_UPDATE)
|
||||
async update(
|
||||
@Param("id") id: string,
|
||||
@Body() body: UpdateQuestionInput,
|
||||
@Body() body: unknown,
|
||||
): Promise<{ success: true; data: { success: true } }> {
|
||||
await this.service.updateQuestion(id, body);
|
||||
const input = updateQuestionSchema.parse(body);
|
||||
await this.service.updateQuestion(id, input);
|
||||
return { success: true, data: { success: true } };
|
||||
}
|
||||
|
||||
|
||||
106
services/content/src/questions/questions.dto.test.ts
Normal file
106
services/content/src/questions/questions.dto.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
createQuestionSchema,
|
||||
updateQuestionSchema,
|
||||
listQuestionsSchema,
|
||||
questionTypeSchema,
|
||||
questionSourceSchema,
|
||||
} from "./questions.dto.js";
|
||||
|
||||
describe("questions.dto", () => {
|
||||
describe("questionTypeSchema", () => {
|
||||
it("should accept all valid question types", () => {
|
||||
const validTypes = [
|
||||
"single_choice",
|
||||
"multiple_choice",
|
||||
"short_answer",
|
||||
"essay",
|
||||
];
|
||||
for (const t of validTypes) {
|
||||
expect(questionTypeSchema.parse(t)).toBe(t);
|
||||
}
|
||||
});
|
||||
|
||||
it("should reject invalid question type", () => {
|
||||
expect(() => questionTypeSchema.parse("invalid_type")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("questionSourceSchema", () => {
|
||||
it("should accept all valid question sources", () => {
|
||||
const validSources = ["manual", "ai_generated", "imported"];
|
||||
for (const s of validSources) {
|
||||
expect(questionSourceSchema.parse(s)).toBe(s);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("createQuestionSchema", () => {
|
||||
it("should parse valid input and apply defaults", () => {
|
||||
const result = createQuestionSchema.parse({
|
||||
knowledgePointId: "kp-1",
|
||||
type: "single_choice",
|
||||
content: "What is 2+2?",
|
||||
answer: "4",
|
||||
createdBy: "user-1",
|
||||
});
|
||||
expect(result.knowledgePointId).toBe("kp-1");
|
||||
expect(result.type).toBe("single_choice");
|
||||
expect(result.content).toBe("What is 2+2?");
|
||||
expect(result.answer).toBe("4");
|
||||
expect(result.difficulty).toBe(3);
|
||||
expect(result.source).toBe("manual");
|
||||
});
|
||||
|
||||
it("should reject missing answer", () => {
|
||||
expect(() =>
|
||||
createQuestionSchema.parse({
|
||||
knowledgePointId: "kp-1",
|
||||
type: "essay",
|
||||
content: "Write an essay",
|
||||
createdBy: "user-1",
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("should reject invalid type", () => {
|
||||
expect(() =>
|
||||
createQuestionSchema.parse({
|
||||
knowledgePointId: "kp-1",
|
||||
type: "invalid",
|
||||
content: "content",
|
||||
answer: "answer",
|
||||
createdBy: "user-1",
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateQuestionSchema", () => {
|
||||
it("should parse partial update with status", () => {
|
||||
const result = updateQuestionSchema.parse({ status: "published" });
|
||||
expect(result.status).toBe("published");
|
||||
});
|
||||
|
||||
it("should reject invalid status value", () => {
|
||||
expect(() => updateQuestionSchema.parse({ status: "invalid" })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("listQuestionsSchema", () => {
|
||||
it("should default page and pageSize", () => {
|
||||
const result = listQuestionsSchema.parse({});
|
||||
expect(result.page).toBe(1);
|
||||
expect(result.pageSize).toBe(20);
|
||||
});
|
||||
|
||||
it("should coerce string numbers for page and pageSize", () => {
|
||||
const result = listQuestionsSchema.parse({
|
||||
page: "3",
|
||||
pageSize: "50",
|
||||
});
|
||||
expect(result.page).toBe(3);
|
||||
expect(result.pageSize).toBe(50);
|
||||
});
|
||||
});
|
||||
});
|
||||
51
services/content/src/questions/questions.dto.ts
Normal file
51
services/content/src/questions/questions.dto.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const questionTypeSchema = z.enum([
|
||||
"single_choice",
|
||||
"multiple_choice",
|
||||
"short_answer",
|
||||
"essay",
|
||||
]);
|
||||
|
||||
export const questionSourceSchema = z.enum([
|
||||
"manual",
|
||||
"ai_generated",
|
||||
"imported",
|
||||
]);
|
||||
|
||||
export const createQuestionSchema = z.object({
|
||||
knowledgePointId: z.string().min(1).max(32),
|
||||
type: questionTypeSchema,
|
||||
content: z.string().min(1),
|
||||
options: z.record(z.unknown()).nullish(),
|
||||
answer: z.string().min(1),
|
||||
explanation: z.string().optional(),
|
||||
difficulty: z.number().int().min(1).max(5).optional().default(3),
|
||||
source: questionSourceSchema.optional().default("manual"),
|
||||
createdBy: z.string().min(1).max(32),
|
||||
metadata: z.record(z.unknown()).nullish(),
|
||||
});
|
||||
|
||||
export const updateQuestionSchema = z.object({
|
||||
content: z.string().min(1).optional(),
|
||||
options: z.record(z.unknown()).nullish(),
|
||||
answer: z.string().min(1).optional(),
|
||||
explanation: z.string().optional(),
|
||||
difficulty: z.number().int().min(1).max(5).optional(),
|
||||
status: z
|
||||
.enum(["draft", "pending_review", "published", "rejected", "archived"])
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const listQuestionsSchema = z.object({
|
||||
knowledgePointId: z.string().optional(),
|
||||
type: questionTypeSchema.optional(),
|
||||
difficulty: z.coerce.number().int().min(1).max(5).optional(),
|
||||
status: z.string().optional(),
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
||||
});
|
||||
|
||||
export type CreateQuestionDto = z.infer<typeof createQuestionSchema>;
|
||||
export type UpdateQuestionDto = z.infer<typeof updateQuestionSchema>;
|
||||
export type ListQuestionsDto = z.infer<typeof listQuestionsSchema>;
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { QuestionsController } from "./questions.controller.js";
|
||||
import { QuestionsService } from "./questions.service.js";
|
||||
import { OutboxModule } from "../shared/outbox/outbox.module.js";
|
||||
|
||||
@Module({
|
||||
imports: [OutboxModule],
|
||||
controllers: [QuestionsController],
|
||||
providers: [QuestionsService],
|
||||
exports: [QuestionsService],
|
||||
|
||||
104
services/content/src/questions/questions.repository.test.ts
Normal file
104
services/content/src/questions/questions.repository.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const mockGetDb = vi.fn();
|
||||
|
||||
vi.mock("../config/database.js", () => ({
|
||||
getDb: () => mockGetDb(),
|
||||
}));
|
||||
|
||||
import { QuestionsRepository } from "./questions.repository.js";
|
||||
|
||||
function createMockDb(resolvedValue: unknown): unknown {
|
||||
const handler: ProxyHandler<Record<PropertyKey, unknown>> = {
|
||||
get: (_target, prop) => {
|
||||
if (prop === "then") {
|
||||
return (onFulfilled?: (v: unknown) => unknown) =>
|
||||
Promise.resolve(
|
||||
typeof onFulfilled === "function"
|
||||
? onFulfilled(resolvedValue)
|
||||
: resolvedValue,
|
||||
);
|
||||
}
|
||||
return () => new Proxy({}, handler);
|
||||
},
|
||||
};
|
||||
return new Proxy({}, handler);
|
||||
}
|
||||
|
||||
describe("QuestionsRepository", () => {
|
||||
let repo: QuestionsRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
repo = new QuestionsRepository();
|
||||
});
|
||||
|
||||
it("findById should return first matching question", async () => {
|
||||
const q = { id: "q-1", content: "Q" };
|
||||
mockGetDb.mockReturnValue(createMockDb([q]));
|
||||
const result = await repo.findById("q-1");
|
||||
expect(result).toBe(q);
|
||||
});
|
||||
|
||||
it("findById should return undefined when no match", async () => {
|
||||
mockGetDb.mockReturnValue(createMockDb([]));
|
||||
const result = await repo.findById("missing");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("findByKnowledgePointId should return questions array", async () => {
|
||||
const qs = [{ id: "q-1" }];
|
||||
mockGetDb.mockReturnValue(createMockDb(qs));
|
||||
const result = await repo.findByKnowledgePointId("kp-1");
|
||||
expect(result).toBe(qs);
|
||||
});
|
||||
|
||||
it("find should apply query filters and pagination", async () => {
|
||||
const qs = [{ id: "q-1" }];
|
||||
mockGetDb.mockReturnValue(createMockDb(qs));
|
||||
const result = await repo.find({
|
||||
knowledgePointId: "kp-1",
|
||||
type: "single_choice",
|
||||
difficulty: 3,
|
||||
status: "published",
|
||||
page: 2,
|
||||
pageSize: 10,
|
||||
});
|
||||
expect(result).toBe(qs);
|
||||
});
|
||||
|
||||
it("find should use defaults when query is empty", async () => {
|
||||
const qs: unknown[] = [];
|
||||
mockGetDb.mockReturnValue(createMockDb(qs));
|
||||
const result = await repo.find();
|
||||
expect(result).toBe(qs);
|
||||
});
|
||||
|
||||
it("create should insert a new question", async () => {
|
||||
mockGetDb.mockReturnValue(createMockDb(undefined));
|
||||
await repo.create({
|
||||
id: "q-1",
|
||||
knowledgePointId: "kp-1",
|
||||
type: "single_choice",
|
||||
content: "Q",
|
||||
answer: "A",
|
||||
difficulty: 3,
|
||||
status: "draft",
|
||||
source: "manual",
|
||||
createdBy: "user-1",
|
||||
});
|
||||
expect(mockGetDb).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("update should update a question by id", async () => {
|
||||
mockGetDb.mockReturnValue(createMockDb(undefined));
|
||||
await repo.update("q-1", { content: "New" });
|
||||
expect(mockGetDb).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("delete should delete a question by id", async () => {
|
||||
mockGetDb.mockReturnValue(createMockDb(undefined));
|
||||
await repo.delete("q-1");
|
||||
expect(mockGetDb).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "../config/database.js";
|
||||
import { getDb } from "../config/database.js";
|
||||
import {
|
||||
questions,
|
||||
type Question,
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
|
||||
export class QuestionsRepository {
|
||||
async findById(id: string): Promise<Question | undefined> {
|
||||
const [result] = await db
|
||||
const [result] = await getDb()
|
||||
.select()
|
||||
.from(questions)
|
||||
.where(eq(questions.id, id))
|
||||
@@ -17,22 +17,49 @@ export class QuestionsRepository {
|
||||
}
|
||||
|
||||
async findByKnowledgePointId(knowledgePointId: string): Promise<Question[]> {
|
||||
return db
|
||||
return getDb()
|
||||
.select()
|
||||
.from(questions)
|
||||
.where(eq(questions.knowledgePointId, knowledgePointId));
|
||||
}
|
||||
|
||||
async find(query?: {
|
||||
knowledgePointId?: string;
|
||||
type?: string;
|
||||
difficulty?: number;
|
||||
status?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}): Promise<Question[]> {
|
||||
const db = getDb();
|
||||
let q = db.select().from(questions).$dynamic();
|
||||
if (query?.knowledgePointId) {
|
||||
q = q.where(eq(questions.knowledgePointId, query.knowledgePointId));
|
||||
}
|
||||
if (query?.type) {
|
||||
q = q.where(eq(questions.type, query.type));
|
||||
}
|
||||
if (query?.difficulty !== undefined) {
|
||||
q = q.where(eq(questions.difficulty, query.difficulty));
|
||||
}
|
||||
if (query?.status) {
|
||||
q = q.where(eq(questions.status, query.status));
|
||||
}
|
||||
const pageSize = query?.pageSize ?? 20;
|
||||
const page = query?.page ?? 1;
|
||||
return q.limit(pageSize).offset((page - 1) * pageSize);
|
||||
}
|
||||
|
||||
async create(data: NewQuestion): Promise<void> {
|
||||
await db.insert(questions).values(data);
|
||||
await getDb().insert(questions).values(data);
|
||||
}
|
||||
|
||||
async update(id: string, data: Partial<NewQuestion>): Promise<void> {
|
||||
await db.update(questions).set(data).where(eq(questions.id, id));
|
||||
await getDb().update(questions).set(data).where(eq(questions.id, id));
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await db.delete(questions).where(eq(questions.id, id));
|
||||
await getDb().delete(questions).where(eq(questions.id, id));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,23 +1,41 @@
|
||||
import {
|
||||
mysqlTable,
|
||||
char,
|
||||
varchar,
|
||||
text,
|
||||
int,
|
||||
tinyint,
|
||||
timestamp,
|
||||
json,
|
||||
index,
|
||||
} from "drizzle-orm/mysql-core";
|
||||
|
||||
export const questions = mysqlTable("content_questions", {
|
||||
id: char("id", { length: 36 }).notNull().primaryKey(),
|
||||
knowledgePointId: char("knowledge_point_id", { length: 36 }).notNull(),
|
||||
type: varchar("type", { length: 50 }).notNull(),
|
||||
content: text("content").notNull(),
|
||||
answer: text("answer"),
|
||||
explanation: text("explanation"),
|
||||
difficulty: int("difficulty").default(3),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(),
|
||||
});
|
||||
export const questions = mysqlTable(
|
||||
"content_questions",
|
||||
{
|
||||
id: varchar("id", { length: 32 }).notNull().primaryKey(),
|
||||
knowledgePointId: varchar("knowledge_point_id", { length: 32 }).notNull(),
|
||||
type: varchar("type", { length: 32 }).notNull(),
|
||||
content: text("content").notNull(),
|
||||
options: json("options").$type<Record<string, unknown> | null>(),
|
||||
answer: text("answer").notNull(),
|
||||
explanation: text("explanation"),
|
||||
difficulty: tinyint("difficulty").notNull().default(3),
|
||||
status: varchar("status", { length: 32 }).notNull().default("draft"),
|
||||
source: varchar("source", { length: 32 }).notNull().default("manual"),
|
||||
createdBy: varchar("created_by", { length: 32 }).notNull(),
|
||||
metadata: json("metadata").$type<Record<string, unknown> | null>(),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(),
|
||||
},
|
||||
(table) => ({
|
||||
kpIdx: index("idx_questions_kp").on(table.knowledgePointId),
|
||||
typeDifficultyIdx: index("idx_questions_type_difficulty").on(
|
||||
table.type,
|
||||
table.difficulty,
|
||||
),
|
||||
statusIdx: index("idx_questions_status").on(table.status),
|
||||
sourceIdx: index("idx_questions_source").on(table.source),
|
||||
}),
|
||||
);
|
||||
|
||||
export type Question = typeof questions.$inferSelect;
|
||||
export type NewQuestion = typeof questions.$inferInsert;
|
||||
|
||||
177
services/content/src/questions/questions.service.test.ts
Normal file
177
services/content/src/questions/questions.service.test.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { QuestionsService } from "./questions.service.js";
|
||||
import { NotFoundError } from "../shared/errors/application-error.js";
|
||||
|
||||
vi.mock("./questions.repository.js", () => ({
|
||||
questionsRepository: {
|
||||
findById: vi.fn(),
|
||||
findByKnowledgePointId: vi.fn(),
|
||||
find: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { questionsRepository } from "./questions.repository.js";
|
||||
|
||||
const mockOutbox = {
|
||||
publish: vi.fn().mockResolvedValue("event-id"),
|
||||
};
|
||||
|
||||
describe("QuestionsService", () => {
|
||||
let service: QuestionsService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
service = new QuestionsService(mockOutbox as never);
|
||||
});
|
||||
|
||||
describe("createQuestion", () => {
|
||||
it("should create a question and publish event", async () => {
|
||||
const input = {
|
||||
knowledgePointId: "kp-1",
|
||||
type: "single_choice",
|
||||
content: "What is 2+2?",
|
||||
answer: "4",
|
||||
createdBy: "user-1",
|
||||
};
|
||||
const result = await service.createQuestion(input);
|
||||
|
||||
expect(result.id).toBeDefined();
|
||||
expect(questionsRepository.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
knowledgePointId: "kp-1",
|
||||
type: "single_choice",
|
||||
content: "What is 2+2?",
|
||||
answer: "4",
|
||||
status: "draft",
|
||||
source: "manual",
|
||||
createdBy: "user-1",
|
||||
}),
|
||||
);
|
||||
expect(mockOutbox.publish).toHaveBeenCalledWith(
|
||||
"question.created",
|
||||
"Question",
|
||||
result.id,
|
||||
expect.objectContaining({
|
||||
knowledge_point_id: "kp-1",
|
||||
type: "single_choice",
|
||||
created_by: "user-1",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should default difficulty to 3 and source to manual", async () => {
|
||||
await service.createQuestion({
|
||||
knowledgePointId: "kp-1",
|
||||
type: "essay",
|
||||
content: "Write an essay",
|
||||
answer: "Sample answer",
|
||||
createdBy: "user-1",
|
||||
});
|
||||
expect(questionsRepository.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
difficulty: 3,
|
||||
source: "manual",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getQuestion", () => {
|
||||
it("should throw NotFoundError when not found", async () => {
|
||||
vi.mocked(questionsRepository.findById).mockResolvedValue(undefined);
|
||||
await expect(service.getQuestion("nope")).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateQuestion", () => {
|
||||
it("should publish published event when status is published", async () => {
|
||||
vi.mocked(questionsRepository.findById).mockResolvedValue({
|
||||
id: "q-1",
|
||||
knowledgePointId: "kp-1",
|
||||
type: "single_choice",
|
||||
content: "Q",
|
||||
options: null,
|
||||
answer: "A",
|
||||
explanation: null,
|
||||
difficulty: 3,
|
||||
status: "draft",
|
||||
source: "manual",
|
||||
createdBy: "u",
|
||||
metadata: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
await service.updateQuestion("q-1", { status: "published" });
|
||||
|
||||
expect(mockOutbox.publish).toHaveBeenCalledWith(
|
||||
"question.published",
|
||||
"Question",
|
||||
"q-1",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("should publish updated event for non-status updates", async () => {
|
||||
vi.mocked(questionsRepository.findById).mockResolvedValue({
|
||||
id: "q-1",
|
||||
knowledgePointId: "kp-1",
|
||||
type: "single_choice",
|
||||
content: "Q",
|
||||
options: null,
|
||||
answer: "A",
|
||||
explanation: null,
|
||||
difficulty: 3,
|
||||
status: "draft",
|
||||
source: "manual",
|
||||
createdBy: "u",
|
||||
metadata: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
await service.updateQuestion("q-1", { content: "New content" });
|
||||
|
||||
expect(mockOutbox.publish).toHaveBeenCalledWith(
|
||||
"question.updated",
|
||||
"Question",
|
||||
"q-1",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteQuestion", () => {
|
||||
it("should delete and publish deleted event", async () => {
|
||||
vi.mocked(questionsRepository.findById).mockResolvedValue({
|
||||
id: "q-1",
|
||||
knowledgePointId: "kp-1",
|
||||
type: "single_choice",
|
||||
content: "Q",
|
||||
options: null,
|
||||
answer: "A",
|
||||
explanation: null,
|
||||
difficulty: 3,
|
||||
status: "draft",
|
||||
source: "manual",
|
||||
createdBy: "u",
|
||||
metadata: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
await service.deleteQuestion("q-1");
|
||||
|
||||
expect(questionsRepository.delete).toHaveBeenCalledWith("q-1");
|
||||
expect(mockOutbox.publish).toHaveBeenCalledWith(
|
||||
"question.deleted",
|
||||
"Question",
|
||||
"q-1",
|
||||
{ deleted: true },
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,58 +1,78 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { questionsRepository } from "./questions.repository.js";
|
||||
import type { Question } from "./questions.schema.js";
|
||||
import {
|
||||
NotFoundError,
|
||||
ValidationError,
|
||||
} from "../shared/errors/application-error.js";
|
||||
import type { Question, NewQuestion } from "./questions.schema.js";
|
||||
import { OutboxService } from "../shared/outbox/outbox.service.js";
|
||||
import { AGGREGATE_TYPES, EVENT_TYPES } from "../shared/outbox/events.js";
|
||||
import { NotFoundError } from "../shared/errors/application-error.js";
|
||||
|
||||
export interface CreateQuestionInput {
|
||||
knowledgePointId: string;
|
||||
type: string;
|
||||
content: string;
|
||||
answer?: string;
|
||||
options?: Record<string, unknown> | null;
|
||||
answer: string;
|
||||
explanation?: string;
|
||||
difficulty?: number;
|
||||
source?: string;
|
||||
createdBy: string;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface UpdateQuestionInput {
|
||||
type?: string;
|
||||
content?: string;
|
||||
options?: Record<string, unknown> | null;
|
||||
answer?: string;
|
||||
explanation?: string;
|
||||
difficulty?: number;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
const VALID_TYPES = new Set([
|
||||
"single_choice",
|
||||
"multiple_choice",
|
||||
"short_answer",
|
||||
"essay",
|
||||
]);
|
||||
export interface ListQuestionsInput {
|
||||
knowledgePointId?: string;
|
||||
type?: string;
|
||||
difficulty?: number;
|
||||
status?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class QuestionsService {
|
||||
async createQuestion(input: CreateQuestionInput): Promise<{ id: string }> {
|
||||
if (!input.knowledgePointId || !input.type || !input.content) {
|
||||
throw new ValidationError("knowledgePointId, type, content are required");
|
||||
}
|
||||
if (!VALID_TYPES.has(input.type)) {
|
||||
throw new ValidationError(
|
||||
`Invalid question type: ${input.type}. Must be one of: ${[...VALID_TYPES].join(", ")}`,
|
||||
);
|
||||
}
|
||||
constructor(private readonly outbox: OutboxService) {}
|
||||
|
||||
const id = randomUUID();
|
||||
await questionsRepository.create({
|
||||
async createQuestion(input: CreateQuestionInput): Promise<{ id: string }> {
|
||||
const id = createId();
|
||||
const record: NewQuestion = {
|
||||
id,
|
||||
knowledgePointId: input.knowledgePointId,
|
||||
type: input.type,
|
||||
content: input.content,
|
||||
options: input.options ?? null,
|
||||
answer: input.answer,
|
||||
explanation: input.explanation,
|
||||
difficulty: input.difficulty,
|
||||
});
|
||||
difficulty: input.difficulty ?? 3,
|
||||
status: "draft",
|
||||
source: input.source ?? "manual",
|
||||
createdBy: input.createdBy,
|
||||
metadata: input.metadata ?? null,
|
||||
};
|
||||
await questionsRepository.create(record);
|
||||
|
||||
await this.outbox.publish(
|
||||
EVENT_TYPES.QUESTION_CREATED,
|
||||
AGGREGATE_TYPES.QUESTION,
|
||||
id,
|
||||
{
|
||||
knowledge_point_id: record.knowledgePointId,
|
||||
type: record.type,
|
||||
difficulty: record.difficulty,
|
||||
status: record.status,
|
||||
source: record.source,
|
||||
created_by: record.createdBy,
|
||||
},
|
||||
);
|
||||
|
||||
return { id };
|
||||
}
|
||||
|
||||
@@ -68,16 +88,35 @@ export class QuestionsService {
|
||||
return questionsRepository.findByKnowledgePointId(knowledgePointId);
|
||||
}
|
||||
|
||||
async list(query: ListQuestionsInput): Promise<Question[]> {
|
||||
return questionsRepository.find(query);
|
||||
}
|
||||
|
||||
async updateQuestion(id: string, data: UpdateQuestionInput): Promise<void> {
|
||||
await this.getQuestion(id);
|
||||
if (data.type && !VALID_TYPES.has(data.type)) {
|
||||
throw new ValidationError(`Invalid question type: ${data.type}`);
|
||||
}
|
||||
const existing = await this.getQuestion(id);
|
||||
await questionsRepository.update(id, data);
|
||||
|
||||
const eventType =
|
||||
data.status === "published"
|
||||
? EVENT_TYPES.QUESTION_PUBLISHED
|
||||
: EVENT_TYPES.QUESTION_UPDATED;
|
||||
|
||||
await this.outbox.publish(eventType, AGGREGATE_TYPES.QUESTION, id, {
|
||||
content: data.content ?? existing.content,
|
||||
difficulty: data.difficulty ?? existing.difficulty,
|
||||
status: data.status ?? existing.status,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteQuestion(id: string): Promise<void> {
|
||||
await this.getQuestion(id);
|
||||
await questionsRepository.delete(id);
|
||||
|
||||
await this.outbox.publish(
|
||||
EVENT_TYPES.QUESTION_DELETED,
|
||||
AGGREGATE_TYPES.QUESTION,
|
||||
id,
|
||||
{ deleted: true },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
export type ErrorType =
|
||||
| 'validation'
|
||||
| 'not_found'
|
||||
| 'permission_denied'
|
||||
| 'conflict'
|
||||
| 'business'
|
||||
| 'database'
|
||||
| 'internal';
|
||||
| "validation"
|
||||
| "not_found"
|
||||
| "permission_denied"
|
||||
| "conflict"
|
||||
| "business"
|
||||
| "database"
|
||||
| "internal"
|
||||
| "neo4j_unavailable";
|
||||
|
||||
export interface ErrorDetails {
|
||||
[key: string]: unknown;
|
||||
@@ -40,57 +41,70 @@ export abstract class ApplicationError extends Error {
|
||||
}
|
||||
|
||||
export class ValidationError extends ApplicationError {
|
||||
readonly type = 'validation' as const;
|
||||
readonly type = "validation" as const;
|
||||
readonly statusCode = 400;
|
||||
constructor(message: string, details?: ErrorDetails) {
|
||||
super(message, 'CONTENT_VALIDATION_ERROR', details);
|
||||
super(message, "CONTENT_VALIDATION_ERROR", details);
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends ApplicationError {
|
||||
readonly type = 'not_found' as const;
|
||||
readonly type = "not_found" as const;
|
||||
readonly statusCode = 404;
|
||||
constructor(resource: string, id: string) {
|
||||
super(`${resource} not found: ${id}`, 'CONTENT_NOT_FOUND', { resource, id });
|
||||
super(`${resource} not found: ${id}`, "CONTENT_NOT_FOUND", {
|
||||
resource,
|
||||
id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class PermissionDeniedError extends ApplicationError {
|
||||
readonly type = 'permission_denied' as const;
|
||||
readonly type = "permission_denied" as const;
|
||||
readonly statusCode = 403;
|
||||
constructor(permission: string) {
|
||||
super(`Permission denied: ${permission}`, 'CONTENT_PERMISSION_DENIED', { permission });
|
||||
super(`Permission denied: ${permission}`, "CONTENT_PERMISSION_DENIED", {
|
||||
permission,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class ConflictError extends ApplicationError {
|
||||
readonly type = 'conflict' as const;
|
||||
readonly type = "conflict" as const;
|
||||
readonly statusCode = 409;
|
||||
constructor(message: string, details?: ErrorDetails) {
|
||||
super(message, 'CONTENT_CONFLICT', details);
|
||||
super(message, "CONTENT_CONFLICT", details);
|
||||
}
|
||||
}
|
||||
|
||||
export class BusinessError extends ApplicationError {
|
||||
readonly type = 'business' as const;
|
||||
readonly type = "business" as const;
|
||||
readonly statusCode = 422;
|
||||
constructor(message: string, details?: ErrorDetails) {
|
||||
super(message, 'CONTENT_BUSINESS_ERROR', details);
|
||||
super(message, "CONTENT_BUSINESS_ERROR", details);
|
||||
}
|
||||
}
|
||||
|
||||
export class DatabaseError extends ApplicationError {
|
||||
readonly type = 'database' as const;
|
||||
readonly type = "database" as const;
|
||||
readonly statusCode = 500;
|
||||
constructor(message: string, details?: ErrorDetails) {
|
||||
super(message, 'CONTENT_DATABASE_ERROR', details);
|
||||
super(message, "CONTENT_DATABASE_ERROR", details);
|
||||
}
|
||||
}
|
||||
|
||||
export class InternalError extends ApplicationError {
|
||||
readonly type = 'internal' as const;
|
||||
readonly type = "internal" as const;
|
||||
readonly statusCode = 500;
|
||||
constructor(message: string, details?: ErrorDetails) {
|
||||
super(message, 'CONTENT_INTERNAL_ERROR', details);
|
||||
super(message, "CONTENT_INTERNAL_ERROR", details);
|
||||
}
|
||||
}
|
||||
|
||||
export class Neo4jUnavailableError extends ApplicationError {
|
||||
readonly type = "neo4j_unavailable" as const;
|
||||
readonly statusCode = 503;
|
||||
constructor(message: string, details?: ErrorDetails) {
|
||||
super(message, "CONTENT_NEO4J_UNAVAILABLE", details);
|
||||
}
|
||||
}
|
||||
|
||||
211
services/content/src/shared/errors/global-error.filter.test.ts
Normal file
211
services/content/src/shared/errors/global-error.filter.test.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { ArgumentsHost } from "@nestjs/common";
|
||||
import { HttpException } from "@nestjs/common";
|
||||
import type { Request, Response } from "express";
|
||||
import { z, ZodError } from "zod";
|
||||
import { GlobalErrorFilter } from "./global-error.filter.js";
|
||||
import {
|
||||
NotFoundError,
|
||||
ValidationError,
|
||||
InternalError,
|
||||
} from "./application-error.js";
|
||||
|
||||
function createMockHost(
|
||||
req: Partial<Request>,
|
||||
res: Partial<Response>,
|
||||
): ArgumentsHost {
|
||||
return {
|
||||
switchToHttp: () => ({
|
||||
getResponse: () => res,
|
||||
getRequest: () => req,
|
||||
}),
|
||||
} as unknown as ArgumentsHost;
|
||||
}
|
||||
|
||||
function createMockResponse(): {
|
||||
response: Response;
|
||||
statusMock: ReturnType<typeof vi.fn>;
|
||||
jsonMock: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const statusMock = vi.fn().mockReturnThis();
|
||||
const jsonMock = vi.fn();
|
||||
return {
|
||||
response: { status: statusMock, json: jsonMock } as unknown as Response,
|
||||
statusMock,
|
||||
jsonMock,
|
||||
};
|
||||
}
|
||||
|
||||
function createMockRequest(headers: Record<string, unknown> = {}): Request {
|
||||
return { headers } as unknown as Request;
|
||||
}
|
||||
|
||||
describe("GlobalErrorFilter", () => {
|
||||
let filter: GlobalErrorFilter;
|
||||
|
||||
beforeEach(() => {
|
||||
filter = new GlobalErrorFilter();
|
||||
});
|
||||
|
||||
describe("ApplicationError branch", () => {
|
||||
it("should respond with ApplicationError statusCode and toJSON body", () => {
|
||||
const { response, statusMock, jsonMock } = createMockResponse();
|
||||
const host = createMockHost(
|
||||
createMockRequest({ "x-request-id": "trace-abc" }),
|
||||
response,
|
||||
);
|
||||
const error = new NotFoundError("Chapter", "ch-1");
|
||||
|
||||
filter.catch(error, host);
|
||||
|
||||
expect(statusMock).toHaveBeenCalledWith(404);
|
||||
expect(jsonMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
success: false,
|
||||
error: expect.objectContaining({
|
||||
code: "CONTENT_NOT_FOUND",
|
||||
traceId: "trace-abc",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(error.traceId).toBe("trace-abc");
|
||||
});
|
||||
|
||||
it("should handle ValidationError with statusCode 400", () => {
|
||||
const { response, statusMock, jsonMock } = createMockResponse();
|
||||
const host = createMockHost(createMockRequest(), response);
|
||||
const error = new ValidationError("Invalid input");
|
||||
|
||||
filter.catch(error, host);
|
||||
|
||||
expect(statusMock).toHaveBeenCalledWith(400);
|
||||
expect(jsonMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: expect.objectContaining({ code: "CONTENT_VALIDATION_ERROR" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ZodError branch", () => {
|
||||
it("should respond with statusCode 400 and CONTENT_VALIDATION_ERROR", () => {
|
||||
const { response, statusMock, jsonMock } = createMockResponse();
|
||||
const host = createMockHost(
|
||||
createMockRequest({ "x-request-id": "trace-zod" }),
|
||||
response,
|
||||
);
|
||||
const parseResult = z.object({ x: z.string() }).safeParse({ x: 123 });
|
||||
const zodError = parseResult.error as ZodError;
|
||||
|
||||
filter.catch(zodError, host);
|
||||
|
||||
expect(statusMock).toHaveBeenCalledWith(400);
|
||||
expect(jsonMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
success: false,
|
||||
error: expect.objectContaining({
|
||||
code: "CONTENT_VALIDATION_ERROR",
|
||||
message: "Validation failed",
|
||||
traceId: "trace-zod",
|
||||
details: expect.any(Object),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HttpException branch", () => {
|
||||
it("should handle HttpException with string response", () => {
|
||||
const { response, statusMock, jsonMock } = createMockResponse();
|
||||
const host = createMockHost(createMockRequest(), response);
|
||||
const error = new HttpException("Forbidden resource", 403);
|
||||
|
||||
filter.catch(error, host);
|
||||
|
||||
expect(statusMock).toHaveBeenCalledWith(403);
|
||||
expect(jsonMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: expect.objectContaining({
|
||||
code: "HTTP_ERROR",
|
||||
message: "Forbidden resource",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle HttpException with object response containing message string", () => {
|
||||
const { response, statusMock, jsonMock } = createMockResponse();
|
||||
const host = createMockHost(createMockRequest(), response);
|
||||
const error = new HttpException({ message: "Custom error message" }, 422);
|
||||
|
||||
filter.catch(error, host);
|
||||
|
||||
expect(statusMock).toHaveBeenCalledWith(422);
|
||||
expect(jsonMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: expect.objectContaining({
|
||||
message: "Custom error message",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should fall back to exception.message for object response without message", () => {
|
||||
const { response, statusMock, jsonMock } = createMockResponse();
|
||||
const host = createMockHost(createMockRequest(), response);
|
||||
const error = new HttpException({ data: "value" }, 400);
|
||||
|
||||
filter.catch(error, host);
|
||||
|
||||
expect(statusMock).toHaveBeenCalledWith(400);
|
||||
expect(jsonMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: expect.objectContaining({
|
||||
code: "HTTP_ERROR",
|
||||
message: error.message,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unknown error branch", () => {
|
||||
it("should respond with statusCode 500 and INTERNAL_ERROR", () => {
|
||||
const { response, statusMock, jsonMock } = createMockResponse();
|
||||
const host = createMockHost(
|
||||
createMockRequest({ "x-request-id": "trace-unknown" }),
|
||||
response,
|
||||
);
|
||||
const error = new Error("something exploded");
|
||||
|
||||
filter.catch(error, host);
|
||||
|
||||
expect(statusMock).toHaveBeenCalledWith(500);
|
||||
expect(jsonMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
success: false,
|
||||
error: expect.objectContaining({
|
||||
code: "INTERNAL_ERROR",
|
||||
message: "An unexpected error occurred",
|
||||
traceId: "trace-unknown",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should default traceId to unknown when x-request-id header is missing", () => {
|
||||
const { response, statusMock, jsonMock } = createMockResponse();
|
||||
const host = createMockHost(createMockRequest(), response);
|
||||
const error = new InternalError("boom");
|
||||
|
||||
filter.catch(error, host);
|
||||
|
||||
expect(jsonMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: expect.objectContaining({ traceId: "unknown" }),
|
||||
}),
|
||||
);
|
||||
expect(statusMock).toHaveBeenCalledWith(500);
|
||||
});
|
||||
});
|
||||
});
|
||||
83
services/content/src/shared/health/health.controller.test.ts
Normal file
83
services/content/src/shared/health/health.controller.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { HttpException } from "@nestjs/common";
|
||||
|
||||
const mockGetDb = vi.fn();
|
||||
const mockGetNeo4jSession = vi.fn();
|
||||
const mockIsKafkaConnected = vi.fn();
|
||||
|
||||
vi.mock("../../config/database.js", () => ({
|
||||
getDb: () => mockGetDb(),
|
||||
}));
|
||||
|
||||
vi.mock("../../config/neo4j.js", () => ({
|
||||
getNeo4jSession: () => mockGetNeo4jSession(),
|
||||
}));
|
||||
|
||||
vi.mock("../../config/kafka.js", () => ({
|
||||
isKafkaProducerConnected: () => mockIsKafkaConnected(),
|
||||
}));
|
||||
|
||||
vi.mock("../../config/env.js", () => ({
|
||||
env: { NEO4J_URL: undefined, NEO4J_PASSWORD: undefined },
|
||||
}));
|
||||
|
||||
import { HealthController } from "./health.controller.js";
|
||||
|
||||
describe("HealthController", () => {
|
||||
let controller: HealthController;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
controller = new HealthController();
|
||||
});
|
||||
|
||||
describe("liveness", () => {
|
||||
it("should return ok status with service name", () => {
|
||||
const result = controller.liveness();
|
||||
expect(result.status).toBe("ok");
|
||||
expect(result.service).toBe("content");
|
||||
expect(result.timestamp).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("readiness", () => {
|
||||
it("should return ok when all dependencies healthy", async () => {
|
||||
mockGetDb.mockReturnValue({
|
||||
execute: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
mockIsKafkaConnected.mockReturnValue(true);
|
||||
const result = await controller.readiness();
|
||||
expect(result.status).toBe("ok");
|
||||
expect(result.dependencies).toHaveLength(3);
|
||||
const names = result.dependencies.map((d) => d.name);
|
||||
expect(names).toEqual(["mysql", "neo4j", "kafka"]);
|
||||
expect(result.dependencies.every((d) => d.status === "ok")).toBe(true);
|
||||
});
|
||||
|
||||
it("should report neo4j as ok when not configured", async () => {
|
||||
mockGetDb.mockReturnValue({
|
||||
execute: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
mockIsKafkaConnected.mockReturnValue(true);
|
||||
const result = await controller.readiness();
|
||||
const neo4jDep = result.dependencies.find((d) => d.name === "neo4j");
|
||||
expect(neo4jDep?.status).toBe("ok");
|
||||
});
|
||||
|
||||
it("should report mysql error when db check fails", async () => {
|
||||
mockGetDb.mockReturnValue({
|
||||
execute: vi.fn().mockRejectedValue(new Error("db down")),
|
||||
});
|
||||
mockIsKafkaConnected.mockReturnValue(true);
|
||||
await expect(controller.readiness()).rejects.toThrow(HttpException);
|
||||
});
|
||||
|
||||
it("should report kafka error when producer disconnected", async () => {
|
||||
mockGetDb.mockReturnValue({
|
||||
execute: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
mockIsKafkaConnected.mockReturnValue(false);
|
||||
await expect(controller.readiness()).rejects.toThrow(HttpException);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,25 @@
|
||||
import { Controller, Get, HttpException, HttpStatus } from "@nestjs/common";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { db } from "../../config/database.js";
|
||||
import { getDb } from "../../config/database.js";
|
||||
import { getNeo4jSession } from "../../config/neo4j.js";
|
||||
import { isKafkaProducerConnected } from "../../config/kafka.js";
|
||||
import { env } from "../../config/env.js";
|
||||
|
||||
const SERVICE_NAME = "content";
|
||||
|
||||
interface DependencyCheck {
|
||||
name: string;
|
||||
status: "ok" | "error";
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface ReadinessResponse {
|
||||
status: "ok" | "error";
|
||||
service: string;
|
||||
timestamp: string;
|
||||
dependencies: DependencyCheck[];
|
||||
}
|
||||
|
||||
@Controller()
|
||||
export class HealthController {
|
||||
@Get("healthz")
|
||||
@@ -16,29 +32,83 @@ export class HealthController {
|
||||
}
|
||||
|
||||
@Get("readyz")
|
||||
async readiness(): Promise<{
|
||||
status: string;
|
||||
service: string;
|
||||
timestamp: string;
|
||||
}> {
|
||||
async readiness(): Promise<ReadinessResponse> {
|
||||
const dependencies: DependencyCheck[] = [];
|
||||
|
||||
// 1. DB check
|
||||
try {
|
||||
await db.execute(sql`SELECT 1`);
|
||||
return {
|
||||
await getDb().execute(sql`SELECT 1`);
|
||||
dependencies.push({ name: "mysql", status: "ok" });
|
||||
} catch (err) {
|
||||
dependencies.push({
|
||||
name: "mysql",
|
||||
status: "error",
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Neo4j check (optional — only if configured)
|
||||
if (env.NEO4J_URL && env.NEO4J_PASSWORD) {
|
||||
const session = getNeo4jSession();
|
||||
if (!session) {
|
||||
dependencies.push({
|
||||
name: "neo4j",
|
||||
status: "error",
|
||||
error: "driver not initialized",
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
await session.executeRead((tx) => tx.run("RETURN 1"));
|
||||
dependencies.push({ name: "neo4j", status: "ok" });
|
||||
} catch (err) {
|
||||
dependencies.push({
|
||||
name: "neo4j",
|
||||
status: "error",
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
dependencies.push({
|
||||
name: "neo4j",
|
||||
status: "ok",
|
||||
service: SERVICE_NAME,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
} catch (error) {
|
||||
error: "not configured (optional)",
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Kafka check (non-blocking — producer may be disconnected if broker unavailable)
|
||||
if (isKafkaProducerConnected()) {
|
||||
dependencies.push({ name: "kafka", status: "ok" });
|
||||
} else {
|
||||
dependencies.push({
|
||||
name: "kafka",
|
||||
status: "error",
|
||||
error: "producer not connected",
|
||||
});
|
||||
}
|
||||
|
||||
const allOk = dependencies.every((d) => d.status === "ok");
|
||||
const status = allOk ? "ok" : "error";
|
||||
|
||||
if (!allOk) {
|
||||
throw new HttpException(
|
||||
{
|
||||
status: "error",
|
||||
status,
|
||||
service: SERVICE_NAME,
|
||||
timestamp: new Date().toISOString(),
|
||||
error:
|
||||
error instanceof Error ? error.message : "database unreachable",
|
||||
},
|
||||
dependencies,
|
||||
} satisfies ReadinessResponse,
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
status,
|
||||
service: SERVICE_NAME,
|
||||
timestamp: new Date().toISOString(),
|
||||
dependencies,
|
||||
} satisfies ReadinessResponse;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const mockCloseDb = vi.fn();
|
||||
const mockCloseNeo4j = vi.fn();
|
||||
|
||||
vi.mock("../../config/database.js", () => ({
|
||||
closeDb: () => mockCloseDb(),
|
||||
}));
|
||||
|
||||
vi.mock("../../config/neo4j.js", () => ({
|
||||
closeNeo4j: () => mockCloseNeo4j(),
|
||||
}));
|
||||
|
||||
import { LifecycleService } from "./lifecycle.service.js";
|
||||
|
||||
describe("LifecycleService", () => {
|
||||
let service: LifecycleService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
service = new LifecycleService();
|
||||
});
|
||||
|
||||
describe("onModuleInit", () => {
|
||||
it("should not throw on init", () => {
|
||||
expect(() => service.onModuleInit()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("onApplicationShutdown", () => {
|
||||
it("should close neo4j and db on shutdown", async () => {
|
||||
mockCloseNeo4j.mockResolvedValue(undefined);
|
||||
mockCloseDb.mockResolvedValue(undefined);
|
||||
await service.onApplicationShutdown("SIGTERM");
|
||||
expect(mockCloseNeo4j).toHaveBeenCalled();
|
||||
expect(mockCloseDb).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle signal undefined as unknown", async () => {
|
||||
mockCloseNeo4j.mockResolvedValue(undefined);
|
||||
mockCloseDb.mockResolvedValue(undefined);
|
||||
await service.onApplicationShutdown();
|
||||
expect(mockCloseDb).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should swallow errors during shutdown", async () => {
|
||||
mockCloseNeo4j.mockRejectedValue(new Error("neo4j close failed"));
|
||||
mockCloseDb.mockResolvedValue(undefined);
|
||||
await expect(
|
||||
service.onApplicationShutdown("SIGTERM"),
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
112
services/content/src/shared/outbox/events.test.ts
Normal file
112
services/content/src/shared/outbox/events.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
CONTENT_TOPICS,
|
||||
AGGREGATE_TYPES,
|
||||
EVENT_TYPES,
|
||||
getTopicForEvent,
|
||||
buildEventPayload,
|
||||
} from "./events.js";
|
||||
|
||||
describe("events", () => {
|
||||
describe("CONTENT_TOPICS", () => {
|
||||
it("should have 4 aggregate topics", () => {
|
||||
expect(Object.keys(CONTENT_TOPICS)).toHaveLength(4);
|
||||
expect(CONTENT_TOPICS.TEXTBOOK).toBe("edu.content.textbook.events");
|
||||
expect(CONTENT_TOPICS.CHAPTER).toBe("edu.content.chapter.events");
|
||||
expect(CONTENT_TOPICS.KNOWLEDGE_POINT).toBe(
|
||||
"edu.content.knowledge_point.events",
|
||||
);
|
||||
expect(CONTENT_TOPICS.QUESTION).toBe("edu.content.question.events");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AGGREGATE_TYPES", () => {
|
||||
it("should have 4 aggregate types", () => {
|
||||
expect(AGGREGATE_TYPES.TEXTBOOK).toBe("Textbook");
|
||||
expect(AGGREGATE_TYPES.CHAPTER).toBe("Chapter");
|
||||
expect(AGGREGATE_TYPES.KNOWLEDGE_POINT).toBe("KnowledgePoint");
|
||||
expect(AGGREGATE_TYPES.QUESTION).toBe("Question");
|
||||
});
|
||||
});
|
||||
|
||||
describe("EVENT_TYPES", () => {
|
||||
it("should have 15 event types", () => {
|
||||
expect(Object.keys(EVENT_TYPES)).toHaveLength(15);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTopicForEvent", () => {
|
||||
it("should route textbook events to textbook topic", () => {
|
||||
expect(getTopicForEvent(EVENT_TYPES.TEXTBOOK_CREATED)).toBe(
|
||||
CONTENT_TOPICS.TEXTBOOK,
|
||||
);
|
||||
expect(getTopicForEvent(EVENT_TYPES.TEXTBOOK_UPDATED)).toBe(
|
||||
CONTENT_TOPICS.TEXTBOOK,
|
||||
);
|
||||
expect(getTopicForEvent(EVENT_TYPES.TEXTBOOK_PUBLISHED)).toBe(
|
||||
CONTENT_TOPICS.TEXTBOOK,
|
||||
);
|
||||
expect(getTopicForEvent(EVENT_TYPES.TEXTBOOK_ARCHIVED)).toBe(
|
||||
CONTENT_TOPICS.TEXTBOOK,
|
||||
);
|
||||
});
|
||||
|
||||
it("should route chapter events to chapter topic", () => {
|
||||
expect(getTopicForEvent(EVENT_TYPES.CHAPTER_CREATED)).toBe(
|
||||
CONTENT_TOPICS.CHAPTER,
|
||||
);
|
||||
expect(getTopicForEvent(EVENT_TYPES.CHAPTER_DELETED)).toBe(
|
||||
CONTENT_TOPICS.CHAPTER,
|
||||
);
|
||||
});
|
||||
|
||||
it("should route KP events to knowledge_point topic", () => {
|
||||
expect(getTopicForEvent(EVENT_TYPES.KP_CREATED)).toBe(
|
||||
CONTENT_TOPICS.KNOWLEDGE_POINT,
|
||||
);
|
||||
expect(getTopicForEvent(EVENT_TYPES.KP_PREREQUISITE_ADDED)).toBe(
|
||||
CONTENT_TOPICS.KNOWLEDGE_POINT,
|
||||
);
|
||||
});
|
||||
|
||||
it("should route question events to question topic", () => {
|
||||
expect(getTopicForEvent(EVENT_TYPES.QUESTION_CREATED)).toBe(
|
||||
CONTENT_TOPICS.QUESTION,
|
||||
);
|
||||
expect(getTopicForEvent(EVENT_TYPES.QUESTION_PUBLISHED)).toBe(
|
||||
CONTENT_TOPICS.QUESTION,
|
||||
);
|
||||
});
|
||||
|
||||
it("should return fallback topic for unknown events", () => {
|
||||
expect(getTopicForEvent("unknown.event")).toBe("edu.content.fallback");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildEventPayload", () => {
|
||||
it("should build payload with event_id, aggregate_id, event_type, occurred_at, action", () => {
|
||||
const payload = buildEventPayload("textbook.created", "tb-1", {
|
||||
title: "Test",
|
||||
});
|
||||
|
||||
expect(payload.event_id).toBeDefined();
|
||||
expect(payload.aggregate_id).toBe("tb-1");
|
||||
expect(payload.event_type).toBe("edu.content.textbook.created");
|
||||
expect(payload.occurred_at).toBeGreaterThan(0);
|
||||
expect(payload.action).toBe("created");
|
||||
expect(payload.title).toBe("Test");
|
||||
});
|
||||
|
||||
it("should extract action from event type with multiple dots", () => {
|
||||
const payload = buildEventPayload(
|
||||
"knowledge_point.prerequisite_added",
|
||||
"kp-1",
|
||||
{},
|
||||
);
|
||||
expect(payload.action).toBe("prerequisite_added");
|
||||
expect(payload.event_type).toBe(
|
||||
"edu.content.knowledge_point.prerequisite_added",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
80
services/content/src/shared/outbox/events.ts
Normal file
80
services/content/src/shared/outbox/events.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
|
||||
export const CONTENT_TOPICS = {
|
||||
TEXTBOOK: "edu.content.textbook.events",
|
||||
CHAPTER: "edu.content.chapter.events",
|
||||
KNOWLEDGE_POINT: "edu.content.knowledge_point.events",
|
||||
QUESTION: "edu.content.question.events",
|
||||
} as const;
|
||||
|
||||
export const AGGREGATE_TYPES = {
|
||||
TEXTBOOK: "Textbook",
|
||||
CHAPTER: "Chapter",
|
||||
KNOWLEDGE_POINT: "KnowledgePoint",
|
||||
QUESTION: "Question",
|
||||
} as const;
|
||||
|
||||
export const EVENT_TYPES = {
|
||||
TEXTBOOK_CREATED: "textbook.created",
|
||||
TEXTBOOK_UPDATED: "textbook.updated",
|
||||
TEXTBOOK_PUBLISHED: "textbook.published",
|
||||
TEXTBOOK_ARCHIVED: "textbook.archived",
|
||||
CHAPTER_CREATED: "chapter.created",
|
||||
CHAPTER_UPDATED: "chapter.updated",
|
||||
CHAPTER_DELETED: "chapter.deleted",
|
||||
KP_CREATED: "knowledge_point.created",
|
||||
KP_UPDATED: "knowledge_point.updated",
|
||||
KP_PREREQUISITE_ADDED: "knowledge_point.prerequisite_added",
|
||||
KP_PREREQUISITE_REMOVED: "knowledge_point.prerequisite_removed",
|
||||
QUESTION_CREATED: "question.created",
|
||||
QUESTION_UPDATED: "question.updated",
|
||||
QUESTION_PUBLISHED: "question.published",
|
||||
QUESTION_DELETED: "question.deleted",
|
||||
} as const;
|
||||
|
||||
const TOPIC_MAP: Record<string, string> = {
|
||||
[EVENT_TYPES.TEXTBOOK_CREATED]: CONTENT_TOPICS.TEXTBOOK,
|
||||
[EVENT_TYPES.TEXTBOOK_UPDATED]: CONTENT_TOPICS.TEXTBOOK,
|
||||
[EVENT_TYPES.TEXTBOOK_PUBLISHED]: CONTENT_TOPICS.TEXTBOOK,
|
||||
[EVENT_TYPES.TEXTBOOK_ARCHIVED]: CONTENT_TOPICS.TEXTBOOK,
|
||||
[EVENT_TYPES.CHAPTER_CREATED]: CONTENT_TOPICS.CHAPTER,
|
||||
[EVENT_TYPES.CHAPTER_UPDATED]: CONTENT_TOPICS.CHAPTER,
|
||||
[EVENT_TYPES.CHAPTER_DELETED]: CONTENT_TOPICS.CHAPTER,
|
||||
[EVENT_TYPES.KP_CREATED]: CONTENT_TOPICS.KNOWLEDGE_POINT,
|
||||
[EVENT_TYPES.KP_UPDATED]: CONTENT_TOPICS.KNOWLEDGE_POINT,
|
||||
[EVENT_TYPES.KP_PREREQUISITE_ADDED]: CONTENT_TOPICS.KNOWLEDGE_POINT,
|
||||
[EVENT_TYPES.KP_PREREQUISITE_REMOVED]: CONTENT_TOPICS.KNOWLEDGE_POINT,
|
||||
[EVENT_TYPES.QUESTION_CREATED]: CONTENT_TOPICS.QUESTION,
|
||||
[EVENT_TYPES.QUESTION_UPDATED]: CONTENT_TOPICS.QUESTION,
|
||||
[EVENT_TYPES.QUESTION_PUBLISHED]: CONTENT_TOPICS.QUESTION,
|
||||
[EVENT_TYPES.QUESTION_DELETED]: CONTENT_TOPICS.QUESTION,
|
||||
};
|
||||
|
||||
export function getTopicForEvent(eventType: string): string {
|
||||
return TOPIC_MAP[eventType] ?? "edu.content.fallback";
|
||||
}
|
||||
|
||||
export interface EventPayload {
|
||||
event_id: string;
|
||||
aggregate_id: string;
|
||||
event_type: string;
|
||||
occurred_at: number;
|
||||
action: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export function buildEventPayload(
|
||||
eventType: string,
|
||||
aggregateId: string,
|
||||
data: Record<string, unknown>,
|
||||
): EventPayload {
|
||||
const action = eventType.split(".").pop() ?? eventType;
|
||||
return {
|
||||
event_id: createId(),
|
||||
aggregate_id: aggregateId,
|
||||
event_type: `edu.content.${eventType}`,
|
||||
occurred_at: Date.now(),
|
||||
action,
|
||||
...data,
|
||||
};
|
||||
}
|
||||
8
services/content/src/shared/outbox/outbox.module.ts
Normal file
8
services/content/src/shared/outbox/outbox.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { OutboxService } from "./outbox.service.js";
|
||||
|
||||
@Module({
|
||||
providers: [OutboxService],
|
||||
exports: [OutboxService],
|
||||
})
|
||||
export class OutboxModule {}
|
||||
163
services/content/src/shared/outbox/outbox.publisher.test.ts
Normal file
163
services/content/src/shared/outbox/outbox.publisher.test.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const mockProducer = vi.hoisted(() => ({ send: vi.fn() }));
|
||||
|
||||
vi.mock("../../config/kafka.js", () => ({
|
||||
producer: mockProducer,
|
||||
}));
|
||||
|
||||
vi.mock("../observability/logger.js", () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./outbox.repository.js", () => ({
|
||||
outboxRepository: {
|
||||
findPending: vi.fn(),
|
||||
markPublished: vi.fn(),
|
||||
incrementRetry: vi.fn(),
|
||||
markFailed: vi.fn(),
|
||||
create: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { OutboxPublisher } from "./outbox.publisher.js";
|
||||
import { outboxRepository } from "./outbox.repository.js";
|
||||
import type { OutboxMessage } from "./outbox.schema.js";
|
||||
|
||||
function createMessage(overrides: Partial<OutboxMessage> = {}): OutboxMessage {
|
||||
return {
|
||||
id: "msg-1",
|
||||
aggregateType: "Chapter",
|
||||
aggregateId: "ch-1",
|
||||
eventType: "chapter.created",
|
||||
topic: "edu.content.chapter.events",
|
||||
payload: '{"event_id":"e1"}',
|
||||
status: "pending",
|
||||
retryCount: 0,
|
||||
createdAt: new Date(),
|
||||
publishedAt: null,
|
||||
nextRetryAt: null,
|
||||
lastError: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("OutboxPublisher", () => {
|
||||
let publisher: OutboxPublisher;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
publisher = new OutboxPublisher();
|
||||
});
|
||||
|
||||
describe("dispatch (via poll)", () => {
|
||||
it("should send message to kafka and mark as published", async () => {
|
||||
const message = createMessage();
|
||||
vi.mocked(outboxRepository.findPending).mockResolvedValue([message]);
|
||||
mockProducer.send.mockResolvedValue(undefined);
|
||||
|
||||
// Access private poll via casting
|
||||
await (publisher as unknown as { poll: () => Promise<void> }).poll();
|
||||
|
||||
expect(mockProducer.send).toHaveBeenCalledWith({
|
||||
topic: message.topic,
|
||||
messages: [
|
||||
{
|
||||
key: message.aggregateId,
|
||||
value: message.payload,
|
||||
headers: {
|
||||
eventId: message.id,
|
||||
eventType: message.eventType,
|
||||
aggregateType: message.aggregateType,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(outboxRepository.markPublished).toHaveBeenCalledWith(message.id);
|
||||
});
|
||||
|
||||
it("should increment retry when send fails and below max retries", async () => {
|
||||
const message = createMessage({ id: "msg-2", retryCount: 0 });
|
||||
vi.mocked(outboxRepository.findPending).mockResolvedValue([message]);
|
||||
mockProducer.send.mockRejectedValue(new Error("kafka down"));
|
||||
|
||||
await (publisher as unknown as { poll: () => Promise<void> }).poll();
|
||||
|
||||
expect(outboxRepository.incrementRetry).toHaveBeenCalledWith(
|
||||
"msg-2",
|
||||
"kafka down",
|
||||
);
|
||||
expect(outboxRepository.markFailed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should mark failed when retry count reaches max", async () => {
|
||||
const message = createMessage({ id: "msg-3", retryCount: 4 });
|
||||
vi.mocked(outboxRepository.findPending).mockResolvedValue([message]);
|
||||
mockProducer.send.mockRejectedValue(new Error("kafka down"));
|
||||
|
||||
await (publisher as unknown as { poll: () => Promise<void> }).poll();
|
||||
|
||||
expect(outboxRepository.markFailed).toHaveBeenCalledWith(
|
||||
"msg-3",
|
||||
"kafka down",
|
||||
);
|
||||
expect(outboxRepository.incrementRetry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle non-Error rejection in dispatch", async () => {
|
||||
const message = createMessage({ id: "msg-4", retryCount: 0 });
|
||||
vi.mocked(outboxRepository.findPending).mockResolvedValue([message]);
|
||||
mockProducer.send.mockRejectedValue("string error");
|
||||
|
||||
await (publisher as unknown as { poll: () => Promise<void> }).poll();
|
||||
|
||||
expect(outboxRepository.incrementRetry).toHaveBeenCalledWith(
|
||||
"msg-4",
|
||||
"string error",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("poll error handling", () => {
|
||||
it("should swallow errors from findPending", async () => {
|
||||
vi.mocked(outboxRepository.findPending).mockRejectedValue(
|
||||
new Error("db error"),
|
||||
);
|
||||
await expect(
|
||||
(publisher as unknown as { poll: () => Promise<void> }).poll(),
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it("should process multiple messages in a batch", async () => {
|
||||
const messages = [
|
||||
createMessage({ id: "m1" }),
|
||||
createMessage({ id: "m2" }),
|
||||
];
|
||||
vi.mocked(outboxRepository.findPending).mockResolvedValue(messages);
|
||||
mockProducer.send.mockResolvedValue(undefined);
|
||||
|
||||
await (publisher as unknown as { poll: () => Promise<void> }).poll();
|
||||
|
||||
expect(mockProducer.send).toHaveBeenCalledTimes(2);
|
||||
expect(outboxRepository.markPublished).toHaveBeenCalledWith("m1");
|
||||
expect(outboxRepository.markPublished).toHaveBeenCalledWith("m2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("start/stop", () => {
|
||||
it("should start and stop without error", async () => {
|
||||
await publisher.start();
|
||||
await publisher.stop();
|
||||
// Should not throw
|
||||
});
|
||||
|
||||
it("should stop without error when not started", async () => {
|
||||
await publisher.stop();
|
||||
});
|
||||
});
|
||||
});
|
||||
81
services/content/src/shared/outbox/outbox.publisher.ts
Normal file
81
services/content/src/shared/outbox/outbox.publisher.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { logger } from "../observability/logger.js";
|
||||
import { producer } from "../../config/kafka.js";
|
||||
import { outboxRepository } from "./outbox.repository.js";
|
||||
import type { OutboxMessage } from "./outbox.schema.js";
|
||||
|
||||
const POLL_INTERVAL_MS = 5000;
|
||||
const BATCH_SIZE = 100;
|
||||
const MAX_RETRY = 5;
|
||||
|
||||
export class OutboxPublisher {
|
||||
private intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
private isPolling = false;
|
||||
|
||||
async start(): Promise<void> {
|
||||
logger.info("OutboxPublisher started");
|
||||
this.intervalId = setInterval(() => {
|
||||
void this.poll();
|
||||
}, POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId);
|
||||
this.intervalId = null;
|
||||
}
|
||||
logger.info("OutboxPublisher stopped");
|
||||
}
|
||||
|
||||
private async poll(): Promise<void> {
|
||||
if (this.isPolling) return;
|
||||
this.isPolling = true;
|
||||
try {
|
||||
const messages = await outboxRepository.findPending(BATCH_SIZE);
|
||||
for (const message of messages) {
|
||||
await this.dispatch(message);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Outbox poll failed");
|
||||
} finally {
|
||||
this.isPolling = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async dispatch(message: OutboxMessage): Promise<void> {
|
||||
try {
|
||||
await producer.send({
|
||||
topic: message.topic,
|
||||
messages: [
|
||||
{
|
||||
key: message.aggregateId,
|
||||
value: message.payload,
|
||||
headers: {
|
||||
eventId: message.id,
|
||||
eventType: message.eventType,
|
||||
aggregateType: message.aggregateType,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
await outboxRepository.markPublished(message.id);
|
||||
logger.info(
|
||||
{ id: message.id, eventType: message.eventType, topic: message.topic },
|
||||
"Outbox message published",
|
||||
);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
logger.error(
|
||||
{ error, id: message.id, eventType: message.eventType },
|
||||
"Outbox publish failed",
|
||||
);
|
||||
if (message.retryCount + 1 >= MAX_RETRY) {
|
||||
await outboxRepository.markFailed(message.id, errorMessage);
|
||||
} else {
|
||||
await outboxRepository.incrementRetry(message.id, errorMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const outboxPublisher = new OutboxPublisher();
|
||||
59
services/content/src/shared/outbox/outbox.repository.ts
Normal file
59
services/content/src/shared/outbox/outbox.repository.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { eq, sql, and, or, isNull, lte } from "drizzle-orm";
|
||||
import type { MySql2Database } from "drizzle-orm/mysql2";
|
||||
import { getDb } from "../../config/database.js";
|
||||
import {
|
||||
outbox,
|
||||
type OutboxMessage,
|
||||
type NewOutboxMessage,
|
||||
} from "./outbox.schema.js";
|
||||
|
||||
type DbClient = MySql2Database;
|
||||
|
||||
export class OutboxRepository {
|
||||
async create(message: NewOutboxMessage, tx?: DbClient): Promise<void> {
|
||||
const client = tx ?? getDb();
|
||||
await client.insert(outbox).values(message);
|
||||
}
|
||||
|
||||
async findPending(limit: number = 100): Promise<OutboxMessage[]> {
|
||||
const now = new Date();
|
||||
return getDb()
|
||||
.select()
|
||||
.from(outbox)
|
||||
.where(
|
||||
and(
|
||||
eq(outbox.status, "pending"),
|
||||
or(isNull(outbox.nextRetryAt), lte(outbox.nextRetryAt, now)),
|
||||
),
|
||||
)
|
||||
.limit(limit);
|
||||
}
|
||||
|
||||
async markPublished(id: string): Promise<void> {
|
||||
await getDb()
|
||||
.update(outbox)
|
||||
.set({ status: "published", publishedAt: new Date(), lastError: null })
|
||||
.where(eq(outbox.id, id));
|
||||
}
|
||||
|
||||
async incrementRetry(id: string, errorMessage: string): Promise<void> {
|
||||
const backoffMs = 5000 * 2 ** 1;
|
||||
await getDb()
|
||||
.update(outbox)
|
||||
.set({
|
||||
retryCount: sql`${outbox.retryCount} + 1`,
|
||||
nextRetryAt: new Date(Date.now() + backoffMs),
|
||||
lastError: errorMessage,
|
||||
})
|
||||
.where(eq(outbox.id, id));
|
||||
}
|
||||
|
||||
async markFailed(id: string, errorMessage: string): Promise<void> {
|
||||
await getDb()
|
||||
.update(outbox)
|
||||
.set({ status: "failed", lastError: errorMessage })
|
||||
.where(eq(outbox.id, id));
|
||||
}
|
||||
}
|
||||
|
||||
export const outboxRepository = new OutboxRepository();
|
||||
39
services/content/src/shared/outbox/outbox.schema.ts
Normal file
39
services/content/src/shared/outbox/outbox.schema.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
mysqlTable,
|
||||
varchar,
|
||||
text,
|
||||
timestamp,
|
||||
int,
|
||||
index,
|
||||
} from "drizzle-orm/mysql-core";
|
||||
|
||||
export const outbox = mysqlTable(
|
||||
"content_outbox_events",
|
||||
{
|
||||
id: varchar("id", { length: 32 }).notNull().primaryKey(),
|
||||
aggregateType: varchar("aggregate_type", { length: 64 }).notNull(),
|
||||
aggregateId: varchar("aggregate_id", { length: 32 }).notNull(),
|
||||
eventType: varchar("event_type", { length: 100 }).notNull(),
|
||||
topic: varchar("topic", { length: 128 }).notNull(),
|
||||
payload: text("payload").notNull(),
|
||||
status: varchar("status", { length: 20 }).notNull().default("pending"),
|
||||
retryCount: int("retry_count").notNull().default(0),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
publishedAt: timestamp("published_at"),
|
||||
nextRetryAt: timestamp("next_retry_at"),
|
||||
lastError: text("last_error"),
|
||||
},
|
||||
(table) => ({
|
||||
statusRetryIdx: index("idx_outbox_status_retry").on(
|
||||
table.status,
|
||||
table.nextRetryAt,
|
||||
),
|
||||
aggregateIdx: index("idx_outbox_aggregate").on(
|
||||
table.aggregateType,
|
||||
table.aggregateId,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
export type OutboxMessage = typeof outbox.$inferSelect;
|
||||
export type NewOutboxMessage = typeof outbox.$inferInsert;
|
||||
103
services/content/src/shared/outbox/outbox.service.test.ts
Normal file
103
services/content/src/shared/outbox/outbox.service.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@paralleldrive/cuid2", () => ({
|
||||
createId: vi.fn().mockReturnValue("test-event-id"),
|
||||
}));
|
||||
|
||||
vi.mock("./outbox.repository.js", () => ({
|
||||
outboxRepository: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { OutboxService } from "./outbox.service.js";
|
||||
import { outboxRepository } from "./outbox.repository.js";
|
||||
import { getTopicForEvent } from "./events.js";
|
||||
|
||||
describe("OutboxService", () => {
|
||||
let service: OutboxService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
service = new OutboxService();
|
||||
});
|
||||
|
||||
describe("publish", () => {
|
||||
it("should call outboxRepository.create with correct payload structure", async () => {
|
||||
const data = { title: "Test Chapter" };
|
||||
const eventId = await service.publish(
|
||||
"chapter.created",
|
||||
"Chapter",
|
||||
"ch-1",
|
||||
data,
|
||||
);
|
||||
|
||||
expect(outboxRepository.create).toHaveBeenCalledTimes(1);
|
||||
const [message] = vi.mocked(outboxRepository.create).mock.calls[0]!;
|
||||
expect(message.id).toBe("test-event-id");
|
||||
expect(message.aggregateType).toBe("Chapter");
|
||||
expect(message.aggregateId).toBe("ch-1");
|
||||
expect(message.eventType).toBe("chapter.created");
|
||||
expect(message.status).toBe("pending");
|
||||
expect(typeof message.payload).toBe("string");
|
||||
expect(eventId).toBe("test-event-id");
|
||||
});
|
||||
|
||||
it("should set status to pending", async () => {
|
||||
await service.publish("chapter.updated", "Chapter", "ch-1", {});
|
||||
const [message] = vi.mocked(outboxRepository.create).mock.calls[0]!;
|
||||
expect(message.status).toBe("pending");
|
||||
});
|
||||
|
||||
it("should set topic based on event type via getTopicForEvent", async () => {
|
||||
await service.publish("chapter.created", "Chapter", "ch-1", {});
|
||||
const [message] = vi.mocked(outboxRepository.create).mock.calls[0]!;
|
||||
expect(message.topic).toBe(getTopicForEvent("chapter.created"));
|
||||
});
|
||||
|
||||
it("should serialize payload as JSON string containing event data", async () => {
|
||||
const data = { title: "Test Chapter", order: 2 };
|
||||
await service.publish("chapter.created", "Chapter", "ch-1", data);
|
||||
const [message] = vi.mocked(outboxRepository.create).mock.calls[0]!;
|
||||
|
||||
const parsed = JSON.parse(message.payload);
|
||||
expect(parsed.aggregate_id).toBe("ch-1");
|
||||
expect(parsed.event_type).toBe("edu.content.chapter.created");
|
||||
expect(parsed.action).toBe("created");
|
||||
expect(parsed.title).toBe("Test Chapter");
|
||||
expect(parsed.order).toBe(2);
|
||||
});
|
||||
|
||||
it("should return the generated eventId", async () => {
|
||||
const eventId = await service.publish(
|
||||
"chapter.deleted",
|
||||
"Chapter",
|
||||
"ch-1",
|
||||
{},
|
||||
);
|
||||
expect(eventId).toBe("test-event-id");
|
||||
});
|
||||
|
||||
it("should pass tx to repository when provided", async () => {
|
||||
const tx = {} as never;
|
||||
await service.publish("chapter.created", "Chapter", "ch-1", {}, tx);
|
||||
const [, passedTx] = vi.mocked(outboxRepository.create).mock.calls[0]!;
|
||||
expect(passedTx).toBe(tx);
|
||||
});
|
||||
|
||||
it("should pass undefined tx when not provided", async () => {
|
||||
await service.publish("chapter.created", "Chapter", "ch-1", {});
|
||||
const [, passedTx] = vi.mocked(outboxRepository.create).mock.calls[0]!;
|
||||
expect(passedTx).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should include aggregate data in payload", async () => {
|
||||
const data = { name: "Chapter A", status: "draft" };
|
||||
await service.publish("chapter.created", "Chapter", "ch-9", data);
|
||||
const [message] = vi.mocked(outboxRepository.create).mock.calls[0]!;
|
||||
const parsed = JSON.parse(message.payload);
|
||||
expect(parsed.name).toBe("Chapter A");
|
||||
expect(parsed.status).toBe("draft");
|
||||
});
|
||||
});
|
||||
});
|
||||
35
services/content/src/shared/outbox/outbox.service.ts
Normal file
35
services/content/src/shared/outbox/outbox.service.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import type { MySql2Database } from "drizzle-orm/mysql2";
|
||||
import { outboxRepository } from "./outbox.repository.js";
|
||||
import { getTopicForEvent, buildEventPayload } from "./events.js";
|
||||
|
||||
@Injectable()
|
||||
export class OutboxService {
|
||||
async publish(
|
||||
eventType: string,
|
||||
aggregateType: string,
|
||||
aggregateId: string,
|
||||
data: Record<string, unknown>,
|
||||
tx?: MySql2Database,
|
||||
): Promise<string> {
|
||||
const eventId = createId();
|
||||
const topic = getTopicForEvent(eventType);
|
||||
const payload = buildEventPayload(eventType, aggregateId, data);
|
||||
|
||||
await outboxRepository.create(
|
||||
{
|
||||
id: eventId,
|
||||
aggregateType,
|
||||
aggregateId,
|
||||
eventType,
|
||||
topic,
|
||||
payload: JSON.stringify(payload),
|
||||
status: "pending",
|
||||
},
|
||||
tx,
|
||||
);
|
||||
|
||||
return eventId;
|
||||
}
|
||||
}
|
||||
187
services/content/src/shared/sync/neo4j-sync.worker.ts
Normal file
187
services/content/src/shared/sync/neo4j-sync.worker.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { logger } from "../observability/logger.js";
|
||||
import { neo4jSyncConsumer } from "../../config/kafka.js";
|
||||
import { getNeo4jSession } from "../../config/neo4j.js";
|
||||
import { CONTENT_TOPICS, EVENT_TYPES } from "../outbox/events.js";
|
||||
|
||||
interface EventEnvelope {
|
||||
event_id: string;
|
||||
aggregate_id: string;
|
||||
event_type: string;
|
||||
occurred_at: number;
|
||||
action: string;
|
||||
kp_id?: string;
|
||||
chapter_id?: string;
|
||||
title?: string;
|
||||
difficulty?: number;
|
||||
prerequisite_id?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const processedEvents = new Set<string>();
|
||||
const DEDUP_MAX_SIZE = 10000;
|
||||
|
||||
export class Neo4jSyncWorker {
|
||||
private running = false;
|
||||
|
||||
async start(): Promise<void> {
|
||||
try {
|
||||
await neo4jSyncConsumer.connect();
|
||||
await neo4jSyncConsumer.subscribe({
|
||||
topic: CONTENT_TOPICS.KNOWLEDGE_POINT,
|
||||
fromBeginning: false,
|
||||
});
|
||||
|
||||
this.running = true;
|
||||
await neo4jSyncConsumer.run({
|
||||
eachMessage: async ({ message }) => {
|
||||
await this.handleMessage(message);
|
||||
},
|
||||
});
|
||||
logger.info("Neo4jSyncWorker started");
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
"Neo4jSyncWorker failed to start (Neo4j/Kafka may be unavailable)",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.running = false;
|
||||
try {
|
||||
await neo4jSyncConsumer.stop();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
logger.info("Neo4jSyncWorker stopped");
|
||||
}
|
||||
|
||||
isRunning(): boolean {
|
||||
return this.running;
|
||||
}
|
||||
|
||||
private async handleMessage(message: {
|
||||
key: Buffer | null;
|
||||
value: Buffer | null;
|
||||
}): Promise<void> {
|
||||
const value = message.value?.toString();
|
||||
if (!value) return;
|
||||
|
||||
let event: EventEnvelope;
|
||||
try {
|
||||
event = JSON.parse(value) as EventEnvelope;
|
||||
} catch {
|
||||
logger.error({ value }, "Failed to parse event payload");
|
||||
return;
|
||||
}
|
||||
|
||||
if (processedEvents.has(event.event_id)) {
|
||||
return;
|
||||
}
|
||||
processedEvents.add(event.event_id);
|
||||
if (processedEvents.size > DEDUP_MAX_SIZE) {
|
||||
const first = processedEvents.values().next().value;
|
||||
if (first) processedEvents.delete(first);
|
||||
}
|
||||
|
||||
const session = getNeo4jSession();
|
||||
if (!session) {
|
||||
logger.debug(
|
||||
{ eventType: event.event_type },
|
||||
"Neo4j not available, skipping sync",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.syncEvent(session, event);
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
{
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
eventId: event.event_id,
|
||||
eventType: event.event_type,
|
||||
},
|
||||
"Neo4j sync failed",
|
||||
);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
}
|
||||
|
||||
private async syncEvent(
|
||||
session: NonNullable<ReturnType<typeof getNeo4jSession>>,
|
||||
event: EventEnvelope,
|
||||
): Promise<void> {
|
||||
switch (event.event_type) {
|
||||
case `edu.content.${EVENT_TYPES.KP_CREATED}`:
|
||||
case `edu.content.${EVENT_TYPES.KP_UPDATED}`:
|
||||
await this.upsertNode(session, event);
|
||||
break;
|
||||
case `edu.content.${EVENT_TYPES.KP_PREREQUISITE_ADDED}`:
|
||||
await this.addPrerequisite(session, event);
|
||||
break;
|
||||
case `edu.content.${EVENT_TYPES.KP_PREREQUISITE_REMOVED}`:
|
||||
await this.removePrerequisite(session, event);
|
||||
break;
|
||||
default:
|
||||
logger.debug(
|
||||
{ eventType: event.event_type },
|
||||
"Unhandled event type for Neo4j sync",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async upsertNode(
|
||||
session: NonNullable<ReturnType<typeof getNeo4jSession>>,
|
||||
event: EventEnvelope,
|
||||
): Promise<void> {
|
||||
await session.executeWrite((tx) =>
|
||||
tx.run(
|
||||
`MERGE (kp:KnowledgePoint {id: $id})
|
||||
SET kp.title = $title, kp.difficulty = $difficulty, kp.updatedAt = datetime()`,
|
||||
{
|
||||
id: event.kp_id ?? event.aggregate_id,
|
||||
title: event.title ?? "",
|
||||
difficulty: event.difficulty ?? 3,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private async addPrerequisite(
|
||||
session: NonNullable<ReturnType<typeof getNeo4jSession>>,
|
||||
event: EventEnvelope,
|
||||
): Promise<void> {
|
||||
await session.executeWrite((tx) =>
|
||||
tx.run(
|
||||
`MATCH (kp:KnowledgePoint {id: $kpId}), (prereq:KnowledgePoint {id: $prereqId})
|
||||
MERGE (prereq)-[:PREREQUISITE_OF]->(kp)`,
|
||||
{
|
||||
kpId: event.aggregate_id,
|
||||
prereqId: event.prerequisite_id ?? "",
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private async removePrerequisite(
|
||||
session: NonNullable<ReturnType<typeof getNeo4jSession>>,
|
||||
event: EventEnvelope,
|
||||
): Promise<void> {
|
||||
await session.executeWrite((tx) =>
|
||||
tx.run(
|
||||
`MATCH (prereq:KnowledgePoint {id: $prereqId})-[r:PREREQUISITE_OF]->(kp:KnowledgePoint {id: $kpId})
|
||||
DELETE r`,
|
||||
{
|
||||
kpId: event.aggregate_id,
|
||||
prereqId: event.prerequisite_id ?? "",
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const neo4jSyncWorker = new Neo4jSyncWorker();
|
||||
90
services/content/src/textbooks/textbooks.controller.test.ts
Normal file
90
services/content/src/textbooks/textbooks.controller.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { TextbooksController } from "./textbooks.controller.js";
|
||||
|
||||
vi.mock("./textbooks.service.js", () => ({
|
||||
TextbooksService: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockService = {
|
||||
create: vi.fn(),
|
||||
list: vi.fn(),
|
||||
getById: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
};
|
||||
|
||||
describe("TextbooksController", () => {
|
||||
let controller: TextbooksController;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
controller = new TextbooksController(mockService as never);
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
it("should parse input and call service.create", async () => {
|
||||
mockService.create.mockResolvedValue({ id: "tb-1" });
|
||||
const result = await controller.create({
|
||||
title: "Math",
|
||||
subjectId: "subj-1",
|
||||
gradeId: "g-1",
|
||||
});
|
||||
expect(mockService.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: "Math",
|
||||
subjectId: "subj-1",
|
||||
gradeId: "g-1",
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({ success: true, data: { id: "tb-1" } });
|
||||
});
|
||||
|
||||
it("should throw on invalid body", async () => {
|
||||
await expect(controller.create({ title: "" })).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("list", () => {
|
||||
it("should parse query and call service.list", async () => {
|
||||
const data = [{ id: "tb-1" }];
|
||||
mockService.list.mockResolvedValue(data);
|
||||
const result = await controller.list({ page: "2", pageSize: "10" });
|
||||
expect(mockService.list).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ page: 2, pageSize: 10 }),
|
||||
);
|
||||
expect(result).toEqual({ success: true, data });
|
||||
});
|
||||
});
|
||||
|
||||
describe("getById", () => {
|
||||
it("should call service.getById", async () => {
|
||||
const data = { id: "tb-1", title: "T" };
|
||||
mockService.getById.mockResolvedValue(data);
|
||||
const result = await controller.getById("tb-1");
|
||||
expect(mockService.getById).toHaveBeenCalledWith("tb-1");
|
||||
expect(result).toEqual({ success: true, data });
|
||||
});
|
||||
});
|
||||
|
||||
describe("update", () => {
|
||||
it("should parse input and call service.update", async () => {
|
||||
const result = await controller.update("tb-1", { title: "New" });
|
||||
expect(mockService.update).toHaveBeenCalledWith("tb-1", { title: "New" });
|
||||
expect(result).toEqual({ success: true, data: { success: true } });
|
||||
});
|
||||
|
||||
it("should throw on invalid status", async () => {
|
||||
await expect(
|
||||
controller.update("tb-1", { status: "invalid" }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("remove", () => {
|
||||
it("should call service.delete", async () => {
|
||||
const result = await controller.remove("tb-1");
|
||||
expect(mockService.delete).toHaveBeenCalledWith("tb-1");
|
||||
expect(result).toEqual({ success: true, data: { success: true } });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,17 +6,19 @@ import {
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
TextbooksService,
|
||||
type CreateTextbookInput,
|
||||
type UpdateTextbookInput,
|
||||
} from "./textbooks.service.js";
|
||||
import { TextbooksService } from "./textbooks.service.js";
|
||||
import type { Textbook } from "./textbooks.schema.js";
|
||||
import {
|
||||
Permissions,
|
||||
RequirePermission,
|
||||
} from "../middleware/permission.guard.js";
|
||||
import {
|
||||
createTextbookSchema,
|
||||
updateTextbookSchema,
|
||||
listTextbooksSchema,
|
||||
} from "./textbooks.dto.js";
|
||||
|
||||
@Controller("textbooks")
|
||||
export class TextbooksController {
|
||||
@@ -25,16 +27,20 @@ export class TextbooksController {
|
||||
@Post()
|
||||
@RequirePermission(Permissions.CONTENT_TEXTBOOK_CREATE)
|
||||
async create(
|
||||
@Body() body: CreateTextbookInput,
|
||||
@Body() body: unknown,
|
||||
): Promise<{ success: true; data: { id: string } }> {
|
||||
const result = await this.service.create(body);
|
||||
const input = createTextbookSchema.parse(body);
|
||||
const result = await this.service.create(input);
|
||||
return { success: true, data: result };
|
||||
}
|
||||
|
||||
@Get()
|
||||
@RequirePermission(Permissions.CONTENT_TEXTBOOK_READ)
|
||||
async list(): Promise<{ success: true; data: Textbook[] }> {
|
||||
const data = await this.service.list();
|
||||
async list(
|
||||
@Query() query: unknown,
|
||||
): Promise<{ success: true; data: Textbook[] }> {
|
||||
const input = listTextbooksSchema.parse(query);
|
||||
const data = await this.service.list(input);
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
@@ -51,9 +57,10 @@ export class TextbooksController {
|
||||
@RequirePermission(Permissions.CONTENT_TEXTBOOK_UPDATE)
|
||||
async update(
|
||||
@Param("id") id: string,
|
||||
@Body() body: UpdateTextbookInput,
|
||||
@Body() body: unknown,
|
||||
): Promise<{ success: true; data: { success: true } }> {
|
||||
await this.service.update(id, body);
|
||||
const input = updateTextbookSchema.parse(body);
|
||||
await this.service.update(id, input);
|
||||
return { success: true, data: { success: true } };
|
||||
}
|
||||
|
||||
|
||||
62
services/content/src/textbooks/textbooks.dto.test.ts
Normal file
62
services/content/src/textbooks/textbooks.dto.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
createTextbookSchema,
|
||||
updateTextbookSchema,
|
||||
listTextbooksSchema,
|
||||
} from "./textbooks.dto.js";
|
||||
|
||||
describe("textbooks.dto", () => {
|
||||
describe("createTextbookSchema", () => {
|
||||
it("should parse valid input", () => {
|
||||
const result = createTextbookSchema.parse({
|
||||
title: "Math",
|
||||
subjectId: "subj-1",
|
||||
gradeId: "g-1",
|
||||
});
|
||||
expect(result.title).toBe("Math");
|
||||
expect(result.version).toBe("1.0");
|
||||
});
|
||||
|
||||
it("should reject empty title", () => {
|
||||
expect(() =>
|
||||
createTextbookSchema.parse({ title: "", subjectId: "s", gradeId: "g" }),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("should reject missing subjectId", () => {
|
||||
expect(() =>
|
||||
createTextbookSchema.parse({ title: "T", gradeId: "g" }),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateTextbookSchema", () => {
|
||||
it("should parse partial update", () => {
|
||||
const result = updateTextbookSchema.parse({ title: "New" });
|
||||
expect(result.title).toBe("New");
|
||||
});
|
||||
|
||||
it("should accept status enum", () => {
|
||||
const result = updateTextbookSchema.parse({ status: "published" });
|
||||
expect(result.status).toBe("published");
|
||||
});
|
||||
|
||||
it("should reject invalid status", () => {
|
||||
expect(() => updateTextbookSchema.parse({ status: "invalid" })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("listTextbooksSchema", () => {
|
||||
it("should default page and pageSize", () => {
|
||||
const result = listTextbooksSchema.parse({});
|
||||
expect(result.page).toBe(1);
|
||||
expect(result.pageSize).toBe(20);
|
||||
});
|
||||
|
||||
it("should coerce string numbers", () => {
|
||||
const result = listTextbooksSchema.parse({ page: "3", pageSize: "50" });
|
||||
expect(result.page).toBe(3);
|
||||
expect(result.pageSize).toBe(50);
|
||||
});
|
||||
});
|
||||
});
|
||||
28
services/content/src/textbooks/textbooks.dto.ts
Normal file
28
services/content/src/textbooks/textbooks.dto.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const createTextbookSchema = z.object({
|
||||
title: z.string().min(1).max(255),
|
||||
subjectId: z.string().min(1).max(32),
|
||||
gradeId: z.string().min(1).max(32),
|
||||
version: z.string().max(32).optional().default("1.0"),
|
||||
metadata: z.record(z.unknown()).nullish(),
|
||||
});
|
||||
|
||||
export const updateTextbookSchema = z.object({
|
||||
title: z.string().min(1).max(255).optional(),
|
||||
status: z
|
||||
.enum(["draft", "pending_review", "published", "archived"])
|
||||
.optional(),
|
||||
metadata: z.record(z.unknown()).nullish(),
|
||||
});
|
||||
|
||||
export const listTextbooksSchema = z.object({
|
||||
subjectId: z.string().optional(),
|
||||
gradeId: z.string().optional(),
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
||||
});
|
||||
|
||||
export type CreateTextbookDto = z.infer<typeof createTextbookSchema>;
|
||||
export type UpdateTextbookDto = z.infer<typeof updateTextbookSchema>;
|
||||
export type ListTextbooksDto = z.infer<typeof listTextbooksSchema>;
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TextbooksController } from "./textbooks.controller.js";
|
||||
import { TextbooksService } from "./textbooks.service.js";
|
||||
import { OutboxModule } from "../shared/outbox/outbox.module.js";
|
||||
|
||||
@Module({
|
||||
imports: [OutboxModule],
|
||||
controllers: [TextbooksController],
|
||||
providers: [TextbooksService],
|
||||
exports: [TextbooksService],
|
||||
|
||||
92
services/content/src/textbooks/textbooks.repository.test.ts
Normal file
92
services/content/src/textbooks/textbooks.repository.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const mockGetDb = vi.fn();
|
||||
|
||||
vi.mock("../config/database.js", () => ({
|
||||
getDb: () => mockGetDb(),
|
||||
}));
|
||||
|
||||
import { TextbooksRepository } from "./textbooks.repository.js";
|
||||
|
||||
function createMockDb(resolvedValue: unknown): unknown {
|
||||
const handler: ProxyHandler<Record<PropertyKey, unknown>> = {
|
||||
get: (_target, prop) => {
|
||||
if (prop === "then") {
|
||||
return (onFulfilled?: (v: unknown) => unknown) =>
|
||||
Promise.resolve(
|
||||
typeof onFulfilled === "function"
|
||||
? onFulfilled(resolvedValue)
|
||||
: resolvedValue,
|
||||
);
|
||||
}
|
||||
return () => new Proxy({}, handler);
|
||||
},
|
||||
};
|
||||
return new Proxy({}, handler);
|
||||
}
|
||||
|
||||
describe("TextbooksRepository", () => {
|
||||
let repo: TextbooksRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
repo = new TextbooksRepository();
|
||||
});
|
||||
|
||||
it("findById should return first matching textbook", async () => {
|
||||
const tb = { id: "tb-1", title: "T" };
|
||||
mockGetDb.mockReturnValue(createMockDb([tb]));
|
||||
const result = await repo.findById("tb-1");
|
||||
expect(result).toBe(tb);
|
||||
});
|
||||
|
||||
it("findById should return undefined when no match", async () => {
|
||||
mockGetDb.mockReturnValue(createMockDb([]));
|
||||
const result = await repo.findById("missing");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("find should apply query filters and pagination", async () => {
|
||||
const tbs = [{ id: "tb-1" }];
|
||||
mockGetDb.mockReturnValue(createMockDb(tbs));
|
||||
const result = await repo.find({
|
||||
subjectId: "subj-1",
|
||||
gradeId: "g-1",
|
||||
page: 2,
|
||||
pageSize: 10,
|
||||
});
|
||||
expect(result).toBe(tbs);
|
||||
});
|
||||
|
||||
it("find should use defaults when query is empty", async () => {
|
||||
const tbs: unknown[] = [];
|
||||
mockGetDb.mockReturnValue(createMockDb(tbs));
|
||||
const result = await repo.find();
|
||||
expect(result).toBe(tbs);
|
||||
});
|
||||
|
||||
it("create should insert a new textbook", async () => {
|
||||
mockGetDb.mockReturnValue(createMockDb(undefined));
|
||||
await repo.create({
|
||||
id: "tb-1",
|
||||
title: "Math",
|
||||
subjectId: "subj-1",
|
||||
gradeId: "g-1",
|
||||
version: "1.0",
|
||||
status: "draft",
|
||||
});
|
||||
expect(mockGetDb).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("update should update a textbook by id", async () => {
|
||||
mockGetDb.mockReturnValue(createMockDb(undefined));
|
||||
await repo.update("tb-1", { title: "New" });
|
||||
expect(mockGetDb).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("delete should delete a textbook by id", async () => {
|
||||
mockGetDb.mockReturnValue(createMockDb(undefined));
|
||||
await repo.delete("tb-1");
|
||||
expect(mockGetDb).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
51
services/content/src/textbooks/textbooks.repository.ts
Normal file
51
services/content/src/textbooks/textbooks.repository.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getDb } from "../config/database.js";
|
||||
import {
|
||||
textbooks,
|
||||
type Textbook,
|
||||
type NewTextbook,
|
||||
} from "./textbooks.schema.js";
|
||||
|
||||
export class TextbooksRepository {
|
||||
async findById(id: string): Promise<Textbook | undefined> {
|
||||
const [result] = await getDb()
|
||||
.select()
|
||||
.from(textbooks)
|
||||
.where(eq(textbooks.id, id))
|
||||
.limit(1);
|
||||
return result;
|
||||
}
|
||||
|
||||
async find(query?: {
|
||||
subjectId?: string;
|
||||
gradeId?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}): Promise<Textbook[]> {
|
||||
const db = getDb();
|
||||
let q = db.select().from(textbooks).$dynamic();
|
||||
if (query?.subjectId) {
|
||||
q = q.where(eq(textbooks.subjectId, query.subjectId));
|
||||
}
|
||||
if (query?.gradeId) {
|
||||
q = q.where(eq(textbooks.gradeId, query.gradeId));
|
||||
}
|
||||
const pageSize = query?.pageSize ?? 20;
|
||||
const page = query?.page ?? 1;
|
||||
return q.limit(pageSize).offset((page - 1) * pageSize);
|
||||
}
|
||||
|
||||
async create(data: NewTextbook): Promise<void> {
|
||||
await getDb().insert(textbooks).values(data);
|
||||
}
|
||||
|
||||
async update(id: string, data: Partial<NewTextbook>): Promise<void> {
|
||||
await getDb().update(textbooks).set(data).where(eq(textbooks.id, id));
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await getDb().delete(textbooks).where(eq(textbooks.id, id));
|
||||
}
|
||||
}
|
||||
|
||||
export const textbooksRepository = new TextbooksRepository();
|
||||
@@ -1,36 +1,75 @@
|
||||
import {
|
||||
mysqlTable,
|
||||
varchar,
|
||||
char,
|
||||
timestamp,
|
||||
text,
|
||||
int,
|
||||
tinyint,
|
||||
json,
|
||||
index,
|
||||
} from "drizzle-orm/mysql-core";
|
||||
|
||||
export const textbooks = mysqlTable("content_textbooks", {
|
||||
id: char("id", { length: 36 }).notNull().primaryKey(),
|
||||
title: varchar("title", { length: 200 }).notNull(),
|
||||
subjectId: char("subject_id", { length: 36 }).notNull(),
|
||||
gradeId: char("grade_id", { length: 36 }).notNull(),
|
||||
version: varchar("version", { length: 50 }).notNull(),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(),
|
||||
});
|
||||
export const textbooks = mysqlTable(
|
||||
"content_textbooks",
|
||||
{
|
||||
id: varchar("id", { length: 32 }).notNull().primaryKey(),
|
||||
title: varchar("title", { length: 255 }).notNull(),
|
||||
subjectId: varchar("subject_id", { length: 32 }).notNull(),
|
||||
gradeId: varchar("grade_id", { length: 32 }).notNull(),
|
||||
version: varchar("version", { length: 32 }).notNull().default("1.0"),
|
||||
status: varchar("status", { length: 32 }).notNull().default("draft"),
|
||||
tenantId: varchar("tenant_id", { length: 32 }),
|
||||
metadata: json("metadata").$type<Record<string, unknown> | null>(),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(),
|
||||
},
|
||||
(table) => ({
|
||||
subjectGradeIdx: index("idx_textbooks_subject_grade").on(
|
||||
table.subjectId,
|
||||
table.gradeId,
|
||||
),
|
||||
statusIdx: index("idx_textbooks_status").on(table.status),
|
||||
tenantIdx: index("idx_textbooks_tenant").on(table.tenantId),
|
||||
}),
|
||||
);
|
||||
|
||||
export const chapters = mysqlTable("content_chapters", {
|
||||
id: char("id", { length: 36 }).notNull().primaryKey(),
|
||||
textbookId: char("textbook_id", { length: 36 }).notNull(),
|
||||
title: varchar("title", { length: 200 }).notNull(),
|
||||
order: int("order_num").notNull(),
|
||||
parentId: char("parent_id", { length: 36 }),
|
||||
});
|
||||
export const chapters = mysqlTable(
|
||||
"content_chapters",
|
||||
{
|
||||
id: varchar("id", { length: 32 }).notNull().primaryKey(),
|
||||
textbookId: varchar("textbook_id", { length: 32 }).notNull(),
|
||||
title: varchar("title", { length: 255 }).notNull(),
|
||||
order: int("order_num").notNull().default(0),
|
||||
parentId: varchar("parent_id", { length: 32 }),
|
||||
status: varchar("status", { length: 32 }).notNull().default("draft"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(),
|
||||
},
|
||||
(table) => ({
|
||||
textbookOrderIdx: index("idx_chapters_textbook_order").on(
|
||||
table.textbookId,
|
||||
table.order,
|
||||
),
|
||||
parentIdx: index("idx_chapters_parent").on(table.parentId),
|
||||
}),
|
||||
);
|
||||
|
||||
export const knowledgePoints = mysqlTable("content_knowledge_points", {
|
||||
id: char("id", { length: 36 }).notNull().primaryKey(),
|
||||
chapterId: char("chapter_id", { length: 36 }).notNull(),
|
||||
title: varchar("title", { length: 200 }).notNull(),
|
||||
description: text("description"),
|
||||
});
|
||||
export const knowledgePoints = mysqlTable(
|
||||
"content_knowledge_points",
|
||||
{
|
||||
id: varchar("id", { length: 32 }).notNull().primaryKey(),
|
||||
chapterId: varchar("chapter_id", { length: 32 }).notNull(),
|
||||
title: varchar("title", { length: 255 }).notNull(),
|
||||
description: text("description"),
|
||||
difficulty: tinyint("difficulty").notNull().default(3),
|
||||
metadata: json("metadata").$type<Record<string, unknown> | null>(),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(),
|
||||
},
|
||||
(table) => ({
|
||||
chapterIdx: index("idx_kp_chapter").on(table.chapterId),
|
||||
}),
|
||||
);
|
||||
|
||||
export type Textbook = typeof textbooks.$inferSelect;
|
||||
export type NewTextbook = typeof textbooks.$inferInsert;
|
||||
|
||||
216
services/content/src/textbooks/textbooks.service.test.ts
Normal file
216
services/content/src/textbooks/textbooks.service.test.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { TextbooksService } from "./textbooks.service.js";
|
||||
import { NotFoundError } from "../shared/errors/application-error.js";
|
||||
|
||||
vi.mock("./textbooks.repository.js", () => ({
|
||||
textbooksRepository: {
|
||||
create: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
find: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { textbooksRepository } from "./textbooks.repository.js";
|
||||
|
||||
const mockOutbox = {
|
||||
publish: vi.fn().mockResolvedValue("event-id"),
|
||||
};
|
||||
|
||||
describe("TextbooksService", () => {
|
||||
let service: TextbooksService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
service = new TextbooksService(mockOutbox as never);
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
it("should create a textbook and publish event", async () => {
|
||||
const input = {
|
||||
title: "Math Grade 3",
|
||||
subjectId: "subj-1",
|
||||
gradeId: "grade-3",
|
||||
};
|
||||
const result = await service.create(input);
|
||||
|
||||
expect(result.id).toBeDefined();
|
||||
expect(result.id).toHaveLength(24); // cuid2 length
|
||||
expect(textbooksRepository.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: "Math Grade 3",
|
||||
subjectId: "subj-1",
|
||||
gradeId: "grade-3",
|
||||
version: "1.0",
|
||||
status: "draft",
|
||||
}),
|
||||
);
|
||||
expect(mockOutbox.publish).toHaveBeenCalledWith(
|
||||
"textbook.created",
|
||||
"Textbook",
|
||||
result.id,
|
||||
expect.objectContaining({
|
||||
title: "Math Grade 3",
|
||||
subject_id: "subj-1",
|
||||
grade_id: "grade-3",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should use provided version when specified", async () => {
|
||||
const result = await service.create({
|
||||
title: "Test",
|
||||
subjectId: "s",
|
||||
gradeId: "g",
|
||||
version: "2.0",
|
||||
});
|
||||
expect(textbooksRepository.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ version: "2.0" }),
|
||||
);
|
||||
expect(result.id).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getById", () => {
|
||||
it("should return textbook when found", async () => {
|
||||
const mockTextbook = {
|
||||
id: "kp-1",
|
||||
title: "Test",
|
||||
subjectId: "s",
|
||||
gradeId: "g",
|
||||
version: "1.0",
|
||||
status: "draft",
|
||||
tenantId: null,
|
||||
metadata: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
vi.mocked(textbooksRepository.findById).mockResolvedValue(mockTextbook);
|
||||
const result = await service.getById("kp-1");
|
||||
expect(result).toBe(mockTextbook);
|
||||
});
|
||||
|
||||
it("should throw NotFoundError when not found", async () => {
|
||||
vi.mocked(textbooksRepository.findById).mockResolvedValue(undefined);
|
||||
await expect(service.getById("nonexistent")).rejects.toThrow(
|
||||
NotFoundError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("update", () => {
|
||||
it("should update and publish updated event", async () => {
|
||||
const existing = {
|
||||
id: "t-1",
|
||||
title: "Old",
|
||||
subjectId: "s",
|
||||
gradeId: "g",
|
||||
version: "1.0",
|
||||
status: "draft",
|
||||
tenantId: null,
|
||||
metadata: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
vi.mocked(textbooksRepository.findById).mockResolvedValue(existing);
|
||||
|
||||
await service.update("t-1", { title: "New Title" });
|
||||
|
||||
expect(textbooksRepository.update).toHaveBeenCalledWith("t-1", {
|
||||
title: "New Title",
|
||||
});
|
||||
expect(mockOutbox.publish).toHaveBeenCalledWith(
|
||||
"textbook.updated",
|
||||
"Textbook",
|
||||
"t-1",
|
||||
expect.objectContaining({ title: "New Title" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("should publish published event when status is published", async () => {
|
||||
vi.mocked(textbooksRepository.findById).mockResolvedValue({
|
||||
id: "t-1",
|
||||
title: "T",
|
||||
subjectId: "s",
|
||||
gradeId: "g",
|
||||
version: "1.0",
|
||||
status: "draft",
|
||||
tenantId: null,
|
||||
metadata: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
await service.update("t-1", { status: "published" });
|
||||
|
||||
expect(mockOutbox.publish).toHaveBeenCalledWith(
|
||||
"textbook.published",
|
||||
"Textbook",
|
||||
"t-1",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("should publish archived event when status is archived", async () => {
|
||||
vi.mocked(textbooksRepository.findById).mockResolvedValue({
|
||||
id: "t-1",
|
||||
title: "T",
|
||||
subjectId: "s",
|
||||
gradeId: "g",
|
||||
version: "1.0",
|
||||
status: "draft",
|
||||
tenantId: null,
|
||||
metadata: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
await service.update("t-1", { status: "archived" });
|
||||
|
||||
expect(mockOutbox.publish).toHaveBeenCalledWith(
|
||||
"textbook.archived",
|
||||
"Textbook",
|
||||
"t-1",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("delete", () => {
|
||||
it("should delete and publish archived event", async () => {
|
||||
vi.mocked(textbooksRepository.findById).mockResolvedValue({
|
||||
id: "t-1",
|
||||
title: "T",
|
||||
subjectId: "s",
|
||||
gradeId: "g",
|
||||
version: "1.0",
|
||||
status: "draft",
|
||||
tenantId: null,
|
||||
metadata: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
await service.delete("t-1");
|
||||
|
||||
expect(textbooksRepository.delete).toHaveBeenCalledWith("t-1");
|
||||
expect(mockOutbox.publish).toHaveBeenCalledWith(
|
||||
"textbook.archived",
|
||||
"Textbook",
|
||||
"t-1",
|
||||
{ deleted: true },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("list", () => {
|
||||
it("should call repository find with query", async () => {
|
||||
const mockList = [{ id: "t-1" }];
|
||||
vi.mocked(textbooksRepository.find).mockResolvedValue(mockList as never);
|
||||
const result = await service.list({ subjectId: "s" });
|
||||
expect(result).toBe(mockList);
|
||||
expect(textbooksRepository.find).toHaveBeenCalledWith({ subjectId: "s" });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,57 +1,71 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { db } from "../config/database.js";
|
||||
import { textbooks, type Textbook } from "./textbooks.schema.js";
|
||||
import {
|
||||
NotFoundError,
|
||||
ValidationError,
|
||||
} from "../shared/errors/application-error.js";
|
||||
import { textbooksRepository } from "./textbooks.repository.js";
|
||||
import type { Textbook, NewTextbook } from "./textbooks.schema.js";
|
||||
import { OutboxService } from "../shared/outbox/outbox.service.js";
|
||||
import { AGGREGATE_TYPES, EVENT_TYPES } from "../shared/outbox/events.js";
|
||||
import { NotFoundError } from "../shared/errors/application-error.js";
|
||||
|
||||
export interface CreateTextbookInput {
|
||||
title: string;
|
||||
subjectId: string;
|
||||
gradeId: string;
|
||||
version: string;
|
||||
version?: string;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface UpdateTextbookInput {
|
||||
title?: string;
|
||||
status?: string;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface ListTextbooksInput {
|
||||
subjectId?: string;
|
||||
gradeId?: string;
|
||||
version?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TextbooksService {
|
||||
async create(input: CreateTextbookInput): Promise<{ id: string }> {
|
||||
if (!input.title || !input.subjectId || !input.gradeId || !input.version) {
|
||||
throw new ValidationError(
|
||||
"title, subjectId, gradeId, version are required",
|
||||
);
|
||||
}
|
||||
constructor(private readonly outbox: OutboxService) {}
|
||||
|
||||
const id = randomUUID();
|
||||
await db.insert(textbooks).values({
|
||||
async create(input: CreateTextbookInput): Promise<{ id: string }> {
|
||||
const id = createId();
|
||||
const record: NewTextbook = {
|
||||
id,
|
||||
title: input.title,
|
||||
subjectId: input.subjectId,
|
||||
gradeId: input.gradeId,
|
||||
version: input.version,
|
||||
});
|
||||
version: input.version ?? "1.0",
|
||||
status: "draft",
|
||||
metadata: input.metadata ?? null,
|
||||
};
|
||||
await textbooksRepository.create(record);
|
||||
|
||||
await this.outbox.publish(
|
||||
EVENT_TYPES.TEXTBOOK_CREATED,
|
||||
AGGREGATE_TYPES.TEXTBOOK,
|
||||
id,
|
||||
{
|
||||
title: record.title,
|
||||
subject_id: record.subjectId,
|
||||
grade_id: record.gradeId,
|
||||
version: record.version,
|
||||
status: record.status,
|
||||
},
|
||||
);
|
||||
|
||||
return { id };
|
||||
}
|
||||
|
||||
async list(): Promise<Textbook[]> {
|
||||
return db.select().from(textbooks);
|
||||
async list(query?: ListTextbooksInput): Promise<Textbook[]> {
|
||||
return textbooksRepository.find(query);
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<Textbook> {
|
||||
const [result] = await db
|
||||
.select()
|
||||
.from(textbooks)
|
||||
.where(eq(textbooks.id, id))
|
||||
.limit(1);
|
||||
const result = await textbooksRepository.findById(id);
|
||||
if (!result) {
|
||||
throw new NotFoundError("Textbook", id);
|
||||
}
|
||||
@@ -59,12 +73,32 @@ export class TextbooksService {
|
||||
}
|
||||
|
||||
async update(id: string, data: UpdateTextbookInput): Promise<void> {
|
||||
await this.getById(id);
|
||||
await db.update(textbooks).set(data).where(eq(textbooks.id, id));
|
||||
const existing = await this.getById(id);
|
||||
await textbooksRepository.update(id, data);
|
||||
|
||||
const eventType =
|
||||
data.status === "published"
|
||||
? EVENT_TYPES.TEXTBOOK_PUBLISHED
|
||||
: data.status === "archived"
|
||||
? EVENT_TYPES.TEXTBOOK_ARCHIVED
|
||||
: EVENT_TYPES.TEXTBOOK_UPDATED;
|
||||
|
||||
await this.outbox.publish(eventType, AGGREGATE_TYPES.TEXTBOOK, id, {
|
||||
title: data.title ?? existing.title,
|
||||
status: data.status ?? existing.status,
|
||||
metadata: data.metadata ?? existing.metadata,
|
||||
});
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.getById(id);
|
||||
await db.delete(textbooks).where(eq(textbooks.id, id));
|
||||
await textbooksRepository.delete(id);
|
||||
// 教材删除视为归档事件,下游可感知失效
|
||||
await this.outbox.publish(
|
||||
EVENT_TYPES.TEXTBOOK_ARCHIVED,
|
||||
AGGREGATE_TYPES.TEXTBOOK,
|
||||
id,
|
||||
{ deleted: true },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
22
services/content/vitest.config.ts
Normal file
22
services/content/vitest.config.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
include: ["src/**/*.test.ts"],
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
reporter: ["text", "json-summary"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.module.ts",
|
||||
"src/main.ts",
|
||||
"src/grpc/grpc-types.ts",
|
||||
"src/config/**",
|
||||
"src/shared/observability/**",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user