From 617d3159d6f6965ab04bf4cb26d1d4bcf4b6a310 Mon Sep 17 00:00:00 2001 From: "Kim Hyeon Woo, Lery" Date: Sun, 28 Jan 2024 22:47:01 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B7=B8=EB=A3=B9=20=EC=A6=90=EA=B2=A8?= =?UTF-8?q?=EC=B0=BE=EA=B8=B0=20=ED=95=B4=EC=A0=9C=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#56)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 잘못된 이벤트 파라미터 수정 * feat: 그룹 즐겨찾기 해제 기능 구현 --- .../AddBookmarkCommandHandler.spec.ts | 9 +- .../addBookmark/AddBookmarkCommandHandler.ts | 2 +- .../removeBookmark/RemoveBookmarkCommand.ts | 6 + .../RemoveBookmarkCommandHandler.spec.ts | 112 ++++++++++++++++++ .../RemoveBookmarkCommandHandler.ts | 59 +++++++++ .../GroupBookmarkCreatedHandler.spec.ts | 27 +---- .../GroupBookmarkCreatedHandler.ts | 10 +- .../GroupBookmarkRemovedHandler.spec.ts | 82 +++++++++++++ .../GroupBookmarkRemovedHandler.ts | 50 ++++++++ .../group/event/GroupBookmarkCreated.ts | 5 +- .../group/event/GroupBookmarkRemoved.ts | 6 + src/app/domain/group/model/GroupBookmark.ts | 5 + src/constant/message.ts | 1 - src/constant/template.ts | 4 + 14 files changed, 342 insertions(+), 36 deletions(-) create mode 100644 src/app/application/group/command/removeBookmark/RemoveBookmarkCommand.ts create mode 100644 src/app/application/group/command/removeBookmark/RemoveBookmarkCommandHandler.spec.ts create mode 100644 src/app/application/group/command/removeBookmark/RemoveBookmarkCommandHandler.ts create mode 100644 src/app/application/group/eventHandler/GroupBookmarkRemovedHandler.spec.ts create mode 100644 src/app/application/group/eventHandler/GroupBookmarkRemovedHandler.ts create mode 100644 src/app/domain/group/event/GroupBookmarkRemoved.ts diff --git a/src/app/application/group/command/addBookmark/AddBookmarkCommandHandler.spec.ts b/src/app/application/group/command/addBookmark/AddBookmarkCommandHandler.spec.ts index 93ce2c2..3588124 100644 --- a/src/app/application/group/command/addBookmark/AddBookmarkCommandHandler.spec.ts +++ b/src/app/application/group/command/addBookmark/AddBookmarkCommandHandler.spec.ts @@ -101,15 +101,16 @@ describe('AddBookmarkCommandHandler', () => { ).rejects.toThrowError(Message.DEFAULT_BOOKMARKED_GROUP); }); - test('이미 즐겨찾기 중이라면 예외가 발생해야 한다', async () => { + test('이미 즐겨찾기 중이라면 새로운 즐겨찾기를 생성하지 않아야 한다', async () => { const bookmark = DomainFixture.generateGroupBookmark(); groupBookmarkRepository.findByGroupIdAndUserId = jest .fn() .mockResolvedValue(bookmark); + jest.spyOn(groupBookmarkFactory, 'create'); - await expect( - handler.execute(new AddBookmarkCommand(groupId, userId)), - ).rejects.toThrowError(Message.ALREADY_BOOKMARKED_GROUP); + await handler.execute(new AddBookmarkCommand(groupId, userId)); + + expect(groupBookmarkFactory.create).not.toBeCalled(); }); test('즐겨찾기를 생성해야 한다', async () => { diff --git a/src/app/application/group/command/addBookmark/AddBookmarkCommandHandler.ts b/src/app/application/group/command/addBookmark/AddBookmarkCommandHandler.ts index 134ccbe..bfa7ea0 100644 --- a/src/app/application/group/command/addBookmark/AddBookmarkCommandHandler.ts +++ b/src/app/application/group/command/addBookmark/AddBookmarkCommandHandler.ts @@ -53,7 +53,7 @@ export class AddBookmarkCommandHandler userId, ); if (prevBookmark) { - throw new UnprocessableEntityException(Message.ALREADY_BOOKMARKED_GROUP); + return; } const newBookmark = this.groupBookmarkFactory.create({ diff --git a/src/app/application/group/command/removeBookmark/RemoveBookmarkCommand.ts b/src/app/application/group/command/removeBookmark/RemoveBookmarkCommand.ts new file mode 100644 index 0000000..e5f5f28 --- /dev/null +++ b/src/app/application/group/command/removeBookmark/RemoveBookmarkCommand.ts @@ -0,0 +1,6 @@ +export class RemoveBookmarkCommand { + constructor( + readonly groupId: string, + readonly userId: string, + ) {} +} diff --git a/src/app/application/group/command/removeBookmark/RemoveBookmarkCommandHandler.spec.ts b/src/app/application/group/command/removeBookmark/RemoveBookmarkCommandHandler.spec.ts new file mode 100644 index 0000000..d1c8072 --- /dev/null +++ b/src/app/application/group/command/removeBookmark/RemoveBookmarkCommandHandler.spec.ts @@ -0,0 +1,112 @@ +import { Test } from '@nestjs/testing'; +import { advanceTo, clear } from 'jest-date-mock'; + +import { AddBookmarkCommand } from '@sight/app/application/group/command/addBookmark/AddBookmarkCommand'; +import { RemoveBookmarkCommandHandler } from '@sight/app/application/group/command/removeBookmark/RemoveBookmarkCommandHandler'; + +import { + GroupBookmarkRepository, + IGroupBookmarkRepository, +} from '@sight/app/domain/group/IGroupBookmarkRepository'; +import { + GroupRepository, + IGroupRepository, +} from '@sight/app/domain/group/IGroupRepository'; +import { + CUSTOMER_SERVICE_GROUP_ID, + PRACTICE_GROUP_ID, +} from '@sight/app/domain/group/model/constant'; + +import { DomainFixture } from '@sight/__test__/fixtures'; +import { generateEmptyProviders } from '@sight/__test__/util'; +import { Message } from '@sight/constant/message'; + +describe('RemoveBookmarkCommandHandler', () => { + let handler: RemoveBookmarkCommandHandler; + let groupRepository: jest.Mocked; + let groupBookmarkRepository: jest.Mocked; + + beforeAll(async () => { + advanceTo(new Date()); + + const testModule = await Test.createTestingModule({ + providers: [ + RemoveBookmarkCommandHandler, + ...generateEmptyProviders(GroupRepository, GroupBookmarkRepository), + ], + }).compile(); + + handler = testModule.get(RemoveBookmarkCommandHandler); + groupRepository = testModule.get(GroupRepository); + groupBookmarkRepository = testModule.get(GroupBookmarkRepository); + }); + + afterAll(() => { + clear(); + }); + + describe('execute', () => { + const groupId = 'groupId'; + const userId = 'userId'; + + beforeEach(() => { + const group = DomainFixture.generateGroup(); + const groupBookmark = DomainFixture.generateGroupBookmark(); + + groupRepository.findById = jest.fn().mockResolvedValue(group); + groupBookmarkRepository.findByGroupIdAndUserId = jest + .fn() + .mockResolvedValue(groupBookmark); + + groupBookmarkRepository.remove = jest.fn(); + }); + + test('그룹이 존재하지 않으면 예외가 발생해야 한다', async () => { + groupRepository.findById = jest.fn().mockResolvedValue(null); + + await expect( + handler.execute(new AddBookmarkCommand(groupId, userId)), + ).rejects.toThrowError(Message.GROUP_NOT_FOUND); + }); + + test('고객 센터 그룹이라면 예외가 발생해야 한다', async () => { + const customerServiceGroup = DomainFixture.generateGroup({ + id: CUSTOMER_SERVICE_GROUP_ID, + }); + groupRepository.findById = jest + .fn() + .mockResolvedValue(customerServiceGroup); + + await expect( + handler.execute(new AddBookmarkCommand(groupId, userId)), + ).rejects.toThrowError(Message.DEFAULT_BOOKMARKED_GROUP); + }); + + test('그룹 활용 실습 그룹이라면 예외가 발생해야 한다', async () => { + const practiceGroup = DomainFixture.generateGroup({ + id: PRACTICE_GROUP_ID, + }); + groupRepository.findById = jest.fn().mockResolvedValue(practiceGroup); + + await expect( + handler.execute(new AddBookmarkCommand(groupId, userId)), + ).rejects.toThrowError(Message.DEFAULT_BOOKMARKED_GROUP); + }); + + test('아직 그룹을 즐겨찾기하지 않았다면 무시해야 한다', async () => { + groupBookmarkRepository.findByGroupIdAndUserId = jest + .fn() + .mockResolvedValue(null); + + await handler.execute(new AddBookmarkCommand(groupId, userId)); + + expect(groupBookmarkRepository.remove).not.toBeCalled(); + }); + + test('그룹을 즐겨찾기 해제해야 한다', async () => { + await handler.execute(new AddBookmarkCommand(groupId, userId)); + + expect(groupBookmarkRepository.remove).toBeCalled(); + }); + }); +}); diff --git a/src/app/application/group/command/removeBookmark/RemoveBookmarkCommandHandler.ts b/src/app/application/group/command/removeBookmark/RemoveBookmarkCommandHandler.ts new file mode 100644 index 0000000..0c3fbdd --- /dev/null +++ b/src/app/application/group/command/removeBookmark/RemoveBookmarkCommandHandler.ts @@ -0,0 +1,59 @@ +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; +import { + Inject, + NotFoundException, + UnprocessableEntityException, +} from '@nestjs/common'; + +import { Transactional } from '@sight/core/persistence/transaction/Transactional'; + +import { RemoveBookmarkCommand } from '@sight/app/application/group/command/removeBookmark/RemoveBookmarkCommand'; + +import { + GroupBookmarkRepository, + IGroupBookmarkRepository, +} from '@sight/app/domain/group/IGroupBookmarkRepository'; +import { + GroupRepository, + IGroupRepository, +} from '@sight/app/domain/group/IGroupRepository'; + +import { Message } from '@sight/constant/message'; + +@CommandHandler(RemoveBookmarkCommand) +export class RemoveBookmarkCommandHandler + implements ICommandHandler +{ + constructor( + @Inject(GroupRepository) + private readonly groupRepository: IGroupRepository, + @Inject(GroupBookmarkRepository) + private readonly groupBookmarkRepository: IGroupBookmarkRepository, + ) {} + + @Transactional() + async execute(command: RemoveBookmarkCommand): Promise { + const { groupId, userId } = command; + + const group = await this.groupRepository.findById(groupId); + if (!group) { + throw new NotFoundException(Message.GROUP_NOT_FOUND); + } + + if (group.isCustomerServiceGroup() || group.isPracticeGroup()) { + throw new UnprocessableEntityException(Message.DEFAULT_BOOKMARKED_GROUP); + } + + const prevBookmark = + await this.groupBookmarkRepository.findByGroupIdAndUserId( + groupId, + userId, + ); + if (!prevBookmark) { + return; + } + + prevBookmark.remove(); + await this.groupBookmarkRepository.remove(prevBookmark); + } +} diff --git a/src/app/application/group/eventHandler/GroupBookmarkCreatedHandler.spec.ts b/src/app/application/group/eventHandler/GroupBookmarkCreatedHandler.spec.ts index 601d2d7..1da5a4c 100644 --- a/src/app/application/group/eventHandler/GroupBookmarkCreatedHandler.spec.ts +++ b/src/app/application/group/eventHandler/GroupBookmarkCreatedHandler.spec.ts @@ -1,9 +1,6 @@ import { Test } from '@nestjs/testing'; import { advanceTo, clear } from 'jest-date-mock'; -import { ClsService } from 'nestjs-cls'; -import { IRequester } from '@sight/core/auth/IRequester'; -import { UserRole } from '@sight/core/auth/UserRole'; import { MessageBuilder } from '@sight/core/message/MessageBuilder'; import { GroupBookmarkCreatedHandler } from '@sight/app/application/group/eventHandler/GroupBookmarkCreatedHandler'; @@ -23,7 +20,6 @@ import { generateEmptyProviders } from '@sight/__test__/util'; describe('GroupBookmarkCreatedHandler', () => { let handler: GroupBookmarkCreatedHandler; - let clsService: jest.Mocked; let messageBuilder: jest.Mocked; let slackSender: jest.Mocked; let groupRepository: jest.Mocked; @@ -34,17 +30,11 @@ describe('GroupBookmarkCreatedHandler', () => { const testModule = await Test.createTestingModule({ providers: [ GroupBookmarkCreatedHandler, - ...generateEmptyProviders( - ClsService, - MessageBuilder, - SlackSender, - GroupRepository, - ), + ...generateEmptyProviders(MessageBuilder, SlackSender, GroupRepository), ], }).compile(); handler = testModule.get(GroupBookmarkCreatedHandler); - clsService = testModule.get(ClsService); messageBuilder = testModule.get(MessageBuilder); slackSender = testModule.get(SlackSender); groupRepository = testModule.get(GroupRepository); @@ -55,16 +45,12 @@ describe('GroupBookmarkCreatedHandler', () => { }); describe('handle', () => { - const requesterUserId = 'requesterUserId'; + const groupId = 'groupId'; + const userId = 'userId'; beforeEach(() => { - const requester: IRequester = { - userId: requesterUserId, - role: UserRole.USER, - }; const group = DomainFixture.generateGroup(); - clsService.get = jest.fn().mockReturnValue(requester); groupRepository.findById = jest.fn().mockResolvedValue(group); messageBuilder.build = jest.fn().mockReturnValue('message'); @@ -72,8 +58,7 @@ describe('GroupBookmarkCreatedHandler', () => { }); test('그룹이 존재하지 않으면 메시지를 보내지 않아야 한다', async () => { - const groupId = 'groupId'; - const event = new GroupBookmarkCreated(groupId); + const event = new GroupBookmarkCreated(groupId, userId); groupRepository.findById.mockResolvedValue(null); @@ -84,13 +69,13 @@ describe('GroupBookmarkCreatedHandler', () => { test('요청자에게 메시지를 보내야 한다', async () => { const groupId = 'groupId'; - const event = new GroupBookmarkCreated(groupId); + const event = new GroupBookmarkCreated(groupId, userId); await handler.handle(event); expect(slackSender.send).toBeCalledTimes(1); expect(slackSender.send).toBeCalledWith( - expect.objectContaining({ targetUserId: requesterUserId }), + expect.objectContaining({ targetUserId: userId }), ); }); }); diff --git a/src/app/application/group/eventHandler/GroupBookmarkCreatedHandler.ts b/src/app/application/group/eventHandler/GroupBookmarkCreatedHandler.ts index 8f2aa91..fa9f9a5 100644 --- a/src/app/application/group/eventHandler/GroupBookmarkCreatedHandler.ts +++ b/src/app/application/group/eventHandler/GroupBookmarkCreatedHandler.ts @@ -1,8 +1,6 @@ import { Inject } from '@nestjs/common'; import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; -import { ClsService } from 'nestjs-cls'; -import { IRequester } from '@sight/core/auth/IRequester'; import { MessageBuilder } from '@sight/core/message/MessageBuilder'; import { Transactional } from '@sight/core/persistence/transaction/Transactional'; @@ -24,8 +22,6 @@ export class GroupBookmarkCreatedHandler implements IEventHandler { constructor( - @Inject(ClsService) - private readonly clsService: ClsService, @Inject(MessageBuilder) private readonly messageBuilder: MessageBuilder, @Inject(SlackSender) @@ -36,22 +32,20 @@ export class GroupBookmarkCreatedHandler @Transactional() async handle(event: GroupBookmarkCreated): Promise { - const { groupId } = event; + const { groupId, userId } = event; const group = await this.groupRepository.findById(groupId); if (!group) { return; } - const requester: IRequester = this.clsService.get('requester'); - const message = this.messageBuilder.build( Template.ADD_GROUP_BOOKMARK.notification, { groupId, groupTitle: group.title }, ); this.slackSender.send({ category: SlackMessageCategory.GROUP_ACTIVITY_FOR_ME, - targetUserId: requester.userId, + targetUserId: userId, message, }); } diff --git a/src/app/application/group/eventHandler/GroupBookmarkRemovedHandler.spec.ts b/src/app/application/group/eventHandler/GroupBookmarkRemovedHandler.spec.ts new file mode 100644 index 0000000..79ac40d --- /dev/null +++ b/src/app/application/group/eventHandler/GroupBookmarkRemovedHandler.spec.ts @@ -0,0 +1,82 @@ +import { Test } from '@nestjs/testing'; +import { advanceTo, clear } from 'jest-date-mock'; + +import { MessageBuilder } from '@sight/core/message/MessageBuilder'; + +import { GroupBookmarkRemovedHandler } from '@sight/app/application/group/eventHandler/GroupBookmarkRemovedHandler'; + +import { GroupBookmarkRemoved } from '@sight/app/domain/group/event/GroupBookmarkRemoved'; +import { + ISlackSender, + SlackSender, +} from '@sight/app/domain/adapter/ISlackSender'; +import { + GroupRepository, + IGroupRepository, +} from '@sight/app/domain/group/IGroupRepository'; + +import { DomainFixture } from '@sight/__test__/fixtures'; +import { generateEmptyProviders } from '@sight/__test__/util'; + +describe('GroupBookmarkRemovedHandler', () => { + let handler: GroupBookmarkRemovedHandler; + let messageBuilder: jest.Mocked; + let slackSender: jest.Mocked; + let groupRepository: jest.Mocked; + + beforeAll(async () => { + advanceTo(new Date()); + + const testModule = await Test.createTestingModule({ + providers: [ + GroupBookmarkRemovedHandler, + ...generateEmptyProviders(MessageBuilder, SlackSender, GroupRepository), + ], + }).compile(); + + handler = testModule.get(GroupBookmarkRemovedHandler); + messageBuilder = testModule.get(MessageBuilder); + slackSender = testModule.get(SlackSender); + groupRepository = testModule.get(GroupRepository); + }); + + afterAll(() => { + clear(); + }); + + describe('handle', () => { + const groupId = 'groupId'; + const userId = 'userId'; + + beforeEach(() => { + const group = DomainFixture.generateGroup(); + + groupRepository.findById = jest.fn().mockResolvedValue(group); + messageBuilder.build = jest.fn().mockReturnValue('message'); + + slackSender.send = jest.fn(); + }); + + test('그룹이 존재하지 않으면 메시지를 보내지 않아야 한다', async () => { + const event = new GroupBookmarkRemoved(groupId, userId); + + groupRepository.findById.mockResolvedValue(null); + + await handler.handle(event); + + expect(slackSender.send).not.toBeCalled(); + }); + + test('요청자에게 메시지를 보내야 한다', async () => { + const groupId = 'groupId'; + const event = new GroupBookmarkRemoved(groupId, userId); + + await handler.handle(event); + + expect(slackSender.send).toBeCalledTimes(1); + expect(slackSender.send).toBeCalledWith( + expect.objectContaining({ targetUserId: userId }), + ); + }); + }); +}); diff --git a/src/app/application/group/eventHandler/GroupBookmarkRemovedHandler.ts b/src/app/application/group/eventHandler/GroupBookmarkRemovedHandler.ts new file mode 100644 index 0000000..28ec8d7 --- /dev/null +++ b/src/app/application/group/eventHandler/GroupBookmarkRemovedHandler.ts @@ -0,0 +1,50 @@ +import { Inject } from '@nestjs/common'; +import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; + +import { MessageBuilder } from '@sight/core/message/MessageBuilder'; + +import { GroupBookmarkRemoved } from '@sight/app/domain/group/event/GroupBookmarkRemoved'; +import { SlackMessageCategory } from '@sight/app/domain/message/model/constant'; +import { + SlackSender, + ISlackSender, +} from '@sight/app/domain/adapter/ISlackSender'; +import { + GroupRepository, + IGroupRepository, +} from '@sight/app/domain/group/IGroupRepository'; + +import { Template } from '@sight/constant/template'; + +@EventsHandler(GroupBookmarkRemoved) +export class GroupBookmarkRemovedHandler + implements IEventHandler +{ + constructor( + @Inject(MessageBuilder) + private readonly messageBuilder: MessageBuilder, + @Inject(SlackSender) + private readonly slackSender: ISlackSender, + @Inject(GroupRepository) + private readonly groupRepository: IGroupRepository, + ) {} + + async handle(event: GroupBookmarkRemoved): Promise { + const { groupId, userId } = event; + + const group = await this.groupRepository.findById(groupId); + if (!group) { + return; + } + + const message = this.messageBuilder.build( + Template.REMOVE_GROUP_BOOKMARK.notification, + { groupId, groupTitle: group.title }, + ); + this.slackSender.send({ + category: SlackMessageCategory.GROUP_ACTIVITY_FOR_ME, + targetUserId: userId, + message, + }); + } +} diff --git a/src/app/domain/group/event/GroupBookmarkCreated.ts b/src/app/domain/group/event/GroupBookmarkCreated.ts index 3760789..5d85810 100644 --- a/src/app/domain/group/event/GroupBookmarkCreated.ts +++ b/src/app/domain/group/event/GroupBookmarkCreated.ts @@ -1,3 +1,6 @@ export class GroupBookmarkCreated { - constructor(readonly groupId: string) {} + constructor( + readonly groupId: string, + readonly userId: string, + ) {} } diff --git a/src/app/domain/group/event/GroupBookmarkRemoved.ts b/src/app/domain/group/event/GroupBookmarkRemoved.ts new file mode 100644 index 0000000..090e9a2 --- /dev/null +++ b/src/app/domain/group/event/GroupBookmarkRemoved.ts @@ -0,0 +1,6 @@ +export class GroupBookmarkRemoved { + constructor( + readonly groupId: string, + readonly userId: string, + ) {} +} diff --git a/src/app/domain/group/model/GroupBookmark.ts b/src/app/domain/group/model/GroupBookmark.ts index ea04691..49ea8b9 100644 --- a/src/app/domain/group/model/GroupBookmark.ts +++ b/src/app/domain/group/model/GroupBookmark.ts @@ -1,4 +1,5 @@ import { AggregateRoot } from '@nestjs/cqrs'; +import { GroupBookmarkRemoved } from '../event/GroupBookmarkRemoved'; export type GroupBookmarkConstructorParams = { id: string; @@ -21,6 +22,10 @@ export class GroupBookmark extends AggregateRoot { this._createdAt = params.createdAt; } + remove(): void { + this.apply(new GroupBookmarkRemoved(this._id, this._userId)); + } + get id(): string { return this._id; } diff --git a/src/constant/message.ts b/src/constant/message.ts index ba34603..6f22b47 100644 --- a/src/constant/message.ts +++ b/src/constant/message.ts @@ -21,6 +21,5 @@ export const Message = { CANNOT_MODIFY_CUSTOMER_SERVICE_GROUP: 'Cannot modify customer service group', ALREADY_GROUP_ENABLED_PORTFOLIO: 'Already group enabled portfolio', ALREADY_GROUP_DISABLED_PORTFOLIO: 'Already group disabled portfolio', - ALREADY_BOOKMARKED_GROUP: 'Already bookmarked group', DEFAULT_BOOKMARKED_GROUP: 'Cannot add bookmark default bookmarked group', }; diff --git a/src/constant/template.ts b/src/constant/template.ts index 349ea6e..19bb0e4 100644 --- a/src/constant/template.ts +++ b/src/constant/template.ts @@ -8,4 +8,8 @@ export const Template = { notification: ':groupTitle: 그룹을 즐겨 찾습니다.', }, + REMOVE_GROUP_BOOKMARK: { + notification: + ':groupTitle: 그룹을 더 이상 즐겨 찾지 않습니다.', + }, } as const;