chore(content): merge content full implementation into main
Merge feat/content-ai09 with complete content service
This commit is contained in:
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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user