Skip to content

Commit

Permalink
Merge pull request #52 from crt25/feature/CRT-5_api_class
Browse files Browse the repository at this point in the history
[CRT-5] Added Classes API
  • Loading branch information
Tyratox authored Nov 5, 2024
2 parents eed053f + cae8d2d commit ed43c35
Show file tree
Hide file tree
Showing 18 changed files with 558 additions and 5 deletions.
5 changes: 3 additions & 2 deletions backend/src/api/api.module.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
193 changes: 193 additions & 0 deletions backend/src/api/classes/classes.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -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<PrismaClient>;

beforeEach(async () => {
prismaMock = mockDeep<PrismaClient>();

const module: TestingModule = await Test.createTestingModule({
imports: [CoreModule],
controllers: [ClassesController],
providers: [
ClassesService,
{
provide: PrismaService,
useValue: prismaMock,
},
],
}).compile();

controller = module.get<ClassesController>(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)),
);
});
});
100 changes: 100 additions & 0 deletions backend/src/api/classes/classes.controller.ts
Original file line number Diff line number Diff line change
@@ -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<ExistingClassDto> {
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<ExistingClassWithTeacherDto[]> {
// 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<ExistingClassExtendedDto> {
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<ExistingClassDto> {
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<DeletedClassDto> {
const klass = await this.classesService.deleteById(id);
return plainToInstance(DeletedClassDto, klass);
}
}
9 changes: 9 additions & 0 deletions backend/src/api/classes/classes.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
20 changes: 20 additions & 0 deletions backend/src/api/classes/classes.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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>(ClassesService);
});

it("should be defined", () => {
expect(service).toBeDefined();
});
});
59 changes: 59 additions & 0 deletions backend/src/api/classes/classes.service.ts
Original file line number Diff line number Diff line change
@@ -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<ClassExtended> {
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<Prisma.ClassFindManyArgs, "select" | "include">,
): Promise<ClassWithTeacher[]> {
return this.prisma.class.findMany({
...args,
include: { teacher: { select: { id: true, name: true } } },
});
}

create(klass: Prisma.ClassUncheckedCreateInput): Promise<Class> {
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<Class> {
return this.prisma.class.update({
data: klass,
where: { id },
});
}

deleteById(id: ClassId): Promise<Class> {
return this.prisma.class.delete({ where: { id } });
}
}
Loading

0 comments on commit ed43c35

Please sign in to comment.