From c531803c7a9b52ab21308b2b361f5c14540435cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=98=81=EC=9A=B0?= Date: Thu, 13 Jun 2024 11:06:51 +0900 Subject: [PATCH 1/4] =?UTF-8?q?test:=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20=EC=99=B8=EB=B6=80=EB=A7=81=ED=81=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20API=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/test/project/ws-link.e2e-spec.ts | 91 ++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 backend/test/project/ws-link.e2e-spec.ts diff --git a/backend/test/project/ws-link.e2e-spec.ts b/backend/test/project/ws-link.e2e-spec.ts new file mode 100644 index 0000000..7dd9888 --- /dev/null +++ b/backend/test/project/ws-link.e2e-spec.ts @@ -0,0 +1,91 @@ +import { + app, + appInit, + connectServer, + createMember, + createProject, + getProjectLinkId, + joinProject, + memberFixture, + memberFixture2, + projectPayload, +} from 'test/setup'; +import { + emitJoinLanding, + expectUpdatedMemberStatus, + handleConnectErrorWithReject, + handleErrorWithReject, + initLanding, + initLandingAndReturnId, +} from './ws-common'; + +describe('WS link', () => { + beforeEach(async () => { + await app.close(); + await appInit(); + await app.listen(3000); + }); + describe('link create', () => { + it('should return created link data when received create link request', async () => { + let socket1; + let socket2; + return new Promise(async (resolve, reject) => { + // 회원1 회원가입 + 프로젝트 생성 + const accessToken = (await createMember(memberFixture, app)) + .accessToken; + const project = await createProject(accessToken, projectPayload, app); + const projectLinkId = await getProjectLinkId(accessToken, project.id); + + // 회원2 회원가입 + 프로젝트 참여 + const accessToken2 = (await createMember(memberFixture2, app)) + .accessToken; + await joinProject(accessToken2, projectLinkId); + + socket1 = connectServer(project.id, accessToken); + handleConnectErrorWithReject(socket1, reject); + handleErrorWithReject(socket1, reject); + await emitJoinLanding(socket1); + await initLanding(socket1); + + socket2 = connectServer(project.id, accessToken2); + handleConnectErrorWithReject(socket2, reject); + handleErrorWithReject(socket2, reject); + await emitJoinLanding(socket2); + const memberId2 = await initLandingAndReturnId(socket2); + await expectUpdatedMemberStatus(socket1, 'on', memberId2); + + const url = '유효한 url'; + const description = '피그마'; + const requestData = { + action: 'create', + content: { url, description }, + }; + socket1.emit('link', requestData); + await Promise.all([ + expectCreateLink(socket1, url, description), + expectCreateLink(socket2, url, description), + ]); + resolve(); + }).finally(() => { + socket1.close(); + socket2.close(); + }); + }); + + const expectCreateLink = (socket, url, description) => { + return new Promise((resolve, reject) => { + socket.on('landing', async (data) => { + const { content, action, domain } = data; + expect(domain).toBe('link'); + expect(action).toBe('create'); + expect(content?.description).toBe(description); + expect(content?.id).toBeDefined(); + expect(content?.url).toBe(url); + expect(content?.description).toBe(description); + socket.off('landing'); + resolve(); + }); + }); + }; + }); +}); From 4e07a19bc6e09f7b81eae54ab29bcb3dd8d3a88b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=98=81=EC=9A=B0?= Date: Thu, 13 Jun 2024 11:09:18 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20=EC=99=B8=EB=B6=80=EB=A7=81=ED=81=AC=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=ED=8B=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 프로젝트 외부링크 엔티티 추가 - 링크 엔티티를 TypeOrm, NestJS 모듈에 등록 --- backend/src/app.module.ts | 2 + backend/src/project/entity/link.entity..ts | 43 ++++++++++++++++++++ backend/src/project/entity/project.entity.ts | 4 ++ backend/src/project/project.module.ts | 3 +- 4 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 backend/src/project/entity/link.entity..ts diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 5983db0..35feb21 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -28,6 +28,7 @@ import { ErrorExceptionFilter } from './common/exception-filter/exception.filter import { AuthenticationGuard } from './common/guard/authentication.guard'; import { MemberRepository } from './member/repository/member.repository'; import { Memo } from './project/entity/memo.entity'; +import { Link } from './project/entity/link.entity.'; @Module({ imports: [ @@ -47,6 +48,7 @@ import { Memo } from './project/entity/memo.entity'; Project, ProjectToMember, Memo, + Link, ], synchronize: ConfigService.get('NODE_ENV') == 'PROD' ? false : true, }), diff --git a/backend/src/project/entity/link.entity..ts b/backend/src/project/entity/link.entity..ts new file mode 100644 index 0000000..4ecde83 --- /dev/null +++ b/backend/src/project/entity/link.entity..ts @@ -0,0 +1,43 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { Project } from './project.entity'; + +@Entity() +export class Link { + @PrimaryGeneratedColumn('increment', { type: 'int' }) + id: number; + + @Column({ type: 'int', name: 'project_id' }) + projectId: number; + + @ManyToOne(() => Project, (project) => project.id, { nullable: false }) + @JoinColumn({ name: 'project_id' }) + project: Project; + + @Column({ type: 'varchar', length: 255, nullable: false }) + url: string; + + @Column({ type: 'varchar', length: 255, nullable: false }) + description: string; + + @CreateDateColumn({ type: 'timestamp' }) + created_at: Date; + + @UpdateDateColumn({ type: 'timestamp' }) + updated_at: Date; + + static of(project: Project, url: string, description: string) { + const newLink = new Link(); + newLink.project = project; + newLink.url = url; + newLink.description = description; + return newLink; + } +} diff --git a/backend/src/project/entity/project.entity.ts b/backend/src/project/entity/project.entity.ts index 11eed16..c6698cb 100644 --- a/backend/src/project/entity/project.entity.ts +++ b/backend/src/project/entity/project.entity.ts @@ -8,6 +8,7 @@ import { Generated, JoinColumn, } from 'typeorm'; +import { Link } from './link.entity.'; import { Memo } from './memo.entity'; import { ProjectToMember } from './project-member.entity'; @@ -41,6 +42,9 @@ export class Project { @OneToMany(() => Memo, (memo) => memo.id) memoList: Memo[]; + @OneToMany(() => Link, (link) => link.id) + linkList: Link[]; + static of(title: string, subject: string) { const newProject = new Project(); newProject.title = title; diff --git a/backend/src/project/project.module.ts b/backend/src/project/project.module.ts index 22f3d94..fc3b301 100644 --- a/backend/src/project/project.module.ts +++ b/backend/src/project/project.module.ts @@ -11,11 +11,12 @@ import { MemberRepository } from 'src/member/repository/member.repository'; import { ProjectWebsocketGateway } from './websocket.gateway'; import { Memo } from './entity/memo.entity'; import { MemberService } from 'src/member/service/member.service'; +import { Link } from './entity/link.entity.'; @Module({ imports: [ LesserJwtModule, - TypeOrmModule.forFeature([Project, ProjectToMember, Member, Memo]), + TypeOrmModule.forFeature([Project, ProjectToMember, Member, Memo, Link]), ], controllers: [ProjectController], providers: [ From 77ecba5551f60d43f5893d42d32b98846a25bdf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=98=81=EC=9A=B0?= Date: Thu, 13 Jun 2024 11:09:51 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20=EB=A7=81=ED=81=AC=20=EC=B6=94=EA=B0=80=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 프로젝트 링크 추가 레포지토리 메서드 구현 - 프로젝트 링크 추가 서비스 메서드 구현 --- backend/src/project/project.repository.ts | 7 ++++++ .../project/service/project.service.spec.ts | 22 +++++++++++++++++++ .../src/project/service/project.service.ts | 6 +++++ 3 files changed, 35 insertions(+) diff --git a/backend/src/project/project.repository.ts b/backend/src/project/project.repository.ts index 596d9dd..82029cf 100644 --- a/backend/src/project/project.repository.ts +++ b/backend/src/project/project.repository.ts @@ -6,6 +6,7 @@ import { ProjectToMember } from './entity/project-member.entity'; import { Member } from 'src/member/entity/member.entity'; import { Memo, memoColor } from './entity/memo.entity'; import { MemberRepository } from 'src/member/repository/member.repository'; +import { Link } from './entity/link.entity.'; @Injectable() export class ProjectRepository { @@ -18,6 +19,8 @@ export class ProjectRepository { private readonly memberRepository: Repository, @InjectRepository(Memo) private readonly memoRepository: Repository, + @InjectRepository(Link) + private readonly linkRepository: Repository, ) {} create(project: Project): Promise { @@ -89,4 +92,8 @@ export class ProjectRepository { where: { id }, }); } + + createLink(link: Link): Promise { + return this.linkRepository.save(link); + } } diff --git a/backend/src/project/service/project.service.spec.ts b/backend/src/project/service/project.service.spec.ts index 9b71cbb..e6be132 100644 --- a/backend/src/project/service/project.service.spec.ts +++ b/backend/src/project/service/project.service.spec.ts @@ -5,6 +5,7 @@ import { Member } from 'src/member/entity/member.entity'; import { Project } from '../entity/project.entity'; import { ProjectToMember } from '../entity/project-member.entity'; import { Memo, memoColor } from '../entity/memo.entity'; +import { Link } from '../entity/link.entity.'; describe('ProjectService', () => { let projectService: ProjectService; @@ -28,6 +29,7 @@ describe('ProjectService', () => { updateMemoColor: jest.fn(), findMemoById: jest.fn(), getProjectMemberList: jest.fn(), + createLink: jest.fn(), }, }, ], @@ -263,4 +265,24 @@ describe('ProjectService', () => { ).rejects.toThrow('project does not have this memo'); }); }); + + describe('Create link', () => { + const [title, subject] = ['title', 'subject']; + const project = Project.of(title, subject); + it('should return created link', async () => { + const url = 'url'; + const description = 'description'; + jest + .spyOn(projectRepository, 'createLink') + .mockResolvedValue(Link.of(project, url, description)); + + const link: Link = await projectService.createLink( + project, + url, + description, + ); + expect(link.url).toBe(url); + expect(link.description).toBe(description); + }); + }); }); diff --git a/backend/src/project/service/project.service.ts b/backend/src/project/service/project.service.ts index 0955f2f..d163961 100644 --- a/backend/src/project/service/project.service.ts +++ b/backend/src/project/service/project.service.ts @@ -4,6 +4,7 @@ import { Member } from 'src/member/entity/member.entity'; import { Project } from '../entity/project.entity'; import { ProjectToMember } from '../entity/project-member.entity'; import { Memo, memoColor } from '../entity/memo.entity'; +import { Link } from '../entity/link.entity.'; @Injectable() export class ProjectService { @@ -76,4 +77,9 @@ export class ProjectService { await this.projectRepository.updateMemoColor(id, color); return true; } + + createLink(project: Project, url: string, description: string) { + const newLink = Link.of(project, url, description); + return this.projectRepository.createLink(newLink); + } } From fd38a6a4230bccfeabf8731461a966c1f361de30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=98=81=EC=9A=B0?= Date: Thu, 13 Jun 2024 11:10:24 +0900 Subject: [PATCH 4/4] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20=EB=A7=81=ED=81=AC=EC=B6=94=EA=B0=80=20API=20?= =?UTF-8?q?=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 웹소켓 게이트웨이에 프로젝트 링크추가 API 구현 - DTO추가 --- .../src/project/dto/LinkCreateRequest.dto.ts | 20 ++++++++++++ backend/src/project/websocket.gateway.ts | 31 +++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 backend/src/project/dto/LinkCreateRequest.dto.ts diff --git a/backend/src/project/dto/LinkCreateRequest.dto.ts b/backend/src/project/dto/LinkCreateRequest.dto.ts new file mode 100644 index 0000000..dee49be --- /dev/null +++ b/backend/src/project/dto/LinkCreateRequest.dto.ts @@ -0,0 +1,20 @@ +import { Type } from 'class-transformer'; +import { IsNotEmpty, IsString, Matches, ValidateNested } from 'class-validator'; + +class Link { + @IsString() + url: string; + + @IsString() + description: string; +} + +export class LinkCreateRequestDto { + @Matches(/^create$/) + action: string; + + @IsNotEmpty() + @ValidateNested() + @Type(() => Link) + content: Link; +} diff --git a/backend/src/project/websocket.gateway.ts b/backend/src/project/websocket.gateway.ts index 4483ec3..ca1b969 100644 --- a/backend/src/project/websocket.gateway.ts +++ b/backend/src/project/websocket.gateway.ts @@ -23,6 +23,7 @@ import { InitLandingResponseDto } from './dto/InitLandingResponse.dto'; import { MemoColorUpdateRequestDto } from './dto/MemoColorUpdateRequest.dto'; import { MemberUpdateRequestDto } from './dto/MemberUpdateRequest.dto'; import { MemberStatus } from './enum/MemberStatus.enum'; +import { LinkCreateRequestDto } from './dto/LinkCreateRequest.dto'; export interface ClientSocket extends Socket { projectId?: number; @@ -252,6 +253,36 @@ export class ProjectWebsocketGateway this.sendMemberStatusUpdate(client); } + @SubscribeMessage('link') + async handleLinkEvent( + @ConnectedSocket() client: ClientSocket, + @MessageBody() data: LinkCreateRequestDto, + ) { + if (data.action === 'create') { + const errors = await validate(plainToClass(LinkCreateRequestDto, data)); + if (errors.length > 0) { + const errorList = this.getRecursiveErrorMsgList(errors); + client.emit('error', { errorList }); + return; + } + const { content } = data as LinkCreateRequestDto; + const createLink = await this.projectService.createLink( + client.project, + content.url, + content.description, + ); + client.nsp.to('landing').emit('landing', { + domain: 'link', + action: 'create', + content: { + id: createLink.id, + url: createLink.url, + description: createLink.description, + }, + }); + } + } + notifyJoinToConnectedMembers(projectId: number, member: Member) { const projectNamespace = this.namespaceMap.get(projectId); if (!projectNamespace) return;