diff --git a/BE/src/category/controller/category.controller.spec.ts b/BE/src/category/controller/category.controller.spec.ts index 7bc476e..c2132a5 100644 --- a/BE/src/category/controller/category.controller.spec.ts +++ b/BE/src/category/controller/category.controller.spec.ts @@ -7,7 +7,10 @@ import { oauthRequestFixture, } from '../../member/fixture/member.fixture'; import { CreateCategoryRequest } from '../dto/createCategoryRequest'; -import { CategoryNameEmptyException } from '../exception/category.exception'; +import { + CategoryNameEmptyException, + CategoryNotFoundException, +} from '../exception/category.exception'; import { ManipulatedTokenNotFiltered } from '../../token/exception/token.exception'; import { Request } from 'express'; import * as request from 'supertest'; @@ -23,6 +26,7 @@ import { TokenModule } from '../../token/token.module'; import { AuthService } from '../../auth/service/auth.service'; import { Token } from '../../token/entity/token'; import { + beCategoryFixture, categoryListFixture, categoryListResponseFixture, defaultCategoryListFixture, @@ -31,6 +35,7 @@ import { import { CategoryListResponse } from '../dto/categoryListResponse'; import { TokenService } from '../../token/service/token.service'; import { CategoryRepository } from '../repository/category.repository'; +import { UnauthorizedException } from '@nestjs/common'; describe('CategoryController', () => { let controller: CategoryController; @@ -38,6 +43,7 @@ describe('CategoryController', () => { const mockCategoryService = { createCategory: jest.fn(), findUsingCategories: jest.fn(), + deleteCategoryById: jest.fn(), }; const mockTokenService = { @@ -142,6 +148,73 @@ describe('CategoryController', () => { CategoryListResponse.of(defaultCategoryListResponseFixture), ); }); + + it('Member객체가 있고, 존재하는 id의 삭제를 요청하면, Undefined가 반환된다.', async () => { + //given + const category = new Category(1, 'CS', memberFixture, new Date()); + + //when + mockCategoryService.deleteCategoryById.mockResolvedValue(undefined); + //then + await expect( + controller.deleteCategoryById(mockReqWithMemberFixture, category.id), + ).resolves.toBeUndefined(); + }); + + it('Member객체가 없이 id만을 요청하면 ManipulatedToken을 반환한다. => 미들웨어 통과지만 Repository에서 찾지 못한 경우', async () => { + //given + const category = new Category(1, 'CS', memberFixture, new Date()); + + //when + mockCategoryService.deleteCategoryById.mockRejectedValue( + new ManipulatedTokenNotFiltered(), + ); + //then + await expect( + controller.deleteCategoryById( + { user: undefined } as unknown as Request, + category.id, + ), + ).rejects.toThrow(new ManipulatedTokenNotFiltered()); + }); + + it('Member객체가 있지만, id로 조회한 Category의 Member와 다르면 UnauthorizedException을 반환한다.', async () => { + //given + const category = new Category( + 1, + 'CS', + new Member(3, 'ja@ja.com', 'ja', 'http://www.gomterview.com', new Date()), + new Date(), + ); + + //when + mockCategoryService.deleteCategoryById.mockRejectedValue( + new UnauthorizedException(), + ); + //then + await expect( + controller.deleteCategoryById(mockReqWithMemberFixture, category.id), + ).rejects.toThrow(new UnauthorizedException()); + }); + + it('Member객체가 있지만 id로 조회한 Category가 없을 경우 CategoryNotFoundException을 반환한다.', async () => { + //given + const category = new Category( + 1, + 'CS', + new Member(3, 'ja@ja.com', 'ja', 'http://www.gomterview.com', new Date()), + new Date(), + ); + + //when + mockCategoryService.deleteCategoryById.mockRejectedValue( + new CategoryNotFoundException(), + ); + //then + await expect( + controller.deleteCategoryById(mockReqWithMemberFixture, category.id + 1), + ).rejects.toThrow(new CategoryNotFoundException()); + }); }); describe('CategoryController 통합테스트', () => { @@ -164,25 +237,24 @@ describe('CategoryController 통합테스트', () => { moduleFixture.get(CategoryRepository); }); + beforeEach(async () => { + await categoryRepository.query('delete from MemberQuestion'); + await categoryRepository.query('delete from Question'); + await categoryRepository.query('delete from Category'); + await categoryRepository.query('delete from Member'); + await categoryRepository.query('delete from token'); + }); + it('카테고리 저장을 성공시 201상태코드가 반환된다.', (done) => { - memberRepository - .save(memberFixture) - .then(() => { - return authService.login(oauthRequestFixture); - }) - .then((token) => { - const agent = request.agent(app.getHttpServer()); - agent - .post(`/api/category`) - .set('Cookie', [`accessToken=${token}`]) - .send(new CreateCategoryRequest('tester')) - .expect(201) - .then((response) => { - expect(response).toBeUndefined(); - return; - }); - }) - .then(done); + authService.login(oauthRequestFixture).then((validToken) => { + const agent = request.agent(app.getHttpServer()); + agent + .post('/api/category') + .set('Cookie', [`accessToken=${validToken}`]) + .send(new CreateCategoryRequest('tester')) + .expect(201) + .then(() => done()); + }); }); it('회원이 카테고리 조회시 200코드와 CategoryListResponse가 반환된다.', (done) => { @@ -237,9 +309,27 @@ describe('CategoryController 통합테스트', () => { .then(done); }); - afterEach(async () => { - await categoryRepository.query('delete from Category'); - await categoryRepository.query('delete from Member'); - await categoryRepository.query('delete from token'); + it('회원의 카테고리를 삭제한다.', (done) => { + //given + const category = Category.from(beCategoryFixture, memberFixture); + const oauthRequest = { + name: memberFixture.nickname, + email: memberFixture.email, + img: memberFixture.profileImg, + }; + //when + + //then + categoryRepository + .save(category) + .then(() => authService.login(oauthRequest)) + .then((token) => { + const agent = request.agent(app.getHttpServer()); + agent + .delete(`/api/category?id=1`) + .set('Cookie', [`accessToken=${token}`]) + .expect(200) + .then(done); + }); }); }); diff --git a/BE/src/category/controller/category.controller.ts b/BE/src/category/controller/category.controller.ts index 89e5002..8a0e0e3 100644 --- a/BE/src/category/controller/category.controller.ts +++ b/BE/src/category/controller/category.controller.ts @@ -1,4 +1,13 @@ -import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common'; +import { + Body, + Controller, + Delete, + Get, + Post, + Query, + Req, + UseGuards, +} from '@nestjs/common'; import { ApiBody, ApiCookieAuth, @@ -61,4 +70,19 @@ export class CategoryController { return CategoryListResponse.of(categories); } + + @Delete() + @UseGuards(AuthGuard('jwt')) + @ApiCookieAuth() + @ApiOperation({ + summary: '카테고리를 삭제한다.', + }) + @ApiResponse(createApiResponseOption(204, '카테고리 삭제', null)) + async deleteCategoryById( + @Req() req: Request, + @Query('categoryId') categoryId: number, + ) { + const member = req.user as Member; + await this.categoryService.deleteCategoryById(member, categoryId); + } } diff --git a/BE/src/category/entity/category.ts b/BE/src/category/entity/category.ts index 8f4839e..3c7fc95 100644 --- a/BE/src/category/entity/category.ts +++ b/BE/src/category/entity/category.ts @@ -1,14 +1,6 @@ -import { - Column, - Entity, - JoinColumn, - JoinTable, - ManyToMany, - ManyToOne, -} from 'typeorm'; +import { Column, Entity, JoinColumn, ManyToOne } from 'typeorm'; import { DefaultEntity } from '../../app.entity'; import { Member } from '../../member/entity/member'; -import { Question } from '../../question/entity/question'; import { CreateCategoryRequest } from '../dto/createCategoryRequest'; @Entity({ name: 'Category' }) @@ -16,14 +8,10 @@ export class Category extends DefaultEntity { @Column() name: string; - @ManyToOne(() => Member, { nullable: true, onDelete: 'CASCADE' }) + @ManyToOne(() => Member, { nullable: true, onDelete: 'CASCADE', eager: true }) @JoinColumn({ name: 'memberId' }) member: Member; - @ManyToMany(() => Question) - @JoinTable({ name: 'CategoryQuestion' }) - questions: Question[]; - constructor(id: number, name: string, member: Member, createdAt: Date) { super(id, createdAt); this.member = member; @@ -34,6 +22,10 @@ export class Category extends DefaultEntity { return new Category(null, inputObj.name, member, new Date()); } + isOwnedBy(member: Member) { + return this.member.equals(member); + } + getName() { return this.name; } diff --git a/BE/src/category/exception/category.exception.ts b/BE/src/category/exception/category.exception.ts index 5e813b0..dc64d3d 100644 --- a/BE/src/category/exception/category.exception.ts +++ b/BE/src/category/exception/category.exception.ts @@ -6,4 +6,10 @@ class CategoryNameEmptyException extends HttpException { } } -export { CategoryNameEmptyException }; +class CategoryNotFoundException extends HttpException { + constructor() { + super('카테고리자 존재하지 않습니다.', 404); + } +} + +export { CategoryNameEmptyException, CategoryNotFoundException }; diff --git a/BE/src/category/repository/category.repository.ts b/BE/src/category/repository/category.repository.ts index 4caed6a..1c8473f 100644 --- a/BE/src/category/repository/category.repository.ts +++ b/BE/src/category/repository/category.repository.ts @@ -34,6 +34,10 @@ export class CategoryRepository { await this.repository.remove(category); } + async findByCategoryId(categoryId) { + return await this.repository.findOneBy({ id: categoryId }); + } + async query(query: string) { return await this.repository.query(query); } diff --git a/BE/src/category/service/category.service.spec.ts b/BE/src/category/service/category.service.spec.ts index 9152866..a2cb4bf 100644 --- a/BE/src/category/service/category.service.spec.ts +++ b/BE/src/category/service/category.service.spec.ts @@ -4,14 +4,17 @@ import { MemberRepository } from '../../member/repository/member.repository'; import { CategoryRepository } from '../repository/category.repository'; import { memberFixture } from '../../member/fixture/member.fixture'; import { CreateCategoryRequest } from '../dto/createCategoryRequest'; -import { CategoryNameEmptyException } from '../exception/category.exception'; +import { + CategoryNameEmptyException, + CategoryNotFoundException, +} from '../exception/category.exception'; import { ManipulatedTokenNotFiltered } from '../../token/exception/token.exception'; import { createIntegrationTestModule } from '../../util/test.util'; import { CategoryModule } from '../category.module'; import { MemberModule } from '../../member/member.module'; import { Member } from '../../member/entity/member'; import { Category } from '../entity/category'; -import { INestApplication } from '@nestjs/common'; +import { INestApplication, UnauthorizedException } from '@nestjs/common'; import { Question } from '../../question/entity/question'; import { CategoryResponse } from '../dto/categoryResponse'; import { @@ -27,6 +30,7 @@ describe('CategoryService', () => { const mockCategoryRepository = { save: jest.fn(), findAllByMemberId: jest.fn(), + findByCategoryId: jest.fn(), remove: jest.fn(), }; @@ -125,6 +129,64 @@ describe('CategoryService', () => { categoryFixtures.map(CategoryResponse.from), ); }); + + it('member가 있고, category가 회원의 카테고리라면 성공적으로 삭제를 한다.', async () => { + //given + + //when + mockCategoryRepository.findByCategoryId.mockResolvedValue( + Category.from(beCategoryFixture, memberFixture), + ); + mockCategoryRepository.remove.mockResolvedValue(undefined); + + //then + await expect( + service.deleteCategoryById(memberFixture, beCategoryFixture.id), + ).resolves.toBeUndefined(); + }); + + it('member가 undefined라면 ManipulatedTokenException을 발생시킨다.', async () => { + //given + + //when + mockCategoryRepository.findByCategoryId.mockResolvedValue( + Category.from(beCategoryFixture, memberFixture), + ); + + //then + await expect( + service.deleteCategoryById(undefined, beCategoryFixture.id), + ).rejects.toThrow(new ManipulatedTokenNotFiltered()); + }); + + it('categoryId가 존재하지 않는 id라면, CategoryNotFoundException을 발생시킨다.', async () => { + //given + + //when + mockCategoryRepository.findByCategoryId.mockResolvedValue(undefined); + + //then + await expect( + service.deleteCategoryById(memberFixture, beCategoryFixture.id), + ).rejects.toThrow(new CategoryNotFoundException()); + }); + + it('categoryId가 존재하지만, 자신의 카테고리가 아니라면 UnauthorizedException을 발생시킨다.', async () => { + //given + + //when + mockCategoryRepository.findByCategoryId.mockResolvedValue( + Category.from( + beCategoryFixture, + new Member(20, 'ja@ja.com', 'ja', 'https://www.google.com', new Date()), + ), + ); + + //then + await expect( + service.deleteCategoryById(memberFixture, beCategoryFixture.id), + ).rejects.toThrow(new UnauthorizedException()); + }); }); describe('CategoryService 통합테스트', () => { @@ -215,6 +277,23 @@ describe('CategoryService 통합테스트', () => { ]); }); + it('회원 카테고리를 삭제한다.', async () => { + //given + await memberRepository.save(memberFixture); + + //when + await saveMembersCategory(memberFixture); + await saveDefaultCategory(); + const categoryId = ( + await categoryService.findUsingCategories(memberFixture) + ).pop().id; + + //then + await expect( + categoryService.deleteCategoryById(memberFixture, categoryId), + ).resolves.toBeUndefined(); + }); + afterEach(async () => { await categoryRepository.query('delete from Category'); await categoryRepository.query('delete from Member'); diff --git a/BE/src/category/service/category.service.ts b/BE/src/category/service/category.service.ts index e0f06cb..688f40b 100644 --- a/BE/src/category/service/category.service.ts +++ b/BE/src/category/service/category.service.ts @@ -1,9 +1,12 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; import { CategoryRepository } from '../repository/category.repository'; import { CreateCategoryRequest } from '../dto/createCategoryRequest'; import { Member } from '../../member/entity/member'; import { isEmpty } from 'class-validator'; -import { CategoryNameEmptyException } from '../exception/category.exception'; +import { + CategoryNameEmptyException, + CategoryNotFoundException, +} from '../exception/category.exception'; import { Category } from '../entity/category'; import { validateManipulatedToken } from 'src/util/token.util'; import { CategoryResponse } from '../dto/categoryResponse'; @@ -34,4 +37,19 @@ export class CategoryService { return categories.map(CategoryResponse.from); } + + async deleteCategoryById(member: Member, categoryId: number) { + validateManipulatedToken(member); + + const category = await this.categoryRepository.findByCategoryId(categoryId); + if (isEmpty(category)) { + throw new CategoryNotFoundException(); + } + + if (!category.isOwnedBy(member)) { + throw new UnauthorizedException(); + } + + await this.categoryRepository.remove(category); + } } diff --git a/BE/src/member/entity/member.ts b/BE/src/member/entity/member.ts index a3ba1f8..a009292 100644 --- a/BE/src/member/entity/member.ts +++ b/BE/src/member/entity/member.ts @@ -1,5 +1,6 @@ import { DefaultEntity } from 'src/app.entity'; import { Column, Entity } from 'typeorm'; +import { objectEquals } from '../../util/util'; @Entity({ name: 'Member' }) export class Member extends DefaultEntity { @@ -26,4 +27,8 @@ export class Member extends DefaultEntity { this.nickname = nickname; this.profileImg = profileImg; } + + equals(member: Member) { + return objectEquals(this, member); + } } diff --git a/BE/src/token/strategy/access.token.strategy.ts b/BE/src/token/strategy/access.token.strategy.ts index bca2b89..83e44ff 100644 --- a/BE/src/token/strategy/access.token.strategy.ts +++ b/BE/src/token/strategy/access.token.strategy.ts @@ -8,10 +8,7 @@ import { InvalidTokenException } from '../exception/token.exception'; import { Request } from 'express'; @Injectable() -export class AccessTokenStrategy extends PassportStrategy( - Strategy, - 'jwt-soft', -) { +export class AccessTokenStrategy extends PassportStrategy(Strategy, 'jwt') { constructor(private memberRepository: MemberRepository) { super({ jwtFromRequest: (req: Request) => { diff --git a/BE/src/util/util.ts b/BE/src/util/util.ts new file mode 100644 index 0000000..c741653 --- /dev/null +++ b/BE/src/util/util.ts @@ -0,0 +1,2 @@ +export const objectEquals = (obj1: object, obj2: object) => + Object.entries(obj1).toString() === Object.entries(obj2).toString();