From c13ae07b6cbff3315e7883d1abad161e5f479e9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=98=81=EC=9A=B0?= Date: Tue, 30 Jul 2024 15:07:51 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=EC=97=90=ED=94=BD=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1,=20=EC=88=98=EC=A0=95=20API=EC=97=90=20=EC=9A=B0?= =?UTF-8?q?=EC=84=A0=EC=88=9C=EC=9C=84=20=EB=8D=B0=EC=9D=B4=ED=84=B0(rankV?= =?UTF-8?q?alue)=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lexorank 패키지 설치 - epic 엔티티 - rankValue 프로퍼티 추가 - 에픽의 rankValue가 project에서 고유하도록 유니크 제약조건 추가 - project 레포지토리, project 서비스, epic컨트롤러에 rankValue 정보 추가 - Epic DTO에 rankValue 정보 추가 - LexoRank형식인지 검증할 수 있는 IsLexoRankValue 데코레이터 추가 - E2E 테스트 - 에픽, 스토리, 태스크, 백로그 테스트에 rankValue정보 추가 - 에픽 테스트에 rankValue update테스트 추가 --- backend/package-lock.json | 67 ++++++++++++------- backend/package.json | 1 + .../src/common/decorator/IsLexoRankValue.ts | 18 +++++ .../project/dto/epic/EpicCreateNotify.dto.ts | 9 ++- .../project/dto/epic/EpicCreateRequest.dto.ts | 6 ++ .../project/dto/epic/EpicUpdateNotify.dto.ts | 8 ++- .../project/dto/epic/EpicUpdateRequest.dto.ts | 8 ++- backend/src/project/entity/epic.entity.ts | 13 +++- backend/src/project/project.repository.ts | 5 ++ .../src/project/service/project.service.ts | 24 +++++-- .../ws-project-epic.controller.ts | 10 ++- .../ws-backlog-page/ws-epic.e2e-spec.ts | 66 ++++++++++++++++-- .../ws-init-backlog.e2e-spec.ts | 8 +-- .../ws-backlog-page/ws-story.e2e-spec.ts | 15 +++-- .../ws-backlog-page/ws-task.e2e-spec.ts | 14 ++-- 15 files changed, 212 insertions(+), 60 deletions(-) create mode 100644 backend/src/common/decorator/IsLexoRankValue.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index dd3fbbe..9145877 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -20,6 +20,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "cookie-parser": "^1.4.6", + "lexorank": "^1.0.5", "mysql2": "^3.9.1", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1", @@ -3044,6 +3045,14 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "dev": true }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.1.tgz", + "integrity": "sha512-+H+kuK34PfMaI9PNU/NSjBKL5hh/KDM9J72kwYeYEm0A8B1AC4fuCy3qsjnA7lxklgyXsB68yn8Z2xoZEjgwCQ==", + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/babel-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", @@ -3271,12 +3280,12 @@ } }, "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "dependencies": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" }, "engines": { "node": ">=8" @@ -4187,9 +4196,9 @@ } }, "node_modules/engine.io": { - "version": "6.5.4", - "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.5.4.tgz", - "integrity": "sha512-KdVSDKhVKyOi+r5uEabrDLZw2qXStVvCsEB/LN3mw4WFi6Gx50jTyuxYVCwAAC0U46FdnzP/ScKRBTXb/NiEOg==", + "version": "6.5.5", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.5.5.tgz", + "integrity": "sha512-C5Pn8Wk+1vKBoHghJODM63yk8MvrO9EWZUfkAt5HAqIgPE4/8FF0PEGHXtEd40l223+cE5ABWuPzm38PHFXfMA==", "dependencies": { "@types/cookie": "^0.4.1", "@types/cors": "^2.8.12", @@ -4200,22 +4209,22 @@ "cors": "~2.8.5", "debug": "~4.3.1", "engine.io-parser": "~5.2.1", - "ws": "~8.11.0" + "ws": "~8.17.1" }, "engines": { "node": ">=10.2.0" } }, "node_modules/engine.io-client": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.5.3.tgz", - "integrity": "sha512-9Z0qLB0NIisTRt1DZ/8U2k12RJn8yls/nXMZLn+/N8hANT3TcYjKFKcwbw5zFQiN4NTde3TSY9zb79e1ij6j9Q==", + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.5.4.tgz", + "integrity": "sha512-GeZeeRjpD2qf49cZQ0Wvh/8NJNfeXkXXcoGh+F77oEAgo9gUHwT1fCRxSNU+YEEaysOJTnsFHmM5oAcPy4ntvQ==", "dev": true, "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.3.1", "engine.io-parser": "~5.2.1", - "ws": "~8.11.0", + "ws": "~8.17.1", "xmlhttprequest-ssl": "~2.0.0" } }, @@ -4785,9 +4794,9 @@ } }, "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, "dependencies": { "to-regex-range": "^5.0.1" @@ -6551,6 +6560,11 @@ "node": ">= 0.8.0" } }, + "node_modules/lexorank": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/lexorank/-/lexorank-1.0.5.tgz", + "integrity": "sha512-K1B/Yr/gIU0wm68hk/yB0p/mv6xM3ShD5aci42vOwcjof8slG8Kpo3Q7+1WTv7DaRHKWRgLPqrFDt+4GtuFAtA==" + }, "node_modules/libphonenumber-js": { "version": "1.10.58", "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.10.58.tgz", @@ -6892,10 +6906,11 @@ "dev": true }, "node_modules/mysql2": { - "version": "3.9.1", - "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.9.1.tgz", - "integrity": "sha512-3njoWAAhGBYy0tWBabqUQcLtczZUxrmmtc2vszQUekg3kTJyZ5/IeLC3Fo04u6y6Iy5Sba7pIIa2P/gs8D3ZeQ==", + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.11.0.tgz", + "integrity": "sha512-J9phbsXGvTOcRVPR95YedzVSxJecpW5A5+cQ57rhHIFXteTP10HCs+VBjS7DHIKfEaI1zQ5tlVrquCd64A6YvA==", "dependencies": { + "aws-ssl-profiles": "^1.1.1", "denque": "^2.1.0", "generate-function": "^2.3.1", "iconv-lite": "^0.6.3", @@ -8214,12 +8229,12 @@ } }, "node_modules/socket.io-adapter": { - "version": "2.5.4", - "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.4.tgz", - "integrity": "sha512-wDNHGXGewWAjQPt3pyeYBtpWSq9cLE5UW1ZUPL/2eGK9jtse/FpXib7epSTsz0Q0m+6sg6Y4KtcFTlah1bdOVg==", + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.5.tgz", + "integrity": "sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==", "dependencies": { "debug": "~4.3.4", - "ws": "~8.11.0" + "ws": "~8.17.1" } }, "node_modules/socket.io-client": { @@ -9481,15 +9496,15 @@ "dev": true }, "node_modules/ws": { - "version": "8.11.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz", - "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==", + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", "engines": { "node": ">=10.0.0" }, "peerDependencies": { "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" + "utf-8-validate": ">=5.0.2" }, "peerDependenciesMeta": { "bufferutil": { diff --git a/backend/package.json b/backend/package.json index 9328e96..58c3b18 100644 --- a/backend/package.json +++ b/backend/package.json @@ -33,6 +33,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "cookie-parser": "^1.4.6", + "lexorank": "^1.0.5", "mysql2": "^3.9.1", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1", diff --git a/backend/src/common/decorator/IsLexoRankValue.ts b/backend/src/common/decorator/IsLexoRankValue.ts new file mode 100644 index 0000000..362b848 --- /dev/null +++ b/backend/src/common/decorator/IsLexoRankValue.ts @@ -0,0 +1,18 @@ +import { registerDecorator } from 'class-validator'; + +export function IsLexoRankValue() { + return function (object: Object, propertyName: string) { + registerDecorator({ + name: 'IsLexoRankValue', + target: object.constructor, + propertyName: propertyName, + options: { message: 'invalid LexoRank format' }, + validator: { + validate(value: any) { + const lexorankPattern = new RegExp(`^[012]\\|.*`, 'i'); + return lexorankPattern.test(value); + }, + }, + }); + }; +} diff --git a/backend/src/project/dto/epic/EpicCreateNotify.dto.ts b/backend/src/project/dto/epic/EpicCreateNotify.dto.ts index f3f730b..c90c4b8 100644 --- a/backend/src/project/dto/epic/EpicCreateNotify.dto.ts +++ b/backend/src/project/dto/epic/EpicCreateNotify.dto.ts @@ -4,11 +4,14 @@ class Epic { id: number; name: string; color: EpicColor; - static of(id: number, name: string, color: EpicColor) { + rankValue: string; + + static of(id: number, name: string, color: EpicColor, rankValue: string) { const dto = new Epic(); dto.id = id; dto.name = name; dto.color = color; + dto.rankValue = rankValue; return dto; } } @@ -18,11 +21,11 @@ export class EpicCreateNotifyDto { action: string; content: Epic; - static of(id: number, name: string, color: EpicColor) { + static of(id: number, name: string, color: EpicColor, rankValue: string) { const dto = new EpicCreateNotifyDto(); dto.domain = 'epic'; dto.action = 'create'; - dto.content = Epic.of(id, name, color); + dto.content = Epic.of(id, name, color, rankValue); return dto; } } diff --git a/backend/src/project/dto/epic/EpicCreateRequest.dto.ts b/backend/src/project/dto/epic/EpicCreateRequest.dto.ts index 4acff8f..07a1f6e 100644 --- a/backend/src/project/dto/epic/EpicCreateRequest.dto.ts +++ b/backend/src/project/dto/epic/EpicCreateRequest.dto.ts @@ -7,6 +7,7 @@ import { Matches, ValidateNested, } from 'class-validator'; +import { IsLexoRankValue } from 'src/common/decorator/IsLexoRankValue'; import { EpicColor } from 'src/project/entity/epic.entity'; class Epic { @@ -16,6 +17,11 @@ class Epic { @IsEnum(EpicColor) color: EpicColor; + + @IsString() + @IsLexoRankValue() + @Length(2, 255) + rankValue: string; } export class EpicCreateRequestDto { diff --git a/backend/src/project/dto/epic/EpicUpdateNotify.dto.ts b/backend/src/project/dto/epic/EpicUpdateNotify.dto.ts index 9e76b9a..fe58e93 100644 --- a/backend/src/project/dto/epic/EpicUpdateNotify.dto.ts +++ b/backend/src/project/dto/epic/EpicUpdateNotify.dto.ts @@ -4,11 +4,13 @@ class Epic { id: number; name?: string; color?: EpicColor; - static of(id: number, name: string, color: EpicColor) { + rankValue?: string; + static of(id: number, name: string, color: EpicColor, rankValue: string) { const dto = new Epic(); dto.id = id; if (name !== undefined) dto.name = name; if (color !== undefined) dto.color = color; + if (rankValue !== undefined) dto.rankValue = rankValue; return dto; } } @@ -18,11 +20,11 @@ export class EpicUpdateNotifyDto { action: string; content: Epic; - static of(id: number, name: string, color: EpicColor) { + static of(id: number, name: string, color: EpicColor, rankValue: string) { const dto = new EpicUpdateNotifyDto(); dto.domain = 'epic'; dto.action = 'update'; - dto.content = Epic.of(id, name, color); + dto.content = Epic.of(id, name, color, rankValue); return dto; } } diff --git a/backend/src/project/dto/epic/EpicUpdateRequest.dto.ts b/backend/src/project/dto/epic/EpicUpdateRequest.dto.ts index d77c847..57f18f2 100644 --- a/backend/src/project/dto/epic/EpicUpdateRequest.dto.ts +++ b/backend/src/project/dto/epic/EpicUpdateRequest.dto.ts @@ -9,13 +9,14 @@ import { ValidateNested, Length, } from 'class-validator'; +import { IsLexoRankValue } from 'src/common/decorator/IsLexoRankValue'; import { EpicColor } from 'src/project/entity/epic.entity'; import { AtLeastOneProperty } from 'src/project/util/validation.util'; class Epic { @IsNotEmpty() @IsInt() - @AtLeastOneProperty(['name', 'color']) + @AtLeastOneProperty(['name', 'color', 'rankValue']) id: number; @IsOptional() @@ -26,6 +27,11 @@ class Epic { @IsOptional() @IsEnum(EpicColor) color?: EpicColor; + + @IsOptional() + @IsLexoRankValue() + @Length(2, 255) + rankValue?: string; } export class EpicUpdateRequestDto { diff --git a/backend/src/project/entity/epic.entity.ts b/backend/src/project/entity/epic.entity.ts index d0acec1..63fa4e7 100644 --- a/backend/src/project/entity/epic.entity.ts +++ b/backend/src/project/entity/epic.entity.ts @@ -6,6 +6,7 @@ import { ManyToOne, OneToMany, PrimaryGeneratedColumn, + Unique, UpdateDateColumn, } from 'typeorm'; import { Project } from './project.entity'; @@ -22,6 +23,7 @@ export enum EpicColor { } @Entity() +@Unique(['rankValue', 'projectId']) export class Epic { @PrimaryGeneratedColumn('increment', { type: 'int' }) id: number; @@ -51,11 +53,20 @@ export class Epic { @OneToMany(() => Story, (story) => story.epic) storyList: Story[]; - static of(project: Project, name: string, color: EpicColor) { + @Column({ type: 'varchar', length: 255, nullable: false, name: 'rank_value' }) + rankValue: string; + + static of( + project: Project, + name: string, + color: EpicColor, + rankValue: string, + ) { const newEpic = new Epic(); newEpic.project = project; newEpic.name = name; newEpic.color = color; + newEpic.rankValue = rankValue; return newEpic; } } diff --git a/backend/src/project/project.repository.ts b/backend/src/project/project.repository.ts index 30cbe54..a5a3245 100644 --- a/backend/src/project/project.repository.ts +++ b/backend/src/project/project.repository.ts @@ -134,6 +134,7 @@ export class ProjectRepository { id: number, name?: string, color?: EpicColor, + rankValue?: string, ): Promise { const updateData: any = {}; @@ -144,6 +145,10 @@ export class ProjectRepository { updateData.color = color; } + if (rankValue !== undefined) { + updateData.rankValue = rankValue; + } + const result = await this.epicRepository.update( { id, project: { id: project.id } }, updateData, diff --git a/backend/src/project/service/project.service.ts b/backend/src/project/service/project.service.ts index 2b727bf..9090f4b 100644 --- a/backend/src/project/service/project.service.ts +++ b/backend/src/project/service/project.service.ts @@ -95,8 +95,13 @@ export class ProjectService { return result ? true : false; } - createEpic(project: Project, name: string, color: EpicColor) { - const newEpic = Epic.of(project, name, color); + createEpic( + project: Project, + name: string, + color: EpicColor, + rankValue: string, + ) { + const newEpic = Epic.of(project, name, color, rankValue); return this.projectRepository.createEpic(newEpic); } @@ -110,8 +115,15 @@ export class ProjectService { id: number, name?: string, color?: EpicColor, + rankValue?: string, ): Promise { - return this.projectRepository.updateEpic(project, id, name, color); + return this.projectRepository.updateEpic( + project, + id, + name, + color, + rankValue, + ); } async createStory( @@ -211,8 +223,8 @@ export class ProjectService { assignedMemberId, ); } - - getProjectBacklog(project: Project){ - return this.projectRepository.getProjectBacklog(project); + + getProjectBacklog(project: Project) { + return this.projectRepository.getProjectBacklog(project); } } diff --git a/backend/src/project/ws-controller/ws-project-epic.controller.ts b/backend/src/project/ws-controller/ws-project-epic.controller.ts index 4841438..fc3dcfe 100644 --- a/backend/src/project/ws-controller/ws-project-epic.controller.ts +++ b/backend/src/project/ws-controller/ws-project-epic.controller.ts @@ -26,6 +26,7 @@ export class WsProjectEpicController { client.project, content.name, content.color, + content.rankValue, ); client.nsp .to('backlog') @@ -35,6 +36,7 @@ export class WsProjectEpicController { createdEpic.id, createdEpic.name, createdEpic.color, + createdEpic.rankValue, ), ); } @@ -71,6 +73,7 @@ export class WsProjectEpicController { content.id, content.name, content.color, + content.rankValue, ); if (isUpdated) { @@ -78,7 +81,12 @@ export class WsProjectEpicController { .to('backlog') .emit( 'backlog', - EpicUpdateNotifyDto.of(content.id, content.name, content.color), + EpicUpdateNotifyDto.of( + content.id, + content.name, + content.color, + content.rankValue, + ), ); } } diff --git a/backend/test/project/ws-backlog-page/ws-epic.e2e-spec.ts b/backend/test/project/ws-backlog-page/ws-epic.e2e-spec.ts index beccb4a..4d4a83a 100644 --- a/backend/test/project/ws-backlog-page/ws-epic.e2e-spec.ts +++ b/backend/test/project/ws-backlog-page/ws-epic.e2e-spec.ts @@ -1,3 +1,5 @@ +import { LexoRank } from 'lexorank'; +import { resolve } from 'path'; import { Socket } from 'socket.io-client'; import { app, appInit } from 'test/setup'; import { @@ -20,20 +22,21 @@ describe('WS epic', () => { await Promise.all([initBacklog(socket1), initBacklog(socket2)]); const name = '회원'; const color = 'yellow'; + const rankValue = LexoRank.middle().toString(); const requestData = { action: 'create', - content: { name, color }, + content: { name, color, rankValue }, }; socket1.emit('epic', requestData); await Promise.all([ - expectCreateEpic(socket1, name, color), - expectCreateEpic(socket2, name, color), + expectCreateEpic(socket1, name, color, rankValue), + expectCreateEpic(socket2, name, color, rankValue), ]); socket1.close(); socket2.close(); }); - const expectCreateEpic = (socket, name, color) => { + const expectCreateEpic = (socket, name, color, rankValue) => { return new Promise((resolve) => { socket.once('backlog', async (data) => { const { content, action, domain } = data; @@ -42,6 +45,7 @@ describe('WS epic', () => { expect(content?.id).toBeDefined(); expect(content?.name).toBe(name); expect(content?.color).toBe(color); + expect(content?.rankValue).toBe(rankValue); resolve(); }); }); @@ -56,9 +60,10 @@ describe('WS epic', () => { await Promise.all([initBacklog(socket1), initBacklog(socket2)]); const name = '회원'; const color = 'yellow'; + const rankValue = LexoRank.middle().toString(); let requestData: any = { action: 'create', - content: { name, color }, + content: { name, color, rankValue }, }; socket1.emit('epic', requestData); @@ -99,9 +104,10 @@ describe('WS epic', () => { await initBacklog(socket); const name = '회원'; let color = 'yellow'; + const rankValue = LexoRank.middle().toString(); let requestData: any = { action: 'create', - content: { name, color }, + content: { name, color, rankValue }, }; socket.emit('epic', requestData); const id = await getEpicId(socket); @@ -137,9 +143,10 @@ describe('WS epic', () => { await initBacklog(socket); let name = '회원'; let color = 'yellow'; + const rankValue = LexoRank.middle().toString(); let requestData: any = { action: 'create', - content: { name, color }, + content: { name, color, rankValue }, }; socket.emit('epic', requestData); const id = await getEpicId(socket); @@ -170,6 +177,51 @@ describe('WS epic', () => { }); }); }; + + it('should return updated epic data when update rankValue', async () => { + const socket = await getMemberJoinedLandingPage(); + socket.emit('joinBacklog'); + await initBacklog(socket); + const name1 = '회원'; + const color1 = 'yellow'; + const rankValue1 = LexoRank.middle().toString(); + const requestData1: any = { + action: 'create', + content: { name: name1, color: color1, rankValue: rankValue1 }, + }; + socket.emit('epic', requestData1); + const id1 = await getEpicId(socket); + + const name2 = '회원'; + const color2 = 'yellow'; + const rankValue2 = LexoRank.parse(rankValue1).genNext().toString(); + const requestData2: any = { + action: 'create', + content: { name: name2, color: color2, rankValue: rankValue2 }, + }; + socket.emit('epic', requestData2); + const id2 = await getEpicId(socket); + + const updateRankValue = LexoRank.parse(rankValue2).genNext().toString(); + const requestData3 = { + action: 'update', + content: { id: id1, rankValue: updateRankValue }, + }; + + socket.emit('epic', requestData3); + await new Promise((resolve) => { + socket.once('backlog', async (data) => { + const { content, action, domain } = data; + expect(domain).toBe('epic'); + expect(action).toBe('update'); + expect(content?.id).toBe(id1); + expect(content?.rankValue).toBe(updateRankValue); + resolve(); + }); + }); + + socket.close(); + }); }); }); diff --git a/backend/test/project/ws-backlog-page/ws-init-backlog.e2e-spec.ts b/backend/test/project/ws-backlog-page/ws-init-backlog.e2e-spec.ts index 5886ad5..7e09c7d 100644 --- a/backend/test/project/ws-backlog-page/ws-init-backlog.e2e-spec.ts +++ b/backend/test/project/ws-backlog-page/ws-init-backlog.e2e-spec.ts @@ -1,8 +1,7 @@ +import { LexoRank } from 'lexorank'; import { Socket } from 'socket.io-client'; import { app, appInit } from 'test/setup'; -import { - getTwoMemberJoinedLandingPage, -} from '../ws-common'; +import { getTwoMemberJoinedLandingPage } from '../ws-common'; describe('WS epic', () => { beforeEach(async () => { @@ -18,9 +17,10 @@ describe('WS epic', () => { await initBacklog(socket1); const epicName = '회원'; const epicColor = 'yellow'; + const epicRankValue = LexoRank.middle().toString(); socket1.emit('epic', { action: 'create', - content: { name: epicName, color: epicColor }, + content: { name: epicName, color: epicColor, rankValue: epicRankValue }, }); const epicId = await getEpicId(socket1); const storyTitle = '타이틀'; diff --git a/backend/test/project/ws-backlog-page/ws-story.e2e-spec.ts b/backend/test/project/ws-backlog-page/ws-story.e2e-spec.ts index 8fa0e84..b94294c 100644 --- a/backend/test/project/ws-backlog-page/ws-story.e2e-spec.ts +++ b/backend/test/project/ws-backlog-page/ws-story.e2e-spec.ts @@ -1,3 +1,4 @@ +import { LexoRank } from 'lexorank'; import { Socket } from 'socket.io-client'; import { app, appInit } from 'test/setup'; import { @@ -21,9 +22,10 @@ describe('WS story', () => { const name = '회원'; const color = 'yellow'; + const rankValue = LexoRank.middle().toString(); let requestData: any = { action: 'create', - content: { name, color }, + content: { name, color, rankValue }, }; socket1.emit('epic', requestData); const [epicId] = await Promise.all([ @@ -72,9 +74,11 @@ describe('WS story', () => { const name = '회원'; const color = 'yellow'; + const rankValue = LexoRank.middle().toString(); + let requestData: any = { action: 'create', - content: { name, color }, + content: { name, color, rankValue }, }; socket.emit('epic', requestData); const [epicId] = await Promise.all([getEpicId(socket)]); @@ -119,9 +123,11 @@ describe('WS story', () => { const name = '회원'; const color = 'yellow'; + const rankValue = LexoRank.middle().toString(); + let requestData: any = { action: 'create', - content: { name, color }, + content: { name, color, rankValue }, }; socket.emit('epic', requestData); const [epicId] = await Promise.all([getEpicId(socket)]); @@ -167,9 +173,10 @@ describe('WS story', () => { const name = '회원'; const color = 'yellow'; + const rankValue = LexoRank.middle().toString(); let requestData: any = { action: 'create', - content: { name, color }, + content: { name, color, rankValue }, }; socket.emit('epic', requestData); const [epicId] = await Promise.all([getEpicId(socket)]); diff --git a/backend/test/project/ws-backlog-page/ws-task.e2e-spec.ts b/backend/test/project/ws-backlog-page/ws-task.e2e-spec.ts index 2118840..329da68 100644 --- a/backend/test/project/ws-backlog-page/ws-task.e2e-spec.ts +++ b/backend/test/project/ws-backlog-page/ws-task.e2e-spec.ts @@ -1,3 +1,4 @@ +import { LexoRank } from 'lexorank'; import { Socket } from 'socket.io-client'; import { app, appInit } from 'test/setup'; import { @@ -21,9 +22,10 @@ describe('WS task', () => { const name = '회원'; const color = 'yellow'; + const rankValue = LexoRank.middle().toString(); let requestData: any = { action: 'create', - content: { name, color }, + content: { name, color, rankValue }, }; socket1.emit('epic', requestData); const [epicId] = await Promise.all([ @@ -178,9 +180,11 @@ describe('WS task', () => { const name = '회원'; const color = 'yellow'; + const rankValue = LexoRank.middle().toString(); + let requestData: any = { action: 'create', - content: { name, color }, + content: { name, color, rankValue }, }; socket.emit('epic', requestData); const [epicId] = await Promise.all([getEpicId(socket)]); @@ -245,9 +249,10 @@ describe('WS task', () => { const name = '회원'; const color = 'yellow'; + const rankValue = LexoRank.middle().toString(); let requestData: any = { action: 'create', - content: { name, color }, + content: { name, color, rankValue }, }; socket.emit('epic', requestData); const [epicId] = await Promise.all([getEpicId(socket)]); @@ -353,9 +358,10 @@ describe('WS task', () => { const name = '회원'; const color = 'yellow'; + const rankValue = LexoRank.middle().toString(); let requestData: any = { action: 'create', - content: { name, color }, + content: { name, color, rankValue }, }; socket.emit('epic', requestData); const [epicId] = await Promise.all([getEpicId(socket)]); From de1eec62ee02e2c3915a3a4e16c722ae75d60150 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=98=81=EC=9A=B0?= Date: Tue, 30 Jul 2024 16:37:04 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20=EC=8A=A4=ED=86=A0=EB=A6=AC=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1,=20=EC=88=98=EC=A0=95=20API=EC=97=90=20?= =?UTF-8?q?=EC=9A=B0=EC=84=A0=EC=88=9C=EC=9C=84=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0(rankValue)=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - story 엔티티 - rankValue 프로퍼티 추가 - 스토리의 rankValue가 project에서 고유하도록 유니크 제약조건 추가 - project 레포지토리, project 서비스의 story 관련 update, create 메서드에 rankValue정보 추가 - story 컨트롤러에 rankValue 정보 추가 - Story DTO에 rankValue 정보 추가 - E2E 테스트 - 스토리, 태스크, 백로그 테스트에 스토리의 rankValue정보 추가 - 스토리 테스트에 같은 에픽 내 rankValue 업데이트 테스트 추가 - 스토리 테스트에 다른 에픽으로의 rankValue 업데이트 테스트 추가 --- .../dto/story/StoryCreateNotify.dto.ts | 3 + .../dto/story/StoryCreateRequest.dto.ts | 6 + .../dto/story/StoryUpdateNotify.dto.ts | 6 +- .../dto/story/StoryUpdateRequest.dto.ts | 16 +- backend/src/project/entity/story.entity.ts | 7 + backend/src/project/project.repository.ts | 4 + .../src/project/service/project.service.ts | 5 +- .../ws-project-story.controller.ts | 3 + .../ws-init-backlog.e2e-spec.ts | 9 +- .../ws-backlog-page/ws-story.e2e-spec.ts | 139 ++++++++++++++++-- .../ws-backlog-page/ws-task.e2e-spec.ts | 23 +-- 11 files changed, 184 insertions(+), 37 deletions(-) diff --git a/backend/src/project/dto/story/StoryCreateNotify.dto.ts b/backend/src/project/dto/story/StoryCreateNotify.dto.ts index fbb3ad1..f59276c 100644 --- a/backend/src/project/dto/story/StoryCreateNotify.dto.ts +++ b/backend/src/project/dto/story/StoryCreateNotify.dto.ts @@ -5,6 +5,8 @@ class StoryDto { point: number; status: StoryStatus; epicId: number; + rankValue: string; + static of(story: Story) { const dto = new StoryDto(); dto.id = story.id; @@ -12,6 +14,7 @@ class StoryDto { dto.point = story.point; dto.status = story.status; dto.epicId = story.epicId; + dto.rankValue = story.rankValue; return dto; } } diff --git a/backend/src/project/dto/story/StoryCreateRequest.dto.ts b/backend/src/project/dto/story/StoryCreateRequest.dto.ts index 71bf166..6f1229c 100644 --- a/backend/src/project/dto/story/StoryCreateRequest.dto.ts +++ b/backend/src/project/dto/story/StoryCreateRequest.dto.ts @@ -10,6 +10,7 @@ import { Min, ValidateNested, } from 'class-validator'; +import { IsLexoRankValue } from 'src/common/decorator/IsLexoRankValue'; import { StoryStatus } from 'src/project/entity/story.entity'; class Story { @@ -27,6 +28,11 @@ class Story { @IsInt() epicId: number; + + @IsString() + @IsLexoRankValue() + @Length(2, 255) + rankValue: string; } export class StoryCreateRequestDto { diff --git a/backend/src/project/dto/story/StoryUpdateNotify.dto.ts b/backend/src/project/dto/story/StoryUpdateNotify.dto.ts index 9201512..8505c31 100644 --- a/backend/src/project/dto/story/StoryUpdateNotify.dto.ts +++ b/backend/src/project/dto/story/StoryUpdateNotify.dto.ts @@ -6,6 +6,7 @@ class Story { title?: string; point?: number; status?: StoryStatus; + rankValue?: string; static of( id: number, @@ -13,6 +14,7 @@ class Story { title: string | undefined, point: number | undefined, status: StoryStatus | undefined, + rankValue: string | undefined, ) { const dto = new Story(); dto.id = id; @@ -20,6 +22,7 @@ class Story { if (point !== undefined) dto.point = point; if (status !== undefined) dto.status = status; if (epicId !== undefined) dto.epicId = epicId; + if (rankValue !== undefined) dto.rankValue = rankValue; return dto; } } @@ -35,11 +38,12 @@ export class StoryUpdateNotifyDto { title: string | undefined, point: number | undefined, status: StoryStatus | undefined, + rankValue: string | undefined, ) { const dto = new StoryUpdateNotifyDto(); dto.domain = 'story'; dto.action = 'update'; - dto.content = Story.of(id, epicId, title, point, status); + dto.content = Story.of(id, epicId, title, point, status, rankValue); return dto; } } diff --git a/backend/src/project/dto/story/StoryUpdateRequest.dto.ts b/backend/src/project/dto/story/StoryUpdateRequest.dto.ts index e5cbebc..ffd8ae1 100644 --- a/backend/src/project/dto/story/StoryUpdateRequest.dto.ts +++ b/backend/src/project/dto/story/StoryUpdateRequest.dto.ts @@ -11,17 +11,13 @@ import { Min, ValidateNested, } from 'class-validator'; +import { IsLexoRankValue } from 'src/common/decorator/IsLexoRankValue'; import { StoryStatus } from 'src/project/entity/story.entity'; import { AtLeastOneProperty } from 'src/project/util/validation.util'; class Story { @IsInt() - @AtLeastOneProperty([ - 'epicId', - 'title', - 'point', - 'status' - ]) + @AtLeastOneProperty(['epicId', 'title', 'point', 'status', 'rankValue']) id: number; @IsOptional() @@ -32,7 +28,7 @@ class Story { @IsString() @Length(1, 100) title?: string; - + @IsOptional() @IsInt() @Min(0) @@ -42,6 +38,12 @@ class Story { @IsOptional() @IsEnum(StoryStatus) status?: StoryStatus; + + @IsOptional() + @IsString() + @IsLexoRankValue() + @Length(2, 255) + rankValue?: string; } export class StoryUpdateRequestDto { diff --git a/backend/src/project/entity/story.entity.ts b/backend/src/project/entity/story.entity.ts index 7f76456..07a67f7 100644 --- a/backend/src/project/entity/story.entity.ts +++ b/backend/src/project/entity/story.entity.ts @@ -5,6 +5,7 @@ import { ManyToOne, OneToMany, PrimaryGeneratedColumn, + Unique, } from 'typeorm'; import { Epic } from './epic.entity'; import { Project } from './project.entity'; @@ -17,6 +18,7 @@ export enum StoryStatus { } @Entity() +@Unique(['rankValue', 'epicId']) export class Story { @PrimaryGeneratedColumn('increment', { type: 'int' }) id: number; @@ -50,12 +52,16 @@ export class Story { @OneToMany(() => Task, (task) => task.story) taskList: Task[]; + @Column({ type: 'varchar', length: 255, nullable: false, name: 'rank_value' }) + rankValue: string; + static of( project: Project, epicId: number, title: string, point: number, status: StoryStatus, + rankValue: string, ) { const newStory = new Story(); newStory.project = project; @@ -63,6 +69,7 @@ export class Story { newStory.title = title; newStory.point = point; newStory.status = status; + newStory.rankValue = rankValue; return newStory; } } diff --git a/backend/src/project/project.repository.ts b/backend/src/project/project.repository.ts index a5a3245..1697d2c 100644 --- a/backend/src/project/project.repository.ts +++ b/backend/src/project/project.repository.ts @@ -181,6 +181,7 @@ export class ProjectRepository { title: string | undefined, point: number | undefined, status: StoryStatus | undefined, + rankValue: string | undefined, ): Promise { const updateData: any = {}; @@ -196,6 +197,9 @@ export class ProjectRepository { if (status !== undefined) { updateData.status = status; } + if (rankValue !== undefined) { + updateData.rankValue = rankValue; + } const result = await this.storyRepository.update( { id, project: { id: project.id } }, diff --git a/backend/src/project/service/project.service.ts b/backend/src/project/service/project.service.ts index 9090f4b..9271aa9 100644 --- a/backend/src/project/service/project.service.ts +++ b/backend/src/project/service/project.service.ts @@ -132,10 +132,11 @@ export class ProjectService { title: string, point: number, status: StoryStatus, + rankValue: string, ) { const epic = await this.projectRepository.getEpicById(project, epicId); if (!epic) throw new Error('epic id not found'); - const newStory = Story.of(project, epicId, title, point, status); + const newStory = Story.of(project, epicId, title, point, status, rankValue); return this.projectRepository.createStory(newStory); } @@ -151,6 +152,7 @@ export class ProjectService { title: string | undefined, point: number | undefined, status: StoryStatus | undefined, + rankValue: string | undefined, ): Promise { if (epicId !== undefined) { const epic = await this.projectRepository.getEpicById(project, epicId); @@ -163,6 +165,7 @@ export class ProjectService { title, point, status, + rankValue, ); } diff --git a/backend/src/project/ws-controller/ws-project-story.controller.ts b/backend/src/project/ws-controller/ws-project-story.controller.ts index d6abc6a..33573cd 100644 --- a/backend/src/project/ws-controller/ws-project-story.controller.ts +++ b/backend/src/project/ws-controller/ws-project-story.controller.ts @@ -28,6 +28,7 @@ export class WsProjectStoryController { content.title, content.point, content.status, + content.rankValue, ); client.nsp .to('backlog') @@ -68,6 +69,7 @@ export class WsProjectStoryController { content.title, content.point, content.status, + content.rankValue, ); if (isUpdated) { @@ -81,6 +83,7 @@ export class WsProjectStoryController { content.title, content.point, content.status, + content.rankValue, ), ); } diff --git a/backend/test/project/ws-backlog-page/ws-init-backlog.e2e-spec.ts b/backend/test/project/ws-backlog-page/ws-init-backlog.e2e-spec.ts index 7e09c7d..3a52584 100644 --- a/backend/test/project/ws-backlog-page/ws-init-backlog.e2e-spec.ts +++ b/backend/test/project/ws-backlog-page/ws-init-backlog.e2e-spec.ts @@ -17,10 +17,14 @@ describe('WS epic', () => { await initBacklog(socket1); const epicName = '회원'; const epicColor = 'yellow'; - const epicRankValue = LexoRank.middle().toString(); + const middleRankValue = LexoRank.middle().toString(); socket1.emit('epic', { action: 'create', - content: { name: epicName, color: epicColor, rankValue: epicRankValue }, + content: { + name: epicName, + color: epicColor, + rankValue: middleRankValue, + }, }); const epicId = await getEpicId(socket1); const storyTitle = '타이틀'; @@ -33,6 +37,7 @@ describe('WS epic', () => { point: storyPoint, status: storyStatus, epicId, + rankValue: middleRankValue, }, }); const storyId = await getStoryId(socket1); diff --git a/backend/test/project/ws-backlog-page/ws-story.e2e-spec.ts b/backend/test/project/ws-backlog-page/ws-story.e2e-spec.ts index b94294c..186bbf0 100644 --- a/backend/test/project/ws-backlog-page/ws-story.e2e-spec.ts +++ b/backend/test/project/ws-backlog-page/ws-story.e2e-spec.ts @@ -22,10 +22,10 @@ describe('WS story', () => { const name = '회원'; const color = 'yellow'; - const rankValue = LexoRank.middle().toString(); + const middleRankValue = LexoRank.middle().toString(); let requestData: any = { action: 'create', - content: { name, color, rankValue }, + content: { name, color, rankValue: middleRankValue }, }; socket1.emit('epic', requestData); const [epicId] = await Promise.all([ @@ -38,19 +38,19 @@ describe('WS story', () => { const status = '시작전'; requestData = { action: 'create', - content: { title, point, status, epicId }, + content: { title, point, status, epicId, rankValue: middleRankValue }, }; socket1.emit('story', requestData); await Promise.all([ - expectCreateStory(socket1, epicId, title, point), - expectCreateStory(socket2, epicId, title, point), + expectCreateStory(socket1, epicId, title, point, middleRankValue), + expectCreateStory(socket2, epicId, title, point, middleRankValue), ]); socket1.close(); socket2.close(); }); - const expectCreateStory = (socket, epicId, title, point) => { + const expectCreateStory = (socket, epicId, title, point, rankValue) => { return new Promise((resolve) => { socket.once('backlog', async (data) => { const { content, action, domain } = data; @@ -60,6 +60,7 @@ describe('WS story', () => { expect(content?.epicId).toBe(epicId); expect(content?.title).toBe(title); expect(content?.point).toBe(point); + expect(content?.rankValue).toBe(rankValue); resolve(); }); }); @@ -74,11 +75,11 @@ describe('WS story', () => { const name = '회원'; const color = 'yellow'; - const rankValue = LexoRank.middle().toString(); + const middleRankValue = LexoRank.middle().toString(); let requestData: any = { action: 'create', - content: { name, color, rankValue }, + content: { name, color, rankValue: middleRankValue }, }; socket.emit('epic', requestData); const [epicId] = await Promise.all([getEpicId(socket)]); @@ -88,7 +89,7 @@ describe('WS story', () => { const status = '시작전'; requestData = { action: 'create', - content: { title, point, status, epicId }, + content: { title, point, status, epicId, rankValue: middleRankValue }, }; socket.emit('story', requestData); const storyId = await getStoryId(socket); @@ -123,11 +124,11 @@ describe('WS story', () => { const name = '회원'; const color = 'yellow'; - const rankValue = LexoRank.middle().toString(); + const middleRankValue = LexoRank.middle().toString(); let requestData: any = { action: 'create', - content: { name, color, rankValue }, + content: { name, color, rankValue: middleRankValue }, }; socket.emit('epic', requestData); const [epicId] = await Promise.all([getEpicId(socket)]); @@ -137,7 +138,7 @@ describe('WS story', () => { const status = '시작전'; requestData = { action: 'create', - content: { title, point, status, epicId }, + content: { title, point, status, epicId, rankValue: middleRankValue }, }; socket.emit('story', requestData); const storyId = await getStoryId(socket); @@ -173,10 +174,10 @@ describe('WS story', () => { const name = '회원'; const color = 'yellow'; - const rankValue = LexoRank.middle().toString(); + const middleRankValue = LexoRank.middle().toString(); let requestData: any = { action: 'create', - content: { name, color, rankValue }, + content: { name, color, rankValue: middleRankValue }, }; socket.emit('epic', requestData); const [epicId] = await Promise.all([getEpicId(socket)]); @@ -186,7 +187,7 @@ describe('WS story', () => { const status = '시작전'; requestData = { action: 'create', - content: { title, point, status, epicId }, + content: { title, point, status, epicId, rankValue: middleRankValue }, }; socket.emit('story', requestData); const storyId = await getStoryId(socket); @@ -203,6 +204,114 @@ describe('WS story', () => { }); socket.close(); }); + + it('should return updated story data when update rankValue within same epic', async () => { + const socket = await getMemberJoinedLandingPage(); + socket.emit('joinBacklog'); + await initBacklog(socket); + + const name = '회원'; + const color = 'yellow'; + const middleRankValue = LexoRank.middle().toString(); + + let requestData: any = { + action: 'create', + content: { name, color, rankValue: middleRankValue }, + }; + socket.emit('epic', requestData); + const [epicId] = await Promise.all([getEpicId(socket)]); + + const title = '타이틀'; + const point = 2; + const status = '시작전'; + requestData = { + action: 'create', + content: { title, point, status, epicId, rankValue: middleRankValue }, + }; + socket.emit('story', requestData); + const storyId = await getStoryId(socket); + + const newRankValue = LexoRank.parse(middleRankValue).genNext().toString(); + requestData = { + action: 'update', + content: { id: storyId, rankValue: newRankValue }, + }; + socket.emit('story', requestData); + await new Promise((resolve) => { + socket.once('backlog', (data) => { + const { content, action, domain } = data; + expect(domain).toBe('story'); + expect(action).toBe('update'); + expect(content?.id).toBe(storyId); + expect(content?.rankValue).toBe(newRankValue); + resolve(); + }); + }); + socket.close(); + }); + + it('should return updated story data when update rankValue within different epic', async () => { + const socket = await getMemberJoinedLandingPage(); + socket.emit('joinBacklog'); + await initBacklog(socket); + + const name = '회원'; + const color = 'yellow'; + const middleRankValue = LexoRank.middle().toString(); + + const requestData1: any = { + action: 'create', + content: { name, color, rankValue: middleRankValue }, + }; + socket.emit('epic', requestData1); + const epicId1 = await getEpicId(socket); + + const requestData2: any = { + action: 'create', + content: { + name, + color, + rankValue: LexoRank.parse(middleRankValue).genNext().toString(), + }, + }; + socket.emit('epic', requestData2); + const epicId2 = await getEpicId(socket); + + const title = '타이틀'; + const point = 2; + const status = '시작전'; + const requestData3 = { + action: 'create', + content: { + title, + point, + status, + epicId: epicId1, + rankValue: middleRankValue, + }, + }; + socket.emit('story', requestData3); + const storyId = await getStoryId(socket); + + //변경햘 에픽에서의 첫번째 스토리이기 때문에 middle 메서드를 사용한다. + const newRankValue = LexoRank.middle().toString(); + const requestData4 = { + action: 'update', + content: { id: storyId, epicId: epicId2, rankValue: newRankValue }, + }; + socket.emit('story', requestData4); + await new Promise((resolve) => { + socket.once('backlog', (data) => { + const { content, action, domain } = data; + expect(domain).toBe('story'); + expect(action).toBe('update'); + expect(content?.id).toBe(storyId); + expect(content?.rankValue).toBe(newRankValue); + resolve(); + }); + }); + socket.close(); + }); }); }); diff --git a/backend/test/project/ws-backlog-page/ws-task.e2e-spec.ts b/backend/test/project/ws-backlog-page/ws-task.e2e-spec.ts index 329da68..e1d4baf 100644 --- a/backend/test/project/ws-backlog-page/ws-task.e2e-spec.ts +++ b/backend/test/project/ws-backlog-page/ws-task.e2e-spec.ts @@ -22,10 +22,10 @@ describe('WS task', () => { const name = '회원'; const color = 'yellow'; - const rankValue = LexoRank.middle().toString(); + const middleRankValue = LexoRank.middle().toString(); let requestData: any = { action: 'create', - content: { name, color, rankValue }, + content: { name, color, rankValue: middleRankValue }, }; socket1.emit('epic', requestData); const [epicId] = await Promise.all([ @@ -43,6 +43,7 @@ describe('WS task', () => { point: storyPoint, status: storyStatus, epicId, + rankValue: middleRankValue, }, }; socket1.emit('story', requestData); @@ -180,11 +181,11 @@ describe('WS task', () => { const name = '회원'; const color = 'yellow'; - const rankValue = LexoRank.middle().toString(); + const middleRankValue = LexoRank.middle().toString(); let requestData: any = { action: 'create', - content: { name, color, rankValue }, + content: { name, color, rankValue: middleRankValue }, }; socket.emit('epic', requestData); const [epicId] = await Promise.all([getEpicId(socket)]); @@ -194,7 +195,7 @@ describe('WS task', () => { const status = '시작전'; requestData = { action: 'create', - content: { title, point, status, epicId }, + content: { title, point, status, epicId, rankValue: middleRankValue }, }; socket.emit('story', requestData); const storyId = await getStoryId(socket); @@ -249,10 +250,10 @@ describe('WS task', () => { const name = '회원'; const color = 'yellow'; - const rankValue = LexoRank.middle().toString(); + const middleRankValue = LexoRank.middle().toString(); let requestData: any = { action: 'create', - content: { name, color, rankValue }, + content: { name, color, rankValue: middleRankValue }, }; socket.emit('epic', requestData); const [epicId] = await Promise.all([getEpicId(socket)]); @@ -262,7 +263,7 @@ describe('WS task', () => { const status = '시작전'; requestData = { action: 'create', - content: { title, point, status, epicId }, + content: { title, point, status, epicId, rankValue: middleRankValue }, }; socket.emit('story', requestData); const storyId = await getStoryId(socket); @@ -358,10 +359,10 @@ describe('WS task', () => { const name = '회원'; const color = 'yellow'; - const rankValue = LexoRank.middle().toString(); + const middleRankValue = LexoRank.middle().toString(); let requestData: any = { action: 'create', - content: { name, color, rankValue }, + content: { name, color, rankValue: middleRankValue }, }; socket.emit('epic', requestData); const [epicId] = await Promise.all([getEpicId(socket)]); @@ -371,7 +372,7 @@ describe('WS task', () => { const status = '시작전'; requestData = { action: 'create', - content: { title, point, status, epicId }, + content: { title, point, status, epicId, rankValue: middleRankValue }, }; socket.emit('story', requestData); const storyId = await getStoryId(socket); From e27398e6dfbc2dde964beb9edc31d3051f0cb11d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=98=81=EC=9A=B0?= Date: Wed, 31 Jul 2024 13:45:43 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20=ED=83=9C=EC=8A=A4=ED=81=AC=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1,=20=EC=88=98=EC=A0=95=20API=EC=97=90=20?= =?UTF-8?q?=EC=9A=B0=EC=84=A0=EC=88=9C=EC=9C=84=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0(rankValue)=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - task 엔티티 - rankValue 프로퍼티 추가 - task의 rankValue가 project에서 고유하도록 유니크 제약조건 추가 - project 레포지토리, project 서비스의 task 관련 update, create 메서드에 rankValue정보 추가 - task 컨트롤러에 rankValue 정보 추가 - task DTO에 rankValue 정보 추가 - E2E 테스트 - 태스크, init 백로그 테스트에 태스크의 rankValue정보 추가 - 태스크 테스트에 같은 스토리 내 rankValue 업데이트 테스트 추가 - 태스크 테스트에 다른 스토리으로의 rankValue 업데이트 테스트 추가 --- .../project/dto/task/TaskCreateNotify.dto.ts | 3 + .../project/dto/task/TaskCreateRequest.dto.ts | 6 + .../project/dto/task/TaskUpdateNotify.dto.ts | 5 + .../project/dto/task/TaskUpdateRequest.dto.ts | 8 + backend/src/project/entity/task.entity.ts | 8 +- backend/src/project/project.repository.ts | 4 + .../src/project/service/project.service.ts | 4 + .../ws-project-task.controller.ts | 3 + .../ws-init-backlog.e2e-spec.ts | 1 + .../ws-backlog-page/ws-task.e2e-spec.ts | 171 ++++++++++++++++++ 10 files changed, 212 insertions(+), 1 deletion(-) diff --git a/backend/src/project/dto/task/TaskCreateNotify.dto.ts b/backend/src/project/dto/task/TaskCreateNotify.dto.ts index 81e3ca8..8a68be0 100644 --- a/backend/src/project/dto/task/TaskCreateNotify.dto.ts +++ b/backend/src/project/dto/task/TaskCreateNotify.dto.ts @@ -8,6 +8,8 @@ class TaskDto { status: TaskStatus; assignedMemberId: number|null; storyId: number; + rankValue: string; + static of(task: Task) { const dto = new TaskDto(); dto.id = task.id; @@ -18,6 +20,7 @@ class TaskDto { dto.status = task.status; dto.assignedMemberId = task.assignedMemberId; dto.storyId = task.storyId; + dto.rankValue = task.rankValue; return dto; } } diff --git a/backend/src/project/dto/task/TaskCreateRequest.dto.ts b/backend/src/project/dto/task/TaskCreateRequest.dto.ts index e2486c5..f0e8669 100644 --- a/backend/src/project/dto/task/TaskCreateRequest.dto.ts +++ b/backend/src/project/dto/task/TaskCreateRequest.dto.ts @@ -16,6 +16,7 @@ import { ValidationOptions, ValidationArguments, } from 'class-validator'; +import { IsLexoRankValue } from 'src/common/decorator/IsLexoRankValue'; export function IsOneDecimalPlace(validationOptions?: ValidationOptions) { return function (object: Object, propertyName: string) { @@ -61,6 +62,11 @@ class Task { @IsInt() storyId: number; + + @IsString() + @IsLexoRankValue() + @Length(2, 255) + rankValue: string; } export class TaskCreateRequestDto { diff --git a/backend/src/project/dto/task/TaskUpdateNotify.dto.ts b/backend/src/project/dto/task/TaskUpdateNotify.dto.ts index befe961..ed70992 100644 --- a/backend/src/project/dto/task/TaskUpdateNotify.dto.ts +++ b/backend/src/project/dto/task/TaskUpdateNotify.dto.ts @@ -8,6 +8,7 @@ class Task { actualTime?: number; status?: TaskStatus; assignedMemberId?: number; + rankValue?: string; static of( id: number, @@ -17,6 +18,7 @@ class Task { actualTime: number | undefined, status: TaskStatus | undefined, assignedMemberId: number | undefined, + rankValue: string | undefined, ) { const dto = new Task(); dto.id = id; @@ -26,6 +28,7 @@ class Task { if (actualTime !== undefined) dto.actualTime = actualTime; if (status !== undefined) dto.status = status; if (assignedMemberId !== undefined) dto.assignedMemberId = assignedMemberId; + if (rankValue !== undefined) dto.rankValue = rankValue; return dto; } } @@ -43,6 +46,7 @@ export class TaskUpdateNotifyDto { actualTime: number | undefined, status: TaskStatus | undefined, assignedMemberId: number | undefined, + rankValue: string | undefined, ) { const dto = new TaskUpdateNotifyDto(); dto.domain = 'task'; @@ -55,6 +59,7 @@ export class TaskUpdateNotifyDto { actualTime, status, assignedMemberId, + rankValue, ); return dto; } diff --git a/backend/src/project/dto/task/TaskUpdateRequest.dto.ts b/backend/src/project/dto/task/TaskUpdateRequest.dto.ts index ec8adba..93d0724 100644 --- a/backend/src/project/dto/task/TaskUpdateRequest.dto.ts +++ b/backend/src/project/dto/task/TaskUpdateRequest.dto.ts @@ -9,6 +9,7 @@ import { Matches, ValidateNested, } from 'class-validator'; +import { IsLexoRankValue } from 'src/common/decorator/IsLexoRankValue'; import { TaskStatus } from 'src/project/entity/task.entity'; import { AtLeastOneProperty } from 'src/project/util/validation.util'; import { IsOneDecimalPlace } from './TaskCreateRequest.dto'; @@ -22,6 +23,7 @@ class Task { 'actualTime', 'assignedMemberId', 'status', + 'rankValue', ]) id: number; @@ -49,6 +51,12 @@ class Task { @IsOptional() @IsEnum(TaskStatus) status?: TaskStatus; + + @IsOptional() + @IsString() + @IsLexoRankValue() + @Length(2, 255) + rankValue?: string; } export class TaskUpdateRequestDto { diff --git a/backend/src/project/entity/task.entity.ts b/backend/src/project/entity/task.entity.ts index 8a1efe4..13af5c3 100644 --- a/backend/src/project/entity/task.entity.ts +++ b/backend/src/project/entity/task.entity.ts @@ -5,6 +5,7 @@ import { JoinColumn, ManyToOne, PrimaryGeneratedColumn, + Unique, } from 'typeorm'; import { Project } from './project.entity'; import { Story } from './story.entity'; @@ -16,6 +17,7 @@ export enum TaskStatus { } @Entity() +@Unique(['rankValue', 'storyId']) export class Task { @PrimaryGeneratedColumn('increment', { type: 'int' }) id: number; @@ -62,6 +64,9 @@ export class Task { @JoinColumn({ name: 'member_id' }) member: Member; + @Column({ type: 'varchar', length: 255, nullable: false, name: 'rank_value' }) + rankValue: string; + static of( project: Project, storyId: number, @@ -71,17 +76,18 @@ export class Task { actualTime: number, memberId: number, status: TaskStatus, + rankValue: string, ) { const newTask = new Task(); newTask.project = project; newTask.storyId = storyId; newTask.title = title; newTask.displayId = displayId; - newTask.expectedTime = expectedTime; newTask.actualTime = actualTime; newTask.assignedMemberId = memberId; newTask.status = status; + newTask.rankValue = rankValue; return newTask; } } diff --git a/backend/src/project/project.repository.ts b/backend/src/project/project.repository.ts index 1697d2c..64e5645 100644 --- a/backend/src/project/project.repository.ts +++ b/backend/src/project/project.repository.ts @@ -245,6 +245,7 @@ export class ProjectRepository { actualTime: number | undefined, status: TaskStatus | undefined, assignedMemberId: number | undefined, + rankValue: string | undefined, ): Promise { const updateData: any = {}; @@ -266,6 +267,9 @@ export class ProjectRepository { if (assignedMemberId !== undefined) { updateData.assignedMemberId = assignedMemberId; } + if (rankValue !== undefined) { + updateData.rankValue = rankValue; + } const result = await this.taskRepository.update( { id, project: { id: project.id } }, diff --git a/backend/src/project/service/project.service.ts b/backend/src/project/service/project.service.ts index 9271aa9..c7c59c3 100644 --- a/backend/src/project/service/project.service.ts +++ b/backend/src/project/service/project.service.ts @@ -177,6 +177,7 @@ export class ProjectService { status: TaskStatus, assignedMemberId: number, storyId: number, + rankValue: string, ) { const story = await this.projectRepository.getStoryById(project, storyId); if (!story) throw new Error('Story id not found'); @@ -192,6 +193,7 @@ export class ProjectService { actualTime, assignedMemberId, status, + rankValue, ); return this.projectRepository.createTask(newTask); } @@ -210,6 +212,7 @@ export class ProjectService { actualTime: number | undefined, status: TaskStatus | undefined, assignedMemberId: number | undefined, + rankValue: string | undefined, ): Promise { if (storyId !== undefined) { const story = await this.projectRepository.getStoryById(project, storyId); @@ -224,6 +227,7 @@ export class ProjectService { actualTime, status, assignedMemberId, + rankValue, ); } diff --git a/backend/src/project/ws-controller/ws-project-task.controller.ts b/backend/src/project/ws-controller/ws-project-task.controller.ts index 9563286..36bff54 100644 --- a/backend/src/project/ws-controller/ws-project-task.controller.ts +++ b/backend/src/project/ws-controller/ws-project-task.controller.ts @@ -31,6 +31,7 @@ export class WsProjectTaskController { content.status, content.assignedMemberId, content.storyId, + content.rankValue, ); client.nsp .to('backlog') @@ -73,6 +74,7 @@ export class WsProjectTaskController { content.actualTime, content.status, content.assignedMemberId, + content.rankValue, ); if (isUpdated) { @@ -88,6 +90,7 @@ export class WsProjectTaskController { content.actualTime, content.status, content.assignedMemberId, + content.rankValue, ), ); } diff --git a/backend/test/project/ws-backlog-page/ws-init-backlog.e2e-spec.ts b/backend/test/project/ws-backlog-page/ws-init-backlog.e2e-spec.ts index 3a52584..7eaeb3e 100644 --- a/backend/test/project/ws-backlog-page/ws-init-backlog.e2e-spec.ts +++ b/backend/test/project/ws-backlog-page/ws-init-backlog.e2e-spec.ts @@ -56,6 +56,7 @@ describe('WS epic', () => { status: taskStatus, assignedMemberId: taskAssignedMemberId, storyId, + rankValue: middleRankValue, }, }); diff --git a/backend/test/project/ws-backlog-page/ws-task.e2e-spec.ts b/backend/test/project/ws-backlog-page/ws-task.e2e-spec.ts index e1d4baf..139934b 100644 --- a/backend/test/project/ws-backlog-page/ws-task.e2e-spec.ts +++ b/backend/test/project/ws-backlog-page/ws-task.e2e-spec.ts @@ -61,6 +61,7 @@ describe('WS task', () => { null, null, null, + middleRankValue, ); await testCreateTask( @@ -72,6 +73,7 @@ describe('WS task', () => { 2.1, 3.3, null, + LexoRank.parse(middleRankValue).genNext().toString(), ); await testCreateTask( @@ -83,6 +85,7 @@ describe('WS task', () => { null, null, null, + LexoRank.parse(middleRankValue).genNext().genNext().toString(), ); await testCreateTask( @@ -94,6 +97,11 @@ describe('WS task', () => { null, null, null, + LexoRank.parse(middleRankValue) + .genNext() + .genNext() + .genNext() + .toString(), ); socket1.close(); @@ -109,6 +117,7 @@ describe('WS task', () => { expectedTime, actualTime, assignedMemberId, + rankValue, ) => { const requestData = { action: 'create', @@ -119,6 +128,7 @@ describe('WS task', () => { expectedTime, actualTime, assignedMemberId, + rankValue, }, }; socket1.emit('task', requestData); @@ -132,6 +142,7 @@ describe('WS task', () => { expectedTime, actualTime, assignedMemberId, + rankValue, ), expectCreateTask( socket2, @@ -141,6 +152,7 @@ describe('WS task', () => { expectedTime, actualTime, assignedMemberId, + rankValue, ), ]); }; @@ -153,6 +165,7 @@ describe('WS task', () => { expectedTime, actualTime, assignedMemberId, + rankValue, ) => { return new Promise((resolve) => { socket.once('backlog', async (data) => { @@ -167,6 +180,7 @@ describe('WS task', () => { expect(content?.actualTime).toBe(actualTime); expect(content?.expectedTime).toBe(expectedTime); expect(content?.assignedMemberId).toBe(assignedMemberId); + expect(content?.rankValue).toBe(rankValue); resolve(); }); }); @@ -214,6 +228,7 @@ describe('WS task', () => { expectedTime, actualTime, assignedMemberId, + rankValue: middleRankValue, }, }; socket.emit('task', requestData); @@ -282,6 +297,7 @@ describe('WS task', () => { expectedTime, actualTime, assignedMemberId, + rankValue: middleRankValue, }, }; socket.emit('task', requestData); @@ -391,6 +407,7 @@ describe('WS task', () => { expectedTime, actualTime, assignedMemberId, + rankValue: middleRankValue, }, }; socket.emit('task', requestData); @@ -422,6 +439,160 @@ describe('WS task', () => { socket.close(); }); + + it('should return updated task data when update rankValue within same story', async () => { + const socket = await getMemberJoinedLandingPage(); + socket.emit('joinBacklog'); + await initBacklog(socket); + + const name = '회원'; + const color = 'yellow'; + const middleRankValue = LexoRank.middle().toString(); + + let requestData: any = { + action: 'create', + content: { name, color, rankValue: middleRankValue }, + }; + socket.emit('epic', requestData); + const [epicId] = await Promise.all([getEpicId(socket)]); + + const title = '타이틀'; + const point = 2; + const status = '시작전'; + requestData = { + action: 'create', + content: { title, point, status, epicId, rankValue: middleRankValue }, + }; + socket.emit('story', requestData); + const storyId = await getStoryId(socket); + + let taskTitle = '타이틀'; + let taskStatus = '시작전'; + let expectedTime = null; + let actualTime = null; + let assignedMemberId = null; + requestData = { + action: 'create', + content: { + title: taskTitle, + status: taskStatus, + storyId, + expectedTime, + actualTime, + assignedMemberId, + rankValue: middleRankValue, + }, + }; + socket.emit('task', requestData); + const taskId = await getTaskId(socket); + + const newRankValue = LexoRank.parse(middleRankValue).genNext().toString(); + requestData = { + action: 'update', + content: { + id: taskId, + rankValue: newRankValue, + }, + }; + socket.emit('task', requestData); + + await new Promise((resolve) => { + socket.once('backlog', async (data) => { + const { content, action, domain } = data; + expect(domain).toBe('task'); + expect(action).toBe('update'); + expect(content?.id).toBe(taskId); + expect(content?.rankValue).toBe(newRankValue); + resolve(); + }); + }); + + socket.close(); + }); + + it('should return updated task data when update rankValue within same story', async () => { + const socket = await getMemberJoinedLandingPage(); + socket.emit('joinBacklog'); + await initBacklog(socket); + + const name = '회원'; + const color = 'yellow'; + const middleRankValue = LexoRank.middle().toString(); + + let requestData: any = { + action: 'create', + content: { name, color, rankValue: middleRankValue }, + }; + socket.emit('epic', requestData); + const epicId = await getEpicId(socket); + + const title = '타이틀'; + const point = 2; + const status = '시작전'; + requestData = { + action: 'create', + content: { title, point, status, epicId, rankValue: middleRankValue }, + }; + socket.emit('story', requestData); + const storyId1 = await getStoryId(socket); + + requestData = { + action: 'create', + content: { + title, + point, + status, + epicId, + rankValue: LexoRank.parse(middleRankValue).genNext().toString(), + }, + }; + socket.emit('story', requestData); + const storyId2 = await getStoryId(socket); + + let taskTitle = '타이틀'; + let taskStatus = '시작전'; + let expectedTime = null; + let actualTime = null; + let assignedMemberId = null; + requestData = { + action: 'create', + content: { + title: taskTitle, + status: taskStatus, + storyId: storyId1, + expectedTime, + actualTime, + assignedMemberId, + rankValue: middleRankValue, + }, + }; + socket.emit('task', requestData); + const taskId = await getTaskId(socket); + + const newRankValue = LexoRank.middle().toString(); + requestData = { + action: 'update', + content: { + id: taskId, + stroyId: storyId2, + rankValue: newRankValue, + }, + }; + socket.emit('task', requestData); + + await new Promise((resolve) => { + socket.once('backlog', async (data) => { + const { content, action, domain } = data; + expect(domain).toBe('task'); + expect(action).toBe('update'); + expect(content?.id).toBe(taskId); + expect(content?.rankValue).toBe(newRankValue); + resolve(); + }); + }); + + socket.close(); + }); }); }); From ec5509727604c49d3da25fe6785e88f5a3cce241 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=98=81=EC=9A=B0?= Date: Wed, 31 Jul 2024 14:28:08 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20=EB=B0=B1=EB=A1=9C=EA=B7=B8=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API=EC=97=90=20=EC=9A=B0=EC=84=A0=EC=88=9C?= =?UTF-8?q?=EC=9C=84=20=EB=8D=B0=EC=9D=B4=ED=84=B0(rankValue)=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - initBacklog 테스트에 rankValue 정보 추가 - initBacklog Response DTO에 rankValue 정보 추가 --- backend/src/project/dto/InitBacklogResponse.dto.ts | 6 ++++++ .../project/ws-backlog-page/ws-init-backlog.e2e-spec.ts | 5 +++++ 2 files changed, 11 insertions(+) diff --git a/backend/src/project/dto/InitBacklogResponse.dto.ts b/backend/src/project/dto/InitBacklogResponse.dto.ts index 7d16d43..20a1dbb 100644 --- a/backend/src/project/dto/InitBacklogResponse.dto.ts +++ b/backend/src/project/dto/InitBacklogResponse.dto.ts @@ -10,6 +10,7 @@ class TaskDto { actualTime: number | null; status: TaskStatus; assignedMemberId: number | null; + rankValue: string; static of(task: Task): TaskDto { const dto = new TaskDto(); @@ -20,6 +21,7 @@ class TaskDto { dto.actualTime = task.actualTime; dto.status = task.status; dto.assignedMemberId = task.assignedMemberId; + dto.rankValue = task.rankValue; return dto; } } @@ -29,6 +31,7 @@ class StoryDto { title: string; point: number | null; status: StoryStatus; + rankValue: string; taskList: TaskDto[]; static of(story: Story): StoryDto { @@ -37,6 +40,7 @@ class StoryDto { dto.title = story.title; dto.point = story.point; dto.status = story.status; + dto.rankValue = story.rankValue; dto.taskList = story.taskList.map(TaskDto.of); return dto; } @@ -46,6 +50,7 @@ class EpicDto { id: number; name: string; color: EpicColor; + rankValue: string; storyList: StoryDto[]; static of(epic: Epic): EpicDto { @@ -53,6 +58,7 @@ class EpicDto { dto.id = epic.id; dto.name = epic.name; dto.color = epic.color; + dto.rankValue = epic.rankValue; dto.storyList = epic.storyList.map(StoryDto.of); return dto; } diff --git a/backend/test/project/ws-backlog-page/ws-init-backlog.e2e-spec.ts b/backend/test/project/ws-backlog-page/ws-init-backlog.e2e-spec.ts index 7eaeb3e..a0ee2f2 100644 --- a/backend/test/project/ws-backlog-page/ws-init-backlog.e2e-spec.ts +++ b/backend/test/project/ws-backlog-page/ws-init-backlog.e2e-spec.ts @@ -75,12 +75,16 @@ describe('WS epic', () => { expect(epic.id).toBe(epicId); expect(epic.name).toBe(epicName); expect(epic.color).toBe(epicColor); + expect(epic.rankValue).toBe(middleRankValue); + expect(epic.storyList).toHaveLength(1); const story = epic.storyList[0]; expect(story.id).toBe(storyId); expect(story.title).toBe(storyTitle); expect(story.point).toBe(storyPoint); expect(story.status).toBe(storyStatus); + expect(story.rankValue).toBe(middleRankValue); + expect(story.taskList).toHaveLength(1); const task = story.taskList[0]; expect(task.id).toBe(taskId); @@ -90,6 +94,7 @@ describe('WS epic', () => { expect(task.actualTime).toBe(taskActualTime); expect(task.status).toBe(taskStatus); expect(task.assignedMemberId).toBe(taskAssignedMemberId); + expect(task.rankValue).toBe(middleRankValue); } catch (e) { reject(e); } From 6cd8791b49864d51dce88d3ae4c908d75e11147f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=98=81=EC=9A=B0?= Date: Thu, 1 Aug 2024 23:50:28 +0900 Subject: [PATCH 5/5] =?UTF-8?q?feat:=20Epic,=20Task,=20Story=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1,=20=EC=88=98=EC=A0=95=EC=8B=9C=20=EC=A4=91=EB=B3=B5?= =?UTF-8?q?=EB=90=9C=20rankValue=EB=A5=BC=20=EC=84=9C=EB=B2=84=EA=B0=80=20?= =?UTF-8?q?=EC=A1=B0=EC=A0=95=ED=95=B4,=20=EC=A1=B0=EC=A0=95=EB=90=9C=20ra?= =?UTF-8?q?nkValue=EB=A5=BC=20=EB=B0=98=ED=99=98=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Entity - 유니크 제약조건 이름 명시 - Repository - Create 메서드를 Unique오류이며, RankValue중복일 경우 커스텀 에러를 throw하게 변경 - Update 메서드를 Unique오류이며, RankValue중복일 경우 커스텀 에러를 throw하게 변경 - getNext[Epic/Task/Story]ByRankValue 메서드 구현 - 특정 rankValue값 다음 순서의 rankValue를 가진 엔티티 반환 - Controller - update시 조정된 rankValue값을 반영해 유저에게 반환하도록 수정 - Test - Epic/Task/Story 생성 시 동시에 중복된 rankValue를 전송했을때 조정되는것 테스트 - Epic/Task/Story 변경 시 동시에 중복된 rankValue를 전송했을때 조정되는것 테스트 --- backend/src/project/entity/epic.entity.ts | 2 +- backend/src/project/entity/story.entity.ts | 2 +- backend/src/project/entity/task.entity.ts | 2 +- backend/src/project/project.repository.ts | 130 ++++++++-- .../src/project/service/project.service.ts | 225 +++++++++++++++--- .../ws-project-epic.controller.ts | 17 +- .../ws-project-story.controller.ts | 21 +- .../ws-project-task.controller.ts | 25 +- .../ws-backlog-page/ws-epic.e2e-spec.ts | 116 +++++++++ .../ws-backlog-page/ws-story.e2e-spec.ts | 136 +++++++++++ .../ws-backlog-page/ws-task.e2e-spec.ts | 171 +++++++++++++ 11 files changed, 757 insertions(+), 90 deletions(-) diff --git a/backend/src/project/entity/epic.entity.ts b/backend/src/project/entity/epic.entity.ts index 63fa4e7..51376dd 100644 --- a/backend/src/project/entity/epic.entity.ts +++ b/backend/src/project/entity/epic.entity.ts @@ -23,7 +23,7 @@ export enum EpicColor { } @Entity() -@Unique(['rankValue', 'projectId']) +@Unique('EPIC_UQ_RANK_VALUE_AND_PROJECT_ID', ['rankValue', 'projectId']) export class Epic { @PrimaryGeneratedColumn('increment', { type: 'int' }) id: number; diff --git a/backend/src/project/entity/story.entity.ts b/backend/src/project/entity/story.entity.ts index 07a67f7..ca74d06 100644 --- a/backend/src/project/entity/story.entity.ts +++ b/backend/src/project/entity/story.entity.ts @@ -18,7 +18,7 @@ export enum StoryStatus { } @Entity() -@Unique(['rankValue', 'epicId']) +@Unique('STORY_UQ_RANK_VALUE_AND_EPIC_ID', ['rankValue', 'epicId']) export class Story { @PrimaryGeneratedColumn('increment', { type: 'int' }) id: number; diff --git a/backend/src/project/entity/task.entity.ts b/backend/src/project/entity/task.entity.ts index 13af5c3..e43d85e 100644 --- a/backend/src/project/entity/task.entity.ts +++ b/backend/src/project/entity/task.entity.ts @@ -17,7 +17,7 @@ export enum TaskStatus { } @Entity() -@Unique(['rankValue', 'storyId']) +@Unique('TASK_UQ_RANK_VALUE_AND_STORY_ID', ['rankValue', 'storyId']) export class Task { @PrimaryGeneratedColumn('increment', { type: 'int' }) id: number; diff --git a/backend/src/project/project.repository.ts b/backend/src/project/project.repository.ts index 64e5645..29637a7 100644 --- a/backend/src/project/project.repository.ts +++ b/backend/src/project/project.repository.ts @@ -1,5 +1,5 @@ import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { Connection, DataSource, MoreThan, Repository } from 'typeorm'; import { Injectable } from '@nestjs/common'; import { Project } from './entity/project.entity'; import { ProjectToMember } from './entity/project-member.entity'; @@ -9,6 +9,7 @@ import { Link } from './entity/link.entity.'; import { Epic, EpicColor } from './entity/epic.entity'; import { Story, StoryStatus } from './entity/story.entity'; import { Task, TaskStatus } from './entity/task.entity'; +import { LexoRank } from 'lexorank'; @Injectable() export class ProjectRepository { @@ -29,6 +30,7 @@ export class ProjectRepository { private readonly storyRepository: Repository, @InjectRepository(Task) private readonly taskRepository: Repository, + private readonly dataSource: DataSource, ) {} create(project: Project): Promise { @@ -117,8 +119,27 @@ export class ProjectRepository { return result.affected ? result.affected : 0; } - createEpic(epic: Epic): Promise { - return this.epicRepository.save(epic); + async createEpic(epic: Epic): Promise { + try { + return await this.epicRepository.save(epic); + } catch (e) { + if ( + e.code === 'ER_DUP_ENTRY' && + e.sqlMessage.includes('EPIC_UQ_RANK_VALUE_AND_PROJECT_ID') + ) + throw new Error('DUPLICATED RANK VALUE'); + throw e; + } + } + + getNextEpicByRankValue(projectId: number, rankValue: string) { + return this.epicRepository.findOne({ + where: { + projectId, + rankValue: MoreThan(rankValue), + }, + order: { rankValue: 'ASC' }, + }); } async deleteEpic(project: Project, epicId: number): Promise { @@ -149,11 +170,20 @@ export class ProjectRepository { updateData.rankValue = rankValue; } - const result = await this.epicRepository.update( - { id, project: { id: project.id } }, - updateData, - ); - return !!result.affected; + try { + const result = await this.epicRepository.update( + { id, project: { id: project.id } }, + updateData, + ); + return !!result.affected; + } catch (e) { + if ( + e.code === 'ER_DUP_ENTRY' && + e.sqlMessage.includes('EPIC_UQ_RANK_VALUE_AND_PROJECT_ID') + ) + throw new Error('DUPLICATED RANK VALUE'); + throw e; + } } getEpicById(project: Project, id: number) { @@ -162,8 +192,27 @@ export class ProjectRepository { }); } - createStory(story: Story): Promise { - return this.storyRepository.save(story); + async createStory(story: Story): Promise { + try { + return await this.storyRepository.save(story); + } catch (e) { + if ( + e.code === 'ER_DUP_ENTRY' && + e.sqlMessage.includes('STORY_UQ_RANK_VALUE_AND_EPIC_ID') + ) + throw new Error('DUPLICATED RANK VALUE'); + throw e; + } + } + + getNextStoryByRankValue(epicId: number, rankValue: string) { + return this.storyRepository.findOne({ + where: { + epicId, + rankValue: MoreThan(rankValue), + }, + order: { rankValue: 'ASC' }, + }); } async deleteStory(project: Project, storyId: number): Promise { @@ -201,11 +250,20 @@ export class ProjectRepository { updateData.rankValue = rankValue; } - const result = await this.storyRepository.update( - { id, project: { id: project.id } }, - updateData, - ); - return !!result.affected; + try { + const result = await this.storyRepository.update( + { id, project: { id: project.id } }, + updateData, + ); + return !!result.affected; + } catch (e) { + if ( + e.code === 'ER_DUP_ENTRY' && + e.sqlMessage.includes('STORY_UQ_RANK_VALUE_AND_EPIC_ID') + ) + throw new Error('DUPLICATED RANK VALUE'); + throw e; + } } getStoryById(project: Project, id: number) { @@ -224,8 +282,27 @@ export class ProjectRepository { return targetProject.displayIdCount; } - async createTask(task: Task) { - return this.taskRepository.save(task); + async createTask(task: Task): Promise { + try { + return await this.taskRepository.save(task); + } catch (e) { + if ( + e.code === 'ER_DUP_ENTRY' && + e.sqlMessage.includes('TASK_UQ_RANK_VALUE_AND_STORY_ID') + ) + throw new Error('DUPLICATED RANK VALUE'); + throw e; + } + } + + getNextTaskByRankValue(storyId: number, rankValue: string) { + return this.taskRepository.findOne({ + where: { + storyId, + rankValue: MoreThan(rankValue), + }, + order: { rankValue: 'ASC' }, + }); } async deleteTask(project: Project, taskId: number): Promise { @@ -271,11 +348,20 @@ export class ProjectRepository { updateData.rankValue = rankValue; } - const result = await this.taskRepository.update( - { id, project: { id: project.id } }, - updateData, - ); - return !!result.affected; + try { + const result = await this.taskRepository.update( + { id, project: { id: project.id } }, + updateData, + ); + return !!result.affected; + } catch (e) { + if ( + e.code === 'ER_DUP_ENTRY' && + e.sqlMessage.includes('TASK_UQ_RANK_VALUE_AND_STORY_ID') + ) + throw new Error('DUPLICATED RANK VALUE'); + throw e; + } } getProjectBacklog(project: Project) { diff --git a/backend/src/project/service/project.service.ts b/backend/src/project/service/project.service.ts index c7c59c3..56233f7 100644 --- a/backend/src/project/service/project.service.ts +++ b/backend/src/project/service/project.service.ts @@ -8,6 +8,8 @@ import { Link } from '../entity/link.entity.'; import { Epic, EpicColor } from '../entity/epic.entity'; import { Story, StoryStatus } from '../entity/story.entity'; import { Task, TaskStatus } from '../entity/task.entity'; +import e from 'express'; +import { LexoRank } from 'lexorank'; @Injectable() export class ProjectService { @@ -95,14 +97,45 @@ export class ProjectService { return result ? true : false; } - createEpic( + private async getAdjustedEpicRankValue( + currentRankValue: string, + nextRankValue: string | null, + ): Promise { + if (!nextRankValue) { + return LexoRank.parse(currentRankValue).genNext().toString(); + } else { + const nextRank = LexoRank.parse(nextRankValue); + return LexoRank.parse(currentRankValue).between(nextRank).toString(); + } + } + + async createEpic( project: Project, name: string, color: EpicColor, rankValue: string, ) { + const maxRetries = 10; + let attempts = 0; const newEpic = Epic.of(project, name, color, rankValue); - return this.projectRepository.createEpic(newEpic); + while (attempts < maxRetries) { + try { + return await this.projectRepository.createEpic(newEpic); + } catch (e) { + if (e.message === 'DUPLICATED RANK VALUE') { + const nextEpic = await this.projectRepository.getNextEpicByRankValue( + newEpic.projectId, + newEpic.rankValue, + ); + newEpic.rankValue = await this.getAdjustedEpicRankValue( + newEpic.rankValue, + nextEpic?.rankValue, + ); + attempts++; + if (attempts === 10) throw e; + } else throw e; + } + } } async deleteEpic(project: Project, epicId: number) { @@ -110,20 +143,45 @@ export class ProjectService { return result ? true : false; } - updateEpic( + async updateEpic( project: Project, id: number, name?: string, color?: EpicColor, rankValue?: string, - ): Promise { - return this.projectRepository.updateEpic( - project, - id, - name, - color, - rankValue, - ); + ) { + const maxRetries = 10; + let attempts = 0; + + let updatedRankValue = rankValue; + + while (attempts < maxRetries) { + try { + return { + isUpdated: await this.projectRepository.updateEpic( + project, + id, + name, + color, + updatedRankValue, + ), + updatedRankValue, + }; + } catch (e) { + if (e.message === 'DUPLICATED RANK VALUE') { + const nextEpic = await this.projectRepository.getNextEpicByRankValue( + project.id, + updatedRankValue, + ); + updatedRankValue = await this.getAdjustedEpicRankValue( + updatedRankValue, + nextEpic?.rankValue, + ); + attempts++; + if (attempts === 10) throw e; + } else throw e; + } + } } async createStory( @@ -136,8 +194,30 @@ export class ProjectService { ) { const epic = await this.projectRepository.getEpicById(project, epicId); if (!epic) throw new Error('epic id not found'); + + const maxRetries = 10; + let attempts = 0; const newStory = Story.of(project, epicId, title, point, status, rankValue); - return this.projectRepository.createStory(newStory); + while (attempts < maxRetries) { + try { + return await this.projectRepository.createStory(newStory); + } catch (e) { + if (e.message === 'DUPLICATED RANK VALUE') { + const nextStory = + await this.projectRepository.getNextStoryByRankValue( + newStory.epicId, + newStory.rankValue, + ); + newStory.rankValue = await this.getAdjustedEpicRankValue( + newStory.rankValue, + nextStory?.rankValue, + ); + + attempts++; + if (attempts === 10) throw e; + } else throw e; + } + } } async deleteStory(project: Project, storyId: number) { @@ -153,20 +233,48 @@ export class ProjectService { point: number | undefined, status: StoryStatus | undefined, rankValue: string | undefined, - ): Promise { + ) { if (epicId !== undefined) { const epic = await this.projectRepository.getEpicById(project, epicId); if (!epic) throw new Error('epic id not found'); } - return this.projectRepository.updateStory( - project, - id, - epicId, - title, - point, - status, - rankValue, - ); + + const maxRetries = 100; + let attempts = 0; + + let updatedRankValue = rankValue; + + while (attempts < maxRetries) { + try { + return { + isUpdated: await this.projectRepository.updateStory( + project, + id, + epicId, + title, + point, + status, + updatedRankValue, + ), + updatedRankValue, + }; + } catch (e) { + if (e.message === 'DUPLICATED RANK VALUE') { + const nextStory = + await this.projectRepository.getNextStoryByRankValue( + project.id, + updatedRankValue, + ); + + updatedRankValue = await this.getAdjustedEpicRankValue( + updatedRankValue, + nextStory?.rankValue, + ); + attempts++; + if (attempts === maxRetries) throw e; + } else throw e; + } + } } async createTask( @@ -184,6 +292,8 @@ export class ProjectService { const displayIdCount = await this.projectRepository.getAndIncrementDisplayIdCount(project); + const maxRetries = 10; + let attempts = 0; const newTask = Task.of( project, storyId, @@ -195,7 +305,25 @@ export class ProjectService { status, rankValue, ); - return this.projectRepository.createTask(newTask); + while (attempts < maxRetries) { + try { + return await this.projectRepository.createTask(newTask); + } catch (e) { + if (e.message === 'DUPLICATED RANK VALUE') { + const nextTask = await this.projectRepository.getNextTaskByRankValue( + newTask.storyId, + newTask.rankValue, + ); + newTask.rankValue = await this.getAdjustedEpicRankValue( + newTask.rankValue, + nextTask?.rankValue, + ); + + attempts++; + if (attempts === 10) throw e; + } else throw e; + } + } } async deleteTask(project: Project, taskId: number) { @@ -213,22 +341,49 @@ export class ProjectService { status: TaskStatus | undefined, assignedMemberId: number | undefined, rankValue: string | undefined, - ): Promise { + ) { if (storyId !== undefined) { const story = await this.projectRepository.getStoryById(project, storyId); if (!story) throw new Error('story id not found'); } - return this.projectRepository.updateTask( - project, - id, - storyId, - title, - expectedTime, - actualTime, - status, - assignedMemberId, - rankValue, - ); + + const maxRetries = 100; + let attempts = 0; + + let updatedRankValue = rankValue; + + while (attempts < maxRetries) { + try { + return { + isUpdated: await this.projectRepository.updateTask( + project, + id, + storyId, + title, + expectedTime, + actualTime, + status, + assignedMemberId, + updatedRankValue, + ), + updatedRankValue, + }; + } catch (e) { + if (e.message === 'DUPLICATED RANK VALUE') { + const nextTask = await this.projectRepository.getNextTaskByRankValue( + project.id, + updatedRankValue, + ); + + updatedRankValue = await this.getAdjustedEpicRankValue( + updatedRankValue, + nextTask?.rankValue, + ); + attempts++; + if (attempts === maxRetries) throw e; + } else throw e; + } + } } getProjectBacklog(project: Project) { diff --git a/backend/src/project/ws-controller/ws-project-epic.controller.ts b/backend/src/project/ws-controller/ws-project-epic.controller.ts index fc3dcfe..8a47d14 100644 --- a/backend/src/project/ws-controller/ws-project-epic.controller.ts +++ b/backend/src/project/ws-controller/ws-project-epic.controller.ts @@ -68,13 +68,14 @@ export class WsProjectEpicController { return; } const { content } = data as EpicUpdateRequestDto; - const isUpdated = await this.projectService.updateEpic( - client.project, - content.id, - content.name, - content.color, - content.rankValue, - ); + const { isUpdated, updatedRankValue } = + await this.projectService.updateEpic( + client.project, + content.id, + content.name, + content.color, + content.rankValue, + ); if (isUpdated) { client.nsp @@ -85,7 +86,7 @@ export class WsProjectEpicController { content.id, content.name, content.color, - content.rankValue, + updatedRankValue, ), ); } diff --git a/backend/src/project/ws-controller/ws-project-story.controller.ts b/backend/src/project/ws-controller/ws-project-story.controller.ts index 33573cd..843773d 100644 --- a/backend/src/project/ws-controller/ws-project-story.controller.ts +++ b/backend/src/project/ws-controller/ws-project-story.controller.ts @@ -62,15 +62,16 @@ export class WsProjectStoryController { return; } const { content } = data as StoryUpdateRequestDto; - const isUpdated = await this.projectService.updateStory( - client.project, - content.id, - content.epicId, - content.title, - content.point, - content.status, - content.rankValue, - ); + const { isUpdated, updatedRankValue } = + await this.projectService.updateStory( + client.project, + content.id, + content.epicId, + content.title, + content.point, + content.status, + content.rankValue, + ); if (isUpdated) { client.nsp @@ -83,7 +84,7 @@ export class WsProjectStoryController { content.title, content.point, content.status, - content.rankValue, + updatedRankValue, ), ); } diff --git a/backend/src/project/ws-controller/ws-project-task.controller.ts b/backend/src/project/ws-controller/ws-project-task.controller.ts index 36bff54..4ff4b6b 100644 --- a/backend/src/project/ws-controller/ws-project-task.controller.ts +++ b/backend/src/project/ws-controller/ws-project-task.controller.ts @@ -65,17 +65,18 @@ export class WsProjectTaskController { return; } const { content } = data as TaskUpdateRequestDto; - const isUpdated = await this.projectService.updateTask( - client.project, - content.id, - content.storyId, - content.title, - content.expectedTime, - content.actualTime, - content.status, - content.assignedMemberId, - content.rankValue, - ); + const { isUpdated, updatedRankValue } = + await this.projectService.updateTask( + client.project, + content.id, + content.storyId, + content.title, + content.expectedTime, + content.actualTime, + content.status, + content.assignedMemberId, + content.rankValue, + ); if (isUpdated) { client.nsp @@ -90,7 +91,7 @@ export class WsProjectTaskController { content.actualTime, content.status, content.assignedMemberId, - content.rankValue, + updatedRankValue, ), ); } diff --git a/backend/test/project/ws-backlog-page/ws-epic.e2e-spec.ts b/backend/test/project/ws-backlog-page/ws-epic.e2e-spec.ts index 4d4a83a..923db41 100644 --- a/backend/test/project/ws-backlog-page/ws-epic.e2e-spec.ts +++ b/backend/test/project/ws-backlog-page/ws-epic.e2e-spec.ts @@ -50,6 +50,42 @@ describe('WS epic', () => { }); }); }; + + it('should return created epic data when creating multiple epics simultaneously', async () => { + const socket = await getMemberJoinedLandingPage(); + socket.emit('joinBacklog'); + await initBacklog(socket); + const name = '회원'; + const color = 'yellow'; + const rankValue = LexoRank.middle().toString(); + const requestData = { + action: 'create', + content: { name, color, rankValue }, + }; + + socket.emit('epic', requestData); + socket.emit('epic', requestData); + + await Promise.all([ + new Promise((resolve) => { + socket.on('backlog', async (data) => { + const { content, action, domain } = data; + if (domain === 'epic' && action === 'create') { + if (content.rankValue === rankValue) resolve(); + } + }); + }), + new Promise((resolve) => { + socket.on('backlog', async (data) => { + const { content, action, domain } = data; + if (domain === 'epic' && action === 'create') { + if (content.rankValue !== rankValue) resolve(); + } + }); + }), + ]); + socket.close(); + }); }); describe('epic delete', () => { @@ -222,6 +258,86 @@ describe('WS epic', () => { socket.close(); }); + + it('should return updated epic data when updating multiple epics simultaneously', async () => { + const socket = await getMemberJoinedLandingPage(); + socket.emit('joinBacklog'); + await initBacklog(socket); + const name = '회원'; + const color = 'yellow'; + const rankValue1 = LexoRank.middle().toString(); + let requestData: any = { + action: 'create', + content: { name, color, rankValue: rankValue1 }, + }; + socket.emit('epic', requestData); + await waitCreateEpicNotify(socket); + + const rankValue2 = LexoRank.parse(rankValue1).genNext().toString(); + requestData = { + action: 'create', + content: { name, color, rankValue: rankValue2 }, + }; + socket.emit('epic', requestData); + await waitCreateEpicNotify(socket); + + const rankValue3 = LexoRank.parse(rankValue2).genNext().toString(); + requestData = { + action: 'create', + content: { name, color, rankValue: rankValue3 }, + }; + socket.emit('epic', requestData); + const epicId3 = await getEpicId(socket); + + const rankValue4 = LexoRank.parse(rankValue3).genNext().toString(); + requestData = { + action: 'create', + content: { name, color, rankValue: rankValue4 }, + }; + socket.emit('epic', requestData); + const epicId4 = await getEpicId(socket); + + const newRankValue = LexoRank.parse(rankValue1).between( + LexoRank.parse(rankValue2), + ); + const requestData3 = { + action: 'update', + content: { id: epicId3, rankValue: newRankValue.toString() }, + }; + const requestData4 = { + action: 'update', + content: { id: epicId4, rankValue: newRankValue.toString() }, + }; + socket.emit('epic', requestData3); + socket.emit('epic', requestData4); + + await new Promise((resolve) => { + const flag = {}; + socket.on('backlog', async (data) => { + const { content, action, domain } = data; + if (domain === 'epic' && action === 'update') { + flag[content.id] = content.rankValue === newRankValue.toString(); + if ( + Object.values(flag).length === 2 && + Object.values(flag).includes(true) && + Object.values(flag).includes(false) + ) + resolve(); + } + }); + }); + socket.close(); + }); + const waitCreateEpicNotify = (socket) => { + return new Promise((resolve) => { + socket.on('backlog', async (data) => { + const { content, action, domain } = data; + if (domain === 'epic' && action === 'create') { + resolve(); + } + }); + }); + }; }); }); diff --git a/backend/test/project/ws-backlog-page/ws-story.e2e-spec.ts b/backend/test/project/ws-backlog-page/ws-story.e2e-spec.ts index 186bbf0..b0f867a 100644 --- a/backend/test/project/ws-backlog-page/ws-story.e2e-spec.ts +++ b/backend/test/project/ws-backlog-page/ws-story.e2e-spec.ts @@ -65,6 +65,50 @@ describe('WS story', () => { }); }); }; + + it('should return created story data when creating multiple stories simultaneously', async () => { + const socket = await getMemberJoinedLandingPage(); + socket.emit('joinBacklog'); + await initBacklog(socket); + const name = '회원'; + const color = 'yellow'; + const rankValue = LexoRank.middle().toString(); + let requestData: any = { + action: 'create', + content: { name, color, rankValue }, + }; + + socket.emit('epic', requestData); + const epicId = await getEpicId(socket); + + const title = '타이틀'; + const point = 2; + const status = '시작전'; + requestData = { + action: 'create', + content: { title, point, status, epicId, rankValue }, + }; + socket.emit('story', requestData); + socket.emit('story', requestData); + + await new Promise((resolve) => { + const flag = {}; + socket.on('backlog', async (data) => { + const { content, action, domain } = data; + if (domain === 'story' && action === 'create') { + flag[content.id] = content.rankValue === rankValue.toString(); + if ( + Object.values(flag).length === 2 && + Object.values(flag).includes(true) && + Object.values(flag).includes(false) + ) + resolve(); + } + }); + }); + + socket.close(); + }); }); describe('story delete', () => { @@ -312,6 +356,98 @@ describe('WS story', () => { }); socket.close(); }); + + it('should return updated story data when updating multiple stories simultaneously', async () => { + const socket = await getMemberJoinedLandingPage(); + socket.emit('joinBacklog'); + await initBacklog(socket); + + const name = '회원'; + const color = 'yellow'; + const middleRankValue = LexoRank.middle().toString(); + + let requestData: any = { + action: 'create', + content: { name, color, rankValue: middleRankValue }, + }; + socket.emit('epic', requestData); + const epicId = await getEpicId(socket); + + const title = '타이틀'; + const point = 2; + const status = '시작전'; + const rankValue1 = middleRankValue; + requestData = { + action: 'create', + content: { + title, + point, + status, + epicId, + rankValue: rankValue1, + }, + }; + socket.emit('story', requestData); + await waitCreateStoryNotify(socket); + + const rankValue2 = LexoRank.parse(rankValue1).genNext().toString(); + requestData.content.rankValue = rankValue2; + socket.emit('story', requestData); + await waitCreateStoryNotify(socket); + + const rankValue3 = LexoRank.parse(rankValue2).genNext().toString(); + requestData.content.rankValue = rankValue3; + socket.emit('story', requestData); + const storyId3 = await getStoryId(socket); + + const rankValue4 = LexoRank.parse(rankValue3).genNext().toString(); + requestData.content.rankValue = rankValue4; + socket.emit('story', requestData); + const storyId4 = await getStoryId(socket); + + const newRankValue = LexoRank.parse(rankValue1).between( + LexoRank.parse(rankValue2), + ); + const requestData3 = { + action: 'update', + content: { id: storyId3, rankValue: newRankValue.toString() }, + }; + + const requestData4 = { + action: 'update', + content: { id: storyId4, rankValue: newRankValue.toString() }, + }; + socket.emit('story', requestData3); + socket.emit('story', requestData4); + + await new Promise((resolve) => { + const flag = {}; + socket.on('backlog', async (data) => { + const { content, action, domain } = data; + if (domain === 'story' && action === 'update') { + flag[content.id] = content.rankValue === newRankValue.toString(); + if ( + Object.values(flag).length === 2 && + Object.values(flag).includes(true) && + Object.values(flag).includes(false) + ) + resolve(); + } + }); + }); + socket.close(); + }); + + const waitCreateStoryNotify = (socket) => { + return new Promise((resolve) => { + socket.on('backlog', async (data) => { + const { content, action, domain } = data; + if (domain === 'story' && action === 'create') { + resolve(); + } + }); + }); + }; }); }); diff --git a/backend/test/project/ws-backlog-page/ws-task.e2e-spec.ts b/backend/test/project/ws-backlog-page/ws-task.e2e-spec.ts index 139934b..399f4d7 100644 --- a/backend/test/project/ws-backlog-page/ws-task.e2e-spec.ts +++ b/backend/test/project/ws-backlog-page/ws-task.e2e-spec.ts @@ -185,6 +185,70 @@ describe('WS task', () => { }); }); }; + + it('should return created task data when creating multiple tasks simultaneously', async () => { + const socket = await getMemberJoinedLandingPage(); + socket.emit('joinBacklog'); + await initBacklog(socket); + const name = '회원'; + const color = 'yellow'; + const rankValue = LexoRank.middle().toString(); + let requestData: any = { + action: 'create', + content: { name, color, rankValue }, + }; + + socket.emit('epic', requestData); + const epicId = await getEpicId(socket); + + const title = '타이틀'; + const point = 2; + const status = '시작전'; + requestData = { + action: 'create', + content: { title, point, status, epicId, rankValue }, + }; + socket.emit('story', requestData); + const storyId = await getStoryId(socket); + + let taskTitle = '타이틀'; + let taskStatus = '시작전'; + let expectedTime = null; + let actualTime = null; + let assignedMemberId = null; + requestData = { + action: 'create', + content: { + title: taskTitle, + status: taskStatus, + storyId, + expectedTime, + actualTime, + assignedMemberId, + rankValue, + }, + }; + socket.emit('task', requestData); + socket.emit('task', requestData); + + await new Promise((resolve) => { + const flag = {}; + socket.on('backlog', async (data) => { + const { content, action, domain } = data; + if (domain === 'task' && action === 'create') { + flag[content.id] = content.rankValue === rankValue.toString(); + if ( + Object.values(flag).length === 2 && + Object.values(flag).includes(true) && + Object.values(flag).includes(false) + ) + resolve(); + } + }); + }); + + socket.close(); + }); }); describe('task delete', () => { @@ -593,6 +657,113 @@ describe('WS task', () => { socket.close(); }); + + it('should return updated task data when updating multiple tasks simultaneously', async () => { + const socket = await getMemberJoinedLandingPage(); + socket.emit('joinBacklog'); + await initBacklog(socket); + + const name = '회원'; + const color = 'yellow'; + const middleRankValue = LexoRank.middle().toString(); + + let requestData: any = { + action: 'create', + content: { name, color, rankValue: middleRankValue }, + }; + socket.emit('epic', requestData); + const epicId = await getEpicId(socket); + + const title = '타이틀'; + const point = 2; + const status = '시작전'; + requestData = { + action: 'create', + content: { title, point, status, epicId, rankValue: middleRankValue }, + }; + socket.emit('story', requestData); + const storyId = await getStoryId(socket); + + let taskTitle = '타이틀'; + let taskStatus = '시작전'; + let expectedTime = null; + let actualTime = null; + let assignedMemberId = null; + const rankValue1 = middleRankValue; + requestData = { + action: 'create', + content: { + title: taskTitle, + status: taskStatus, + storyId, + expectedTime, + actualTime, + assignedMemberId, + rankValue: rankValue1, + }, + }; + + socket.emit('task', requestData); + await waitCreateTaskNotify(socket); + + const rankValue2 = LexoRank.parse(rankValue1).genNext().toString(); + requestData.content.rankValue = rankValue2; + socket.emit('task', requestData); + await waitCreateTaskNotify(socket); + + const rankValue3 = LexoRank.parse(rankValue2).genNext().toString(); + requestData.content.rankValue = rankValue3; + socket.emit('task', requestData); + const taskId3 = await getTaskId(socket); + + const rankValue4 = LexoRank.parse(rankValue3).genNext().toString(); + requestData.content.rankValue = rankValue4; + socket.emit('task', requestData); + const taskId4 = await getTaskId(socket); + + const newRankValue = LexoRank.parse(rankValue1).between( + LexoRank.parse(rankValue2), + ); + + const requestData3 = { + action: 'update', + content: { id: taskId3, rankValue: newRankValue.toString() }, + }; + const requestData4 = { + action: 'update', + content: { id: taskId4, rankValue: newRankValue.toString() }, + }; + socket.emit('task', requestData3); + socket.emit('task', requestData4); + + await new Promise((resolve) => { + const flag = {}; + socket.on('backlog', async (data) => { + const { content, action, domain } = data; + if (domain === 'task' && action === 'update') { + flag[content.id] = content.rankValue === newRankValue.toString(); + if ( + Object.values(flag).length === 2 && + Object.values(flag).includes(true) && + Object.values(flag).includes(false) + ) + resolve(); + } + }); + }); + socket.close(); + }); + + const waitCreateTaskNotify = (socket) => { + return new Promise((resolve) => { + socket.on('backlog', async (data) => { + const { content, action, domain } = data; + if (domain === 'task' && action === 'create') { + resolve(); + } + }); + }); + }; }); });