Skip to content

Commit

Permalink
feat: 그룹 포트폴리오 활성화 구현 (#48)
Browse files Browse the repository at this point in the history
* feat: 그룹 포트폴리오 활성화 구현

* refactor: 임포트 순서 수정
  • Loading branch information
Coalery authored Jan 13, 2024
1 parent eb34281 commit cf0d9ed
Show file tree
Hide file tree
Showing 11 changed files with 302 additions and 1 deletion.
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export class EnablePortfolioCommand {
constructor(
readonly groupId: string,
readonly requesterUserId: string,
) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { Test } from '@nestjs/testing';
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';

describe('EnablePortfolioCommandHandler', () => {
let handler: EnablePortfolioCommandHandler;
let groupRepository: jest.Mocked<IGroupRepository>;

beforeAll(async () => {
advanceTo(new Date());

const testModule = await Test.createTestingModule({
providers: [
EnablePortfolioCommandHandler,
{ provide: GroupRepository, useValue: {} },
],
}).compile();

handler = testModule.get(EnablePortfolioCommandHandler);
groupRepository = testModule.get(GroupRepository);
});

afterAll(() => {
clear();
});

describe('handle', () => {
let group: Group;

const groupId = 'groupId';
const groupAdminUserId = 'groupAdminUserId';

beforeEach(() => {
group = DomainFixture.generateGroup({
adminUserId: groupAdminUserId,
hasPortfolio: false,
});

groupRepository.findById = jest.fn().mockResolvedValue(group);

groupRepository.save = jest.fn();
});

test('그룹이 존재하지 않는다면 예외를 발생시켜야 한다', async () => {
groupRepository.findById = jest.fn().mockResolvedValue(null);

await expect(
handler.execute(new EnablePortfolioCommand(groupId, groupAdminUserId)),
).rejects.toThrowError(Message.GROUP_NOT_FOUND);
});

test('요청자가 그룹장이 아니라면 예외를 발생시켜야 한다', async () => {
const otherUserId = 'otherUserId';

await expect(
handler.execute(new EnablePortfolioCommand(groupId, otherUserId)),
).rejects.toThrowError(Message.ONLY_GROUP_ADMIN_CAN_EDIT_GROUP);
});

test('그룹이 고객센터 그룹이라면 예외를 발생시켜야 한다', async () => {
jest.spyOn(group, 'isCustomerServiceGroup').mockReturnValue(true);

await expect(
handler.execute(new EnablePortfolioCommand(groupId, groupAdminUserId)),
).rejects.toThrowError(Message.CANNOT_MODIFY_CUSTOMER_SERVICE_GROUP);
});

test('그룹의 포트폴리오를 활성화 시켜야 한다', async () => {
jest.spyOn(group, 'enablePortfolio');

await handler.execute(
new EnablePortfolioCommand(groupId, groupAdminUserId),
);

expect(group.enablePortfolio).toBeCalled();
});

test('그룹을 저장해야 한다', async () => {
await handler.execute(
new EnablePortfolioCommand(groupId, groupAdminUserId),
);

expect(groupRepository.save).toBeCalledWith(group);
expect(groupRepository.save).toBeCalledTimes(1);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import {
ForbiddenException,
Inject,
NotFoundException,
UnprocessableEntityException,
} from '@nestjs/common';

import { Transactional } from '@sight/core/persistence/transaction/Transactional';

import { EnablePortfolioCommand } from '@sight/app/application/group/command/enablePortfolio/EnablePortfolioCommand';

import {
GroupRepository,
IGroupRepository,
} from '@sight/app/domain/group/IGroupRepository';

import { Message } from '@sight/constant/message';

@CommandHandler(EnablePortfolioCommand)
export class EnablePortfolioCommandHandler
implements ICommandHandler<EnablePortfolioCommand>
{
constructor(
@Inject(GroupRepository)
private readonly groupRepository: IGroupRepository,
) {}

@Transactional()
async execute(command: EnablePortfolioCommand): Promise<void> {
const { groupId, requesterUserId } = command;

const group = await this.groupRepository.findById(groupId);
if (!group) {
throw new NotFoundException(Message.GROUP_NOT_FOUND);
}

if (group.adminUserId !== requesterUserId) {
throw new ForbiddenException(Message.ONLY_GROUP_ADMIN_CAN_EDIT_GROUP);
}

if (group.isCustomerServiceGroup()) {
throw new UnprocessableEntityException(
Message.CANNOT_MODIFY_CUSTOMER_SERVICE_GROUP,
);
}

group.enablePortfolio();
await this.groupRepository.save(group);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,10 @@ describe('ModifyGroupCommandHandler', () => {

beforeEach(() => {
command = new ModifyGroupCommand(groupId, requesterUserId, params);
group = DomainFixture.generateGroup({ adminUserId: requesterUserId });
group = DomainFixture.generateGroup({
category: GroupCategory.STUDY,
adminUserId: requesterUserId,
});
const groupMember = DomainFixture.generateGroupMember();
const interests = params.interestIds.map((interestId) =>
DomainFixture.generateInterest({ id: interestId }),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// TODO: 핸들러의 구현 방향성이 잡히고, 리팩토링이 완료되면 테스트 작성 예정
describe('GroupPortfolioEnabledHandler', () => {
test.todo('그룹이 존재하지 않으면 아무것도 하지 않아야 한다');

test.todo('모든 그룹 멤버들에게 포인트를 지급해야 한다');

test.todo('그룹 로그를 생성해야 한다');

test.todo('메시지를 전송해야 한다');
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
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 { GroupLogFactory } from '@sight/app/domain/group/GroupLogFactory';
import { SlackMessageCategory } from '@sight/app/domain/message/model/constant';
import {
ISlackSender,
SlackSender,
} from '@sight/app/domain/adapter/ISlackSender';
import {
GroupLogRepository,
IGroupLogRepository,
} from '@sight/app/domain/group/IGroupLogRepository';
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';

@EventsHandler(GroupPortfolioEnabled)
export class GroupPortfolioEnabledHandler
implements IEventHandler<GroupPortfolioEnabled>
{
constructor(
@Inject(GroupRepository)
private readonly groupRepository: IGroupRepository,
@Inject(GroupMemberRepository)
private readonly groupMemberRepository: IGroupMemberRepository,
@Inject(GroupLogFactory)
private readonly groupLogFactory: GroupLogFactory,
@Inject(GroupLogRepository)
private readonly groupLogRepository: IGroupLogRepository,
@Inject(UserRepository)
private readonly userRepository: IUserRepository,
@Inject(SlackSender)
private readonly slackSender: ISlackSender,
) {}

@Transactional()
async handle(event: GroupPortfolioEnabled): Promise<void> {
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,
`<u>${group.title}</u> 그룹의 포트폴리오가 발행되었습니다.`,
),
);
await this.userRepository.save(...users);

// TODO: 그룹 로거를 만들어서 사용하도록 수정
const groupLog = this.groupLogFactory.create({
id: this.groupLogRepository.nextId(),
groupId,
userId: '', // TODO: 요청자 정보에 접근할 수 있을 때 수정
message: '포트폴리오가 발행 중입니다.',
});
await this.groupLogRepository.save(groupLog);

this.slackSender.send({
category: SlackMessageCategory.GROUP_ACTIVITY,
message: `<a href="/group/'${groupId}'"><u>${group.title}</u></a> 그룹의 <a href="/folio/'${groupId}'" target="_blank">포트폴리오</a>가 발행 중입니다.`,
sourceUserId: null,
targetUserId: '', // TODO: 요청자 정보에 접근할 수 있을 때 수정
});
}
}
3 changes: 3 additions & 0 deletions src/app/domain/group/event/GroupPortfolioEnabled.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export class GroupPortfolioEnabled {
constructor(readonly groupId: string) {}
}
20 changes: 20 additions & 0 deletions src/app/domain/group/model/Group.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,26 @@ describe('Group', () => {
});
});

describe('enablePortfolio', () => {
test('포트폴리오를 활성화해야 한다', () => {
const group = DomainFixture.generateGroup({
hasPortfolio: false,
});

group.enablePortfolio();

expect(group.hasPortfolio).toEqual(true);
});

test('이미 포트폴리오가 활성화되어 있다면 예외를 발생시켜야 한다', () => {
const group = DomainFixture.generateGroup({
hasPortfolio: true,
});

expect(() => group.enablePortfolio()).toThrow();
});
});

describe('isEditable', () => {
test('그룹이 종료된 상태라면 false를 반환해야 한다', () => {
const group = DomainFixture.generateGroup({
Expand Down
18 changes: 18 additions & 0 deletions src/app/domain/group/model/Group.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
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 {
Expand All @@ -9,6 +11,7 @@ import {
GroupState,
PRACTICE_GROUP_ID,
} from '@sight/app/domain/group/model/constant';
import { Message } from '@sight/constant/message';

import { isDifferentStringArray } from '@sight/util/isDifferentStringArray';

Expand Down Expand Up @@ -109,6 +112,7 @@ export class Group extends AggregateRoot {
updateGrade(grade: GroupAccessGrade): void {
if (this._grade !== grade) {
this._grade = grade;
this._updatedAt = new Date();
this.wake();
this.apply(new GroupUpdated(this.id, 'grade'));
}
Expand Down Expand Up @@ -155,7 +159,21 @@ export class Group extends AggregateRoot {
wake(): void {
if (this._state === GroupState.SUSPEND) {
this._state = GroupState.PROGRESS;
this._updatedAt = new Date();
}
}

enablePortfolio(): void {
if (this._hasPortfolio) {
throw new UnprocessableEntityException(
Message.ALREADY_GROUP_ENABLED_PORTFOLIO,
);
}

this._hasPortfolio = true;
this._updatedAt = new Date();

this.apply(new GroupPortfolioEnabled(this.id));
}

isEditable(): boolean {
Expand Down
2 changes: 2 additions & 0 deletions src/constant/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,6 @@ export const Message = {
UNITED_USER_CAN_ONLY_CHANGE_EMAIL: 'United user can only change email',
GROUP_NOT_EDITABLE: 'Group is not editable',
CANNOT_MODIFY_CUSTOMER_SERVICE_GROUP: 'Cannot modify customer service group',
ALREADY_GROUP_ENABLED_PORTFOLIO: 'Already group enabled portfolio',
ALREADY_GROUP_DISABLED_PORTFOLIO: 'Already group disabled portfolio',
};
1 change: 1 addition & 0 deletions src/constant/point.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ export const Point = {
GROUP_CREATED: 20,
GROUP_ENDED_WITH_SUCCESS: 50,
GROUP_ENDED_WITH_FAIL: 30,
GROUP_ENABLED_PORTFOLIO: 10,
};

0 comments on commit cf0d9ed

Please sign in to comment.