diff --git a/CHANGELOG.md b/CHANGELOG.md index 2668ac79..186b3383 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,8 +34,10 @@ Another example [here](https://co-pilot.dev/changelog) - Add CASL ability for Access control ([#141](https://github.com/chingu-x/chingu-dashboard-be/pull/141)) - Add sprint checkin form submission status for a user ([#149](https://github.com/chingu-x/chingu-dashboard-be/pull/149)) - new command to run both e2e and unit test ([#148](https://github.com/chingu-x/chingu-dashboard-be/pull/148)) +- allow edit and delete for tech stack item([#152](https://github.com/chingu-x/chingu-dashboard-be/pull/152)) - Add voyage project submission status to `/me` endpoint ([#158](https://github.com/chingu-x/chingu-dashboard-be/pull/158)) + ### Changed - Update docker compose and scripts in package.json to include a test database container and remove usage of .env.dev to avoid confusion ([#100](https://github.com/chingu-x/chingu-dashboard-be/pull/100)) diff --git a/prisma/migrations/20240512035506_add_member_id_to_tech_stack_item_table/migration.sql b/prisma/migrations/20240512035506_add_member_id_to_tech_stack_item_table/migration.sql new file mode 100644 index 00000000..0bc4db83 --- /dev/null +++ b/prisma/migrations/20240512035506_add_member_id_to_tech_stack_item_table/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "TeamTechStackItem" ADD COLUMN "voyageTeamMemberId" INTEGER; + +-- AddForeignKey +ALTER TABLE "TeamTechStackItem" ADD CONSTRAINT "TeamTechStackItem_voyageTeamMemberId_fkey" FOREIGN KEY ("voyageTeamMemberId") REFERENCES "VoyageTeamMember"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8a5c5197..827ef5a2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -224,6 +224,7 @@ model VoyageTeamMember { teamResources TeamResource[] projectFeatures ProjectFeature[] checkinForms FormResponseCheckin[] + TeamTechStackItem TeamTechStackItem[] @@unique(fields: [userId, voyageTeamId], name: "userVoyageId") } @@ -242,13 +243,15 @@ model TechStackCategory { } model TeamTechStackItem { - id Int @id @default(autoincrement()) - name String @db.Citext - category TechStackCategory? @relation(fields: [categoryId], references: [id], onUpdate: Cascade, onDelete: SetNull) - categoryId Int? - voyageTeam VoyageTeam @relation(fields: [voyageTeamId], references: [id], onUpdate: Cascade, onDelete: Cascade) - voyageTeamId Int - isSelected Boolean @default(false) + id Int @id @default(autoincrement()) + name String @db.Citext + category TechStackCategory? @relation(fields: [categoryId], references: [id], onUpdate: Cascade, onDelete: SetNull) + categoryId Int? + voyageTeam VoyageTeam @relation(fields: [voyageTeamId], references: [id], onUpdate: Cascade, onDelete: Cascade) + voyageTeamId Int + isSelected Boolean @default(false) + addedBy VoyageTeamMember? @relation(fields: [voyageTeamMemberId], references: [id], onUpdate: Cascade, onDelete: SetNull) + voyageTeamMemberId Int? createdAt DateTime @default(now()) @db.Timestamptz() updatedAt DateTime @updatedAt @@ -292,7 +295,7 @@ model ProjectIdea { title String description String vision String - isSelected Boolean @default(false) + isSelected Boolean @default(false) createdAt DateTime @default(now()) @db.Timestamptz() updatedAt DateTime @updatedAt diff --git a/prisma/seed/voyage-teams.ts b/prisma/seed/voyage-teams.ts index 6ac92713..c9f971a7 100644 --- a/prisma/seed/voyage-teams.ts +++ b/prisma/seed/voyage-teams.ts @@ -1073,11 +1073,16 @@ export const populateVoyageTeams = async () => { create: { votedBy: { connect: { - id: voyageTeamMembers[0].id, + id: voyageTeamMembers[7].id, }, }, }, }, + addedBy: { + connect: { + id: voyageTeamMembers[7].id, + }, + }, }, ], }, @@ -1102,11 +1107,16 @@ export const populateVoyageTeams = async () => { create: { votedBy: { connect: { - id: voyageTeamMembers[2].id, + id: voyageTeamMembers[6].id, }, }, }, }, + addedBy: { + connect: { + id: voyageTeamMembers[6].id, + }, + }, }, ], }, @@ -1131,11 +1141,16 @@ export const populateVoyageTeams = async () => { create: { votedBy: { connect: { - id: voyageTeamMembers[2].id, + id: voyageTeamMembers[6].id, }, }, }, }, + addedBy: { + connect: { + id: voyageTeamMembers[6].id, + }, + }, }, ], }, @@ -1160,11 +1175,16 @@ export const populateVoyageTeams = async () => { create: { votedBy: { connect: { - id: voyageTeamMembers[1].id, + id: voyageTeamMembers[5].id, }, }, }, }, + addedBy: { + connect: { + id: voyageTeamMembers[5].id, + }, + }, }, ], }, @@ -1189,11 +1209,16 @@ export const populateVoyageTeams = async () => { create: { votedBy: { connect: { - id: voyageTeamMembers[0].id, + id: voyageTeamMembers[7].id, }, }, }, }, + addedBy: { + connect: { + id: voyageTeamMembers[7].id, + }, + }, }, ], }, @@ -1218,11 +1243,16 @@ export const populateVoyageTeams = async () => { create: { votedBy: { connect: { - id: voyageTeamMembers[3].id, + id: voyageTeamMembers[4].id, }, }, }, }, + addedBy: { + connect: { + id: voyageTeamMembers[4].id, + }, + }, }, ], }, @@ -1247,11 +1277,16 @@ export const populateVoyageTeams = async () => { create: { votedBy: { connect: { - id: voyageTeamMembers[2].id, + id: voyageTeamMembers[6].id, }, }, }, }, + addedBy: { + connect: { + id: voyageTeamMembers[6].id, + }, + }, }, ], }, @@ -1276,11 +1311,16 @@ export const populateVoyageTeams = async () => { create: { votedBy: { connect: { - id: voyageTeamMembers[3].id, + id: voyageTeamMembers[7].id, }, }, }, }, + addedBy: { + connect: { + id: voyageTeamMembers[7].id, + }, + }, }, ], }, diff --git a/src/app.module.ts b/src/app.module.ts index 429d9682..7b0f1d71 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -30,7 +30,7 @@ import { AbilitiesGuard } from "./auth/guards/abilities.guard"; path: "/", module: ResourcesModule, }, - { path: "teams/:teamId/techs", module: TechsModule }, + { path: "/", module: TechsModule }, { path: "/", module: FeaturesModule }, { path: "teams/:teamId/ideations", diff --git a/src/techs/dto/create-tech.dto.ts b/src/techs/dto/create-tech.dto.ts index 64522d1a..b17a76f1 100644 --- a/src/techs/dto/create-tech.dto.ts +++ b/src/techs/dto/create-tech.dto.ts @@ -11,4 +11,9 @@ export class CreateTeamTechDto { @IsNotEmpty() @ApiProperty({ example: 1 }) techCategoryId: number; + + @IsInt() + @IsNotEmpty() + @ApiProperty({ example: 8 }) + voyageTeamMemberId: number; } diff --git a/src/techs/dto/update-tech.dto.ts b/src/techs/dto/update-tech.dto.ts new file mode 100644 index 00000000..5c37df2a --- /dev/null +++ b/src/techs/dto/update-tech.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsNotEmpty, IsString } from "class-validator"; + +export class UpdateTeamTechDto { + @IsString() + @IsNotEmpty() + @ApiProperty({ example: "Typescipt" }) + techName: string; +} diff --git a/src/techs/techs.controller.ts b/src/techs/techs.controller.ts index 2d281260..db97768d 100644 --- a/src/techs/techs.controller.ts +++ b/src/techs/techs.controller.ts @@ -19,13 +19,17 @@ import { TeamTechResponse, TechItemResponse, TechItemDeleteResponse, + TechItemUpdateResponse, } from "./techs.response"; import { BadRequestErrorResponse, ConflictErrorResponse, + ForbiddenErrorResponse, NotFoundErrorResponse, UnauthorizedErrorResponse, } from "../global/responses/errors"; +import { UpdateTeamTechDto } from "./dto/update-tech.dto"; +import { CustomRequest } from "src/global/types/CustomRequest"; @Controller() @ApiTags("Voyage - Techs") @@ -47,7 +51,7 @@ export class TechsController { required: true, example: 1, }) - @Get() + @Get("teams/:teamId/techs") getAllTechItemsByTeamId(@Param("teamId", ParseIntPipe) teamId: number) { return this.techsService.getAllTechItemsByTeamId(teamId); } @@ -82,17 +86,115 @@ export class TechsController { description: "voyage team Id", type: "Integer", required: true, - example: 1, + example: 2, }) - @Post() + @Post("teams/:teamId/techs") addNewTeamTech( - @Request() req, + @Request() req: CustomRequest, @Param("teamId", ParseIntPipe) teamId: number, @Body(ValidationPipe) createTeamTechDto: CreateTeamTechDto, ) { return this.techsService.addNewTeamTech(req, teamId, createTeamTechDto); } + @ApiOperation({ + summary: "Updates a existing tech stack item in the team", + description: "Requires login", + }) + @ApiResponse({ + status: HttpStatus.OK, + description: "Successfully updated a tech stack item", + type: TechItemUpdateResponse, + }) + @ApiResponse({ + status: HttpStatus.FORBIDDEN, + description: "User is unauthorized to perform this action", + type: ForbiddenErrorResponse, + }) + @ApiResponse({ + status: HttpStatus.UNAUTHORIZED, + description: "Unauthorized when user is not logged in", + type: UnauthorizedErrorResponse, + }) + @ApiResponse({ + status: HttpStatus.BAD_REQUEST, + description: "Bad Request - Tech Stack Item couldn't be updated", + type: BadRequestErrorResponse, + }) + @ApiResponse({ + status: HttpStatus.CONFLICT, + description: "Tech stack item already exist for the team", + type: ConflictErrorResponse, + }) + @ApiResponse({ + status: HttpStatus.NOT_FOUND, + description: "Invalid tech stack item id", + type: NotFoundErrorResponse, + }) + @ApiParam({ + name: "teamTechItemId", + description: "team tech stack item Id", + type: "Integer", + required: true, + example: 1, + }) + @Patch("techs/:teamTechItemId") + updateTeamTech( + @Request() req: CustomRequest, + @Param("teamTechItemId", ParseIntPipe) teamTechItemId: number, + @Body(ValidationPipe) updateTeamTechDto: UpdateTeamTechDto, + ) { + return this.techsService.updateExistingTeamTech( + req, + updateTeamTechDto, + teamTechItemId, + ); + } + + @ApiOperation({ + summary: "Delete a tech stack item of a team", + description: "Requires login", + }) + @ApiResponse({ + status: HttpStatus.OK, + description: "Tech stack item was successfully deleted", + type: TechItemDeleteResponse, + }) + @ApiResponse({ + status: HttpStatus.FORBIDDEN, + description: "User is unauthorized to perform this action", + type: ForbiddenErrorResponse, + }) + @ApiResponse({ + status: HttpStatus.UNAUTHORIZED, + description: "Unauthorized when user is not logged in", + type: UnauthorizedErrorResponse, + }) + @ApiResponse({ + status: HttpStatus.BAD_REQUEST, + description: "Bad Request - Tech stack item couldn't be deleted", + type: BadRequestErrorResponse, + }) + @ApiResponse({ + status: HttpStatus.NOT_FOUND, + description: "Invalid tech stack item id", + type: NotFoundErrorResponse, + }) + @ApiParam({ + name: "teamTechItemId", + description: "team tech stack item Id", + type: "Integer", + required: true, + example: 1, + }) + @Delete("techs/:teamTechItemId") + deleteTeamTech( + @Request() req: CustomRequest, + @Param("teamTechItemId", ParseIntPipe) teamTechItemId: number, + ) { + return this.techsService.deleteTeamTech(req, teamTechItemId); + } + @ApiOperation({ summary: 'Votes for an existing tech / adds the voter to the votedBy list. VotedBy: "UserId:uuid"', @@ -119,26 +221,18 @@ export class TechsController { type: UnauthorizedErrorResponse, }) @ApiParam({ - name: "teamId", - description: "voyage team Id", - type: "Integer", - required: true, - example: 2, - }) - @ApiParam({ - name: "teamTechId", + name: "teamTechItemId", description: "techId of a tech the team has select (TeamTechStackItem)", type: "Integer", required: true, example: 6, }) - @Post("/:teamTechId") + @Post("techs/:teamTechItemId/vote") addExistingTechVote( - @Request() req, - @Param("teamId", ParseIntPipe) teamId: number, - @Param("teamTechId", ParseIntPipe) teamTechId: number, + @Request() req: CustomRequest, + @Param("teamTechItemId", ParseIntPipe) teamTechItemId: number, ) { - return this.techsService.addExistingTechVote(req, teamId, teamTechId); + return this.techsService.addExistingTechVote(req, teamTechItemId); } @ApiOperation({ @@ -166,26 +260,18 @@ export class TechsController { type: UnauthorizedErrorResponse, }) @ApiParam({ - name: "teamId", - description: "voyage team Id", - type: "Integer", - required: true, - example: 2, - }) - @ApiParam({ - name: "teamTechId", + name: "teamTechItemId", description: "techId of a tech the team has select (TeamTechStackItem)", type: "Integer", required: true, example: 6, }) - @Delete("/:teamTechId") + @Delete("techs/:teamTechItemId/vote") removeVote( - @Request() req, - @Param("teamId", ParseIntPipe) teamId: number, - @Param("teamTechId", ParseIntPipe) teamTechId: number, + @Request() req: CustomRequest, + @Param("teamTechItemId", ParseIntPipe) teamTechItemId: number, ) { - return this.techsService.removeVote(req, teamId, teamTechId); + return this.techsService.removeVote(req, teamTechItemId); } @ApiOperation({ @@ -216,7 +302,7 @@ export class TechsController { required: true, example: 2, }) - @Patch("/selections") + @Patch("teams/:teamId/techs/selections") updateTechStackSelections( @Request() req, @Param("teamId", ParseIntPipe) teamId: number, diff --git a/src/techs/techs.response.ts b/src/techs/techs.response.ts index dd3abdea..49dc2cb5 100644 --- a/src/techs/techs.response.ts +++ b/src/techs/techs.response.ts @@ -69,6 +69,23 @@ export class TechItemResponse { updatedAt: Date; } +export class TechItemUpdateResponse { + @ApiProperty({ example: 1 }) + id: number; + + @ApiProperty({ example: "Typescript" }) + name: string; + + @ApiProperty({ example: 8 }) + voyageTeamMemberId: number; + + @ApiProperty({ example: 2 }) + voyageTeamId: number; + + @ApiProperty({ isArray: true }) + teamTechStackItemVotes: TeamTechStackItemVote; +} + export class TechItemDeleteResponse { @ApiProperty({ example: "The vote and tech stack item were deleted" }) message: string; diff --git a/src/techs/techs.service.ts b/src/techs/techs.service.ts index 4ecb152d..9327295d 100644 --- a/src/techs/techs.service.ts +++ b/src/techs/techs.service.ts @@ -1,12 +1,15 @@ import { BadRequestException, ConflictException, + ForbiddenException, Injectable, NotFoundException, } from "@nestjs/common"; import { PrismaService } from "../prisma/prisma.service"; import { CreateTeamTechDto } from "./dto/create-tech.dto"; import { UpdateTechSelectionsDto } from "./dto/update-tech-selections.dto"; +import { UpdateTeamTechDto } from "./dto/update-tech.dto"; +import { CustomRequest } from "src/global/types/CustomRequest"; const MAX_SELECTION_COUNT = 3; @@ -26,6 +29,21 @@ export class TechsService { } }; + validTeamMember = (req: CustomRequest, teamId: number) => { + // teams of which the logged in user is a member + const teams = req.user.voyageTeams; + + // check if the teamId is in the teams array + const voyageMember = teams.filter((team) => team.teamId === teamId); + if (voyageMember.length === 0) { + throw new BadRequestException( + "User is not in the specified team, check voyageTeamId or voyageTeamMemberId is correct.", + ); + } + + return voyageMember[0].memberId; + }; + findVoyageMemberId = async ( req, teamId: number, @@ -126,8 +144,15 @@ export class TechsService { teamId: number, createTechVoteDto: CreateTeamTechDto, ) { + //check for valid teamId + await this.validateTeamId(teamId); + + // To Check if the voyageTeamMemberId in request body is in the voyageTeam const voyageMemberId = await this.findVoyageMemberId(req, teamId); - if (!voyageMemberId) + if ( + !voyageMemberId || + voyageMemberId !== createTechVoteDto.voyageTeamMemberId + ) throw new BadRequestException("Invalid User or Team Id"); try { @@ -136,6 +161,7 @@ export class TechsService { name: createTechVoteDto.techName, categoryId: createTechVoteDto.techCategoryId, voyageTeamId: teamId, + voyageTeamMemberId: createTechVoteDto.voyageTeamMemberId, }, }); @@ -143,7 +169,7 @@ export class TechsService { await this.prisma.teamTechStackItemVote.create({ data: { teamTechId: newTeamTechItem.id, - teamMemberId: voyageMemberId, + teamMemberId: createTechVoteDto.voyageTeamMemberId, }, }); return { @@ -163,30 +189,208 @@ export class TechsService { } } - async addExistingTechVote(req, teamId, teamTechId) { + async updateExistingTeamTech( + req: CustomRequest, + updateTeamTechDto: UpdateTeamTechDto, + teamTechItemId: number, + ) { + const { techName } = updateTeamTechDto; // check if team tech item exists const teamTechItem = await this.prisma.teamTechStackItem.findUnique({ where: { - id: teamTechId, + id: teamTechItemId, + }, + select: { + addedBy: { + select: { + member: { + select: { + id: true, + }, + }, + }, + }, + teamTechStackItemVotes: { + select: { + votedBy: { + select: { + member: { + select: { + id: true, + }, + }, + }, + }, + }, + }, }, }); + if (!teamTechItem) + throw new NotFoundException( + `[Tech Service]: Team Tech Stack Item with id:${teamTechItemId} not found`, + ); + + // check if the tech stack item has votes other than the user created it + if (teamTechItem.teamTechStackItemVotes.length > 1) { + throw new ConflictException( + `[Tech Service]: Tech Stack Item cannot be updated when others have voted for it.`, + ); + } + // The person having the last vote has the ability to edit and delete the tech stack item + if ( + req.user.userId !== + teamTechItem.teamTechStackItemVotes[0].votedBy.member.id + ) { + throw new ForbiddenException( + "[Tech Service]: You cannot update this Tech Stack Item.", + ); + } + + try { + const updateTechStackItem = + await this.prisma.teamTechStackItem.update({ + where: { + id: teamTechItemId, + }, + data: { + name: updateTeamTechDto.techName, + }, + select: { + id: true, + name: true, + voyageTeamMemberId: true, + voyageTeamId: true, + teamTechStackItemVotes: { + select: { + votedBy: { + select: { + member: { + select: { + id: true, + firstName: true, + lastName: true, + avatar: true, + }, + }, + }, + }, + }, + }, + }, + }); + + return updateTechStackItem; + } catch (e) { + if (e.code === "P2002") { + throw new ConflictException( + `[Tech Service]: ${techName} already exists in the available team tech stack.`, + ); + } + throw e; + } + } + + async deleteTeamTech(req: CustomRequest, teamTechItemId: number) { + try { + // check if team tech item exists + + const teamTechItem = await this.prisma.teamTechStackItem.findUnique( + { + where: { + id: teamTechItemId, + }, + select: { + addedBy: { + select: { + member: { + select: { + id: true, + }, + }, + }, + }, + teamTechStackItemVotes: { + select: { + votedBy: { + select: { + member: { + select: { + id: true, + }, + }, + }, + }, + }, + }, + }, + }, + ); + + if (!teamTechItem) + throw new NotFoundException( + `[Tech Service]: Team Tech Stack Item with id:${teamTechItemId} not found`, + ); + + // check if the tech stack item has votes other than the user created it + if (teamTechItem.teamTechStackItemVotes.length > 1) { + throw new ConflictException( + `[Tech Service]: Tech Stack Item cannot be delete when others have voted for it.`, + ); + } + // The person having the last vote has the ability to edit and delete the tech stack item + if ( + req.user.userId !== + teamTechItem.teamTechStackItemVotes[0].votedBy.member.id + ) { + throw new ForbiddenException( + "[Tech Service]: You cannot delete this Tech Stack Item.", + ); + } + + await this.prisma.teamTechStackItem.delete({ + where: { + id: teamTechItemId, + }, + }); + return { + message: "The tech stack item is deleted", + statusCode: 200, + }; + } catch (e) { + throw e; + } + } + + async addExistingTechVote(req, teamTechItemId: number) { + // check if team tech item exists + const teamTechItem = await this.prisma.teamTechStackItem.findUnique({ + where: { + id: teamTechItemId, + }, + }); + if (!teamTechItem) throw new BadRequestException("Team Tech Item not found"); - const voyageMemberId = await this.findVoyageMemberId(req, teamId); - if (!voyageMemberId) throw new BadRequestException("Invalid User"); + + // check if the user is a member of the team + // Note: This can be removed after new authorization is implemented + const voyageMemberId = this.validTeamMember( + req, + teamTechItem.voyageTeamId, + ); try { const teamMemberTechVote = await this.prisma.teamTechStackItemVote.create({ data: { - teamTechId, + teamTechId: teamTechItemId, teamMemberId: voyageMemberId, }, }); // If successful, it returns an object containing the details of the vote return { teamTechStackItemVoteId: teamMemberTechVote.id, - teamTechId, + teamTechId: teamTechItemId, teamMemberId: teamMemberTechVote.teamMemberId, createdAt: teamMemberTechVote.createdAt, updatedAt: teamMemberTechVote.updatedAt, @@ -194,22 +398,36 @@ export class TechsService { } catch (e) { if (e.code === "P2002") { throw new ConflictException( - `User has already voted for techId:${teamTechId}`, + `User has already voted for techId:${teamTechItemId}`, ); } throw e; } } - async removeVote(req, teamId, teamTechId) { - const voyageMemberId = await this.findVoyageMemberId(req, teamId); - if (!voyageMemberId) throw new BadRequestException("Invalid User"); + async removeVote(req: CustomRequest, teamTechItemId: number) { + // check if team tech item exists + const teamTechItem = await this.prisma.teamTechStackItem.findUnique({ + where: { + id: teamTechItemId, + }, + }); + + if (!teamTechItem) + throw new BadRequestException("Team Tech Item not found"); + + // check if the user is a member of the team + // Note: This can be removed after new authorization is implemented + const voyageMemberId = this.validTeamMember( + req, + teamTechItem.voyageTeamId, + ); try { await this.prisma.teamTechStackItemVote.delete({ where: { userTeamStackVote: { - teamTechId, + teamTechId: teamTechItemId, teamMemberId: voyageMemberId, }, }, @@ -219,7 +437,7 @@ export class TechsService { const teamTechItem = await this.prisma.teamTechStackItem.findUnique( { where: { - id: teamTechId, + id: teamTechItemId, }, select: { teamTechStackItemVotes: true, @@ -231,7 +449,7 @@ export class TechsService { // If it's empty, delete the tech item from the database using Prisma ORM await this.prisma.teamTechStackItem.delete({ where: { - id: teamTechId, + id: teamTechItemId, }, }); diff --git a/test/techs.e2e-spec.ts b/test/techs.e2e-spec.ts index cbc88cf2..7915f5f3 100644 --- a/test/techs.e2e-spec.ts +++ b/test/techs.e2e-spec.ts @@ -4,8 +4,11 @@ import * as request from "supertest"; import { AppModule } from "../src/app.module"; import { PrismaService } from "../src/prisma/prisma.service"; import { seed } from "../prisma/seed/seed"; -import { extractResCookieValueByKey } from "./utils"; +import { loginAndGetTokens } from "./utils"; +import * as cookieParser from "cookie-parser"; + const newTechName = "anotherLayerJS"; +const updatedTechName = "updatedLayerJS"; //Tests require the following state and seed data to execute successfully: //Logged in as user Jessica Williamson, member of voyage team 2, as team member 8 @@ -15,23 +18,7 @@ const newTechName = "anotherLayerJS"; describe("Techs Controller (e2e)", () => { let app: INestApplication; let prisma: PrismaService; - let userAccessToken: string; - - async function loginUser() { - await request(app.getHttpServer()) - .post("/auth/login") - .send({ - email: "jessica.williamson@gmail.com", - password: "password", - }) - .expect(200) - .then((res) => { - userAccessToken = extractResCookieValueByKey( - res.headers["set-cookie"], - "access_token", - ); - }); - } + let accessToken: any; beforeAll(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ @@ -42,6 +29,7 @@ describe("Techs Controller (e2e)", () => { app = moduleFixture.createNestApplication(); prisma = moduleFixture.get(PrismaService); app.useGlobalPipes(new ValidationPipe()); + app.use(cookieParser()); await app.init(); }); @@ -51,7 +39,13 @@ describe("Techs Controller (e2e)", () => { }); beforeEach(async () => { - await loginUser(); + await loginAndGetTokens( + "jessica.williamson@gmail.com", + "password", + app, + ).then((tokens) => { + accessToken = tokens.access_token; + }); }); describe("GET voyages/teams/:teamId/techs - get data on all tech categories and items", () => { @@ -60,7 +54,7 @@ describe("Techs Controller (e2e)", () => { return await request(app.getHttpServer()) .get(`/voyages/teams/${teamId}/techs`) - .set("Authorization", `Bearer ${userAccessToken}`) + .set("Cookie", accessToken) .expect(200) .expect("Content-Type", /json/) .expect((res) => { @@ -113,13 +107,15 @@ describe("Techs Controller (e2e)", () => { describe("POST voyages/teams/:teamId/techs - add new tech item", () => { it("should return 201 if new tech item successfully added", async () => { const teamId: number = 2; + const teamMemberId: number = 8; return request(app.getHttpServer()) .post(`/voyages/teams/${teamId}/techs`) - .set("Authorization", `Bearer ${userAccessToken}`) + .set("Cookie", accessToken) .send({ techName: newTechName, techCategoryId: 1, + voyageTeamMemberId: teamMemberId, }) .expect(201) .expect("Content-Type", /json/) @@ -146,8 +142,24 @@ describe("Techs Controller (e2e)", () => { return expect(techStackItem[0].name).toEqual(newTechName); }); + it("should return 400 if invalid team member id provided", async () => { + const teamId: number = 2; + const teamMemberId: number = 4; + + return request(app.getHttpServer()) + .post(`/voyages/teams/${teamId}/techs`) + .set("Cookie", accessToken) + .send({ + techName: newTechName, + techCategoryId: 1, + voyageTeamMemberId: teamMemberId, + }) + .expect(400); + }); + it("should return 401 unauthorized if not logged in", async () => { const teamId: number = 2; + const teamMemberId: number = 8; return request(app.getHttpServer()) .post(`/voyages/teams/${teamId}/techs`) @@ -155,6 +167,7 @@ describe("Techs Controller (e2e)", () => { .send({ techName: newTechName, techCategoryId: 1, + voyageTeamMemberId: teamMemberId, }) .expect(401) .expect("Content-Type", /json/) @@ -168,24 +181,26 @@ describe("Techs Controller (e2e)", () => { }); }); - it("should return 400 if invalid teamId provided", async () => { + it("should return 404 if invalid teamId provided", async () => { const teamId: number = 9999999; + const teamMemberId: number = 8; return request(app.getHttpServer()) .post(`/voyages/teams/${teamId}/techs`) - .set("Authorization", `Bearer ${userAccessToken}`) + .set("Cookie", accessToken) .send({ techName: newTechName, techCategoryId: 1, + voyageTeamMemberId: teamMemberId, }) - .expect(400) + .expect(404) .expect("Content-Type", /json/) .expect((res) => { expect(res.body).toEqual( expect.objectContaining({ message: expect.any(String), error: expect.any(String), - statusCode: 400, + statusCode: 404, }), ); }); @@ -193,13 +208,15 @@ describe("Techs Controller (e2e)", () => { it("should return 409 if tech already exists in database", async () => { const teamId: number = 2; + const teamMemberId: number = 8; return request(app.getHttpServer()) .post(`/voyages/teams/${teamId}/techs`) - .set("Authorization", `Bearer ${userAccessToken}`) + .set("Cookie", accessToken) .send({ techName: newTechName, techCategoryId: 1, + voyageTeamMemberId: teamMemberId, }) .expect(409) .expect("Content-Type", /json/) @@ -215,14 +232,166 @@ describe("Techs Controller (e2e)", () => { }); }); - describe("POST voyages/teams/:teamId/techs/:teamTechId - add user vote for tech item", () => { - it("should return 200 if vote successfully added", async () => { - const teamId: number = 2; + describe("PATCH voyages/techs/:teamTechItemId - update tech stack item of the team", () => { + it("should return 200 and update a tech stack item", async () => { + const techId: number = 1; + + return request(app.getHttpServer()) + .patch(`/voyages/techs/${techId}`) + .set("Cookie", accessToken) + .send({ + techName: updatedTechName, + }) + .expect(200) + .expect("Content-Type", /json/) + .expect((res) => { + expect(res.body).toEqual( + expect.objectContaining({ + id: expect.any(Number), + name: expect.any(String), + voyageTeamMemberId: expect.any(Number), + voyageTeamId: expect.any(Number), + teamTechStackItemVotes: expect.any(Array), + }), + ); + expect(res.body.teamTechStackItemVotes).toEqual( + expect.arrayContaining([ + { + votedBy: { + member: { + id: expect.any(String), + firstName: expect.any(String), + lastName: expect.any(String), + avatar: expect.any(String), + }, + }, + }, + ]), + ); + expect(res.body.name).toEqual(updatedTechName); + }); + }); + it("should return 404 for invalid tech Id", async () => { + const techId: number = 9999999; + + return request(app.getHttpServer()) + .patch(`/voyages/techs/${techId}`) + .set("Cookie", accessToken) + .send({ + techName: updatedTechName, + }) + .expect(404) + .expect("Content-Type", /json/) + .expect((res) => { + expect(res.body).toEqual( + expect.objectContaining({ + message: expect.any(String), + error: expect.any(String), + statusCode: 404, + }), + ); + }); + }); + it("should return 400 for invalid request body", async () => { + const techId: number = 1; + + return request(app.getHttpServer()) + .patch(`/voyages/techs/${techId}`) + .set("Cookie", accessToken) + .send({}) + .expect(400); + }); + it("should return 403 if a user tries to PATCH a tech stack item created by someone else", async () => { const techId: number = 3; return request(app.getHttpServer()) - .post(`/voyages/teams/${teamId}/techs/${techId}`) - .set("Authorization", `Bearer ${userAccessToken}`) + .patch(`/voyages/techs/${techId}`) + .set("Cookie", accessToken) + .send({ + techName: updatedTechName, + }) + .expect(403); + }); + it("should return 401 unauthorized if not logged in", async () => { + const techId: number = 5; + + return request(app.getHttpServer()) + .patch(`/voyages/techs/${techId}`) + .set("Authorization", `Bearer ${undefined}`) + .send({ + techName: updatedTechName, + }) + .expect(401); + }); + }); + describe("DELETE voyages/techs/:teamTechItemId - delete tech stack item", () => { + it("should return 200 after deleting a tech stack item", async () => { + const techId: number = 5; + return request(app.getHttpServer()) + .delete(`/voyages/techs/${techId}`) + .set("Cookie", accessToken) + .expect(200) + .expect(async () => { + const deletedTechStackItem = + await prisma.teamTechStackItem.findFirst({ + where: { + id: techId, + }, + }); + expect(deletedTechStackItem).toBeNull(); + }); + }); + it("should return 404 if invalid tech id provided", async () => { + const techId: number = 9999999; + + return request(app.getHttpServer()) + .delete(`/voyages/techs/${techId}`) + .set("Cookie", accessToken) + .expect(404) + .expect((res) => { + expect(res.body).toEqual( + expect.objectContaining({ + message: expect.any(String), + error: expect.any(String), + statusCode: 404, + }), + ); + }); + }); + it("should return 403 if a user tries to DELETE a resource created by someone else", async () => { + const techId: number = 3; + + return request(app.getHttpServer()) + .delete(`/voyages/techs/${techId}`) + .set("Cookie", accessToken) + .expect(403) + .expect((res) => { + expect(res.body).toEqual( + expect.objectContaining({ + message: expect.any(String), + error: expect.any(String), + statusCode: 403, + }), + ); + }); + }); + it("should return 401 if user is not logged in", async () => { + const techId: number = 5; + + return request(app.getHttpServer()) + .delete(`/voyages/techs/${techId}`) + .set("Authorization", `Bearer ${undefined}`) + .expect(401); + }); + }); + + describe("POST voyages/techs/:teamTechItemId/vote - add user vote for tech item", () => { + it("should return 201 if vote successfully added", async () => { + const techId: number = 6; + + return request(app.getHttpServer()) + .post(`/voyages/techs/${techId}/vote`) + .set("Cookie", accessToken) .expect(201) .expect("Content-Type", /json/) .expect((res) => { @@ -241,7 +410,7 @@ describe("Techs Controller (e2e)", () => { it("- verify that new tech vote is present in database", async () => { const techStackVote = await prisma.teamTechStackItemVote.findMany({ where: { - teamTechId: 3, + teamTechId: 6, teamMemberId: 8, }, }); @@ -249,11 +418,10 @@ describe("Techs Controller (e2e)", () => { }); it("should return 401 unauthorized if not logged in", async () => { - const teamId: number = 2; - const techId: number = 3; + const techId: number = 6; return request(app.getHttpServer()) - .post(`/voyages/teams/${teamId}/techs/${techId}`) + .post(`/voyages/techs/${techId}/vote`) .set("Authorization", `Bearer ${undefined}`) .expect(401) .expect("Content-Type", /json/) @@ -267,33 +435,12 @@ describe("Techs Controller (e2e)", () => { }); }); - it("should return 400 if invalid teamId provided", async () => { - const teamId: number = 9999999; - const techId: number = 3; - - return request(app.getHttpServer()) - .post(`/voyages/teams/${teamId}/techs/${techId}`) - .set("Authorization", `Bearer ${userAccessToken}`) - .expect(400) - .expect("Content-Type", /json/) - .expect((res) => { - expect(res.body).toEqual( - expect.objectContaining({ - message: expect.any(String), - error: expect.any(String), - statusCode: 400, - }), - ); - }); - }); - it("should return 409 if user vote for tech already exists", async () => { - const teamId: number = 2; - const techId: number = 3; + const techId: number = 6; return request(app.getHttpServer()) - .post(`/voyages/teams/${teamId}/techs/${techId}`) - .set("Authorization", `Bearer ${userAccessToken}`) + .post(`/voyages/techs/${techId}/vote`) + .set("Cookie", accessToken) .expect(409) .expect("Content-Type", /json/) .expect((res) => { @@ -308,14 +455,13 @@ describe("Techs Controller (e2e)", () => { }); }); - describe("DELETE voyages/teams/:teamId/techs/:teamTechId - delete user vote for tech", () => { + describe("DELETE voyages/techs/:teamTechItemId/vote - delete user vote for tech", () => { it("should return 200 if tech vote deleted", async () => { - const teamId: number = 2; - const techId: number = 3; + const techId: number = 6; return request(app.getHttpServer()) - .delete(`/voyages/teams/${teamId}/techs/${techId}`) - .set("Authorization", `Bearer ${userAccessToken}`) + .delete(`/voyages/techs/${techId}/vote`) + .set("Cookie", accessToken) .expect(200) .expect("Content-Type", /json/) .expect((res) => { @@ -331,7 +477,7 @@ describe("Techs Controller (e2e)", () => { it("- verify that new tech vote is deleted from database", async () => { const techStackVote = await prisma.teamTechStackItemVote.findMany({ where: { - teamTechId: 3, + teamTechId: 6, teamMemberId: 8, }, }); @@ -339,11 +485,10 @@ describe("Techs Controller (e2e)", () => { }); it("should return 200 if tech last vote was deleted and team tech stack item is deleted", async () => { - const teamId: number = 2; const techId: number = 9; return request(app.getHttpServer()) - .delete(`/voyages/teams/${teamId}/techs/${techId}`) - .set("Authorization", `Bearer ${userAccessToken}`) + .delete(`/voyages/techs/${techId}/vote`) + .set("Cookie", accessToken) .expect(200) .expect("Content-Type", /json/) .expect((res) => { @@ -367,11 +512,10 @@ describe("Techs Controller (e2e)", () => { }); it("should return 401 unauthorized if not logged in", async () => { - const teamId: number = 2; - const techId: number = 3; + const techId: number = 6; return request(app.getHttpServer()) - .delete(`/voyages/teams/${teamId}/techs/${techId}`) + .delete(`/voyages/techs/${techId}/vote`) .set("Authorization", `Bearer ${undefined}`) .expect(401) .expect("Content-Type", /json/) @@ -385,33 +529,12 @@ describe("Techs Controller (e2e)", () => { }); }); - it("should return 400 if invalid teamId provided", async () => { - const teamId: number = 99999; - const techId: number = 3; - - return request(app.getHttpServer()) - .delete(`/voyages/teams/${teamId}/techs/${techId}`) - .set("Authorization", `Bearer ${userAccessToken}`) - .expect(400) - .expect("Content-Type", /json/) - .expect((res) => { - expect(res.body).toEqual( - expect.objectContaining({ - message: expect.any(String), - error: "Bad Request", - statusCode: 400, - }), - ); - }); - }); - it("should return 404 if vote to delete does not exist", async () => { - const teamId: number = 2; - const techId: number = 3; + const techId: number = 6; return request(app.getHttpServer()) - .delete(`/voyages/teams/${teamId}/techs/${techId}`) - .set("Authorization", `Bearer ${userAccessToken}`) + .delete(`/voyages/techs/${techId}/vote`) + .set("Cookie", accessToken) .expect(404) .expect("Content-Type", /json/) .expect((res) => { @@ -432,7 +555,7 @@ describe("Techs Controller (e2e)", () => { return request(app.getHttpServer()) .patch(`/voyages/teams/${teamId}/techs/selections`) - .set("Authorization", `Bearer ${userAccessToken}`) + .set("Cookie", accessToken) .send({ categories: [ { @@ -466,7 +589,7 @@ describe("Techs Controller (e2e)", () => { return request(app.getHttpServer()) .patch(`/voyages/teams/${teamId}/techs/selections`) - .set("Authorization", `Bearer ${userAccessToken}`) + .set("Cookie", accessToken) .send({ categories: [ { @@ -500,7 +623,7 @@ describe("Techs Controller (e2e)", () => { return request(app.getHttpServer()) .patch(`/voyages/teams/${teamId}/techs/selections`) - .set("Authorization", `Bearer ${userAccessToken}`) + .set("Cookie", accessToken) .send({ categories: [ {