test(api-task): add integration tests for task endpoints

This commit is contained in:
2026-04-05 00:13:19 +08:00
parent 32022c1437
commit 3a9b5fb000
5 changed files with 4065 additions and 1 deletions
+464
View File
@@ -0,0 +1,464 @@
import request from "supertest";
import { INestApplication, ValidationPipe } from "@nestjs/common";
import { Test, TestingModule } from "@nestjs/testing";
import { PrismaService } from "../src/prisma/prisma.service";
import { TaskController } from "../src/task/task.controller";
import { TaskService } from "../src/task/task.service";
import { TaskPriority, TaskStatus } from "../generated/prisma/client";
type TaskRecord = {
id: string;
userId: string;
title: string;
contentJson: unknown | null;
contentText: string | null;
priority: TaskPriority;
status: TaskStatus;
ddl: Date | null;
completedAt: Date | null;
version: number;
createdAt: Date;
updatedAt: Date;
};
type TagRecord = {
id: string;
userId: string;
name: string;
};
type TaskTagRecord = {
taskId: string;
tagId: string;
};
type ListWhereInput = {
userId?: string;
status?: TaskStatus;
priority?: TaskPriority;
taskTags?: {
some: {
tag: {
name: {
in: string[];
};
};
};
};
OR?: Array<{
title?: {
contains: string;
mode?: "insensitive";
};
contentText?: {
contains: string;
mode?: "insensitive";
};
}>;
};
class InMemoryPrismaService {
private taskIdSequence = 1;
private tagIdSequence = 1;
private tasks: TaskRecord[] = [];
private tags: TagRecord[] = [];
private taskTags: TaskTagRecord[] = [];
readonly task = {
findMany: async (args: {
where?: ListWhereInput;
orderBy?: { createdAt?: "asc" | "desc"; updatedAt?: "asc" | "desc"; ddl?: "asc" | "desc" };
skip?: number;
take?: number;
}) => {
const where = args.where;
const skip = args.skip ?? 0;
const take = args.take ?? 20;
let filtered = [...this.tasks];
if (where?.userId) {
filtered = filtered.filter((task) => task.userId === where.userId);
}
if (where?.status) {
filtered = filtered.filter((task) => task.status === where.status);
}
if (where?.priority) {
filtered = filtered.filter((task) => task.priority === where.priority);
}
if (where?.taskTags?.some.tag.name.in) {
const expectedTags = new Set(where.taskTags.some.tag.name.in);
filtered = filtered.filter((task) => {
const taskTagNames = this.getTaskTagNames(task.id);
return taskTagNames.some((tagName) => expectedTags.has(tagName));
});
}
if (where?.OR && where.OR.length > 0) {
filtered = filtered.filter((task) =>
where.OR!.some((orCondition) => {
if (orCondition.title?.contains) {
return task.title.toLowerCase().includes(orCondition.title.contains.toLowerCase());
}
if (orCondition.contentText?.contains) {
return (
task.contentText
?.toLowerCase()
.includes(orCondition.contentText.contains.toLowerCase()) ?? false
);
}
return false;
})
);
}
if (args.orderBy) {
const [orderField, orderDirection] = Object.entries(args.orderBy)[0] as [
"createdAt" | "updatedAt" | "ddl",
"asc" | "desc"
];
filtered.sort((left, right) => {
const leftValue = left[orderField];
const rightValue = right[orderField];
if (leftValue === null && rightValue === null) {
return 0;
}
if (leftValue === null) {
return 1;
}
if (rightValue === null) {
return -1;
}
const diff = leftValue.getTime() - rightValue.getTime();
return orderDirection === "asc" ? diff : -diff;
});
}
return filtered.slice(skip, skip + take).map((task) => this.toTaskWithTags(task));
},
count: async (args: { where?: ListWhereInput }) => {
const results = await this.task.findMany({
where: args.where,
skip: 0,
take: Number.MAX_SAFE_INTEGER
});
return results.length;
},
findFirst: async (args: {
where: {
id?: string;
userId?: string;
};
select?: {
id?: boolean;
status?: boolean;
};
}) => {
const task = this.tasks.find(
(item) =>
(args.where.id === undefined || item.id === args.where.id) &&
(args.where.userId === undefined || item.userId === args.where.userId)
);
if (!task) {
return null;
}
if (args.select) {
return {
id: args.select.id ? task.id : undefined,
status: args.select.status ? task.status : undefined
};
}
return this.toTaskWithTags(task);
},
create: async (args: {
data: {
userId: string;
title: string;
contentJson?: unknown;
contentText: string | null;
priority: TaskPriority;
status: TaskStatus;
ddl: Date | null;
completedAt: Date | null;
};
}) => {
const now = new Date();
const task: TaskRecord = {
id: `task_${this.taskIdSequence++}`,
userId: args.data.userId,
title: args.data.title,
contentJson: args.data.contentJson ?? null,
contentText: args.data.contentText,
priority: args.data.priority,
status: args.data.status,
ddl: args.data.ddl,
completedAt: args.data.completedAt,
version: 1,
createdAt: now,
updatedAt: now
};
this.tasks.push(task);
return task;
},
update: async (args: {
where: {
id: string;
};
data: {
title?: string;
contentJson?: unknown;
contentText?: string | null;
priority?: TaskPriority;
status?: TaskStatus;
ddl?: Date | null;
completedAt?: Date | null;
version?: {
increment: number;
};
};
}) => {
const task = this.tasks.find((item) => item.id === args.where.id);
if (!task) {
throw new Error("task not found");
}
if (args.data.title !== undefined) {
task.title = args.data.title;
}
if (args.data.contentJson !== undefined) {
task.contentJson = args.data.contentJson;
}
if (args.data.contentText !== undefined) {
task.contentText = args.data.contentText;
}
if (args.data.priority !== undefined) {
task.priority = args.data.priority;
}
if (args.data.status !== undefined) {
task.status = args.data.status;
}
if (args.data.ddl !== undefined) {
task.ddl = args.data.ddl;
}
if (args.data.completedAt !== undefined) {
task.completedAt = args.data.completedAt;
}
if (args.data.version !== undefined) {
task.version += args.data.version.increment;
}
task.updatedAt = new Date();
return task;
},
deleteMany: async (args: {
where: {
id: string;
userId: string;
};
}) => {
const beforeCount = this.tasks.length;
this.tasks = this.tasks.filter(
(task) => !(task.id === args.where.id && task.userId === args.where.userId)
);
this.taskTags = this.taskTags.filter((taskTag) => taskTag.taskId !== args.where.id);
return {
count: beforeCount - this.tasks.length
};
},
findUniqueOrThrow: async (args: {
where: {
id: string;
};
}) => {
const task = this.tasks.find((item) => item.id === args.where.id);
if (!task) {
throw new Error("task not found");
}
return this.toTaskWithTags(task);
}
};
readonly tag = {
upsert: async (args: {
where: {
userId_name: {
userId: string;
name: string;
};
};
create: {
userId: string;
name: string;
};
}) => {
const existing = this.tags.find(
(tag) =>
tag.userId === args.where.userId_name.userId && tag.name === args.where.userId_name.name
);
if (existing) {
return existing;
}
const createdTag: TagRecord = {
id: `tag_${this.tagIdSequence++}`,
userId: args.create.userId,
name: args.create.name
};
this.tags.push(createdTag);
return createdTag;
}
};
readonly taskTag = {
deleteMany: async (args: {
where: {
taskId: string;
};
}) => {
const beforeCount = this.taskTags.length;
this.taskTags = this.taskTags.filter((taskTag) => taskTag.taskId !== args.where.taskId);
return {
count: beforeCount - this.taskTags.length
};
},
createMany: async (args: {
data: Array<{
taskId: string;
tagId: string;
}>;
}) => {
for (const row of args.data) {
const existing = this.taskTags.find(
(taskTag) => taskTag.taskId === row.taskId && taskTag.tagId === row.tagId
);
if (!existing) {
this.taskTags.push(row);
}
}
return {
count: args.data.length
};
}
};
async $transaction<T>(runner: (tx: InMemoryPrismaService) => Promise<T>): Promise<T> {
return runner(this);
}
private toTaskWithTags(
task: TaskRecord
): TaskRecord & { taskTags: Array<{ tag: { name: string } }> } {
return {
...task,
taskTags: this.taskTags
.filter((taskTag) => taskTag.taskId === task.id)
.map((taskTag) => this.tags.find((tag) => tag.id === taskTag.tagId))
.filter((tag): tag is TagRecord => tag !== undefined)
.map((tag) => ({
tag: {
name: tag.name
}
}))
};
}
private getTaskTagNames(taskId: string): string[] {
return this.taskTags
.filter((taskTag) => taskTag.taskId === taskId)
.map((taskTag) => this.tags.find((tag) => tag.id === taskTag.tagId))
.filter((tag): tag is TagRecord => tag !== undefined)
.map((tag) => tag.name);
}
}
describe("TaskController (integration)", () => {
let app: INestApplication;
const prismaService = new InMemoryPrismaService();
beforeAll(async () => {
const moduleRef: TestingModule = await Test.createTestingModule({
controllers: [TaskController],
providers: [
TaskService,
{ provide: PrismaService, useValue: prismaService as unknown as PrismaService }
]
}).compile();
app = moduleRef.createNestApplication();
app.useGlobalPipes(
new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true
})
);
await app.init();
});
afterAll(async () => {
await app.close();
});
it("should create, query, update and delete a task", async () => {
const createResponse = await request(app.getHttpServer())
.post("/tasks")
.set("x-user-id", "user_1")
.send({
title: "准备周会",
contentText: "整理本周进度",
priority: "HIGH",
tagNames: ["工作", "会议"]
})
.expect(201);
expect(createResponse.body.id).toBeDefined();
expect(createResponse.body.tags).toEqual(["工作", "会议"]);
const taskId = createResponse.body.id as string;
const listResponse = await request(app.getHttpServer())
.get("/tasks")
.set("x-user-id", "user_1")
.query({ tags: "会议" })
.expect(200);
expect(listResponse.body.total).toBe(1);
expect(listResponse.body.items[0].id).toBe(taskId);
const updateResponse = await request(app.getHttpServer())
.patch(`/tasks/${taskId}`)
.set("x-user-id", "user_1")
.send({
status: "DONE"
})
.expect(200);
expect(updateResponse.body.status).toBe("DONE");
expect(updateResponse.body.completedAt).toBeTruthy();
expect(updateResponse.body.version).toBe(2);
await request(app.getHttpServer())
.delete(`/tasks/${taskId}`)
.set("x-user-id", "user_1")
.expect(200)
.expect({
success: true
});
const listAfterDeleteResponse = await request(app.getHttpServer())
.get("/tasks")
.set("x-user-id", "user_1")
.expect(200);
expect(listAfterDeleteResponse.body.total).toBe(0);
});
});