diff --git a/.nvmrc b/.nvmrc index 6aab9b4..9aef5aa 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v18.18.0 +v20.17.0 \ No newline at end of file diff --git a/.prettierrc b/.prettierrc index dcb7279..a20502b 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,4 +1,4 @@ { "singleQuote": true, "trailingComma": "all" -} \ No newline at end of file +} diff --git a/src/__test__/fixtures/GroupBookmarkFixture.ts b/src/__test__/fixtures/GroupBookmarkFixture.ts new file mode 100644 index 0000000..c086ec4 --- /dev/null +++ b/src/__test__/fixtures/GroupBookmarkFixture.ts @@ -0,0 +1,31 @@ +import { faker } from '@faker-js/faker'; + +import { + GroupBookmark, + GroupBookmarkConstructorParams, +} from '@sight/app/domain/group/model/GroupBookmark'; + +function generator( + params: Partial = {}, +): GroupBookmark { + return new GroupBookmark({ + id: faker.string.uuid(), + userId: faker.string.uuid(), + groupId: faker.string.uuid(), + createdAt: faker.date.anytime(), + ...params, + }); +} + +function normal( + params: Partial< + Pick + > = {}, +): GroupBookmark { + return generator(params); +} + +export const GroupBookmarkFixture = { + raw: generator, + normal, +}; diff --git a/src/__test__/fixtures/GroupFixture.ts b/src/__test__/fixtures/GroupFixture.ts new file mode 100644 index 0000000..7d0afb2 --- /dev/null +++ b/src/__test__/fixtures/GroupFixture.ts @@ -0,0 +1,83 @@ +import { faker } from '@faker-js/faker'; +import { + CUSTOMER_SERVICE_GROUP_ID, + GroupAccessGrade, + GroupCategory, + GroupState, + PRACTICE_GROUP_ID, +} from '@sight/app/domain/group/model/constant'; + +import { + Group, + GroupConstructorParams, +} from '@sight/app/domain/group/model/Group'; + +function generator(params: Partial = {}): Group { + return new Group({ + id: faker.string.uuid(), + category: faker.helpers.enumValue(GroupCategory), + state: faker.helpers.enumValue(GroupState), + title: faker.lorem.word(), + authorUserId: faker.string.uuid(), + adminUserId: faker.string.uuid(), + purpose: faker.lorem.sentence(), + interestIds: [faker.string.uuid()], + technology: [faker.lorem.word()], + grade: faker.helpers.enumValue(GroupAccessGrade), + lastUpdaterUserId: faker.string.uuid(), + repository: faker.internet.url(), + allowJoin: faker.datatype.boolean(), + hasPortfolio: faker.datatype.boolean(), + createdAt: faker.date.anytime(), + updatedAt: faker.date.anytime(), + ...params, + }); +} + +function inProgressJoinable( + params: Partial = {}, +): Group { + return generator({ + state: GroupState.PROGRESS, + grade: GroupAccessGrade.MEMBER, + allowJoin: true, + ...params, + }); +} + +function suspended(params: Partial = {}): Group { + return generator({ + state: GroupState.SUSPEND, + ...params, + }); +} + +function successfullyEnd(params: Partial = {}): Group { + return generator({ + state: GroupState.END_SUCCESS, + ...params, + }); +} + +function customerService(params: Partial = {}): Group { + return generator({ + id: CUSTOMER_SERVICE_GROUP_ID, + ...params, + }); +} + +function practice(params: Partial = {}): Group { + return generator({ + id: PRACTICE_GROUP_ID, + ...params, + }); +} + +export const GroupFixture = { + raw: generator, + inProgressJoinable, + successfullyEnd, + suspended, + customerService, + practice, +}; diff --git a/src/__test__/fixtures/domain/group.ts b/src/__test__/fixtures/domain/group.ts index d39b0a7..94ac2fb 100644 --- a/src/__test__/fixtures/domain/group.ts +++ b/src/__test__/fixtures/domain/group.ts @@ -1,18 +1,5 @@ import { faker } from '@faker-js/faker'; -import { - GroupAccessGrade, - GroupCategory, - GroupState, -} from '@sight/app/domain/group/model/constant'; -import { - Group, - GroupConstructorParams, -} from '@sight/app/domain/group/model/Group'; -import { - GroupBookmark, - GroupBookmarkConstructorParams, -} from '@sight/app/domain/group/model/GroupBookmark'; import { GroupLog, GroupLogConstructorParams, @@ -22,28 +9,6 @@ import { GroupMemberConstructorParams, } from '@sight/app/domain/group/model/GroupMember'; -export function generateGroup(params?: Partial): Group { - return new Group({ - id: faker.string.uuid(), - category: faker.helpers.enumValue(GroupCategory), - state: GroupState.PROGRESS, - title: faker.lorem.word(), - authorUserId: faker.string.uuid(), - adminUserId: faker.string.uuid(), - purpose: faker.lorem.sentence(), - interestIds: [faker.string.uuid()], - technology: [faker.lorem.word()], - grade: faker.helpers.enumValue(GroupAccessGrade), - lastUpdaterUserId: faker.string.uuid(), - repository: faker.internet.url(), - allowJoin: faker.datatype.boolean(), - hasPortfolio: faker.datatype.boolean(), - createdAt: faker.date.anytime(), - updatedAt: faker.date.anytime(), - ...params, - }); -} - export function generateGroupMember( params?: Partial, ): GroupMember { @@ -68,15 +33,3 @@ export function generateGroupLog( ...params, }); } - -export function generateGroupBookmark( - params?: Partial, -): GroupBookmark { - return new GroupBookmark({ - id: faker.string.uuid(), - userId: faker.string.uuid(), - groupId: faker.string.uuid(), - createdAt: faker.date.anytime(), - ...params, - }); -} diff --git a/src/app/application/group/command/addBookmark/AddBookmarkCommandHandler.spec.ts b/src/app/application/group/command/addBookmark/AddBookmarkCommandHandler.spec.ts index 3588124..02154b5 100644 --- a/src/app/application/group/command/addBookmark/AddBookmarkCommandHandler.spec.ts +++ b/src/app/application/group/command/addBookmark/AddBookmarkCommandHandler.spec.ts @@ -5,7 +5,6 @@ import { AddBookmarkCommand } from '@sight/app/application/group/command/addBook import { AddBookmarkCommandHandler } from '@sight/app/application/group/command/addBookmark/AddBookmarkCommandHandler'; import { GroupBookmarkFactory } from '@sight/app/domain/group/GroupBookmarkFactory'; -import { Group } from '@sight/app/domain/group/model/Group'; import { GroupBookmarkRepository, IGroupBookmarkRepository, @@ -14,13 +13,11 @@ 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 { Message } from '@sight/constant/message'; +import { SlackSender } from '@sight/app/domain/adapter/ISlackSender'; +import { GroupFixture } from '@sight/__test__/fixtures/GroupFixture'; +import { GroupBookmarkFixture } from '@sight/__test__/fixtures/GroupBookmarkFixture'; describe('AddBookmarkCommandHandler', () => { let handler: AddBookmarkCommandHandler; @@ -28,15 +25,33 @@ describe('AddBookmarkCommandHandler', () => { let groupRepository: jest.Mocked; let groupBookmarkRepository: jest.Mocked; - beforeAll(async () => { + beforeEach(async () => { advanceTo(new Date()); const testModule = await Test.createTestingModule({ providers: [ AddBookmarkCommandHandler, GroupBookmarkFactory, - { provide: GroupRepository, useValue: {} }, - { provide: GroupBookmarkRepository, useValue: {} }, + { + provide: GroupRepository, + useValue: { + findById: jest.fn(), + }, + }, + { + provide: GroupBookmarkRepository, + useValue: { + findByGroupIdAndUserId: jest.fn(), + nextId: jest.fn(), + save: jest.fn(), + }, + }, + { + provide: SlackSender, + useValue: { + send: jest.fn(), + }, + }, ], }).compile(); @@ -46,29 +61,12 @@ describe('AddBookmarkCommandHandler', () => { groupBookmarkRepository = testModule.get(GroupBookmarkRepository); }); - afterAll(() => { - clear(); - }); + afterEach(() => clear()); describe('execute', () => { - let group: Group; - - const newBookmarkId = 'new-bookmark-id'; const groupId = 'groupId'; const userId = 'userId'; - beforeEach(() => { - group = DomainFixture.generateGroup({ id: groupId }); - - groupRepository.findById = jest.fn().mockResolvedValue(group); - groupBookmarkRepository.findByGroupIdAndUserId = jest - .fn() - .mockResolvedValue(null); - groupBookmarkRepository.nextId = jest.fn().mockReturnValue(newBookmarkId); - - groupBookmarkRepository.save = jest.fn(); - }); - test('그룹이 존재하지 않으면 예외가 발생해야 한다', async () => { groupRepository.findById = jest.fn().mockResolvedValue(null); @@ -78,12 +76,8 @@ describe('AddBookmarkCommandHandler', () => { }); test('고객 센터 그룹이라면 예외가 발생해야 한다', async () => { - const customerServiceGroup = DomainFixture.generateGroup({ - id: CUSTOMER_SERVICE_GROUP_ID, - }); - groupRepository.findById = jest - .fn() - .mockResolvedValue(customerServiceGroup); + const customerServiceGroup = GroupFixture.customerService(); + groupRepository.findById.mockResolvedValue(customerServiceGroup); await expect( handler.execute(new AddBookmarkCommand(groupId, userId)), @@ -91,10 +85,8 @@ describe('AddBookmarkCommandHandler', () => { }); test('그룹 활용 실습 그룹이라면 예외가 발생해야 한다', async () => { - const practiceGroup = DomainFixture.generateGroup({ - id: PRACTICE_GROUP_ID, - }); - groupRepository.findById = jest.fn().mockResolvedValue(practiceGroup); + const practiceGroup = GroupFixture.practice(); + groupRepository.findById.mockResolvedValue(practiceGroup); await expect( handler.execute(new AddBookmarkCommand(groupId, userId)), @@ -102,10 +94,13 @@ describe('AddBookmarkCommandHandler', () => { }); test('이미 즐겨찾기 중이라면 새로운 즐겨찾기를 생성하지 않아야 한다', async () => { - const bookmark = DomainFixture.generateGroupBookmark(); - groupBookmarkRepository.findByGroupIdAndUserId = jest - .fn() - .mockResolvedValue(bookmark); + const group = GroupFixture.inProgressJoinable(); + const bookmark = GroupBookmarkFixture.normal(); + + groupRepository.findById.mockResolvedValue(group); + groupBookmarkRepository.findByGroupIdAndUserId.mockResolvedValue( + bookmark, + ); jest.spyOn(groupBookmarkFactory, 'create'); await handler.execute(new AddBookmarkCommand(groupId, userId)); @@ -114,6 +109,12 @@ describe('AddBookmarkCommandHandler', () => { }); test('즐겨찾기를 생성해야 한다', async () => { + const group = GroupFixture.inProgressJoinable(); + const newBookmarkId = 'new-bookmark-id'; + + groupRepository.findById.mockResolvedValue(group); + groupBookmarkRepository.nextId.mockReturnValue(newBookmarkId); + const expected = groupBookmarkFactory.create({ id: newBookmarkId, groupId, @@ -122,7 +123,6 @@ describe('AddBookmarkCommandHandler', () => { await handler.execute(new AddBookmarkCommand(groupId, userId)); - expect(groupBookmarkRepository.save).toBeCalledTimes(1); expect(groupBookmarkRepository.save).toBeCalledWith(expected); }); }); diff --git a/src/app/application/group/command/addBookmark/AddBookmarkCommandHandler.ts b/src/app/application/group/command/addBookmark/AddBookmarkCommandHandler.ts index bfa7ea0..021245d 100644 --- a/src/app/application/group/command/addBookmark/AddBookmarkCommandHandler.ts +++ b/src/app/application/group/command/addBookmark/AddBookmarkCommandHandler.ts @@ -8,6 +8,7 @@ import { import { Transactional } from '@sight/core/persistence/transaction/Transactional'; import { AddBookmarkCommand } from '@sight/app/application/group/command/addBookmark/AddBookmarkCommand'; +import { AddBookmarkCommandResult } from '@sight/app/application/group/command/addBookmark/AddBookmarkCommandResult'; import { GroupBookmarkFactory } from '@sight/app/domain/group/GroupBookmarkFactory'; import { @@ -20,6 +21,13 @@ import { } from '@sight/app/domain/group/IGroupRepository'; import { Message } from '@sight/constant/message'; +import { Template } from '@sight/constant/template'; +import { + ISlackSender, + SlackSender, +} from '@sight/app/domain/adapter/ISlackSender'; +import { SlackMessageCategory } from '@sight/app/domain/message/model/constant'; +import { MessageBuilder } from '@sight/core/message/MessageBuilder'; @CommandHandler(AddBookmarkCommand) export class AddBookmarkCommandHandler @@ -32,10 +40,14 @@ export class AddBookmarkCommandHandler private readonly groupBookmarkFactory: GroupBookmarkFactory, @Inject(GroupBookmarkRepository) private readonly groupBookmarkRepository: IGroupBookmarkRepository, + @Inject(SlackSender) + private readonly slackSender: ISlackSender, ) {} @Transactional() - async execute(command: AddBookmarkCommand): Promise { + async execute( + command: AddBookmarkCommand, + ): Promise { const { groupId, userId } = command; const group = await this.groupRepository.findById(groupId); @@ -53,7 +65,7 @@ export class AddBookmarkCommandHandler userId, ); if (prevBookmark) { - return; + return new AddBookmarkCommandResult(prevBookmark); } const newBookmark = this.groupBookmarkFactory.create({ @@ -62,5 +74,16 @@ export class AddBookmarkCommandHandler userId, }); await this.groupBookmarkRepository.save(newBookmark); + + this.slackSender.send({ + category: SlackMessageCategory.GROUP_ACTIVITY_FOR_ME, + targetUserId: userId, + message: MessageBuilder.build(Template.ADD_GROUP_BOOKMARK.notification, { + groupId, + groupTitle: group.title, + }), + }); + + return new AddBookmarkCommandResult(newBookmark); } } diff --git a/src/app/application/group/command/addBookmark/AddBookmarkCommandResult.ts b/src/app/application/group/command/addBookmark/AddBookmarkCommandResult.ts new file mode 100644 index 0000000..00a5ee4 --- /dev/null +++ b/src/app/application/group/command/addBookmark/AddBookmarkCommandResult.ts @@ -0,0 +1,5 @@ +import { GroupBookmark } from '@sight/app/domain/group/model/GroupBookmark'; + +export class AddBookmarkCommandResult { + constructor(readonly bookmark: GroupBookmark) {} +} diff --git a/src/app/application/group/command/changeGroupState/ChangeGroupStateCommandHandler.spec.ts b/src/app/application/group/command/changeGroupState/ChangeGroupStateCommandHandler.spec.ts index 64e4339..9b91a4e 100644 --- a/src/app/application/group/command/changeGroupState/ChangeGroupStateCommandHandler.spec.ts +++ b/src/app/application/group/command/changeGroupState/ChangeGroupStateCommandHandler.spec.ts @@ -16,9 +16,9 @@ import { IGroupRepository, } from '@sight/app/domain/group/IGroupRepository'; -import { DomainFixture } from '@sight/__test__/fixtures'; import { generateEmptyProviders } from '@sight/__test__/util'; import { Message } from '@sight/constant/message'; +import { GroupFixture } from '@sight/__test__/fixtures/GroupFixture'; describe('ChangeGroupStateCommandHandler', () => { let handler: ChangeGroupStateCommandHandler; @@ -52,7 +52,7 @@ describe('ChangeGroupStateCommandHandler', () => { const nextState = GroupState.END_SUCCESS; beforeEach(() => { - group = DomainFixture.generateGroup({ + group = GroupFixture.raw({ adminUserId: requesterUserId, }); @@ -104,13 +104,13 @@ describe('ChangeGroupStateCommandHandler', () => { ); }); - test('그룹을 저장하고 변경된 상태를 반환해야 한다', async () => { + test('상태가 변경된 그룹을 반환해야 한다', async () => { const command = new ChangeGroupStateCommand( { userId: requesterUserId, isManager: false }, groupId, nextState, ); - const expected = new ChangeGroupStateCommandResult(nextState); + const expected = new ChangeGroupStateCommandResult(group); const result = await handler.execute(command); diff --git a/src/app/application/group/command/changeGroupState/ChangeGroupStateCommandHandler.ts b/src/app/application/group/command/changeGroupState/ChangeGroupStateCommandHandler.ts index f690c6e..44a8935 100644 --- a/src/app/application/group/command/changeGroupState/ChangeGroupStateCommandHandler.ts +++ b/src/app/application/group/command/changeGroupState/ChangeGroupStateCommandHandler.ts @@ -64,7 +64,7 @@ export class ChangeGroupStateCommandHandler await this.groupLogger.log(groupId, this.buildMessage(nextState)); - return new ChangeGroupStateCommandResult(nextState); + return new ChangeGroupStateCommandResult(group); } private buildMessage(nextState: GroupState): string { diff --git a/src/app/application/group/command/changeGroupState/ChangeGroupStateCommandResult.ts b/src/app/application/group/command/changeGroupState/ChangeGroupStateCommandResult.ts index 7db54f6..9ef4c4c 100644 --- a/src/app/application/group/command/changeGroupState/ChangeGroupStateCommandResult.ts +++ b/src/app/application/group/command/changeGroupState/ChangeGroupStateCommandResult.ts @@ -1,5 +1,5 @@ -import { GroupState } from '@sight/app/domain/group/model/constant'; +import { Group } from '@sight/app/domain/group/model/Group'; export class ChangeGroupStateCommandResult { - constructor(readonly nextState: GroupState) {} + constructor(readonly group: Group) {} } diff --git a/src/app/application/group/command/createGroup/CreateGroupCommand.ts b/src/app/application/group/command/createGroup/CreateGroupCommand.ts index d0af89d..843887c 100644 --- a/src/app/application/group/command/createGroup/CreateGroupCommand.ts +++ b/src/app/application/group/command/createGroup/CreateGroupCommand.ts @@ -3,19 +3,30 @@ import { GroupAccessGrade, GroupCategory, } from '@sight/app/domain/group/model/constant'; +import { Typeof } from '@sight/util/types'; export class CreateGroupCommand implements ICommand { - constructor( - readonly userId: string, - readonly title: string, - readonly category: GroupCategory, - readonly grade: GroupAccessGrade, - // TODO: 아이디어 관련 도메인이 추가된 후에 추가 구현 필요 - // readonly ideaId: string | null, - readonly interestIds: string[], - readonly purpose: string, - readonly technology: string[], - readonly allowJoin: boolean, - readonly repository: string | null, - ) {} + readonly requesterUserId: string; + readonly title: string; + readonly category: GroupCategory; + readonly grade: GroupAccessGrade; + // TODO: 아이디어 관련 도메인이 추가된 후에 추가 구현 필요 + // readonly ideaId: string | null; + readonly interestIds: string[]; + readonly purpose: string; + readonly technology: string[]; + readonly allowJoin: boolean; + readonly repository: string | null; + + constructor(params: Typeof) { + this.requesterUserId = params.requesterUserId; + this.title = params.title; + this.category = params.category; + this.grade = params.grade; + this.interestIds = params.interestIds; + this.purpose = params.purpose; + this.technology = params.technology; + this.allowJoin = params.allowJoin; + this.repository = params.repository; + } } diff --git a/src/app/application/group/command/createGroup/CreateGroupCommandHandler.spec.ts b/src/app/application/group/command/createGroup/CreateGroupCommandHandler.spec.ts index f09f65d..a1620bf 100644 --- a/src/app/application/group/command/createGroup/CreateGroupCommandHandler.spec.ts +++ b/src/app/application/group/command/createGroup/CreateGroupCommandHandler.spec.ts @@ -6,8 +6,6 @@ import { CreateGroupCommandHandler } from '@sight/app/application/group/command/ import { GroupFactory } from '@sight/app/domain/group/GroupFactory'; import { GroupMemberFactory } from '@sight/app/domain/group/GroupMemberFactory'; -import { Group } from '@sight/app/domain/group/model/Group'; -import { GroupMember } from '@sight/app/domain/group/model/GroupMember'; import { GroupMemberRepository, IGroupMemberRepository, @@ -25,37 +23,57 @@ import { InterestRepository, } from '@sight/app/domain/interest/IInterestRepository'; -import { DomainFixture } from '@sight/__test__/fixtures'; -import { generateEmptyProviders } from '@sight/__test__/util'; import { Message } from '@sight/constant/message'; +import { SlackSender } from '@sight/app/domain/adapter/ISlackSender'; +import { GroupFixture } from '@sight/__test__/fixtures/GroupFixture'; +import { DomainFixture } from '@sight/__test__/fixtures'; +import { PointGrantService } from '@sight/app/domain/user/service/PointGrantService'; +import { Point } from '@sight/constant/point'; describe('CreateGroupCommandHandler', () => { let handler: CreateGroupCommandHandler; - let groupFactory: jest.Mocked; - let groupMemberFactory: jest.Mocked; + let groupFactory: GroupFactory; + let groupMemberFactory: GroupMemberFactory; + let pointGrantService: jest.Mocked; let groupRepository: jest.Mocked; let groupMemberRepository: jest.Mocked; let interestRepository: jest.Mocked; - beforeAll(async () => { - advanceTo(new Date()); + beforeAll(() => advanceTo(new Date())); + beforeEach(async () => { const testModule = await Test.createTestingModule({ providers: [ CreateGroupCommandHandler, - ...generateEmptyProviders( - GroupFactory, - GroupMemberFactory, - GroupRepository, - GroupMemberRepository, - InterestRepository, - ), + GroupFactory, + GroupMemberFactory, + { + provide: PointGrantService, + useValue: { grant: jest.fn() }, + }, + { + provide: GroupRepository, + useValue: { nextId: jest.fn(), save: jest.fn() }, + }, + { + provide: GroupMemberRepository, + useValue: { nextId: jest.fn(), save: jest.fn() }, + }, + { + provide: InterestRepository, + useValue: { findByIds: jest.fn() }, + }, + { + provide: SlackSender, + useValue: { send: jest.fn() }, + }, ], }).compile(); handler = testModule.get(CreateGroupCommandHandler); groupFactory = testModule.get(GroupFactory); groupMemberFactory = testModule.get(GroupMemberFactory); + pointGrantService = testModule.get(PointGrantService); groupRepository = testModule.get(GroupRepository); groupMemberRepository = testModule.get(GroupMemberRepository); interestRepository = testModule.get(InterestRepository); @@ -66,76 +84,100 @@ describe('CreateGroupCommandHandler', () => { }); describe('execute', () => { - let command: CreateGroupCommand; - let newGroup: Group; - let groupMember: GroupMember; - - const userId = 'userId'; - const title = 'title'; - const category = GroupCategory.DOCUMENTATION; - const grade = GroupAccessGrade.MEMBER; - const interestIds = ['interestId']; - const purpose = 'purpose'; - const technology = ['github', 'typescript']; - const allowJoin = true; - const repository = 'https://repo.sito.ry'; - - beforeEach(() => { - command = new CreateGroupCommand( - userId, - title, - category, - grade, - interestIds, - purpose, - technology, - allowJoin, - repository, - ); - newGroup = DomainFixture.generateGroup(); - groupMember = DomainFixture.generateGroupMember({ - groupId: newGroup.id, - userId: userId, - }); - - const interests = interestIds.map((interestId) => - DomainFixture.generateInterest({ id: interestId }), - ); - - interestRepository.findByIds = jest.fn().mockResolvedValue(interests); - groupFactory.create = jest.fn().mockReturnValue(newGroup); - groupRepository.nextId = jest.fn().mockReturnValue(newGroup.id); - groupMemberFactory.create = jest.fn().mockReturnValue(groupMember); - groupMemberRepository.nextId = jest.fn().mockReturnValue(groupMember.id); - - groupRepository.save = jest.fn(); - groupMemberRepository.save = jest.fn(); - }); - test('존재하지 않는 관심사일 때 예외가 발생해야 한다', async () => { - interestRepository.findByIds = jest.fn().mockResolvedValue([]); - + interestRepository.findByIds.mockResolvedValue([]); + + const command = new CreateGroupCommand({ + requesterUserId: 'userId', + title: 'title', + category: GroupCategory.DOCUMENTATION, + grade: GroupAccessGrade.MEMBER, + interestIds: ['not-exist-interest-id'], + purpose: 'purpose', + technology: [], + allowJoin: true, + repository: 'https://repo.sitory', + }); await expect(handler.execute(command)).rejects.toThrowError( Message.SOME_INTERESTS_NOT_FOUND, ); }); test('새로운 그룹을 생성한 뒤 저장해야 한다', async () => { + const newGroup = GroupFixture.inProgressJoinable(); + + groupRepository.nextId.mockReturnValue('newGroupId'); + interestRepository.findByIds.mockResolvedValue([]); + jest.spyOn(groupFactory, 'create').mockReturnValue(newGroup); + + const command = new CreateGroupCommand({ + requesterUserId: 'userId', + title: 'title', + category: GroupCategory.DOCUMENTATION, + grade: GroupAccessGrade.MEMBER, + interestIds: [], + purpose: 'purpose', + technology: [], + allowJoin: true, + repository: 'https://repo.sitory', + }); await handler.execute(command); - expect(groupFactory.create).toBeCalledTimes(1); - - expect(groupRepository.save).toBeCalledTimes(1); expect(groupRepository.save).toBeCalledWith(newGroup); }); test('새로운 그룹 멤버 정보를 생성한 뒤 저장해야 한다', async () => { + const newGroupMember = DomainFixture.generateGroupMember(); + + groupRepository.nextId.mockReturnValue('newGroupId'); + interestRepository.findByIds.mockResolvedValue([]); + jest.spyOn(groupMemberFactory, 'create').mockReturnValue(newGroupMember); + + const command = new CreateGroupCommand({ + requesterUserId: 'userId', + title: 'title', + category: GroupCategory.DOCUMENTATION, + grade: GroupAccessGrade.MEMBER, + interestIds: [], + purpose: 'purpose', + technology: [], + allowJoin: true, + repository: 'https://repo.sitory', + }); await handler.execute(command); - expect(groupMemberFactory.create).toBeCalledTimes(1); + expect(groupMemberFactory.create).toBeCalled(); + + expect(groupMemberRepository.save).toBeCalled(); + expect(groupMemberRepository.save).toBeCalledWith(newGroupMember); + }); - expect(groupMemberRepository.save).toBeCalledTimes(1); - expect(groupMemberRepository.save).toBeCalledWith(groupMember); + test('그룹장에게 그룹 생성 포인트를 부여해야 한다', async () => { + const newGroup = GroupFixture.inProgressJoinable(); + + groupRepository.nextId.mockReturnValue('newGroupId'); + interestRepository.findByIds.mockResolvedValue([]); + jest.spyOn(groupFactory, 'create').mockReturnValue(newGroup); + + const command = new CreateGroupCommand({ + requesterUserId: 'userId', + title: 'title', + category: GroupCategory.DOCUMENTATION, + grade: GroupAccessGrade.MEMBER, + interestIds: [], + purpose: 'purpose', + technology: [], + allowJoin: true, + repository: 'https://repo.sitory', + }); + await handler.execute(command); + + expect(pointGrantService.grant).toBeCalledWith( + expect.objectContaining({ + targetUserIds: [newGroup.adminUserId], + amount: Point.GROUP_CREATED, + }), + ); }); }); }); diff --git a/src/app/application/group/command/createGroup/CreateGroupCommandHandler.ts b/src/app/application/group/command/createGroup/CreateGroupCommandHandler.ts index e7d5afa..f4a42af 100644 --- a/src/app/application/group/command/createGroup/CreateGroupCommandHandler.ts +++ b/src/app/application/group/command/createGroup/CreateGroupCommandHandler.ts @@ -21,24 +21,32 @@ import { IInterestRepository, InterestRepository, } from '@sight/app/domain/interest/IInterestRepository'; +import { + ISlackSender, + SlackSender, +} from '@sight/app/domain/adapter/ISlackSender'; import { Message } from '@sight/constant/message'; +import { Point } from '@sight/constant/point'; +import { SlackMessageCategory } from '@sight/app/domain/message/model/constant'; +import { PointGrantService } from '@sight/app/domain/user/service/PointGrantService'; @CommandHandler(CreateGroupCommand) export class CreateGroupCommandHandler implements ICommandHandler { constructor( - @Inject(GroupFactory) private readonly groupFactory: GroupFactory, - @Inject(GroupMemberFactory) private readonly groupMemberFactory: GroupMemberFactory, + private readonly pointGrantService: PointGrantService, @Inject(GroupRepository) private readonly groupRepository: IGroupRepository, @Inject(GroupMemberRepository) private readonly groupMemberRepository: IGroupMemberRepository, @Inject(InterestRepository) private readonly interestRepository: IInterestRepository, + @Inject(SlackSender) + private readonly slackSender: ISlackSender, ) {} @Transactional() @@ -46,7 +54,7 @@ export class CreateGroupCommandHandler command: CreateGroupCommand, ): Promise { const { - userId, + requesterUserId, title, category, grade, @@ -65,13 +73,13 @@ export class CreateGroupCommandHandler category, state: GroupState.PROGRESS, title, - authorUserId: userId, - adminUserId: userId, + authorUserId: requesterUserId, + adminUserId: requesterUserId, purpose, interestIds, technology, grade, - lastUpdaterUserId: userId, + lastUpdaterUserId: requesterUserId, repository, allowJoin, }); @@ -79,11 +87,24 @@ export class CreateGroupCommandHandler const groupMember = this.groupMemberFactory.create({ id: this.groupMemberRepository.nextId(), - userId, + userId: requesterUserId, groupId: newGroup.id, }); await this.groupMemberRepository.save(groupMember); + const message = `${newGroup.title} 그룹을 만들었습니다.`; + await this.pointGrantService.grant({ + targetUserIds: [newGroup.adminUserId], + amount: Point.GROUP_CREATED, + reason: message, + }); + + this.slackSender.send({ + targetUserId: newGroup.adminUserId, + category: SlackMessageCategory.GROUP_ACTIVITY, + message, + }); + return new CreateGroupCommandResult(newGroup); } diff --git a/src/app/application/group/command/disablePortfolio/DisablePortfolioCommandHandler.spec.ts b/src/app/application/group/command/disablePortfolio/DisablePortfolioCommandHandler.spec.ts index 0b969a8..299dc07 100644 --- a/src/app/application/group/command/disablePortfolio/DisablePortfolioCommandHandler.spec.ts +++ b/src/app/application/group/command/disablePortfolio/DisablePortfolioCommandHandler.spec.ts @@ -4,95 +4,164 @@ import { advanceTo, clear } from 'jest-date-mock'; import { DisablePortfolioCommand } from '@sight/app/application/group/command/disablePortfolio/DisablePortfolioCommand'; import { DisablePortfolioCommandHandler } from '@sight/app/application/group/command/disablePortfolio/DisablePortfolioCommandHandler'; -import { Group } from '@sight/app/domain/group/model/Group'; import { GroupRepository, IGroupRepository, } from '@sight/app/domain/group/IGroupRepository'; -import { DomainFixture } from '@sight/__test__/fixtures'; +import { + GroupMemberRepository, + IGroupMemberRepository, +} from '@sight/app/domain/group/IGroupMemberRepository'; +import { + GroupLogger, + IGroupLogger, +} from '@sight/app/domain/group/IGroupLogger'; +import { + ISlackSender, + SlackSender, +} from '@sight/app/domain/adapter/ISlackSender'; import { Message } from '@sight/constant/message'; +import { GroupFixture } from '@sight/__test__/fixtures/GroupFixture'; +import { PointGrantService } from '@sight/app/domain/user/service/PointGrantService'; +import { DomainFixture } from '@sight/__test__/fixtures'; +import { Point } from '@sight/constant/point'; describe('DisablePortfolioCommandHandler', () => { let handler: DisablePortfolioCommandHandler; let groupRepository: jest.Mocked; + let groupMemberRepository: jest.Mocked; + let groupLogger: jest.Mocked; + let pointGrantService: jest.Mocked; + let slackSender: jest.Mocked; - beforeAll(async () => { + beforeEach(async () => { advanceTo(new Date()); const testModule = await Test.createTestingModule({ providers: [ DisablePortfolioCommandHandler, - { provide: GroupRepository, useValue: {} }, + { + provide: GroupRepository, + useValue: { findById: jest.fn(), save: jest.fn() }, + }, + { + provide: GroupMemberRepository, + useValue: { findByGroupId: jest.fn() }, + }, + { + provide: GroupLogger, + useValue: { log: jest.fn() }, + }, + { + provide: PointGrantService, + useValue: { grant: jest.fn() }, + }, + { + provide: SlackSender, + useValue: { send: jest.fn() }, + }, ], }).compile(); handler = testModule.get(DisablePortfolioCommandHandler); groupRepository = testModule.get(GroupRepository); + groupMemberRepository = testModule.get(GroupMemberRepository); + groupLogger = testModule.get(GroupLogger); + pointGrantService = testModule.get(PointGrantService); + slackSender = testModule.get(SlackSender); }); - afterAll(() => { - clear(); - }); + afterEach(() => clear()); - describe('handle', () => { - let group: Group; + describe('execute', () => { + test('그룹이 존재하지 않으면 예외를 발생시켜야 한다', async () => { + groupRepository.findById.mockResolvedValue(null); - const groupId = 'groupId'; - const groupAdminUserId = 'groupAdminUserId'; + const command = new DisablePortfolioCommand('groupId', 'requesterUserId'); + await expect(handler.execute(command)).rejects.toThrow( + Message.GROUP_NOT_FOUND, + ); + }); - beforeEach(() => { - group = DomainFixture.generateGroup({ - adminUserId: groupAdminUserId, - hasPortfolio: true, - }); + test('요청자가 그룹장이 아니라면 예외를 발생시켜야 한다', async () => { + const group = GroupFixture.inProgressJoinable({ hasPortfolio: true }); + const notAdminUserId = 'not-admin-user-id'; - groupRepository.findById = jest.fn().mockResolvedValue(group); + groupRepository.findById.mockResolvedValue(group); - groupRepository.save = jest.fn(); + const command = new DisablePortfolioCommand('groupId', notAdminUserId); + await expect(handler.execute(command)).rejects.toThrow(); }); - test('그룹이 존재하지 않는다면 예외를 발생시켜야 한다', async () => { - groupRepository.findById = jest.fn().mockResolvedValue(null); + test('대상 그룹이 고객센터 그룹이라면 예외를 발생시켜야 한다', async () => { + const group = GroupFixture.customerService({ hasPortfolio: true }); - await expect( - handler.execute(new DisablePortfolioCommand(groupId, groupAdminUserId)), - ).rejects.toThrowError(Message.GROUP_NOT_FOUND); + const command = new DisablePortfolioCommand('groupId', group.adminUserId); + await expect(handler.execute(command)).rejects.toThrow(); }); - test('요청자가 그룹장이 아니라면 예외를 발생시켜야 한다', async () => { - const otherUserId = 'otherUserId'; + test('포트폴리오 발행을 중단시켜야 한다', async () => { + const group = GroupFixture.inProgressJoinable({ hasPortfolio: true }); + const adminUserId = group.adminUserId; + + groupRepository.findById.mockResolvedValue(group); + groupMemberRepository.findByGroupId.mockResolvedValue([]); + jest.spyOn(group, 'disablePortfolio'); - await expect( - handler.execute(new DisablePortfolioCommand(groupId, otherUserId)), - ).rejects.toThrowError(Message.ONLY_GROUP_ADMIN_CAN_EDIT_GROUP); + const command = new DisablePortfolioCommand('groupId', adminUserId); + await handler.execute(command); + + expect(group.disablePortfolio).toBeCalled(); }); - test('그룹이 고객센터 그룹이라면 예외를 발생시켜야 한다', async () => { - jest.spyOn(group, 'isCustomerServiceGroup').mockReturnValue(true); + test('포트폴리오 발행 중단 그룹 로그를 생성해야 한다', async () => { + const group = GroupFixture.inProgressJoinable({ hasPortfolio: true }); + const adminUserId = group.adminUserId; + + groupRepository.findById.mockResolvedValue(group); + groupMemberRepository.findByGroupId.mockResolvedValue([]); + + const command = new DisablePortfolioCommand('groupId', adminUserId); + await handler.execute(command); - await expect( - handler.execute(new DisablePortfolioCommand(groupId, groupAdminUserId)), - ).rejects.toThrowError(Message.CANNOT_MODIFY_CUSTOMER_SERVICE_GROUP); + expect(groupLogger.log).toBeCalled(); }); - test('그룹의 포트폴리오를 비활성화 시켜야 한다', async () => { - jest.spyOn(group, 'disablePortfolio'); + test('모든 그룹원으로부터 포인트를 회수해야 한다', async () => { + const group = GroupFixture.inProgressJoinable({ hasPortfolio: true }); + const adminUserId = group.adminUserId; + const groupMemberUserIds = ['user1', 'user2']; - await handler.execute( - new DisablePortfolioCommand(groupId, groupAdminUserId), - ); + groupRepository.findById.mockResolvedValue(group); + groupMemberRepository.findByGroupId.mockResolvedValue([ + DomainFixture.generateGroupMember({ userId: groupMemberUserIds[0] }), + DomainFixture.generateGroupMember({ userId: groupMemberUserIds[1] }), + ]); - expect(group.disablePortfolio).toBeCalled(); + const command = new DisablePortfolioCommand('groupId', adminUserId); + await handler.execute(command); + + expect(pointGrantService.grant).toHaveBeenCalledWith({ + targetUserIds: groupMemberUserIds, + amount: -Point.GROUP_ENABLED_PORTFOLIO, + reason: expect.any(String), + }); }); - test('그룹을 저장해야 한다', async () => { - await handler.execute( - new DisablePortfolioCommand(groupId, groupAdminUserId), - ); + test('그룹장에게 메시지를 보내야 한다', async () => { + const group = GroupFixture.inProgressJoinable({ hasPortfolio: true }); + const adminUserId = group.adminUserId; - expect(groupRepository.save).toBeCalledWith(group); - expect(groupRepository.save).toBeCalledTimes(1); + groupRepository.findById.mockResolvedValue(group); + groupMemberRepository.findByGroupId.mockResolvedValue([]); + + const command = new DisablePortfolioCommand('groupId', adminUserId); + await handler.execute(command); + + expect(slackSender.send).toHaveBeenCalledWith( + expect.objectContaining({ targetUserId: adminUserId }), + ); }); }); }); diff --git a/src/app/application/group/command/disablePortfolio/DisablePortfolioCommandHandler.ts b/src/app/application/group/command/disablePortfolio/DisablePortfolioCommandHandler.ts index a131e3b..e4a4ab4 100644 --- a/src/app/application/group/command/disablePortfolio/DisablePortfolioCommandHandler.ts +++ b/src/app/application/group/command/disablePortfolio/DisablePortfolioCommandHandler.ts @@ -16,14 +16,39 @@ import { } from '@sight/app/domain/group/IGroupRepository'; import { Message } from '@sight/constant/message'; +import { + SlackSender, + ISlackSender, +} from '@sight/app/domain/adapter/ISlackSender'; +import { + GroupLogger, + IGroupLogger, +} from '@sight/app/domain/group/IGroupLogger'; +import { + GroupMemberRepository, + IGroupMemberRepository, +} from '@sight/app/domain/group/IGroupMemberRepository'; +import { Group } from '@sight/app/domain/group/model/Group'; +import { Template } from '@sight/constant/template'; +import { SlackMessageCategory } from '@sight/app/domain/message/model/constant'; +import { Point } from '@sight/constant/point'; +import { PointGrantService } from '@sight/app/domain/user/service/PointGrantService'; +import { MessageBuilder } from '@sight/core/message/MessageBuilder'; @CommandHandler(DisablePortfolioCommand) export class DisablePortfolioCommandHandler implements ICommandHandler { constructor( + private readonly pointGrantService: PointGrantService, @Inject(GroupRepository) private readonly groupRepository: IGroupRepository, + @Inject(GroupMemberRepository) + private readonly groupMemberRepository: IGroupMemberRepository, + @Inject(GroupLogger) + private readonly groupLogger: IGroupLogger, + @Inject(SlackSender) + private readonly slackSender: ISlackSender, ) {} @Transactional() @@ -35,17 +60,63 @@ export class DisablePortfolioCommandHandler throw new NotFoundException(Message.GROUP_NOT_FOUND); } + this.checkGroupAdmin(group, requesterUserId); + this.checkNotCustomerServiceGroup(group); + + group.disablePortfolio(); + await this.groupRepository.save(group); + + await this.groupLogger.log(groupId, '포트폴리오 발행이 중단되었습니다.'); + await this.collectPointFromMembers(group); + this.sendMessageToAdminUser(group); + } + + private checkGroupAdmin(group: Group, requesterUserId: string): void { if (group.adminUserId !== requesterUserId) { throw new ForbiddenException(Message.ONLY_GROUP_ADMIN_CAN_EDIT_GROUP); } + } + private checkNotCustomerServiceGroup(group: Group): void { if (group.isCustomerServiceGroup()) { throw new UnprocessableEntityException( Message.CANNOT_MODIFY_CUSTOMER_SERVICE_GROUP, ); } + } - group.disablePortfolio(); - await this.groupRepository.save(group); + private async collectPointFromMembers(group: Group): Promise { + if (group.isPracticeGroup()) { + return; + } + + const reason = MessageBuilder.build( + Template.DISABLE_GROUP_PORTFOLIO.point, + { groupTitle: group.title }, + ); + + const groupMembers = await this.groupMemberRepository.findByGroupId( + group.id, + ); + const userIds = groupMembers.map((groupMember) => groupMember.userId); + + await this.pointGrantService.grant({ + targetUserIds: userIds, + amount: -Point.GROUP_ENABLED_PORTFOLIO, + reason, + }); + } + + private sendMessageToAdminUser(group: Group): void { + const message = MessageBuilder.build( + Template.DISABLE_GROUP_PORTFOLIO.notification, + { groupId: group.id, groupTitle: group.title }, + ); + + this.slackSender.send({ + category: SlackMessageCategory.GROUP_ACTIVITY, + targetUserId: group.adminUserId, + message, + }); } } diff --git a/src/app/application/group/command/enablePortfolio/EnablePortfolioCommandHandler.spec.ts b/src/app/application/group/command/enablePortfolio/EnablePortfolioCommandHandler.spec.ts index fa7af72..0056b4e 100644 --- a/src/app/application/group/command/enablePortfolio/EnablePortfolioCommandHandler.spec.ts +++ b/src/app/application/group/command/enablePortfolio/EnablePortfolioCommandHandler.spec.ts @@ -4,95 +4,157 @@ import { advanceTo, clear } from 'jest-date-mock'; import { EnablePortfolioCommand } from '@sight/app/application/group/command/enablePortfolio/EnablePortfolioCommand'; import { EnablePortfolioCommandHandler } from '@sight/app/application/group/command/enablePortfolio/EnablePortfolioCommandHandler'; -import { Group } from '@sight/app/domain/group/model/Group'; import { GroupRepository, IGroupRepository, } from '@sight/app/domain/group/IGroupRepository'; -import { DomainFixture } from '@sight/__test__/fixtures'; import { Message } from '@sight/constant/message'; +import { GroupFixture } from '@sight/__test__/fixtures/GroupFixture'; +import { + GroupMemberRepository, + IGroupMemberRepository, +} from '@sight/app/domain/group/IGroupMemberRepository'; +import { + GroupLogger, + IGroupLogger, +} from '@sight/app/domain/group/IGroupLogger'; +import { PointGrantService } from '@sight/app/domain/user/service/PointGrantService'; +import { + ISlackSender, + SlackSender, +} from '@sight/app/domain/adapter/ISlackSender'; +import { DomainFixture } from '@sight/__test__/fixtures'; +import { Point } from '@sight/constant/point'; describe('EnablePortfolioCommandHandler', () => { let handler: EnablePortfolioCommandHandler; let groupRepository: jest.Mocked; + let groupMemberRepository: jest.Mocked; + let groupLogger: jest.Mocked; + let pointGrantService: jest.Mocked; + let slackSender: jest.Mocked; - beforeAll(async () => { + beforeEach(async () => { advanceTo(new Date()); const testModule = await Test.createTestingModule({ providers: [ EnablePortfolioCommandHandler, - { provide: GroupRepository, useValue: {} }, + { + provide: GroupRepository, + useValue: { findById: jest.fn(), save: jest.fn() }, + }, + { + provide: GroupMemberRepository, + useValue: { findByGroupId: jest.fn() }, + }, + { + provide: GroupLogger, + useValue: { log: jest.fn() }, + }, + { + provide: PointGrantService, + useValue: { grant: jest.fn() }, + }, + { + provide: SlackSender, + useValue: { send: jest.fn() }, + }, ], }).compile(); handler = testModule.get(EnablePortfolioCommandHandler); groupRepository = testModule.get(GroupRepository); + groupMemberRepository = testModule.get(GroupMemberRepository); + groupLogger = testModule.get(GroupLogger); + pointGrantService = testModule.get(PointGrantService); + slackSender = testModule.get(SlackSender); }); - afterAll(() => { - clear(); - }); + afterEach(() => clear()); - describe('handle', () => { - let group: Group; + describe('execute', () => { + test('그룹이 존재하지 않으면 예외를 발생시켜야 한다', async () => { + groupRepository.findById.mockResolvedValue(null); - const groupId = 'groupId'; - const groupAdminUserId = 'groupAdminUserId'; + const command = new EnablePortfolioCommand('groupId', 'requesterUserId'); + await expect(handler.execute(command)).rejects.toThrow( + Message.GROUP_NOT_FOUND, + ); + }); - beforeEach(() => { - group = DomainFixture.generateGroup({ - adminUserId: groupAdminUserId, - hasPortfolio: false, - }); + test('요청자가 그룹장이 아니라면 예외를 발생시켜야 한다', async () => { + const group = GroupFixture.inProgressJoinable({ hasPortfolio: false }); + const notAdminUserId = 'not-admin-user-id'; - groupRepository.findById = jest.fn().mockResolvedValue(group); + groupRepository.findById.mockResolvedValue(group); - groupRepository.save = jest.fn(); + const command = new EnablePortfolioCommand('groupId', notAdminUserId); + await expect(handler.execute(command)).rejects.toThrow(); }); - test('그룹이 존재하지 않는다면 예외를 발생시켜야 한다', async () => { - groupRepository.findById = jest.fn().mockResolvedValue(null); + test('포트폴리오를 발행해야 한다', async () => { + const group = GroupFixture.inProgressJoinable({ hasPortfolio: false }); + const adminUserId = group.adminUserId; - await expect( - handler.execute(new EnablePortfolioCommand(groupId, groupAdminUserId)), - ).rejects.toThrowError(Message.GROUP_NOT_FOUND); - }); + groupRepository.findById.mockResolvedValue(group); + groupMemberRepository.findByGroupId.mockResolvedValue([]); + jest.spyOn(group, 'enablePortfolio'); - test('요청자가 그룹장이 아니라면 예외를 발생시켜야 한다', async () => { - const otherUserId = 'otherUserId'; + const command = new EnablePortfolioCommand('groupId', adminUserId); + await handler.execute(command); - await expect( - handler.execute(new EnablePortfolioCommand(groupId, otherUserId)), - ).rejects.toThrowError(Message.ONLY_GROUP_ADMIN_CAN_EDIT_GROUP); + expect(group.enablePortfolio).toBeCalled(); }); - test('그룹이 고객센터 그룹이라면 예외를 발생시켜야 한다', async () => { - jest.spyOn(group, 'isCustomerServiceGroup').mockReturnValue(true); + test('포트폴리오 발행 그룹 로그를 생성해야 한다', async () => { + const group = GroupFixture.inProgressJoinable({ hasPortfolio: false }); + const adminUserId = group.adminUserId; + + groupRepository.findById.mockResolvedValue(group); + groupMemberRepository.findByGroupId.mockResolvedValue([]); + + const command = new EnablePortfolioCommand('groupId', adminUserId); + await handler.execute(command); - await expect( - handler.execute(new EnablePortfolioCommand(groupId, groupAdminUserId)), - ).rejects.toThrowError(Message.CANNOT_MODIFY_CUSTOMER_SERVICE_GROUP); + expect(groupLogger.log).toBeCalled(); }); - test('그룹의 포트폴리오를 활성화 시켜야 한다', async () => { - jest.spyOn(group, 'enablePortfolio'); + test('모든 그룹원에게 포인트를 부여해야 한다', async () => { + const group = GroupFixture.inProgressJoinable({ hasPortfolio: false }); + const adminUserId = group.adminUserId; + const groupMemberUserIds = ['user1', 'user2']; - await handler.execute( - new EnablePortfolioCommand(groupId, groupAdminUserId), - ); + groupRepository.findById.mockResolvedValue(group); + groupMemberRepository.findByGroupId.mockResolvedValue([ + DomainFixture.generateGroupMember({ userId: groupMemberUserIds[0] }), + DomainFixture.generateGroupMember({ userId: groupMemberUserIds[1] }), + ]); - expect(group.enablePortfolio).toBeCalled(); + const command = new EnablePortfolioCommand('groupId', adminUserId); + await handler.execute(command); + + expect(pointGrantService.grant).toHaveBeenCalledWith({ + targetUserIds: groupMemberUserIds, + amount: Point.GROUP_ENABLED_PORTFOLIO, + reason: expect.any(String), + }); }); - test('그룹을 저장해야 한다', async () => { - await handler.execute( - new EnablePortfolioCommand(groupId, groupAdminUserId), - ); + test('그룹장에게 메시지를 보내야 한다', async () => { + const group = GroupFixture.inProgressJoinable({ hasPortfolio: false }); + const adminUserId = group.adminUserId; - expect(groupRepository.save).toBeCalledWith(group); - expect(groupRepository.save).toBeCalledTimes(1); + groupRepository.findById.mockResolvedValue(group); + groupMemberRepository.findByGroupId.mockResolvedValue([]); + + const command = new EnablePortfolioCommand('groupId', adminUserId); + await handler.execute(command); + + expect(slackSender.send).toHaveBeenCalledWith( + expect.objectContaining({ targetUserId: adminUserId }), + ); }); }); }); diff --git a/src/app/application/group/command/enablePortfolio/EnablePortfolioCommandHandler.ts b/src/app/application/group/command/enablePortfolio/EnablePortfolioCommandHandler.ts index e00d5e6..3c67c76 100644 --- a/src/app/application/group/command/enablePortfolio/EnablePortfolioCommandHandler.ts +++ b/src/app/application/group/command/enablePortfolio/EnablePortfolioCommandHandler.ts @@ -16,14 +16,39 @@ import { } from '@sight/app/domain/group/IGroupRepository'; import { Message } from '@sight/constant/message'; +import { Group } from '@sight/app/domain/group/model/Group'; +import { Template } from '@sight/constant/template'; +import { + GroupMemberRepository, + IGroupMemberRepository, +} from '@sight/app/domain/group/IGroupMemberRepository'; +import { + GroupLogger, + IGroupLogger, +} from '@sight/app/domain/group/IGroupLogger'; +import { + ISlackSender, + SlackSender, +} from '@sight/app/domain/adapter/ISlackSender'; +import { MessageBuilder } from '@sight/core/message/MessageBuilder'; +import { PointGrantService } from '@sight/app/domain/user/service/PointGrantService'; +import { Point } from '@sight/constant/point'; +import { SlackMessageCategory } from '@sight/app/domain/message/model/constant'; @CommandHandler(EnablePortfolioCommand) export class EnablePortfolioCommandHandler implements ICommandHandler { constructor( + private readonly pointGrantService: PointGrantService, @Inject(GroupRepository) private readonly groupRepository: IGroupRepository, + @Inject(GroupMemberRepository) + private readonly groupMemberRepository: IGroupMemberRepository, + @Inject(GroupLogger) + private readonly groupLogger: IGroupLogger, + @Inject(SlackSender) + private readonly slackSender: ISlackSender, ) {} @Transactional() @@ -35,17 +60,62 @@ export class EnablePortfolioCommandHandler throw new NotFoundException(Message.GROUP_NOT_FOUND); } + this.checkGroupAdmin(group, requesterUserId); + this.checkNotCustomerServiceGroup(group); + + group.enablePortfolio(); + await this.groupRepository.save(group); + + await this.groupLogger.log(groupId, '포트폴리오가 발행 중입니다.'); + await this.grantPointToMembers(group); + this.sendMessageToAdminUser(group); + } + + private checkGroupAdmin(group: Group, requesterUserId: string): void { if (group.adminUserId !== requesterUserId) { throw new ForbiddenException(Message.ONLY_GROUP_ADMIN_CAN_EDIT_GROUP); } + } + private checkNotCustomerServiceGroup(group: Group): void { if (group.isCustomerServiceGroup()) { throw new UnprocessableEntityException( Message.CANNOT_MODIFY_CUSTOMER_SERVICE_GROUP, ); } + } - group.enablePortfolio(); - await this.groupRepository.save(group); + private async grantPointToMembers(group: Group): Promise { + if (group.isPracticeGroup()) { + return; + } + + const reason = MessageBuilder.build(Template.ENABLE_GROUP_PORTFOLIO.point, { + groupTitle: group.title, + }); + + const groupMembers = await this.groupMemberRepository.findByGroupId( + group.id, + ); + const userIds = groupMembers.map((groupMember) => groupMember.userId); + + await this.pointGrantService.grant({ + targetUserIds: userIds, + amount: Point.GROUP_ENABLED_PORTFOLIO, + reason, + }); + } + + private sendMessageToAdminUser(group: Group): void { + const message = MessageBuilder.build( + Template.ENABLE_GROUP_PORTFOLIO.notification, + { groupId: group.id, groupTitle: group.title }, + ); + + this.slackSender.send({ + category: SlackMessageCategory.GROUP_ACTIVITY, + targetUserId: group.adminUserId, + message, + }); } } diff --git a/src/app/application/group/command/modifyGroup/ModifyGroupCommandHandler.spec.ts b/src/app/application/group/command/modifyGroup/ModifyGroupCommandHandler.spec.ts index fc6d0c9..5ef50a8 100644 --- a/src/app/application/group/command/modifyGroup/ModifyGroupCommandHandler.spec.ts +++ b/src/app/application/group/command/modifyGroup/ModifyGroupCommandHandler.spec.ts @@ -1,49 +1,63 @@ import { Test } from '@nestjs/testing'; import { advanceTo, clear } from 'jest-date-mock'; -import { ModifyGroupCommandHandler } from '@sight/app/application/group/command/modifyGroup/ModifyGroupCommandHandler'; -import { ModifyGroupCommandResult } from '@sight/app/application/group/command/modifyGroup/ModifyGroupCommandResult'; +import { ModifyGroupCommandHandler } from './ModifyGroupCommandHandler'; import { - ModifyGroupCommand, - ModifyGroupParams, -} from '@sight/app/application/group/command/modifyGroup/ModifyGroupCommand'; - -import { Group } from '@sight/app/domain/group/model/Group'; + GroupRepository, + IGroupRepository, +} from '@sight/app/domain/group/IGroupRepository'; import { GroupMemberRepository, IGroupMemberRepository, } from '@sight/app/domain/group/IGroupMemberRepository'; import { - GroupRepository, - IGroupRepository, -} from '@sight/app/domain/group/IGroupRepository'; + IInterestRepository, + InterestRepository, +} from '@sight/app/domain/interest/IInterestRepository'; +import { + GroupLogger, + IGroupLogger, +} from '@sight/app/domain/group/IGroupLogger'; +import { ModifyGroupCommand } from './ModifyGroupCommand'; import { GroupAccessGrade, GroupCategory, } from '@sight/app/domain/group/model/constant'; -import { - IInterestRepository, - InterestRepository, -} from '@sight/app/domain/interest/IInterestRepository'; - -import { DomainFixture } from '@sight/__test__/fixtures'; import { Message } from '@sight/constant/message'; +import { GroupFixture } from '@sight/__test__/fixtures/GroupFixture'; +import { DomainFixture } from '@sight/__test__/fixtures'; describe('ModifyGroupCommandHandler', () => { let handler: ModifyGroupCommandHandler; let groupRepository: jest.Mocked; let groupMemberRepository: jest.Mocked; let interestRepository: jest.Mocked; + let groupLogger: jest.Mocked; - beforeAll(async () => { + beforeAll(() => advanceTo(new Date())); + + beforeEach(async () => { advanceTo(new Date()); const testModule = await Test.createTestingModule({ providers: [ ModifyGroupCommandHandler, - { provide: GroupRepository, useValue: {} }, - { provide: GroupMemberRepository, useValue: {} }, - { provide: InterestRepository, useValue: {} }, + { + provide: GroupRepository, + useValue: { findById: jest.fn(), save: jest.fn() }, + }, + { + provide: GroupMemberRepository, + useValue: { findByGroupIdAndUserId: jest.fn() }, + }, + { + provide: InterestRepository, + useValue: { findByIds: jest.fn() }, + }, + { + provide: GroupLogger, + useValue: { log: jest.fn() }, + }, ], }).compile(); @@ -51,128 +65,223 @@ describe('ModifyGroupCommandHandler', () => { groupRepository = testModule.get(GroupRepository); groupMemberRepository = testModule.get(GroupMemberRepository); interestRepository = testModule.get(InterestRepository); + groupLogger = testModule.get(GroupLogger); }); - afterAll(() => { - clear(); - }); + afterEach(() => clear()); describe('execute', () => { - let command: ModifyGroupCommand; - let group: Group; - - const groupId = 'groupId'; - const requesterUserId = 'requesterUserId'; - const params: ModifyGroupParams = { - title: 'new-title', - purpose: 'new-purpose', - interestIds: ['new', 'interest', 'ids'], - technology: ['new', 'technology'], - grade: GroupAccessGrade.MEMBER, - repository: 'https://new.reposi/tory', - allowJoin: false, - category: GroupCategory.STUDY, - }; - - beforeEach(() => { - command = new ModifyGroupCommand(groupId, requesterUserId, params); - group = DomainFixture.generateGroup({ - category: GroupCategory.STUDY, - adminUserId: requesterUserId, - }); - const groupMember = DomainFixture.generateGroupMember(); - const interests = params.interestIds.map((interestId) => - DomainFixture.generateInterest({ id: interestId }), - ); - - groupRepository.findById = jest.fn().mockResolvedValue(group); - groupMemberRepository.findByGroupIdAndUserId = jest - .fn() - .mockResolvedValue(groupMember); - interestRepository.findByIds = jest.fn().mockResolvedValue(interests); - group.isEditable = jest.fn().mockReturnValue(true); - - groupRepository.save = jest.fn(); - }); - test('그룹이 존재하지 않으면 예외를 발생시켜야 한다', async () => { - groupRepository.findById = jest.fn().mockResolvedValue(null); + groupRepository.findById.mockResolvedValue(null); - await expect(handler.execute(command)).rejects.toThrowError( + const command = new ModifyGroupCommand('groupId', 'requesterUserId', { + category: GroupCategory.DOCUMENTATION, + title: 'title', + purpose: 'purpose', + interestIds: [], + technology: [], + grade: 'grade', + repository: 'https://repo.sitory', + allowJoin: true, + }); + await expect(handler.execute(command)).rejects.toThrow( Message.GROUP_NOT_FOUND, ); }); - test('요청자가 그룹의 관리자가 아니라면 예외를 발생시켜야 한다', async () => { - const otherUserId = 'other-user-id'; - const commandWithOtherRequester = new ModifyGroupCommand( - groupId, - otherUserId, - params, - ); + test('요청자가 그룹장이 아니라면 예외를 발생시켜야 한다', async () => { + const group = GroupFixture.inProgressJoinable({ + category: GroupCategory.DOCUMENTATION, + }); + const notAdminUserId = 'notAdminUserId'; - await expect( - handler.execute(commandWithOtherRequester), - ).rejects.toThrowError(Message.ONLY_GROUP_ADMIN_CAN_EDIT_GROUP); - }); + groupRepository.findById.mockResolvedValue(group); - test('운영 그룹은 요청자가 그룹의 관리자가 아니더라도 예외를 발생시키지 않는다', async () => { - const manageGroup = DomainFixture.generateGroup({ - category: GroupCategory.MANAGE, + const command = new ModifyGroupCommand(group.id, notAdminUserId, { + category: GroupCategory.DOCUMENTATION, + title: 'title', + purpose: 'purpose', + interestIds: [], + technology: [], + grade: 'grade', + repository: 'https://repo.sitory', + allowJoin: true, }); - groupRepository.findById = jest.fn().mockResolvedValue(manageGroup); - - const otherUserId = 'other-user-id'; - const commandWithOtherRequester = new ModifyGroupCommand( - groupId, - otherUserId, - params, + await expect(handler.execute(command)).rejects.toThrow( + Message.ONLY_GROUP_ADMIN_CAN_EDIT_GROUP, ); - - await expect( - handler.execute(commandWithOtherRequester), - ).resolves.not.toThrow(); }); - test('요청자가 그룹에 속하지 않았다면 예외를 발생시켜야 한다', async () => { - groupMemberRepository.findByGroupIdAndUserId = jest - .fn() - .mockResolvedValue(null); + test('요청자가 그룹에 속해있지 않다면 예외를 발생시켜야 한다', async () => { + // 운영 그룹은 그룹장 확인을 스킵하기 때문에, 그룹장 여부를 통과하더라도 + // 그룹에 속해있지 않은 경우를 확인해야 한다. + + const group = GroupFixture.inProgressJoinable({ + category: GroupCategory.MANAGE, + }); + const notAdminUserId = 'notAdminUserId'; + + groupRepository.findById.mockResolvedValue(group); + groupMemberRepository.findByGroupIdAndUserId.mockResolvedValue(null); - await expect(handler.execute(command)).rejects.toThrowError( + const command = new ModifyGroupCommand(group.id, notAdminUserId, { + category: GroupCategory.MANAGE, + title: 'title', + purpose: 'purpose', + interestIds: [], + technology: [], + grade: 'grade', + repository: 'https://repo.sitory', + allowJoin: true, + }); + await expect(handler.execute(command)).rejects.toThrow( Message.REQUESTER_NOT_JOINED_GROUP, ); }); - test('수정할 수 없는 그룹을 수정하면 예외를 발생시켜야 한다', async () => { + test('수정할 수 없는 그룹이라면 예외를 발생시켜야 한다', async () => { + const group = GroupFixture.inProgressJoinable(); + const groupMember = DomainFixture.generateGroupMember(); + + groupRepository.findById.mockResolvedValue(group); + groupMemberRepository.findByGroupIdAndUserId.mockResolvedValue( + groupMember, + ); jest.spyOn(group, 'isEditable').mockReturnValue(false); - await expect(handler.execute(command)).rejects.toThrowError( + const command = new ModifyGroupCommand(group.id, group.adminUserId, { + category: GroupCategory.MANAGE, + title: 'title', + purpose: 'purpose', + interestIds: [], + technology: [], + grade: 'grade', + repository: 'https://repo.sitory', + allowJoin: true, + }); + await expect(handler.execute(command)).rejects.toThrow( Message.GROUP_NOT_EDITABLE, ); }); - test('변경할 관심사가 존재하지 않으면 예외를 발생시켜야 한다', async () => { - interestRepository.findByIds = jest.fn().mockResolvedValue([]); + test('수정하려는 관심사가 존재하지 않으면 예외를 발생시켜야 한다', async () => { + const group = GroupFixture.inProgressJoinable(); + const groupMember = DomainFixture.generateGroupMember(); - await expect(handler.execute(command)).rejects.toThrowError( + groupRepository.findById.mockResolvedValue(group); + groupMemberRepository.findByGroupIdAndUserId.mockResolvedValue( + groupMember, + ); + interestRepository.findByIds.mockResolvedValue([]); + jest.spyOn(group, 'isEditable').mockReturnValue(true); + + const command = new ModifyGroupCommand(group.id, group.adminUserId, { + category: GroupCategory.MANAGE, + title: 'title', + purpose: 'purpose', + interestIds: ['interestId'], + technology: [], + grade: 'grade', + repository: 'https://repo.sitory', + allowJoin: true, + }); + await expect(handler.execute(command)).rejects.toThrow( Message.SOME_INTERESTS_NOT_FOUND, ); }); - test('변경한 그룹을 저장해야 한다', async () => { + test('변경된 항목이 없다면 그룹을 수정하지 않고 반환해야 한다', async () => { + const group = GroupFixture.inProgressJoinable({ interestIds: [] }); + const groupMember = DomainFixture.generateGroupMember(); + + groupRepository.findById.mockResolvedValue(group); + groupMemberRepository.findByGroupIdAndUserId.mockResolvedValue( + groupMember, + ); + interestRepository.findByIds.mockResolvedValue([]); + jest.spyOn(group, 'isEditable').mockReturnValue(true); + + const command = new ModifyGroupCommand(group.id, group.adminUserId, { + category: group.category, + title: group.title, + purpose: group.purpose, + interestIds: group.interestIds, + technology: group.technology, + grade: group.grade, + repository: group.repository, + allowJoin: group.allowJoin, + }); await handler.execute(command); - expect(groupRepository.save).toBeCalledTimes(1); - expect(groupRepository.save).toBeCalledWith(group); + expect(groupRepository.save).not.toHaveBeenCalled(); }); - test('변경한 그룹을 반환해야 한다', async () => { - const expected = new ModifyGroupCommandResult(group); + test('그룹 정보를 수정해야 한다', async () => { + const group = GroupFixture.inProgressJoinable({ + category: GroupCategory.EDUCATION, + title: 'oldTitle', + purpose: 'oldPurpose', + interestIds: ['oldInterestId'], + technology: ['oldTechnology'], + grade: GroupAccessGrade.MEMBER, + repository: 'https://old.repo.sitory', + allowJoin: false, + }); + const groupMember = DomainFixture.generateGroupMember(); + + groupRepository.findById.mockResolvedValue(group); + groupMemberRepository.findByGroupIdAndUserId.mockResolvedValue( + groupMember, + ); + interestRepository.findByIds.mockResolvedValue([]); + jest.spyOn(group, 'isEditable').mockReturnValue(true); + + const command = new ModifyGroupCommand(group.id, group.adminUserId, { + category: GroupCategory.DOCUMENTATION, + title: 'title', + purpose: 'purpose', + interestIds: [], + technology: ['technology'], + grade: GroupAccessGrade.PRIVATE, + repository: 'https://repo.sitory', + allowJoin: true, + }); + await handler.execute(command); - const result = await handler.execute(command); + expect(group.category).toEqual(GroupCategory.DOCUMENTATION); + expect(group.title).toEqual('title'); + expect(group.purpose).toEqual('purpose'); + expect(group.interestIds).toEqual([]); + expect(group.technology).toEqual(['technology']); + expect(group.grade).toEqual(GroupAccessGrade.PRIVATE); + expect(group.repository).toEqual('https://repo.sitory'); + expect(group.allowJoin).toEqual(true); + }); + + test('그룹 로그가 생성되어야 한다', async () => { + const group = GroupFixture.inProgressJoinable(); + const groupMember = DomainFixture.generateGroupMember(); + + groupRepository.findById.mockResolvedValue(group); + groupMemberRepository.findByGroupIdAndUserId.mockResolvedValue( + groupMember, + ); + interestRepository.findByIds.mockResolvedValue([]); + jest.spyOn(group, 'isEditable').mockReturnValue(true); + + const command = new ModifyGroupCommand(group.id, group.adminUserId, { + category: GroupCategory.DOCUMENTATION, + title: 'title', + purpose: 'purpose', + interestIds: [], + technology: [], + grade: 'grade', + repository: 'https://repo.sitory', + allowJoin: true, + }); + await handler.execute(command); - expect(result).toEqual(expected); + expect(groupLogger.log).toBeCalled(); }); }); }); diff --git a/src/app/application/group/command/modifyGroup/ModifyGroupCommandHandler.ts b/src/app/application/group/command/modifyGroup/ModifyGroupCommandHandler.ts index cd62095..dcff8b9 100644 --- a/src/app/application/group/command/modifyGroup/ModifyGroupCommandHandler.ts +++ b/src/app/application/group/command/modifyGroup/ModifyGroupCommandHandler.ts @@ -8,7 +8,10 @@ import { import { Transactional } from '@sight/core/persistence/transaction/Transactional'; -import { ModifyGroupCommand } from '@sight/app/application/group/command/modifyGroup/ModifyGroupCommand'; +import { + ModifyGroupCommand, + ModifyGroupParams, +} from '@sight/app/application/group/command/modifyGroup/ModifyGroupCommand'; import { ModifyGroupCommandResult } from '@sight/app/application/group/command/modifyGroup/ModifyGroupCommandResult'; import { Group } from '@sight/app/domain/group/model/Group'; @@ -27,6 +30,32 @@ import { } from '@sight/app/domain/interest/IInterestRepository'; import { Message } from '@sight/constant/message'; +import { + GroupLogger, + IGroupLogger, +} from '@sight/app/domain/group/IGroupLogger'; +import { isDifferentStringArray } from '@sight/util/isDifferentStringArray'; + +type UpdatedItem = + | 'category' + | 'title' + | 'purpose' + | 'interests' + | 'technology' + | 'grade' + | 'repository' + | 'allowJoin'; + +const updatedItemToKorean: Record = { + category: '분류', + title: '그룹 이름', + purpose: '목표', + interests: 'IT 분야', + technology: '기술', + grade: '공개 범위', + repository: '저장소', + allowJoin: '참여 신청 허용 여부', +}; @CommandHandler(ModifyGroupCommand) export class ModifyGroupCommandHandler @@ -39,6 +68,8 @@ export class ModifyGroupCommandHandler private readonly groupMemberRepository: IGroupMemberRepository, @Inject(InterestRepository) private readonly interestRepository: IInterestRepository, + @Inject(GroupLogger) + private readonly groupLogger: IGroupLogger, ) {} @Transactional() @@ -47,6 +78,7 @@ export class ModifyGroupCommandHandler ): Promise { const { groupId, requesterUserId, params } = command; const { + category, title, purpose, interestIds, @@ -54,10 +86,9 @@ export class ModifyGroupCommandHandler grade, repository, allowJoin, - category, } = params; - const group = await this.getGroupById(groupId); + const group = await this.getGroupOrThrow(groupId); this.checkGroupAdmin(group, requesterUserId); await this.checkGroupMember(group, requesterUserId); @@ -65,6 +96,12 @@ export class ModifyGroupCommandHandler this.checkGroupEditable(group); await this.checkInterestExists(interestIds); + const updatedItems = this.diffUpdatedItem(group, params); + if (updatedItems.length === 0) { + return new ModifyGroupCommandResult(group); + } + + group.updateCategory(category); group.updateTitle(title); group.updatePurpose(purpose); group.updateInterestIds(interestIds); @@ -72,13 +109,15 @@ export class ModifyGroupCommandHandler group.updateGrade(grade); group.updateRepository(repository); group.updateAllowJoin(allowJoin); - group.updateCategory(category); await this.groupRepository.save(group); + const message = this.buildMessage(group, updatedItems); + await this.groupLogger.log(groupId, message); + return new ModifyGroupCommandResult(group); } - private async getGroupById(groupId: string): Promise { + private async getGroupOrThrow(groupId: string): Promise { const group = await this.groupRepository.findById(groupId); if (!group) { throw new NotFoundException(Message.GROUP_NOT_FOUND); @@ -123,4 +162,38 @@ export class ModifyGroupCommandHandler throw new NotFoundException(Message.SOME_INTERESTS_NOT_FOUND); } } + + private diffUpdatedItem( + group: Group, + params: ModifyGroupParams, + ): UpdatedItem[] { + const updatedItems: UpdatedItem[] = []; + + if (group.category !== params.category) updatedItems.push('category'); + + if (group.title !== params.title) updatedItems.push('title'); + + if (group.purpose !== params.purpose) updatedItems.push('purpose'); + + if (isDifferentStringArray(group.interestIds, params.interestIds)) + updatedItems.push('interests'); + + if (isDifferentStringArray(group.technology, params.technology)) + updatedItems.push('technology'); + + if (group.grade !== params.grade) updatedItems.push('grade'); + + if (group.repository !== params.repository) updatedItems.push('repository'); + + if (group.allowJoin !== params.allowJoin) updatedItems.push('allowJoin'); + + return updatedItems; + } + + private buildMessage(group: Group, updatedItems: UpdatedItem[]): string { + const updateItemsString = updatedItems + .map((item) => updatedItemToKorean[item]) + .join(', '); + return `${group.title} 그룹의 ${updateItemsString}이(가) 수정되었습니다.`; + } } diff --git a/src/app/application/group/command/removeBookmark/RemoveBookmarkCommandHandler.spec.ts b/src/app/application/group/command/removeBookmark/RemoveBookmarkCommandHandler.spec.ts index d1c8072..f08c38c 100644 --- a/src/app/application/group/command/removeBookmark/RemoveBookmarkCommandHandler.spec.ts +++ b/src/app/application/group/command/removeBookmark/RemoveBookmarkCommandHandler.spec.ts @@ -1,38 +1,46 @@ 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 { RemoveBookmarkCommandHandler } from './RemoveBookmarkCommandHandler'; 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'; + GroupBookmarkRepository, + IGroupBookmarkRepository, +} from '@sight/app/domain/group/IGroupBookmarkRepository'; +import { SlackSender } from '@sight/app/domain/adapter/ISlackSender'; +import { RemoveBookmarkCommand } from './RemoveBookmarkCommand'; import { Message } from '@sight/constant/message'; +import { GroupFixture } from '@sight/__test__/fixtures/GroupFixture'; +import { GroupBookmarkFixture } from '@sight/__test__/fixtures/GroupBookmarkFixture'; describe('RemoveBookmarkCommandHandler', () => { let handler: RemoveBookmarkCommandHandler; let groupRepository: jest.Mocked; let groupBookmarkRepository: jest.Mocked; - beforeAll(async () => { + beforeAll(() => advanceTo(new Date())); + + beforeEach(async () => { advanceTo(new Date()); const testModule = await Test.createTestingModule({ providers: [ RemoveBookmarkCommandHandler, - ...generateEmptyProviders(GroupRepository, GroupBookmarkRepository), + { + provide: GroupRepository, + useValue: { findById: jest.fn() }, + }, + { + provide: GroupBookmarkRepository, + useValue: { findByGroupIdAndUserId: jest.fn(), remove: jest.fn() }, + }, + { + provide: SlackSender, + useValue: { send: jest.fn() }, + }, ], }).compile(); @@ -41,72 +49,66 @@ describe('RemoveBookmarkCommandHandler', () => { groupBookmarkRepository = testModule.get(GroupBookmarkRepository); }); - afterAll(() => { - clear(); - }); + afterEach(() => clear()); describe('execute', () => { - const groupId = 'groupId'; - const userId = 'userId'; + test('그룹이 존재하지 않으면 예외를 발생시켜야 한다', async () => { + const command = new RemoveBookmarkCommand('groupId', 'userId'); - beforeEach(() => { - const group = DomainFixture.generateGroup(); - const groupBookmark = DomainFixture.generateGroupBookmark(); + groupRepository.findById.mockResolvedValue(null); - groupRepository.findById = jest.fn().mockResolvedValue(group); - groupBookmarkRepository.findByGroupIdAndUserId = jest - .fn() - .mockResolvedValue(groupBookmark); - - groupBookmarkRepository.remove = jest.fn(); + await expect(handler.execute(command)).rejects.toThrow( + Message.GROUP_NOT_FOUND, + ); }); - test('그룹이 존재하지 않으면 예외가 발생해야 한다', async () => { - groupRepository.findById = jest.fn().mockResolvedValue(null); + test('대상 그룹이 고객 센터 그룹이라면 예외를 발생시켜야 한다', async () => { + const command = new RemoveBookmarkCommand('groupId', 'userId'); + const group = GroupFixture.customerService(); - await expect( - handler.execute(new AddBookmarkCommand(groupId, userId)), - ).rejects.toThrowError(Message.GROUP_NOT_FOUND); - }); + groupRepository.findById.mockResolvedValue(group); - 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); + await expect(handler.execute(command)).rejects.toThrow( + Message.DEFAULT_BOOKMARKED_GROUP, + ); }); - test('그룹 활용 실습 그룹이라면 예외가 발생해야 한다', async () => { - const practiceGroup = DomainFixture.generateGroup({ - id: PRACTICE_GROUP_ID, - }); - groupRepository.findById = jest.fn().mockResolvedValue(practiceGroup); + test('대상 그룹이 실습 그룹이라면 예외를 발생시켜야 한다', async () => { + const command = new RemoveBookmarkCommand('groupId', 'userId'); + const group = GroupFixture.practice(); - await expect( - handler.execute(new AddBookmarkCommand(groupId, userId)), - ).rejects.toThrowError(Message.DEFAULT_BOOKMARKED_GROUP); + groupRepository.findById.mockResolvedValue(group); + + await expect(handler.execute(command)).rejects.toThrow( + Message.DEFAULT_BOOKMARKED_GROUP, + ); }); - test('아직 그룹을 즐겨찾기하지 않았다면 무시해야 한다', async () => { - groupBookmarkRepository.findByGroupIdAndUserId = jest - .fn() - .mockResolvedValue(null); + test('이미 즐겨 찾는 그룹이 아니라면 즐겨찾기를 제거하지 않아야 한다', async () => { + const command = new RemoveBookmarkCommand('groupId', 'userId'); + const group = GroupFixture.inProgressJoinable(); + + groupRepository.findById.mockResolvedValue(group); + groupBookmarkRepository.findByGroupIdAndUserId.mockResolvedValue(null); - await handler.execute(new AddBookmarkCommand(groupId, userId)); + await handler.execute(command); expect(groupBookmarkRepository.remove).not.toBeCalled(); }); - test('그룹을 즐겨찾기 해제해야 한다', async () => { - await handler.execute(new AddBookmarkCommand(groupId, userId)); + test('그룹 즐겨찾기를 제거해야 한다', async () => { + const command = new RemoveBookmarkCommand('groupId', 'userId'); + const group = GroupFixture.inProgressJoinable(); + const bookmark = GroupBookmarkFixture.normal(); + + groupRepository.findById.mockResolvedValue(group); + groupBookmarkRepository.findByGroupIdAndUserId.mockResolvedValue( + bookmark, + ); + + await handler.execute(command); - expect(groupBookmarkRepository.remove).toBeCalled(); + expect(groupBookmarkRepository.remove).toBeCalledWith(bookmark); }); }); }); diff --git a/src/app/application/group/command/removeBookmark/RemoveBookmarkCommandHandler.ts b/src/app/application/group/command/removeBookmark/RemoveBookmarkCommandHandler.ts index 0c3fbdd..6331db3 100644 --- a/src/app/application/group/command/removeBookmark/RemoveBookmarkCommandHandler.ts +++ b/src/app/application/group/command/removeBookmark/RemoveBookmarkCommandHandler.ts @@ -19,6 +19,13 @@ import { } from '@sight/app/domain/group/IGroupRepository'; import { Message } from '@sight/constant/message'; +import { + ISlackSender, + SlackSender, +} from '@sight/app/domain/adapter/ISlackSender'; +import { SlackMessageCategory } from '@sight/app/domain/message/model/constant'; +import { Template } from '@sight/constant/template'; +import { MessageBuilder } from '@sight/core/message/MessageBuilder'; @CommandHandler(RemoveBookmarkCommand) export class RemoveBookmarkCommandHandler @@ -29,6 +36,8 @@ export class RemoveBookmarkCommandHandler private readonly groupRepository: IGroupRepository, @Inject(GroupBookmarkRepository) private readonly groupBookmarkRepository: IGroupBookmarkRepository, + @Inject(SlackSender) + private readonly slackSender: ISlackSender, ) {} @Transactional() @@ -52,8 +61,15 @@ export class RemoveBookmarkCommandHandler if (!prevBookmark) { return; } - - prevBookmark.remove(); await this.groupBookmarkRepository.remove(prevBookmark); + + this.slackSender.send({ + category: SlackMessageCategory.GROUP_ACTIVITY_FOR_ME, + targetUserId: userId, + message: MessageBuilder.build( + Template.REMOVE_GROUP_BOOKMARK.notification, + { groupId, groupTitle: group.title }, + ), + }); } } diff --git a/src/app/application/group/eventHandler/GroupBookmarkCreatedHandler.spec.ts b/src/app/application/group/eventHandler/GroupBookmarkCreatedHandler.spec.ts deleted file mode 100644 index 1da5a4c..0000000 --- a/src/app/application/group/eventHandler/GroupBookmarkCreatedHandler.spec.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { Test } from '@nestjs/testing'; -import { advanceTo, clear } from 'jest-date-mock'; - -import { MessageBuilder } from '@sight/core/message/MessageBuilder'; - -import { GroupBookmarkCreatedHandler } from '@sight/app/application/group/eventHandler/GroupBookmarkCreatedHandler'; - -import { GroupBookmarkCreated } from '@sight/app/domain/group/event/GroupBookmarkCreated'; -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('GroupBookmarkCreatedHandler', () => { - let handler: GroupBookmarkCreatedHandler; - let messageBuilder: jest.Mocked; - let slackSender: jest.Mocked; - let groupRepository: jest.Mocked; - - beforeAll(async () => { - advanceTo(new Date()); - - const testModule = await Test.createTestingModule({ - providers: [ - GroupBookmarkCreatedHandler, - ...generateEmptyProviders(MessageBuilder, SlackSender, GroupRepository), - ], - }).compile(); - - handler = testModule.get(GroupBookmarkCreatedHandler); - 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 GroupBookmarkCreated(groupId, userId); - - groupRepository.findById.mockResolvedValue(null); - - await handler.handle(event); - - expect(slackSender.send).not.toBeCalled(); - }); - - test('요청자에게 메시지를 보내야 한다', async () => { - const groupId = 'groupId'; - const event = new GroupBookmarkCreated(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/GroupBookmarkCreatedHandler.ts b/src/app/application/group/eventHandler/GroupBookmarkCreatedHandler.ts deleted file mode 100644 index fa9f9a5..0000000 --- a/src/app/application/group/eventHandler/GroupBookmarkCreatedHandler.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { Inject } from '@nestjs/common'; -import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; - -import { MessageBuilder } from '@sight/core/message/MessageBuilder'; -import { Transactional } from '@sight/core/persistence/transaction/Transactional'; - -import { GroupBookmarkCreated } from '@sight/app/domain/group/event/GroupBookmarkCreated'; -import { SlackMessageCategory } from '@sight/app/domain/message/model/constant'; -import { - ISlackSender, - SlackSender, -} from '@sight/app/domain/adapter/ISlackSender'; -import { - GroupRepository, - IGroupRepository, -} from '@sight/app/domain/group/IGroupRepository'; - -import { Template } from '@sight/constant/template'; - -@EventsHandler(GroupBookmarkCreated) -export class GroupBookmarkCreatedHandler - implements IEventHandler -{ - constructor( - @Inject(MessageBuilder) - private readonly messageBuilder: MessageBuilder, - @Inject(SlackSender) - private readonly slackSender: ISlackSender, - @Inject(GroupRepository) - private readonly groupRepository: IGroupRepository, - ) {} - - @Transactional() - async handle(event: GroupBookmarkCreated): Promise { - const { groupId, userId } = event; - - const group = await this.groupRepository.findById(groupId); - if (!group) { - return; - } - - const message = this.messageBuilder.build( - Template.ADD_GROUP_BOOKMARK.notification, - { groupId, groupTitle: group.title }, - ); - this.slackSender.send({ - category: SlackMessageCategory.GROUP_ACTIVITY_FOR_ME, - targetUserId: userId, - message, - }); - } -} diff --git a/src/app/application/group/eventHandler/GroupBookmarkRemovedHandler.spec.ts b/src/app/application/group/eventHandler/GroupBookmarkRemovedHandler.spec.ts deleted file mode 100644 index 79ac40d..0000000 --- a/src/app/application/group/eventHandler/GroupBookmarkRemovedHandler.spec.ts +++ /dev/null @@ -1,82 +0,0 @@ -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 deleted file mode 100644 index 28ec8d7..0000000 --- a/src/app/application/group/eventHandler/GroupBookmarkRemovedHandler.ts +++ /dev/null @@ -1,50 +0,0 @@ -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/application/group/eventHandler/GroupCreatedHandler.spec.ts b/src/app/application/group/eventHandler/GroupCreatedHandler.spec.ts deleted file mode 100644 index 972ef58..0000000 --- a/src/app/application/group/eventHandler/GroupCreatedHandler.spec.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { Test } from '@nestjs/testing'; -import { advanceTo, clear } from 'jest-date-mock'; - -import { GroupCreatedHandler } from '@sight/app/application/group/eventHandler/GroupCreatedHandler'; - -import { GroupCreated } from '@sight/app/domain/group/event/GroupCreated'; -import { Group } from '@sight/app/domain/group/model/Group'; -import { User } from '@sight/app/domain/user/model/User'; -import { - ISlackSender, - SlackSender, -} from '@sight/app/domain/adapter/ISlackSender'; -import { - IUserRepository, - UserRepository, -} from '@sight/app/domain/user/IUserRepository'; - -import { DomainFixture } from '@sight/__test__/fixtures'; -import { Message } from '@sight/constant/message'; -import { Point } from '@sight/constant/point'; - -describe('GroupCreatedHandler', () => { - let handler: GroupCreatedHandler; - let slackSender: jest.Mocked; - let userRepository: jest.Mocked; - - beforeAll(async () => { - advanceTo(new Date()); - - const testModule = await Test.createTestingModule({ - providers: [ - GroupCreatedHandler, - { provide: SlackSender, useValue: {} }, - { provide: UserRepository, useValue: {} }, - ], - }).compile(); - - handler = testModule.get(GroupCreatedHandler); - slackSender = testModule.get(SlackSender); - userRepository = testModule.get(UserRepository); - }); - - afterAll(() => { - clear(); - }); - - describe('handle', () => { - let group: Group; - let event: GroupCreated; - let authorUser: User; - - beforeEach(() => { - authorUser = DomainFixture.generateUser(); - group = DomainFixture.generateGroup({ authorUserId: authorUser.id }); - event = new GroupCreated(group); - - userRepository.findById = jest.fn().mockResolvedValue(authorUser); - - userRepository.save = jest.fn(); - slackSender.send = jest.fn(); - - jest.spyOn(authorUser, 'grantPoint'); - }); - - test('그룹 생성자가 존재하지 않으면 예외가 발생해야 한다', async () => { - userRepository.findById = jest.fn().mockResolvedValue(null); - - await expect(handler.handle(event)).rejects.toThrowError( - Message.USER_NOT_FOUND, - ); - }); - - test('그룹 생성자에게 포인트를 부여해줘야 한다', async () => { - await handler.handle(event); - - expect(authorUser.grantPoint).toBeCalledTimes(1); - expect(authorUser.grantPoint).toBeCalledWith( - Point.GROUP_CREATED, - expect.any(String), - ); - }); - - test('슬랙 메시지를 보내야 한다', async () => { - await handler.handle(event); - - expect(slackSender.send).toBeCalledTimes(1); - }); - }); -}); diff --git a/src/app/application/group/eventHandler/GroupCreatedHandler.ts b/src/app/application/group/eventHandler/GroupCreatedHandler.ts deleted file mode 100644 index 6125512..0000000 --- a/src/app/application/group/eventHandler/GroupCreatedHandler.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { Inject, NotFoundException } from '@nestjs/common'; -import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; - -import { GroupCreated } from '@sight/app/domain/group/event/GroupCreated'; -import { SlackMessageCategory } from '@sight/app/domain/message/model/constant'; -import { - ISlackSender, - SlackSender, -} from '@sight/app/domain/adapter/ISlackSender'; -import { - IUserRepository, - UserRepository, -} from '@sight/app/domain/user/IUserRepository'; - -import { Message } from '@sight/constant/message'; -import { Point } from '@sight/constant/point'; -import { Transactional } from '@sight/core/persistence/transaction/Transactional'; - -@EventsHandler(GroupCreated) -export class GroupCreatedHandler implements IEventHandler { - constructor( - @Inject(SlackSender) - private readonly slackSender: ISlackSender, - @Inject(UserRepository) - private readonly userRepository: IUserRepository, - ) {} - - @Transactional() - async handle(event: GroupCreated): Promise { - const { group } = event; - - const authorUser = await this.userRepository.findById(group.authorUserId); - if (!authorUser) { - throw new NotFoundException(Message.USER_NOT_FOUND); - } - - const reason = `${group.title} 그룹을 만들었습니다.`; - authorUser.grantPoint(Point.GROUP_CREATED, reason); - await this.userRepository.save(authorUser); - - this.slackSender.send({ - targetUserId: group.authorUserId, - category: SlackMessageCategory.GROUP_ACTIVITY, - message: `${group.title} 그룹을 만들었습니다.`, - }); - } -} diff --git a/src/app/application/group/eventHandler/GroupPortfolioDisabledHandler.spec.ts b/src/app/application/group/eventHandler/GroupPortfolioDisabledHandler.spec.ts deleted file mode 100644 index 92995f6..0000000 --- a/src/app/application/group/eventHandler/GroupPortfolioDisabledHandler.spec.ts +++ /dev/null @@ -1,155 +0,0 @@ -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 { GroupPortfolioDisabledHandler } from '@sight/app/application/group/eventHandler/GroupPortfolioDisabledHandler'; - -import { PRACTICE_GROUP_ID } from '@sight/app/domain/group/model/constant'; -import { Group } from '@sight/app/domain/group/model/Group'; -import { - ISlackSender, - SlackSender, -} from '@sight/app/domain/adapter/ISlackSender'; -import { - GroupLogger, - IGroupLogger, -} from '@sight/app/domain/group/IGroupLogger'; -import { - GroupMemberRepository, - IGroupMemberRepository, -} from '@sight/app/domain/group/IGroupMemberRepository'; -import { - GroupRepository, - IGroupRepository, -} from '@sight/app/domain/group/IGroupRepository'; -import { - IUserRepository, - UserRepository, -} from '@sight/app/domain/user/IUserRepository'; - -import { DomainFixture } from '@sight/__test__/fixtures'; -import { Point } from '@sight/constant/point'; - -describe('GroupPortfolioDisabledHandler', () => { - let handler: GroupPortfolioDisabledHandler; - let messageBuilder: MessageBuilder; - let clsService: jest.Mocked; - let groupRepository: jest.Mocked; - let groupMemberRepository: jest.Mocked; - let userRepository: jest.Mocked; - let groupLogger: jest.Mocked; - let slackSender: jest.Mocked; - - beforeAll(async () => { - advanceTo(new Date()); - - const testModule = await Test.createTestingModule({ - providers: [ - GroupPortfolioDisabledHandler, - MessageBuilder, - { provide: ClsService, useValue: {} }, - { provide: GroupRepository, useValue: {} }, - { provide: GroupMemberRepository, useValue: {} }, - { provide: UserRepository, useValue: {} }, - { provide: GroupLogger, useValue: {} }, - { provide: SlackSender, useValue: {} }, - ], - }).compile(); - - handler = testModule.get(GroupPortfolioDisabledHandler); - messageBuilder = testModule.get(MessageBuilder); - clsService = testModule.get(ClsService); - groupRepository = testModule.get(GroupRepository); - groupMemberRepository = testModule.get(GroupMemberRepository); - userRepository = testModule.get(UserRepository); - groupLogger = testModule.get(GroupLogger); - slackSender = testModule.get(SlackSender); - }); - - afterAll(() => { - clear(); - }); - - describe('handle', () => { - let group: Group; - - const groupId = 'groupId'; - const requester: IRequester = { - userId: 'requesterUserId', - role: UserRole.USER, - }; - - beforeEach(() => { - group = DomainFixture.generateGroup(); - - clsService.get = jest.fn().mockReturnValue(requester); - groupRepository.findById = jest.fn().mockResolvedValue(group); - groupMemberRepository.findByGroupId = jest.fn().mockResolvedValue([]); - userRepository.findByIds = jest.fn().mockResolvedValue([]); - - jest.spyOn(messageBuilder, 'build'); - groupLogger.log = jest.fn(); - userRepository.save = jest.fn(); - slackSender.send = jest.fn(); - }); - - test('그룹이 존재하지 않으면 아무 동작도 하지 않아야 한다', async () => { - groupRepository.findById = jest.fn().mockResolvedValue(null); - - await handler.handle({ groupId }); - - expect(groupRepository.findById).toBeCalledWith(groupId); - }); - - test('그룹 로그를 남겨야 한다', async () => { - await handler.handle({ groupId }); - - expect(groupLogger.log).toBeCalledTimes(1); - }); - - test('모든 그룹 멤버들에게서 포인트를 회수해야 한다', async () => { - const user = DomainFixture.generateUser(); - const groupMember = DomainFixture.generateGroupMember({ - userId: user.id, - }); - - groupMemberRepository.findByGroupId = jest - .fn() - .mockResolvedValue([groupMember]); - userRepository.findByIds = jest.fn().mockResolvedValue([user]); - jest.spyOn(user, 'grantPoint'); - - await handler.handle({ groupId }); - - expect(user.grantPoint).toBeCalledTimes(1); - expect(user.grantPoint).toBeCalledWith( - -Point.GROUP_ENABLED_PORTFOLIO, - expect.any(String), - ); - - expect(userRepository.save).toBeCalledTimes(1); - expect(userRepository.save).toBeCalledWith(user); - }); - - test('그룹 활용 실습 그룹이면 포인트를 회수하지 않아야 한다', async () => { - const practiceGroup = DomainFixture.generateGroup({ - id: PRACTICE_GROUP_ID, - }); - groupRepository.findById = jest.fn().mockResolvedValue(practiceGroup); - - await handler.handle({ groupId }); - - expect(userRepository.save).not.toBeCalled(); - }); - - test('메시지를 보내야 한다', async () => { - await handler.handle({ groupId }); - - expect(slackSender.send).toBeCalledTimes(1); - }); - }); -}); diff --git a/src/app/application/group/eventHandler/GroupPortfolioDisabledHandler.ts b/src/app/application/group/eventHandler/GroupPortfolioDisabledHandler.ts deleted file mode 100644 index d66fd9d..0000000 --- a/src/app/application/group/eventHandler/GroupPortfolioDisabledHandler.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { Inject } from '@nestjs/common'; -import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; -import { ClsService } from 'nestjs-cls'; - -import { IRequester } from '@sight/core/auth/IRequester'; -import { Transactional } from '@sight/core/persistence/transaction/Transactional'; - -import { GroupPortfolioDisabled } from '@sight/app/domain/group/event/GroupPortfolioDisabled'; -import { Group } from '@sight/app/domain/group/model/Group'; -import { SlackMessageCategory } from '@sight/app/domain/message/model/constant'; -import { - ISlackSender, - SlackSender, -} from '@sight/app/domain/adapter/ISlackSender'; -import { - GroupLogger, - IGroupLogger, -} from '@sight/app/domain/group/IGroupLogger'; -import { - GroupMemberRepository, - IGroupMemberRepository, -} from '@sight/app/domain/group/IGroupMemberRepository'; -import { - GroupRepository, - IGroupRepository, -} from '@sight/app/domain/group/IGroupRepository'; -import { - IUserRepository, - UserRepository, -} from '@sight/app/domain/user/IUserRepository'; - -import { Point } from '@sight/constant/point'; -import { MessageBuilder } from '@sight/core/message/MessageBuilder'; -import { Template } from '@sight/constant/template'; - -@EventsHandler(GroupPortfolioDisabled) -export class GroupPortfolioDisabledHandler - implements IEventHandler -{ - constructor( - private readonly clsService: ClsService, - private readonly messageBuilder: MessageBuilder, - @Inject(GroupRepository) - private readonly groupRepository: IGroupRepository, - @Inject(GroupMemberRepository) - private readonly groupMemberRepository: IGroupMemberRepository, - @Inject(UserRepository) - private readonly userRepository: IUserRepository, - @Inject(GroupLogger) - private readonly groupLogger: IGroupLogger, - @Inject(SlackSender) - private readonly slackSender: ISlackSender, - ) {} - - @Transactional() - async handle(event: GroupPortfolioDisabled): Promise { - const { groupId } = event; - - const group = await this.groupRepository.findById(groupId); - if (!group) { - return; - } - - await this.groupLogger.log(groupId, '포트폴리오 발행이 중단되었습니다.'); - await this.retrievePointFromMembers(group); - - const requester: IRequester = this.clsService.get('requester'); - const message = this.messageBuilder.build( - Template.DISABLE_GROUP_PORTFOLIO.notification, - { groupId: group.id, groupTitle: group.title }, - ); - - this.slackSender.send({ - category: SlackMessageCategory.GROUP_ACTIVITY, - targetUserId: requester.userId, - message, - }); - } - - private async retrievePointFromMembers(group: Group): Promise { - if (group.isPracticeGroup()) { - return; - } - - const groupMembers = await this.groupMemberRepository.findByGroupId( - group.id, - ); - const userIds = groupMembers.map((groupMember) => groupMember.userId); - const users = await this.userRepository.findByIds(userIds); - - const reason = this.messageBuilder.build( - Template.DISABLE_GROUP_PORTFOLIO.point, - { groupTitle: group.title }, - ); - - users.forEach((user) => - user.grantPoint(-Point.GROUP_ENABLED_PORTFOLIO, reason), - ); - await this.userRepository.save(...users); - } -} diff --git a/src/app/application/group/eventHandler/GroupPortfolioEnabledHandler.spec.ts b/src/app/application/group/eventHandler/GroupPortfolioEnabledHandler.spec.ts deleted file mode 100644 index 7cffd4a..0000000 --- a/src/app/application/group/eventHandler/GroupPortfolioEnabledHandler.spec.ts +++ /dev/null @@ -1,10 +0,0 @@ -// TODO: 핸들러의 구현 방향성이 잡히고, 리팩토링이 완료되면 테스트 작성 예정 -describe('GroupPortfolioEnabledHandler', () => { - test.todo('그룹이 존재하지 않으면 아무것도 하지 않아야 한다'); - - test.todo('모든 그룹 멤버들에게 포인트를 지급해야 한다'); - - test.todo('그룹 로그를 생성해야 한다'); - - test.todo('메시지를 전송해야 한다'); -}); diff --git a/src/app/application/group/eventHandler/GroupPortfolioEnabledHandler.ts b/src/app/application/group/eventHandler/GroupPortfolioEnabledHandler.ts deleted file mode 100644 index 9e65b18..0000000 --- a/src/app/application/group/eventHandler/GroupPortfolioEnabledHandler.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { Inject } from '@nestjs/common'; -import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; - -import { Transactional } from '@sight/core/persistence/transaction/Transactional'; - -import { GroupPortfolioEnabled } from '@sight/app/domain/group/event/GroupPortfolioEnabled'; -import { SlackMessageCategory } from '@sight/app/domain/message/model/constant'; -import { - ISlackSender, - SlackSender, -} from '@sight/app/domain/adapter/ISlackSender'; -import { - GroupLogger, - IGroupLogger, -} from '@sight/app/domain/group/IGroupLogger'; -import { - GroupMemberRepository, - IGroupMemberRepository, -} from '@sight/app/domain/group/IGroupMemberRepository'; -import { - GroupRepository, - IGroupRepository, -} from '@sight/app/domain/group/IGroupRepository'; -import { - IUserRepository, - UserRepository, -} from '@sight/app/domain/user/IUserRepository'; - -import { Point } from '@sight/constant/point'; -import { ClsService } from 'nestjs-cls'; -import { IRequester } from '@sight/core/auth/IRequester'; - -@EventsHandler(GroupPortfolioEnabled) -export class GroupPortfolioEnabledHandler - implements IEventHandler -{ - constructor( - @Inject(GroupRepository) - private readonly groupRepository: IGroupRepository, - @Inject(GroupMemberRepository) - private readonly groupMemberRepository: IGroupMemberRepository, - @Inject(GroupLogger) - private readonly groupLogger: IGroupLogger, - @Inject(UserRepository) - private readonly userRepository: IUserRepository, - @Inject(SlackSender) - private readonly slackSender: ISlackSender, - private readonly clsService: ClsService, - ) {} - - @Transactional() - async handle(event: GroupPortfolioEnabled): Promise { - const { groupId } = event; - - const group = await this.groupRepository.findById(groupId); - if (!group) { - return; - } - - const groupMembers = - await this.groupMemberRepository.findByGroupId(groupId); - const userIds = groupMembers.map((groupMember) => groupMember.userId); - const users = await this.userRepository.findByIds(userIds); - - users.forEach((user) => - user.grantPoint( - Point.GROUP_ENABLED_PORTFOLIO, - `${group.title} 그룹의 포트폴리오가 발행되었습니다.`, - ), - ); - await this.userRepository.save(...users); - - await this.groupLogger.log(groupId, '포트폴리오가 발행 중입니다.'); - - const requester: IRequester = this.clsService.get('requester'); - this.slackSender.send({ - category: SlackMessageCategory.GROUP_ACTIVITY, - message: `${group.title} 그룹의 포트폴리오가 발행 중입니다.`, - targetUserId: requester.userId, // TODO: 요청자 정보에 접근할 수 있을 때 수정 - }); - } -} diff --git a/src/app/application/group/eventHandler/GroupStateChangedHandler.ts b/src/app/application/group/eventHandler/GroupStateChangedHandler.ts index f48fea2..309bc17 100644 --- a/src/app/application/group/eventHandler/GroupStateChangedHandler.ts +++ b/src/app/application/group/eventHandler/GroupStateChangedHandler.ts @@ -18,24 +18,21 @@ import { GroupRepository, IGroupRepository, } from '@sight/app/domain/group/IGroupRepository'; -import { - IUserRepository, - UserRepository, -} from '@sight/app/domain/user/IUserRepository'; import { Point } from '@sight/constant/point'; +import { PointGrantService } from '@sight/app/domain/user/service/PointGrantService'; +// TODO[lery]: 그룹 상태 핸들러가 분리되면 그때 제거하기 @EventsHandler(GroupStateChanged) export class GroupStateChangedHandler implements IEventHandler { constructor( + private readonly pointGrantService: PointGrantService, @Inject(GroupRepository) private readonly groupRepository: IGroupRepository, @Inject(GroupMemberRepository) private readonly groupMemberRepository: IGroupMemberRepository, - @Inject(UserRepository) - private readonly userRepository: IUserRepository, @Inject(SlackSender) private readonly slackSender: ISlackSender, ) {} @@ -65,13 +62,11 @@ export class GroupStateChangedHandler ); const userIds = members.map((m) => m.userId); - const users = await this.userRepository.findByIds(userIds); - users.forEach((user) => { - const point = this.buildPoint(prevState, nextState); - const message = `${group.title} ${this.buildMessage(nextState)}`; - user.grantPoint(point, message); + this.pointGrantService.grant({ + targetUserIds: userIds, + amount: this.buildPoint(prevState, nextState), + reason: `${group.title} ${this.buildMessage(nextState)}`, }); - await this.userRepository.save(...users); } private buildMessage(nextState: GroupState): string { diff --git a/src/app/application/group/eventHandler/GroupUpdatedHandler.spec.ts b/src/app/application/group/eventHandler/GroupUpdatedHandler.spec.ts deleted file mode 100644 index 3fd018f..0000000 --- a/src/app/application/group/eventHandler/GroupUpdatedHandler.spec.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { Test } from '@nestjs/testing'; -import { advanceTo, clear } from 'jest-date-mock'; - -import { GroupUpdatedHandler } from '@sight/app/application/group/eventHandler/GroupUpdatedHandler'; - -import { - ISlackSender, - SlackSender, -} from '@sight/app/domain/adapter/ISlackSender'; -import { - GroupMemberRepository, - IGroupMemberRepository, -} from '@sight/app/domain/group/IGroupMemberRepository'; -import { - GroupRepository, - IGroupRepository, -} from '@sight/app/domain/group/IGroupRepository'; -import { - GroupUpdatedMessageBuilder, - IGroupUpdatedMessageBuilder, -} from '@sight/app/domain/group/messageBuilder/IGroupUpdatedMessageBuilder'; - -import { generateEmptyProviders } from '@sight/__test__/util'; -import { - GroupUpdated, - GroupUpdatedItem, -} from '@sight/app/domain/group/event/GroupUpdated'; -import { DomainFixture } from '@sight/__test__/fixtures'; -import { GroupMember } from '@sight/app/domain/group/model/GroupMember'; -import { - GroupLogger, - IGroupLogger, -} from '@sight/app/domain/group/IGroupLogger'; - -describe('GroupUpdatedHandler', () => { - let handler: GroupUpdatedHandler; - let groupLogger: jest.Mocked; - let groupRepository: jest.Mocked; - let groupMemberRepository: jest.Mocked; - let messageBuilder: jest.Mocked; - let slackSender: jest.Mocked; - - beforeAll(async () => { - advanceTo(new Date()); - - const testModule = await Test.createTestingModule({ - providers: [ - GroupUpdatedHandler, - ...generateEmptyProviders( - GroupLogger, - GroupRepository, - GroupMemberRepository, - GroupUpdatedMessageBuilder, - SlackSender, - ), - ], - }).compile(); - - handler = testModule.get(GroupUpdatedHandler); - groupLogger = testModule.get(GroupLogger); - groupRepository = testModule.get(GroupRepository); - groupMemberRepository = testModule.get(GroupMemberRepository); - messageBuilder = testModule.get(GroupUpdatedMessageBuilder); - slackSender = testModule.get(SlackSender); - }); - - afterAll(() => { - clear(); - }); - - describe('handle', () => { - let event: GroupUpdated; - let groupMembers: GroupMember[]; - - const groupId = 'groupId'; - const updatedItem: GroupUpdatedItem = 'title'; - const message = 'message'; - const groupAdminUserId = 'group-admin-user-id'; - - beforeEach(() => { - event = new GroupUpdated(groupId, updatedItem); - groupMembers = [ - DomainFixture.generateGroupMember({ groupId }), - DomainFixture.generateGroupMember({ groupId }), - ]; - const group = DomainFixture.generateGroup({ - id: groupId, - adminUserId: groupAdminUserId, - }); - - groupRepository.findById = jest.fn().mockResolvedValue(group); - messageBuilder.build = jest.fn().mockReturnValue(message); - groupMemberRepository.findByGroupId = jest - .fn() - .mockResolvedValue(groupMembers); - - groupLogger.log = jest.fn(); - slackSender.send = jest.fn(); - }); - - test('그룹이 존재하지 않으면 로그를 생성하지 않아야 한다', async () => { - groupRepository.findById = jest.fn().mockResolvedValue(null); - - await handler.handle(event); - - expect(groupLogger.log).not.toBeCalled(); - }); - - test('로그를 생성하여 저장해야 한다', async () => { - await handler.handle(event); - - expect(groupLogger.log).toBeCalledTimes(1); - }); - - test('모든 그룹 멤버들에게 메시지를 보내야 한다', async () => { - await handler.handle(event); - - expect(slackSender.send).toBeCalledTimes(groupMembers.length); - }); - }); -}); diff --git a/src/app/application/group/eventHandler/GroupUpdatedHandler.ts b/src/app/application/group/eventHandler/GroupUpdatedHandler.ts deleted file mode 100644 index 1e9df74..0000000 --- a/src/app/application/group/eventHandler/GroupUpdatedHandler.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { Inject } from '@nestjs/common'; -import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; - -import { Transactional } from '@sight/core/persistence/transaction/Transactional'; - -import { GroupUpdated } from '@sight/app/domain/group/event/GroupUpdated'; -import { SlackMessageCategory } from '@sight/app/domain/message/model/constant'; -import { - ISlackSender, - SlackSender, -} from '@sight/app/domain/adapter/ISlackSender'; -import { - GroupLogger, - IGroupLogger, -} from '@sight/app/domain/group/IGroupLogger'; -import { - GroupMemberRepository, - IGroupMemberRepository, -} from '@sight/app/domain/group/IGroupMemberRepository'; -import { - GroupRepository, - IGroupRepository, -} from '@sight/app/domain/group/IGroupRepository'; -import { - GroupUpdatedMessageBuilder, - IGroupUpdatedMessageBuilder, -} from '@sight/app/domain/group/messageBuilder/IGroupUpdatedMessageBuilder'; - -@EventsHandler(GroupUpdated) -export class GroupUpdatedHandler implements IEventHandler { - constructor( - @Inject(GroupLogger) - private readonly groupLogger: IGroupLogger, - @Inject(GroupRepository) - private readonly groupRepository: IGroupRepository, - @Inject(GroupMemberRepository) - private readonly groupMemberRepository: IGroupMemberRepository, - @Inject(GroupUpdatedMessageBuilder) - private readonly messageBuilder: IGroupUpdatedMessageBuilder, - @Inject(SlackSender) - private readonly slackSender: ISlackSender, - ) {} - - @Transactional() - async handle(event: GroupUpdated): Promise { - const { groupId, updatedItem } = event; - - const group = await this.groupRepository.findById(groupId); - if (!group) { - return; - } - - const message = this.messageBuilder.build(updatedItem); - await this.groupLogger.log(groupId, message); - - const members = await this.groupMemberRepository.findByGroupId(groupId); - members.forEach((member) => - this.slackSender.send({ - targetUserId: member.userId, - category: SlackMessageCategory.GROUP_ACTIVITY, - message, - }), - ); - } -} diff --git a/src/app/application/user/eventHandler/PointGrantedHandler.spec.ts b/src/app/application/user/eventHandler/PointGrantedHandler.spec.ts deleted file mode 100644 index d70e854..0000000 --- a/src/app/application/user/eventHandler/PointGrantedHandler.spec.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { Test } from '@nestjs/testing'; -import { advanceTo, clear } from 'jest-date-mock'; - -import { PointGrantedHandler } from '@sight/app/application/user/eventHandler/PointGrantedHandler'; - -import { PointGranted } from '@sight/app/domain/user/event/PointGranted'; -import { PointHistory } from '@sight/app/domain/user/model/PointHistory'; -import { User } from '@sight/app/domain/user/model/User'; -import { PointHistoryFactory } from '@sight/app/domain/user/PointHistoryFactory'; -import { - IPointHistoryRepository, - PointHistoryRepository, -} from '@sight/app/domain/user/IPointHistoryRepository'; - -import { DomainFixture } from '@sight/__test__/fixtures'; - -describe('PointGrantedHandler', () => { - let handler: PointGrantedHandler; - let pointHistoryFactory: jest.Mocked; - let pointHistoryRepository: jest.Mocked; - - beforeAll(async () => { - advanceTo(new Date()); - - const testModule = await Test.createTestingModule({ - providers: [ - PointGrantedHandler, - { provide: PointHistoryFactory, useValue: {} }, - { provide: PointHistoryRepository, useValue: {} }, - ], - }).compile(); - - handler = testModule.get(PointGrantedHandler); - pointHistoryFactory = testModule.get(PointHistoryFactory); - pointHistoryRepository = testModule.get(PointHistoryRepository); - }); - - afterAll(() => { - clear(); - }); - - describe('handle', () => { - let user: User; - let pointHistory: PointHistory; - let event: PointGranted; - - const point = 10; - const reason = 'test'; - - beforeEach(() => { - user = DomainFixture.generateUser(); - pointHistory = DomainFixture.generatePointHistory(); - event = new PointGranted(user, point, reason); - - pointHistoryFactory.create = jest.fn().mockReturnValue(pointHistory); - pointHistoryRepository.nextId = jest.fn().mockReturnValue('newId'); - - pointHistoryRepository.save = jest.fn(); - }); - - test('새로운 포인트 히스토리를 생성해서 저장해야 한다', async () => { - await handler.handle(event); - - expect(pointHistoryFactory.create).toBeCalledTimes(1); - - expect(pointHistoryRepository.save).toBeCalledTimes(1); - expect(pointHistoryRepository.save).toBeCalledWith(pointHistory); - }); - }); -}); diff --git a/src/app/application/user/eventHandler/PointGrantedHandler.ts b/src/app/application/user/eventHandler/PointGrantedHandler.ts deleted file mode 100644 index 855bab2..0000000 --- a/src/app/application/user/eventHandler/PointGrantedHandler.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Inject } from '@nestjs/common'; -import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; - -import { Transactional } from '@sight/core/persistence/transaction/Transactional'; - -import { PointGranted } from '@sight/app/domain/user/event/PointGranted'; -import { PointHistoryFactory } from '@sight/app/domain/user/PointHistoryFactory'; -import { - IPointHistoryRepository, - PointHistoryRepository, -} from '@sight/app/domain/user/IPointHistoryRepository'; - -@EventsHandler(PointGranted) -export class PointGrantedHandler implements IEventHandler { - constructor( - @Inject(PointHistoryFactory) - private readonly pointHistoryFactory: PointHistoryFactory, - @Inject(PointHistoryRepository) - private readonly pointHistoryRepository: IPointHistoryRepository, - ) {} - - @Transactional() - async handle(event: PointGranted): Promise { - const { user, point, reason } = event; - - const newHistory = this.pointHistoryFactory.create({ - id: this.pointHistoryRepository.nextId(), - userId: user.id, - reason, - point, - }); - await this.pointHistoryRepository.save(newHistory); - } -} diff --git a/src/app/domain/group/GroupBookmarkFactory.spec.ts b/src/app/domain/group/GroupBookmarkFactory.spec.ts index 6350c2d..02cf91d 100644 --- a/src/app/domain/group/GroupBookmarkFactory.spec.ts +++ b/src/app/domain/group/GroupBookmarkFactory.spec.ts @@ -1,7 +1,6 @@ import { Test } from '@nestjs/testing'; import { advanceTo, clear } from 'jest-date-mock'; -import { GroupBookmarkCreated } from '@sight/app/domain/group/event/GroupBookmarkCreated'; import { GroupBookmarkFactory } from '@sight/app/domain/group/GroupBookmarkFactory'; import { GroupBookmark } from '@sight/app/domain/group/model/GroupBookmark'; @@ -34,7 +33,6 @@ describe('GroupBookmarkFactory', () => { userId, createdAt: new Date(), }); - expected.apply(new GroupBookmarkCreated(groupId, userId)); const bookmark = groupBookmarkFactory.create({ id: bookmarkId, diff --git a/src/app/domain/group/GroupBookmarkFactory.ts b/src/app/domain/group/GroupBookmarkFactory.ts index ef77338..4fda123 100644 --- a/src/app/domain/group/GroupBookmarkFactory.ts +++ b/src/app/domain/group/GroupBookmarkFactory.ts @@ -1,6 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { GroupBookmarkCreated } from '@sight/app/domain/group/event/GroupBookmarkCreated'; import { GroupBookmark, GroupBookmarkConstructorParams, @@ -20,7 +19,6 @@ export class GroupBookmarkFactory { ...params, createdAt: new Date(), }); - newBookmark.apply(new GroupBookmarkCreated(params.groupId, params.userId)); return newBookmark; } diff --git a/src/app/domain/group/GroupFactory.spec.ts b/src/app/domain/group/GroupFactory.spec.ts index 9de7d7c..12bd38a 100644 --- a/src/app/domain/group/GroupFactory.spec.ts +++ b/src/app/domain/group/GroupFactory.spec.ts @@ -1,7 +1,6 @@ import { Test } from '@nestjs/testing'; import { advanceTo, clear } from 'jest-date-mock'; -import { GroupCreated } from '@sight/app/domain/group/event/GroupCreated'; import { GroupFactory } from '@sight/app/domain/group/GroupFactory'; import { Group } from '@sight/app/domain/group/model/Group'; import { @@ -62,7 +61,6 @@ describe('GroupFactory', () => { createdAt: new Date(), updatedAt: new Date(), }); - expected.apply(new GroupCreated(expected)); const group = groupFactory.create({ id: groupId, diff --git a/src/app/domain/group/GroupFactory.ts b/src/app/domain/group/GroupFactory.ts index f1be655..60b26c4 100644 --- a/src/app/domain/group/GroupFactory.ts +++ b/src/app/domain/group/GroupFactory.ts @@ -1,6 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { GroupCreated } from '@sight/app/domain/group/event/GroupCreated'; import { Group, GroupConstructorParams, @@ -22,7 +21,6 @@ export class GroupFactory { createdAt: now, updatedAt: now, }); - newGroup.apply(new GroupCreated(newGroup)); return newGroup; } diff --git a/src/app/domain/group/event/GroupBookmarkCreated.ts b/src/app/domain/group/event/GroupBookmarkCreated.ts deleted file mode 100644 index 5d85810..0000000 --- a/src/app/domain/group/event/GroupBookmarkCreated.ts +++ /dev/null @@ -1,6 +0,0 @@ -export class GroupBookmarkCreated { - 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 deleted file mode 100644 index 090e9a2..0000000 --- a/src/app/domain/group/event/GroupBookmarkRemoved.ts +++ /dev/null @@ -1,6 +0,0 @@ -export class GroupBookmarkRemoved { - constructor( - readonly groupId: string, - readonly userId: string, - ) {} -} diff --git a/src/app/domain/group/event/GroupCreated.ts b/src/app/domain/group/event/GroupCreated.ts deleted file mode 100644 index 0836adb..0000000 --- a/src/app/domain/group/event/GroupCreated.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { Group } from '@sight/app/domain/group/model/Group'; - -export class GroupCreated { - constructor(readonly group: Group) {} -} diff --git a/src/app/domain/group/event/GroupPortfolioDisabled.ts b/src/app/domain/group/event/GroupPortfolioDisabled.ts deleted file mode 100644 index b09277d..0000000 --- a/src/app/domain/group/event/GroupPortfolioDisabled.ts +++ /dev/null @@ -1,3 +0,0 @@ -export class GroupPortfolioDisabled { - constructor(readonly groupId: string) {} -} diff --git a/src/app/domain/group/event/GroupPortfolioEnabled.ts b/src/app/domain/group/event/GroupPortfolioEnabled.ts deleted file mode 100644 index 1c70912..0000000 --- a/src/app/domain/group/event/GroupPortfolioEnabled.ts +++ /dev/null @@ -1,3 +0,0 @@ -export class GroupPortfolioEnabled { - constructor(readonly groupId: string) {} -} diff --git a/src/app/domain/group/event/GroupUpdated.ts b/src/app/domain/group/event/GroupUpdated.ts deleted file mode 100644 index 7d505ef..0000000 --- a/src/app/domain/group/event/GroupUpdated.ts +++ /dev/null @@ -1,16 +0,0 @@ -export type GroupUpdatedItem = - | 'title' - | 'purpose' - | 'interests' - | 'technology' - | 'grade' - | 'repository' - | 'allowJoin' - | 'category'; - -export class GroupUpdated { - constructor( - readonly groupId: string, - readonly updatedItem: GroupUpdatedItem, - ) {} -} diff --git a/src/app/domain/group/messageBuilder/IGroupUpdatedMessageBuilder.ts b/src/app/domain/group/messageBuilder/IGroupUpdatedMessageBuilder.ts deleted file mode 100644 index cfa8e59..0000000 --- a/src/app/domain/group/messageBuilder/IGroupUpdatedMessageBuilder.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { GroupUpdatedItem } from '@sight/app/domain/group/event/GroupUpdated'; - -export const GroupUpdatedMessageBuilder = Symbol('GroupUpdatedMessageBuilder'); - -export interface IGroupUpdatedMessageBuilder { - build: (updatedItem: GroupUpdatedItem) => string; -} diff --git a/src/app/domain/group/model/Group.spec.ts b/src/app/domain/group/model/Group.spec.ts index b39e30a..d43296a 100644 --- a/src/app/domain/group/model/Group.spec.ts +++ b/src/app/domain/group/model/Group.spec.ts @@ -1,12 +1,9 @@ import { advanceTo, clear } from 'jest-date-mock'; -import { - CUSTOMER_SERVICE_GROUP_ID, - GroupState, -} from '@sight/app/domain/group/model/constant'; +import { GroupState } from '@sight/app/domain/group/model/constant'; -import { DomainFixture } from '@sight/__test__/fixtures'; import { Message } from '@sight/constant/message'; +import { GroupFixture } from '@sight/__test__/fixtures/GroupFixture'; describe('Group', () => { beforeAll(() => { @@ -18,26 +15,9 @@ describe('Group', () => { }); describe('changeState', () => { - const nextState = GroupState.PROGRESS; - - test('변경할 상태가 현재 그룹의 상태와 동일하다면 상태를 변경하지 않아야 한다', () => { - const prev = new Date(2023, 7, 19, 0, 0, 0); - const group = DomainFixture.generateGroup({ - state: nextState, - updatedAt: prev, - }); - - group.changeState(nextState); - - expect(group.updatedAt).toEqual(prev); - }); - test('상태를 변경해야 한다', () => { - const prev = new Date(2023, 7, 19, 0, 0, 0); - const group = DomainFixture.generateGroup({ - state: GroupState.PENDING, - updatedAt: prev, - }); + const group = GroupFixture.inProgressJoinable(); + const nextState = GroupState.END_SUCCESS; group.changeState(nextState); @@ -48,9 +28,7 @@ describe('Group', () => { describe('wake', () => { test('그룹이 중단되어 있다면 진행 상태로 변경해야 한다', () => { - const group = DomainFixture.generateGroup({ - state: GroupState.SUSPEND, - }); + const group = GroupFixture.suspended(); group.wake(); @@ -60,9 +38,7 @@ describe('Group', () => { describe('enablePortfolio', () => { test('포트폴리오를 활성화해야 한다', () => { - const group = DomainFixture.generateGroup({ - hasPortfolio: false, - }); + const group = GroupFixture.inProgressJoinable({ hasPortfolio: false }); group.enablePortfolio(); @@ -70,9 +46,7 @@ describe('Group', () => { }); test('이미 포트폴리오가 활성화되어 있다면 예외를 발생시켜야 한다', () => { - const group = DomainFixture.generateGroup({ - hasPortfolio: true, - }); + const group = GroupFixture.inProgressJoinable({ hasPortfolio: true }); expect(() => group.enablePortfolio()).toThrowError( Message.ALREADY_GROUP_ENABLED_PORTFOLIO, @@ -82,9 +56,7 @@ describe('Group', () => { describe('disablePortfolio', () => { test('포트폴리오를 비활성화해야 한다', () => { - const group = DomainFixture.generateGroup({ - hasPortfolio: true, - }); + const group = GroupFixture.inProgressJoinable({ hasPortfolio: true }); group.disablePortfolio(); @@ -92,9 +64,7 @@ describe('Group', () => { }); test('이미 포트폴리오가 비활성화되어 있다면 예외를 발생시켜야 한다', () => { - const group = DomainFixture.generateGroup({ - hasPortfolio: false, - }); + const group = GroupFixture.inProgressJoinable({ hasPortfolio: false }); expect(() => group.disablePortfolio()).toThrowError( Message.ALREADY_GROUP_DISABLED_PORTFOLIO, @@ -104,17 +74,13 @@ describe('Group', () => { describe('isEditable', () => { test('그룹이 종료된 상태라면 false를 반환해야 한다', () => { - const group = DomainFixture.generateGroup({ - state: GroupState.END_SUCCESS, - }); + const group = GroupFixture.successfullyEnd(); expect(group.isEditable()).toEqual(false); }); test('고객센터 그룹이라면 false를 반환해야 한다', () => { - const group = DomainFixture.generateGroup({ - id: CUSTOMER_SERVICE_GROUP_ID, - }); + const group = GroupFixture.customerService(); expect(group.isEditable()).toEqual(false); }); @@ -124,7 +90,7 @@ describe('Group', () => { test.each([GroupState.END_SUCCESS, GroupState.END_FAIL])( '그룹의 상태가 %s일 때, true를 반환해야 한다', (state: GroupState) => { - const group = DomainFixture.generateGroup({ state }); + const group = GroupFixture.raw({ state }); expect(group.isEnd()).toEqual(true); }, ); diff --git a/src/app/domain/group/model/Group.ts b/src/app/domain/group/model/Group.ts index 315a6d8..a33cd8a 100644 --- a/src/app/domain/group/model/Group.ts +++ b/src/app/domain/group/model/Group.ts @@ -1,9 +1,6 @@ import { UnprocessableEntityException } from '@nestjs/common'; import { AggregateRoot } from '@nestjs/cqrs'; -import { GroupPortfolioEnabled } from '@sight/app/domain/group/event/GroupPortfolioEnabled'; -import { GroupStateChanged } from '@sight/app/domain/group/event/GroupStateChanged'; -import { GroupUpdated } from '@sight/app/domain/group/event/GroupUpdated'; import { CUSTOMER_SERVICE_GROUP_ID, GroupAccessGrade, @@ -13,8 +10,6 @@ import { } from '@sight/app/domain/group/model/constant'; import { Message } from '@sight/constant/message'; -import { isDifferentStringArray } from '@sight/util/isDifferentStringArray'; - export type GroupConstructorParams = { id: string; category: GroupCategory; @@ -74,86 +69,56 @@ export class Group extends AggregateRoot { } updateTitle(title: string): void { - if (this._title !== title) { - this._title = title; - this._updatedAt = new Date(); - this.wake(); - this.apply(new GroupUpdated(this.id, 'title')); - } + this._title = title; + this._updatedAt = new Date(); + this.wake(); } updatePurpose(purpose: string | null): void { - if (this._purpose !== purpose) { - this._purpose = purpose; - this._updatedAt = new Date(); - this.wake(); - this.apply(new GroupUpdated(this.id, 'purpose')); - } + this._purpose = purpose; + this._updatedAt = new Date(); + this.wake(); } updateInterestIds(interestIds: string[]): void { - if (isDifferentStringArray(this._interestIds, interestIds)) { - this._interestIds = Array.from(new Set(interestIds)); - this._updatedAt = new Date(); - this.wake(); - this.apply(new GroupUpdated(this.id, 'interests')); - } + this._interestIds = Array.from(new Set(interestIds)); + this._updatedAt = new Date(); + this.wake(); } updateTechnology(technology: string[]): void { - if (isDifferentStringArray(this._technology, technology)) { - this._technology = technology; - this._updatedAt = new Date(); - this.wake(); - this.apply(new GroupUpdated(this.id, 'technology')); - } + this._technology = technology; + this._updatedAt = new Date(); + this.wake(); } updateGrade(grade: GroupAccessGrade): void { - if (this._grade !== grade) { - this._grade = grade; - this._updatedAt = new Date(); - this.wake(); - this.apply(new GroupUpdated(this.id, 'grade')); - } + this._grade = grade; + this._updatedAt = new Date(); + this.wake(); } updateRepository(repository: string | null): void { - if (this._repository !== repository) { - this._repository = repository; - this._updatedAt = new Date(); - this.wake(); - this.apply(new GroupUpdated(this.id, 'repository')); - } + this._repository = repository; + this._updatedAt = new Date(); + this.wake(); } updateAllowJoin(allowJoin: boolean): void { - if (this._allowJoin !== allowJoin) { - this._allowJoin = allowJoin; - this._updatedAt = new Date(); - this.wake(); - this.apply(new GroupUpdated(this.id, 'allowJoin')); - } + this._allowJoin = allowJoin; + this._updatedAt = new Date(); + this.wake(); } updateCategory(category: GroupCategory): void { - if (this._category !== category) { - this._category = category; - this._updatedAt = new Date(); - this.wake(); - this.apply(new GroupUpdated(this.id, 'category')); - } + this._category = category; + this._updatedAt = new Date(); + this.wake(); } changeState(nextState: GroupState) { - if (this._state === nextState) { - return; - } - - const prevState = this._state; this._state = nextState; this._updatedAt = new Date(); - this.apply(new GroupStateChanged(this.id, prevState, nextState)); } wake(): void { @@ -172,8 +137,6 @@ export class Group extends AggregateRoot { this._hasPortfolio = true; this._updatedAt = new Date(); - - this.apply(new GroupPortfolioEnabled(this.id)); } disablePortfolio(): void { @@ -185,8 +148,6 @@ export class Group extends AggregateRoot { this._hasPortfolio = false; this._updatedAt = new Date(); - - this.apply(new GroupPortfolioEnabled(this.id)); } isEditable(): boolean { diff --git a/src/app/domain/group/model/GroupBookmark.ts b/src/app/domain/group/model/GroupBookmark.ts index 49ea8b9..ea04691 100644 --- a/src/app/domain/group/model/GroupBookmark.ts +++ b/src/app/domain/group/model/GroupBookmark.ts @@ -1,5 +1,4 @@ import { AggregateRoot } from '@nestjs/cqrs'; -import { GroupBookmarkRemoved } from '../event/GroupBookmarkRemoved'; export type GroupBookmarkConstructorParams = { id: string; @@ -22,10 +21,6 @@ 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/app/domain/user/event/PointGranted.ts b/src/app/domain/user/event/PointGranted.ts deleted file mode 100644 index 34c30a1..0000000 --- a/src/app/domain/user/event/PointGranted.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { User } from '@sight/app/domain/user/model/User'; - -export class PointGranted { - constructor( - readonly user: User, - readonly point: number, - readonly reason: string, - ) {} -} diff --git a/src/app/domain/user/model/User.spec.ts b/src/app/domain/user/model/User.spec.ts index 1807a16..b301c93 100644 --- a/src/app/domain/user/model/User.spec.ts +++ b/src/app/domain/user/model/User.spec.ts @@ -67,4 +67,15 @@ describe('User', () => { expect(user.updatedAt).toEqual(now); }); }); + + describe('grantPoint', () => { + test('포인트를 부여해야 한다', async () => { + const user = DomainFixture.generateUser({ point: 100 }); + const point = 1000; + + user.grantPoint(point); + + expect(user.point).toEqual(1100); + }); + }); }); diff --git a/src/app/domain/user/model/User.ts b/src/app/domain/user/model/User.ts index ee76a15..457059e 100644 --- a/src/app/domain/user/model/User.ts +++ b/src/app/domain/user/model/User.ts @@ -1,7 +1,6 @@ import { UnprocessableEntityException } from '@nestjs/common'; import { AggregateRoot } from '@nestjs/cqrs'; -import { PointGranted } from '@sight/app/domain/user/event/PointGranted'; import { UserProfileUpdated } from '@sight/app/domain/user/event/UserProfileUpdated'; import { UserState } from '@sight/app/domain/user/model/constant'; import { Profile } from '@sight/app/domain/user/model/Profile'; @@ -98,14 +97,9 @@ export class User extends AggregateRoot { this._updatedAt = new Date(); } - grantPoint(point: number, reason: string): void { - if (point === 0) { - return; - } - + grantPoint(point: number): void { this._point += point; this._updatedAt = new Date(); - this.apply(new PointGranted(this, point, reason)); } get id(): string { diff --git a/src/app/domain/user/service/PointGrantService.spec.ts b/src/app/domain/user/service/PointGrantService.spec.ts new file mode 100644 index 0000000..5234489 --- /dev/null +++ b/src/app/domain/user/service/PointGrantService.spec.ts @@ -0,0 +1,104 @@ +import { Test } from '@nestjs/testing'; +import { advanceTo, clear } from 'jest-date-mock'; + +import { PointGrantService } from './PointGrantService'; +import { IUserRepository, UserRepository } from '../IUserRepository'; +import { PointHistoryFactory } from '../PointHistoryFactory'; +import { + IPointHistoryRepository, + PointHistoryRepository, +} from '../IPointHistoryRepository'; +import { faker } from '@faker-js/faker'; +import { DomainFixture } from '@sight/__test__/fixtures'; + +describe('PointGrantService', () => { + let pointGrantService: PointGrantService; + let userRepository: jest.Mocked; + let pointHistoryRepository: jest.Mocked; + + beforeAll(() => advanceTo(new Date())); + + beforeEach(async () => { + advanceTo(new Date()); + + const testModule = await Test.createTestingModule({ + providers: [ + PointGrantService, + PointHistoryFactory, + { + provide: UserRepository, + useValue: { + findByIds: jest.fn(), + save: jest.fn(), + }, + }, + { + provide: PointHistoryRepository, + useValue: { + nextId: jest.fn().mockImplementation(() => faker.string.uuid()), + save: jest.fn(), + }, + }, + ], + }).compile(); + + pointGrantService = testModule.get(PointGrantService); + userRepository = testModule.get(UserRepository); + pointHistoryRepository = testModule.get(PointHistoryRepository); + }); + + afterEach(() => clear()); + + describe('grant', () => { + test('주어진 유저에게 포인트를 부여해야 한다', async () => { + const targetUserIds = ['user1', 'user2']; + const users = [ + DomainFixture.generateUser({ id: targetUserIds[0], point: 0 }), + DomainFixture.generateUser({ id: targetUserIds[1], point: 0 }), + ]; + const amount = 1000; + + userRepository.findByIds.mockResolvedValue(users); + + await pointGrantService.grant({ + targetUserIds, + amount, + reason: 'test', + }); + + expect(users[0].point).toBe(amount); + expect(users[1].point).toBe(amount); + }); + + test('각 유저마다 포인트 이력을 생성해야 한다', async () => { + const targetUserIds = ['user1', 'user2']; + const users = [ + DomainFixture.generateUser({ id: targetUserIds[0], point: 0 }), + DomainFixture.generateUser({ id: targetUserIds[1], point: 0 }), + ]; + const amount = 1000; + const reason = 'test'; + + userRepository.findByIds.mockResolvedValue(users); + + await pointGrantService.grant({ + targetUserIds, + amount, + reason, + }); + + expect(pointHistoryRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + userId: users[0].id, + reason, + point: amount, + }), + expect.objectContaining({ + userId: users[1].id, + reason, + point: amount, + }), + ); + }); + }); +}); diff --git a/src/app/domain/user/service/PointGrantService.ts b/src/app/domain/user/service/PointGrantService.ts new file mode 100644 index 0000000..3735395 --- /dev/null +++ b/src/app/domain/user/service/PointGrantService.ts @@ -0,0 +1,46 @@ +import { Inject, Injectable } from '@nestjs/common'; + +import { PointHistoryFactory } from '@sight/app/domain/user/PointHistoryFactory'; +import { + IUserRepository, + UserRepository, +} from '@sight/app/domain/user/IUserRepository'; +import { + IPointHistoryRepository, + PointHistoryRepository, +} from '@sight/app/domain/user/IPointHistoryRepository'; + +@Injectable() +export class PointGrantService { + constructor( + @Inject(UserRepository) + private readonly userRepository: IUserRepository, + @Inject(PointHistoryFactory) + private readonly pointHistoryFactory: PointHistoryFactory, + @Inject(PointHistoryRepository) + private readonly pointHistoryRepository: IPointHistoryRepository, + ) {} + + async grant(params: { + targetUserIds: string[]; + amount: number; + reason: string; + }): Promise { + const { targetUserIds, amount, reason } = params; + + const users = await this.userRepository.findByIds(targetUserIds); + users.forEach((user) => user.grantPoint(amount)); + + const newHistories = users.map((user) => + this.pointHistoryFactory.create({ + id: this.pointHistoryRepository.nextId(), + userId: user.id, + reason, + point: amount, + }), + ); + + await this.userRepository.save(...users); + await this.pointHistoryRepository.save(...newHistories); + } +} diff --git a/src/constant/template.ts b/src/constant/template.ts index 19bb0e4..375caac 100644 --- a/src/constant/template.ts +++ b/src/constant/template.ts @@ -1,4 +1,8 @@ export const Template = { + ENABLE_GROUP_PORTFOLIO: { + notification: `:groupTitle: 그룹의 포트폴리오가 발행 중입니다.`, + point: `:groupTitle: 그룹의 포트폴리오가 발행되었습니다.`, + }, DISABLE_GROUP_PORTFOLIO: { notification: ':groupTitle: 그룹의 포트폴리오 발행이 중단되었습니다.', diff --git a/src/core/message/MessageBuilder.spec.ts b/src/core/message/MessageBuilder.spec.ts index 69be461..5c10656 100644 --- a/src/core/message/MessageBuilder.spec.ts +++ b/src/core/message/MessageBuilder.spec.ts @@ -1,30 +1,16 @@ -import { Test } from '@nestjs/testing'; import { advanceTo, clear } from 'jest-date-mock'; import { MessageBuilder } from '@sight/core/message/MessageBuilder'; describe('MessageBuilder', () => { - let messageBuilder: MessageBuilder; - - beforeAll(async () => { - advanceTo(new Date()); - - const testModule = await Test.createTestingModule({ - providers: [MessageBuilder], - }).compile(); - - messageBuilder = testModule.get(MessageBuilder); - }); - - afterAll(() => { - clear(); - }); + beforeEach(() => advanceTo(new Date())); + afterEach(() => clear()); test('파라미터가 없는 메시지는 그대로 반환해야 한다', () => { const GIVEN_MESSAGE = 'Hello, World!'; const expected = GIVEN_MESSAGE; - const actual = messageBuilder.build(GIVEN_MESSAGE, {}); + const actual = MessageBuilder.build(GIVEN_MESSAGE, {}); expect(actual).toEqual(expected); }); @@ -33,7 +19,7 @@ describe('MessageBuilder', () => { const GIVEN_MESSAGE = 'Hello, :username:!'; const expected = 'Hello, Lery!'; - const actual = messageBuilder.build(GIVEN_MESSAGE, { username: 'Lery' }); + const actual = MessageBuilder.build(GIVEN_MESSAGE, { username: 'Lery' }); expect(actual).toEqual(expected); }); @@ -42,7 +28,7 @@ describe('MessageBuilder', () => { const GIVEN_MESSAGE = ':some1: :some2: :some3: :some4: :some5:'; const expected = `1 2 3 4 5`; - const actual = messageBuilder.build(GIVEN_MESSAGE, { + const actual = MessageBuilder.build(GIVEN_MESSAGE, { some1: '1', some2: '2', some3: '3', @@ -57,7 +43,7 @@ describe('MessageBuilder', () => { const GIVEN_MESSAGE = ':some1: :some2: :some1: :some3: :some4:'; const expected = `1 2 1 3 4`; - const actual = messageBuilder.build(GIVEN_MESSAGE, { + const actual = MessageBuilder.build(GIVEN_MESSAGE, { some1: '1', some2: '2', some3: '3', diff --git a/src/core/message/MessageBuilder.ts b/src/core/message/MessageBuilder.ts index e889d56..4b585ad 100644 --- a/src/core/message/MessageBuilder.ts +++ b/src/core/message/MessageBuilder.ts @@ -1,5 +1,3 @@ -import { Injectable } from '@nestjs/common'; - type ExtractParams = T extends `${infer A}:${infer Param}:${infer B}` ? { [key in Param]: string } & ExtractParams<`${A}${B}`> @@ -8,9 +6,11 @@ type ExtractParams = // eslint-disable-next-line @typescript-eslint/ban-types type Prettify = {} & { [P in keyof T]: T[P] }; -@Injectable() export class MessageBuilder { - build(message: T, params: Prettify>) { + static build( + message: T, + params: Prettify>, + ) { return message.replace(/:(\w+):/g, (_, key) => params[key]); } } diff --git a/src/util/types.ts b/src/util/types.ts index ee35ac1..d4c0ed3 100644 --- a/src/util/types.ts +++ b/src/util/types.ts @@ -3,3 +3,5 @@ export type ToUnion = T[keyof T]; export type IsAsyncFunction = T extends (...args: any[]) => Promise ? true : false; + +export type Typeof = T;