diff --git a/backend/src/api/api.module.ts b/backend/src/api/api.module.ts index 6e3c249..73684db 100644 --- a/backend/src/api/api.module.ts +++ b/backend/src/api/api.module.ts @@ -1,10 +1,11 @@ import { Module } from "@nestjs/common"; import { APP_INTERCEPTOR } from "@nestjs/core"; -import { UsersModule } from "./users/users.module"; import * as interceptors from "./interceptors"; +import { UsersModule } from "./users/users.module"; +import { ClassesModule } from "./classes/classes.module"; @Module({ - imports: [UsersModule], + imports: [UsersModule, ClassesModule], providers: [ { provide: APP_INTERCEPTOR, diff --git a/backend/src/api/classes/classes.controller.spec.ts b/backend/src/api/classes/classes.controller.spec.ts new file mode 100644 index 0000000..85866ac --- /dev/null +++ b/backend/src/api/classes/classes.controller.spec.ts @@ -0,0 +1,193 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { DeepMockProxy, mockDeep } from "jest-mock-extended"; +import { plainToInstance } from "class-transformer"; +import { PrismaClient } from "@prisma/client"; +import { CoreModule } from "src/core/core.module"; +import { PrismaService } from "src/prisma/prisma.service"; +import { ClassesController } from "./classes.controller"; +import { ClassesService, ClassExtended } from "./classes.service"; +import { + CreateClassDto, + UpdateClassDto, + ExistingClassDto, + DeletedClassDto, + ExistingClassExtendedDto, + ExistingClassWithTeacherDto, +} from "./dto"; + +describe("ClassesController", () => { + let controller: ClassesController; + let prismaMock: DeepMockProxy; + + beforeEach(async () => { + prismaMock = mockDeep(); + + const module: TestingModule = await Test.createTestingModule({ + imports: [CoreModule], + controllers: [ClassesController], + providers: [ + ClassesService, + { + provide: PrismaService, + useValue: prismaMock, + }, + ], + }).compile(); + + controller = module.get(ClassesController); + + jest.clearAllMocks(); + }); + + it("should be defined", () => { + expect(controller).toBeDefined(); + }); + + it("can create a class", async () => { + const dto: CreateClassDto = { name: "Test Class", teacherId: 33 }; + const createdClass = { id: 1, ...dto }; + prismaMock.class.create.mockResolvedValue(createdClass); + + const result = await controller.create(dto); + + expect(result).toEqual(plainToInstance(ExistingClassDto, createdClass)); + expect(prismaMock.class.create).toHaveBeenCalledWith({ + data: { + name: dto.name, + teacher: { connect: { id: dto.teacherId } }, + }, + }); + }); + + it("can update a class", async () => { + const id = 3; + const dto: UpdateClassDto = { name: "Updated Class", teacherId: 34 }; + const updatedClass = { id, ...dto }; + prismaMock.class.update.mockResolvedValue(updatedClass); + + const result = await controller.update(id, dto); + + expect(result).toEqual(plainToInstance(ExistingClassDto, updatedClass)); + expect(prismaMock.class.update).toHaveBeenCalledWith({ + data: dto, + where: { id }, + }); + }); + + it("can delete a class", async () => { + const id = 99; + const deletedClass = { id, name: "Deleted Class", teacherId: 34 }; + prismaMock.class.delete.mockResolvedValue(deletedClass); + + const result = await controller.remove(id); + + expect(result).toEqual(plainToInstance(DeletedClassDto, deletedClass)); + expect(prismaMock.class.delete).toHaveBeenCalledWith({ + where: { id }, + }); + }); + + it("can find an existing class", async () => { + const klass: ClassExtended = { + id: 1, + name: "Test Class", + sessions: [], + teacherId: 33, + teacher: { name: "Teacher" }, + _count: { students: 0 }, + }; + prismaMock.class.findUniqueOrThrow.mockResolvedValue(klass); + + const result = await controller.findOne(1); + + expect(result).toBeInstanceOf(ExistingClassExtendedDto); + expect(result).toEqual(plainToInstance(ExistingClassExtendedDto, klass)); + expect(prismaMock.class.findUniqueOrThrow).toHaveBeenCalledWith({ + include: { + _count: { + select: { students: true }, + }, + sessions: { + select: { id: true }, + }, + teacher: { + select: { id: true, name: true }, + }, + }, + where: { id: 1 }, + }); + }); + + it("should return not found for a non-existing class", async () => { + prismaMock.class.findUniqueOrThrow.mockRejectedValue( + new Error("Not found"), + ); + + const action = controller.findOne(999); + await expect(action).rejects.toThrow("Not found"); + }); + + it("can retrieve all classes", async () => { + const classes = [ + { + id: 1, + name: "Test Class", + teacherId: 5, + teacher: { id: 5, name: "Jerry Smith" }, + }, + { + id: 2, + name: "Another Class", + teacherId: 6, + teacher: { id: 6, name: "Summer Smith" }, + }, + ]; + + prismaMock.class.findMany.mockResolvedValue(classes); + + const result = await controller.findAll(); + + expect(prismaMock.class.findMany).toHaveBeenCalledTimes(1); + expect(Array.isArray(result)).toBe(true); + expect( + result.every((klass) => klass instanceof ExistingClassWithTeacherDto), + ).toBe(true); + expect(result).toEqual( + classes.map((klass) => plainToInstance(ExistingClassWithTeacherDto, klass)), + ); + }); + + it("can retrieve all classes for a specific teacher", async () => { + const teacherId = 5; + const classes = [ + { + id: 1, + name: "Test Class", + teacherId: teacherId, + teacher: { id: teacherId, name: "Jerry Smith" }, + }, + { + id: 2, + name: "Another Class", + teacherId: teacherId, + teacher: { id: teacherId, name: "Jerry Smith" }, + }, + ]; + + prismaMock.class.findMany.mockResolvedValue(classes); + + const result = await controller.findAll(teacherId); + + expect(prismaMock.class.findMany).toHaveBeenCalledWith({ + where: { teacherId: teacherId }, + include: { teacher: { select: { id: true, name: true } } }, + }); + expect(Array.isArray(result)).toBe(true); + expect( + result.every((klass) => klass instanceof ExistingClassWithTeacherDto), + ).toBe(true); + expect(result).toEqual( + classes.map((klass) => plainToInstance(ExistingClassWithTeacherDto, klass)), + ); + }); +}); diff --git a/backend/src/api/classes/classes.controller.ts b/backend/src/api/classes/classes.controller.ts new file mode 100644 index 0000000..bdffabd --- /dev/null +++ b/backend/src/api/classes/classes.controller.ts @@ -0,0 +1,100 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + ParseIntPipe, + Patch, + Post, + Query, +} from "@nestjs/common"; +import { + ApiCreatedResponse, + ApiForbiddenResponse, + ApiNotFoundResponse, + ApiOkResponse, + ApiQuery, + ApiTags, +} from "@nestjs/swagger"; +import { plainToInstance } from "class-transformer"; +import { + CreateClassDto, + ExistingClassDto, + UpdateClassDto, + DeletedClassDto, + ClassId, + ExistingClassWithTeacherDto, + ExistingClassExtendedDto, +} from "./dto"; +import { ClassesService } from "./classes.service"; + +@Controller("classes") +@ApiTags("classes") +export class ClassesController { + constructor(private readonly classesService: ClassesService) {} + + @Post() + @ApiCreatedResponse({ type: ExistingClassDto }) + @ApiForbiddenResponse() + async create( + @Body() createClassDto: CreateClassDto, + ): Promise { + const klass = await this.classesService.create(createClassDto); + return plainToInstance(ExistingClassDto, klass); + } + + @Get() + @ApiQuery({ name: "teacherId", required: false, type: Number }) + @ApiOkResponse({ type: ExistingClassWithTeacherDto, isArray: true }) + async findAll( + @Query("teacherId", new ParseIntPipe({ optional: true })) + teacherId?: number, + ): Promise { + // TODO: add pagination support + + const classes = await this.classesService.listClassesWithTeacher({ + // TODO: Implement this properly when auth is available + // TODO: Only allow teachers to see their own classes + where: !!teacherId ? { teacherId } : undefined, + }); + + return classes.map((klass) => + plainToInstance(ExistingClassWithTeacherDto, klass), + ); + } + + @Get(":id") + @ApiOkResponse({ type: ExistingClassExtendedDto }) + @ApiForbiddenResponse() + @ApiNotFoundResponse() + async findOne( + @Param("id", ParseIntPipe) id: ClassId, + ): Promise { + const user = await this.classesService.findByIdOrThrow(id); + return plainToInstance(ExistingClassExtendedDto, user); + } + + @Patch(":id") + @ApiCreatedResponse({ type: ExistingClassDto }) + @ApiForbiddenResponse() + @ApiNotFoundResponse() + async update( + @Param("id", ParseIntPipe) id: ClassId, + @Body() updateClassDto: UpdateClassDto, + ): Promise { + const klass = await this.classesService.update(id, updateClassDto); + return plainToInstance(ExistingClassDto, klass); + } + + @Delete(":id") + @ApiOkResponse({ type: DeletedClassDto }) + @ApiForbiddenResponse() + @ApiNotFoundResponse() + async remove( + @Param("id", ParseIntPipe) id: ClassId, + ): Promise { + const klass = await this.classesService.deleteById(id); + return plainToInstance(DeletedClassDto, klass); + } +} diff --git a/backend/src/api/classes/classes.module.ts b/backend/src/api/classes/classes.module.ts new file mode 100644 index 0000000..3b6e52b --- /dev/null +++ b/backend/src/api/classes/classes.module.ts @@ -0,0 +1,9 @@ +import { Module } from "@nestjs/common"; +import { ClassesController } from "./classes.controller"; +import { ClassesService } from "./classes.service"; + +@Module({ + controllers: [ClassesController], + providers: [ClassesService], +}) +export class ClassesModule {} diff --git a/backend/src/api/classes/classes.service.spec.ts b/backend/src/api/classes/classes.service.spec.ts new file mode 100644 index 0000000..2d6be5b --- /dev/null +++ b/backend/src/api/classes/classes.service.spec.ts @@ -0,0 +1,20 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { CoreModule } from "src/core/core.module"; +import { ClassesService } from "./classes.service"; + +describe("ClassesService", () => { + let service: ClassesService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [CoreModule], + providers: [ClassesService], + }).compile(); + + service = module.get(ClassesService); + }); + + it("should be defined", () => { + expect(service).toBeDefined(); + }); +}); diff --git a/backend/src/api/classes/classes.service.ts b/backend/src/api/classes/classes.service.ts new file mode 100644 index 0000000..f572fa3 --- /dev/null +++ b/backend/src/api/classes/classes.service.ts @@ -0,0 +1,59 @@ +import { Injectable } from "@nestjs/common"; +import { Class, Prisma } from "@prisma/client"; +import { PrismaService } from "src/prisma/prisma.service"; +import { ClassId } from "./dto"; + +type StudentCount = { _count: { students: number } }; +type Teacher = { teacher: { name: string | null } }; +type SessionIds = { sessions: { id: number }[] }; + +export type ClassExtended = Class & StudentCount & Teacher & SessionIds; +export type ClassWithTeacher = Class & Teacher; + +@Injectable() +export class ClassesService { + constructor(private readonly prisma: PrismaService) {} + + findByIdOrThrow(id: ClassId): Promise { + return this.prisma.class.findUniqueOrThrow({ + where: { id }, + include: { + sessions: { + select: { + id: true, + }, + }, + teacher: { select: { id: true, name: true } }, + _count: { select: { students: true } }, + }, + }); + } + + listClassesWithTeacher( + args?: Omit, + ): Promise { + return this.prisma.class.findMany({ + ...args, + include: { teacher: { select: { id: true, name: true } } }, + }); + } + + create(klass: Prisma.ClassUncheckedCreateInput): Promise { + const checkedClass: Prisma.ClassCreateInput = { + name: klass.name, + teacher: { connect: { id: klass.teacherId } }, + }; + return this.prisma.class.create({ data: checkedClass }); + } + + update(id: ClassId, klass: Prisma.ClassUpdateInput): Promise { + return this.prisma.class.update({ + data: klass, + where: { id }, + }); + } + + deleteById(id: ClassId): Promise { + return this.prisma.class.delete({ where: { id } }); + } +} diff --git a/backend/src/api/classes/dto/create-class.dto.ts b/backend/src/api/classes/dto/create-class.dto.ts new file mode 100644 index 0000000..18b24e4 --- /dev/null +++ b/backend/src/api/classes/dto/create-class.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsNotEmpty, IsString, IsInt } from "class-validator"; + +export class CreateClassDto { + @IsString() + @IsNotEmpty() + @ApiProperty() + readonly name!: string; + + @IsInt() + @IsNotEmpty() + @ApiProperty() + readonly teacherId!: number; +} diff --git a/backend/src/api/classes/dto/deleted-class.dto.ts b/backend/src/api/classes/dto/deleted-class.dto.ts new file mode 100644 index 0000000..4c8cbcb --- /dev/null +++ b/backend/src/api/classes/dto/deleted-class.dto.ts @@ -0,0 +1,3 @@ +import { ExistingClassDto } from "./existing-class.dto"; + +export class DeletedClassDto extends ExistingClassDto {} diff --git a/backend/src/api/classes/dto/existing-class-extended.dto.spec.ts b/backend/src/api/classes/dto/existing-class-extended.dto.spec.ts new file mode 100644 index 0000000..14acbb7 --- /dev/null +++ b/backend/src/api/classes/dto/existing-class-extended.dto.spec.ts @@ -0,0 +1,60 @@ +import { plainToInstance } from "class-transformer"; +import { ExistingClassExtendedDto } from "./existing-class-extended.dto"; + +describe("ExistingClassExtendedDto", () => { + const klass = { + id: 1, + name: "Test Class", + teacherId: 5, + teacher: { id: 5, name: "Jerry Smith" }, + sessions: [{ id: 1 }, { id: 2 }], + _count: { students: 25 }, + }; + + it("can be constructed", () => { + const classDto = plainToInstance(ExistingClassExtendedDto, klass); + + expect(classDto.id).toEqual(klass.id); + expect(classDto.name).toEqual(klass.name); + expect(classDto.teacherId).toBeUndefined(); + expect(classDto.teacher).toEqual(klass.teacher); + expect(classDto.sessions).toEqual([1, 2]); + expect(classDto.studentCount).toBe(25); + }); + + it("handles empty sessions", () => { + const dto = plainToInstance(ExistingClassExtendedDto, { + ...klass, + sessions: [], + }); + + expect(dto.sessions).toEqual([]); + }); + + it("handles zero student count", () => { + const dto = plainToInstance(ExistingClassExtendedDto, { + ...klass, + _count: { students: 0 }, + }); + + expect(dto.studentCount).toBe(0); + }); + + it("handles undefined sessions", () => { + const dto = plainToInstance(ExistingClassExtendedDto, { + ...klass, + sessions: undefined, + }); + + expect(dto.sessions).toEqual([]); + }); + + it("handles undefined student count", () => { + const dto = plainToInstance(ExistingClassExtendedDto, { + ...klass, + _count: undefined, + }); + + expect(dto.studentCount).toBe(0); + }); +}); diff --git a/backend/src/api/classes/dto/existing-class-extended.dto.ts b/backend/src/api/classes/dto/existing-class-extended.dto.ts new file mode 100644 index 0000000..e447c3c --- /dev/null +++ b/backend/src/api/classes/dto/existing-class-extended.dto.ts @@ -0,0 +1,30 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Expose, Transform } from "class-transformer"; +import { ExistingClassWithTeacherDto } from "./existing-class-with-teacher.dto"; + +type SessionList = { id: number }[]; + +export class ExistingClassExtendedDto extends ExistingClassWithTeacherDto { + @ApiProperty({ + description: "The list of session IDs.", + type: [Number], + example: [1, 2], + }) + @Transform( + ({ value }: { value: SessionList }) => + value?.map((s: { id: number }) => s.id) ?? [], + { toClassOnly: true }, + ) + readonly sessions!: number[]; + + @ApiProperty({ + name: "studentCount", + description: "The number of students in the class.", + type: Number, + example: 25, + }) + @Expose({ name: "_count", toClassOnly: true }) + // Receive _count { students: number }, turn it into studentCount: number + @Transform(({ value }) => value?.students ?? 0, { toClassOnly: true }) + readonly studentCount!: number; +} diff --git a/backend/src/api/classes/dto/existing-class-with-teacher.dto.spec.ts b/backend/src/api/classes/dto/existing-class-with-teacher.dto.spec.ts new file mode 100644 index 0000000..06e8ddb --- /dev/null +++ b/backend/src/api/classes/dto/existing-class-with-teacher.dto.spec.ts @@ -0,0 +1,20 @@ +import { plainToInstance } from "class-transformer"; +import { ExistingClassWithTeacherDto } from "./existing-class-with-teacher.dto"; + +describe("ExistingClassWithTeacherDto", () => { + const klass = { + id: 1, + name: "Test Class", + teacherId: 5, + teacher: { id: 5, name: "Jerry Smith" }, + }; + + it("can be constructed", () => { + const classDto = plainToInstance(ExistingClassWithTeacherDto, klass); + + expect(classDto.id).toEqual(klass.id); + expect(classDto.name).toEqual(klass.name); + expect(classDto.teacherId).toBeUndefined(); + expect(classDto.teacher).toEqual(klass.teacher); + }); +}); diff --git a/backend/src/api/classes/dto/existing-class-with-teacher.dto.ts b/backend/src/api/classes/dto/existing-class-with-teacher.dto.ts new file mode 100644 index 0000000..205204a --- /dev/null +++ b/backend/src/api/classes/dto/existing-class-with-teacher.dto.ts @@ -0,0 +1,17 @@ +import { ExistingClassDto } from "./existing-class.dto"; +import { ApiProperty } from "@nestjs/swagger"; +import { Exclude } from "class-transformer"; + +export class ExistingClassWithTeacherDto extends ExistingClassDto { + @ApiProperty({ + example: { id: 1, name: "John Doe" }, + description: "The teacher of the class.", + }) + readonly teacher!: { + id: number; + name?: string | null; + }; + + @Exclude() + override readonly teacherId!: number; +} diff --git a/backend/src/api/classes/dto/existing-class.dto.ts b/backend/src/api/classes/dto/existing-class.dto.ts new file mode 100644 index 0000000..9d448d2 --- /dev/null +++ b/backend/src/api/classes/dto/existing-class.dto.ts @@ -0,0 +1,13 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Class } from "@prisma/client"; +import { CreateClassDto } from "./create-class.dto"; + +export type ClassId = number; + +export class ExistingClassDto extends CreateClassDto implements Class { + @ApiProperty({ + example: 318, + description: "The class's unique identifier, a positive integer.", + }) + readonly id!: ClassId; +} diff --git a/backend/src/api/classes/dto/index.ts b/backend/src/api/classes/dto/index.ts new file mode 100644 index 0000000..8ac37af --- /dev/null +++ b/backend/src/api/classes/dto/index.ts @@ -0,0 +1,6 @@ +export * from "./create-class.dto"; +export * from "./update-class.dto"; +export * from "./existing-class.dto"; +export * from "./deleted-class.dto"; +export * from "./existing-class-with-teacher.dto"; +export * from "./existing-class-extended.dto"; diff --git a/backend/src/api/classes/dto/update-class.dto.ts b/backend/src/api/classes/dto/update-class.dto.ts new file mode 100644 index 0000000..2b16d1b --- /dev/null +++ b/backend/src/api/classes/dto/update-class.dto.ts @@ -0,0 +1,4 @@ +import { CreateClassDto } from "./create-class.dto"; + +export class UpdateClassDto extends CreateClassDto {} + diff --git a/backend/src/api/interceptors/prisma-not-found.interceptor.ts b/backend/src/api/interceptors/prisma-not-found.interceptor.ts index 5193f45..5a978e2 100644 --- a/backend/src/api/interceptors/prisma-not-found.interceptor.ts +++ b/backend/src/api/interceptors/prisma-not-found.interceptor.ts @@ -13,13 +13,16 @@ const Prisma_NotFound_ErrorCode = "P2025"; @Injectable() export class PrismaNotFoundInterceptor implements NestInterceptor { - intercept(context: ExecutionContext, next: CallHandler): Observable { + intercept( + _context: ExecutionContext, + next: CallHandler, + ): Observable { return next .handle() .pipe( catchError((err) => throwError(() => - this.isPrismaNotFound(err) ? new NotFoundException(err) : err, + this.isPrismaNotFound(err) ? new NotFoundException() : err, ), ), ); diff --git a/backend/src/api/users/users.controller.ts b/backend/src/api/users/users.controller.ts index 75c6570..bffe16b 100644 --- a/backend/src/api/users/users.controller.ts +++ b/backend/src/api/users/users.controller.ts @@ -59,6 +59,7 @@ export class UsersController { @Patch(":id") @ApiCreatedResponse({ type: ExistingUserDto }) @ApiForbiddenResponse() + @ApiNotFoundResponse() async update( @Param("id", ParseIntPipe) id: UserId, @Body() userDto: UpdateUserDto, diff --git a/backend/src/api/users/users.service.spec.ts b/backend/src/api/users/users.service.spec.ts index b445ec3..79be42d 100644 --- a/backend/src/api/users/users.service.spec.ts +++ b/backend/src/api/users/users.service.spec.ts @@ -1,6 +1,6 @@ import { Test, TestingModule } from "@nestjs/testing"; -import { UsersService } from "src/api/users/users.service"; import { CoreModule } from "src/core/core.module"; +import { UsersService } from "src/api/users/users.service"; describe("UsersService", () => { let service: UsersService;