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/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/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/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/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/epic.entity.ts b/backend/src/project/entity/epic.entity.ts index d0acec1..51376dd 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('EPIC_UQ_RANK_VALUE_AND_PROJECT_ID', ['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/entity/story.entity.ts b/backend/src/project/entity/story.entity.ts index 7f76456..ca74d06 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('STORY_UQ_RANK_VALUE_AND_EPIC_ID', ['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/entity/task.entity.ts b/backend/src/project/entity/task.entity.ts index 8a1efe4..e43d85e 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('TASK_UQ_RANK_VALUE_AND_STORY_ID', ['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 30cbe54..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 { @@ -134,6 +155,7 @@ export class ProjectRepository { id: number, name?: string, color?: EpicColor, + rankValue?: string, ): Promise { const updateData: any = {}; @@ -144,11 +166,24 @@ export class ProjectRepository { updateData.color = color; } - const result = await this.epicRepository.update( - { id, project: { id: project.id } }, - updateData, - ); - return !!result.affected; + if (rankValue !== undefined) { + updateData.rankValue = rankValue; + } + + 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) { @@ -157,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 { @@ -176,6 +230,7 @@ export class ProjectRepository { title: string | undefined, point: number | undefined, status: StoryStatus | undefined, + rankValue: string | undefined, ): Promise { const updateData: any = {}; @@ -191,12 +246,24 @@ 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 } }, - 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) { @@ -215,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 { @@ -236,6 +322,7 @@ export class ProjectRepository { actualTime: number | undefined, status: TaskStatus | undefined, assignedMemberId: number | undefined, + rankValue: string | undefined, ): Promise { const updateData: any = {}; @@ -257,12 +344,24 @@ 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 } }, - 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 2b727bf..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,9 +97,45 @@ export class ProjectService { return result ? true : false; } - createEpic(project: Project, name: string, color: EpicColor) { - const newEpic = Epic.of(project, name, color); - return this.projectRepository.createEpic(newEpic); + 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); + 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) { @@ -105,13 +143,45 @@ export class ProjectService { return result ? true : false; } - updateEpic( + async updateEpic( project: Project, id: number, name?: string, color?: EpicColor, - ): Promise { - return this.projectRepository.updateEpic(project, id, name, color); + rankValue?: string, + ) { + 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( @@ -120,11 +190,34 @@ 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); - return this.projectRepository.createStory(newStory); + + const maxRetries = 10; + let attempts = 0; + const newStory = Story.of(project, epicId, title, point, status, rankValue); + 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) { @@ -139,19 +232,49 @@ export class ProjectService { title: string | undefined, point: number | undefined, status: StoryStatus | undefined, - ): Promise { + rankValue: string | undefined, + ) { 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, - ); + + 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( @@ -162,12 +285,15 @@ 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'); const displayIdCount = await this.projectRepository.getAndIncrementDisplayIdCount(project); + const maxRetries = 10; + let attempts = 0; const newTask = Task.of( project, storyId, @@ -177,8 +303,27 @@ export class ProjectService { actualTime, assignedMemberId, 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) { @@ -195,24 +340,53 @@ export class ProjectService { actualTime: number | undefined, status: TaskStatus | undefined, assignedMemberId: number | undefined, - ): Promise { + rankValue: string | undefined, + ) { 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, - ); + + 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){ - 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..8a47d14 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, ), ); } @@ -66,19 +68,26 @@ export class WsProjectEpicController { return; } const { content } = data as EpicUpdateRequestDto; - const isUpdated = await this.projectService.updateEpic( - client.project, - content.id, - content.name, - content.color, - ); + const { isUpdated, updatedRankValue } = + await this.projectService.updateEpic( + client.project, + content.id, + content.name, + content.color, + content.rankValue, + ); if (isUpdated) { client.nsp .to('backlog') .emit( 'backlog', - EpicUpdateNotifyDto.of(content.id, content.name, content.color), + EpicUpdateNotifyDto.of( + content.id, + content.name, + content.color, + 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 d6abc6a..843773d 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') @@ -61,14 +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, - ); + 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 @@ -81,6 +84,7 @@ export class WsProjectStoryController { content.title, content.point, content.status, + 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 9563286..4ff4b6b 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') @@ -64,16 +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, - ); + 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 @@ -88,6 +91,7 @@ export class WsProjectTaskController { content.actualTime, content.status, content.assignedMemberId, + 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 beccb4a..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 @@ -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,10 +45,47 @@ describe('WS epic', () => { expect(content?.id).toBeDefined(); expect(content?.name).toBe(name); expect(content?.color).toBe(color); + expect(content?.rankValue).toBe(rankValue); resolve(); }); }); }; + + 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', () => { @@ -56,9 +96,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 +140,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 +179,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 +213,131 @@ 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(); + }); + + 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-init-backlog.e2e-spec.ts b/backend/test/project/ws-backlog-page/ws-init-backlog.e2e-spec.ts index 5886ad5..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 @@ -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,14 @@ describe('WS epic', () => { await initBacklog(socket1); const epicName = '회원'; const epicColor = 'yellow'; + const middleRankValue = LexoRank.middle().toString(); socket1.emit('epic', { action: 'create', - content: { name: epicName, color: epicColor }, + 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); @@ -51,6 +56,7 @@ describe('WS epic', () => { status: taskStatus, assignedMemberId: taskAssignedMemberId, storyId, + rankValue: middleRankValue, }, }); @@ -69,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); @@ -84,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); } 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..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 @@ -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 middleRankValue = LexoRank.middle().toString(); let requestData: any = { action: 'create', - content: { name, color }, + content: { name, color, rankValue: middleRankValue }, }; socket1.emit('epic', requestData); const [epicId] = await Promise.all([ @@ -36,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; @@ -58,10 +60,55 @@ describe('WS story', () => { expect(content?.epicId).toBe(epicId); expect(content?.title).toBe(title); expect(content?.point).toBe(point); + expect(content?.rankValue).toBe(rankValue); resolve(); }); }); }; + + 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', () => { @@ -72,9 +119,11 @@ describe('WS story', () => { const name = '회원'; const color = 'yellow'; + const middleRankValue = LexoRank.middle().toString(); + let requestData: any = { action: 'create', - content: { name, color }, + content: { name, color, rankValue: middleRankValue }, }; socket.emit('epic', requestData); const [epicId] = await Promise.all([getEpicId(socket)]); @@ -84,7 +133,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); @@ -119,9 +168,11 @@ describe('WS story', () => { const name = '회원'; const color = 'yellow'; + const middleRankValue = LexoRank.middle().toString(); + let requestData: any = { action: 'create', - content: { name, color }, + content: { name, color, rankValue: middleRankValue }, }; socket.emit('epic', requestData); const [epicId] = await Promise.all([getEpicId(socket)]); @@ -131,7 +182,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); @@ -167,9 +218,10 @@ describe('WS story', () => { const name = '회원'; const color = 'yellow'; + const middleRankValue = LexoRank.middle().toString(); let requestData: any = { action: 'create', - content: { name, color }, + content: { name, color, rankValue: middleRankValue }, }; socket.emit('epic', requestData); const [epicId] = await Promise.all([getEpicId(socket)]); @@ -179,7 +231,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); @@ -196,6 +248,206 @@ 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(); + }); + + 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 2118840..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 @@ -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 middleRankValue = LexoRank.middle().toString(); let requestData: any = { action: 'create', - content: { name, color }, + content: { name, color, rankValue: middleRankValue }, }; socket1.emit('epic', requestData); const [epicId] = await Promise.all([ @@ -41,6 +43,7 @@ describe('WS task', () => { point: storyPoint, status: storyStatus, epicId, + rankValue: middleRankValue, }, }; socket1.emit('story', requestData); @@ -58,6 +61,7 @@ describe('WS task', () => { null, null, null, + middleRankValue, ); await testCreateTask( @@ -69,6 +73,7 @@ describe('WS task', () => { 2.1, 3.3, null, + LexoRank.parse(middleRankValue).genNext().toString(), ); await testCreateTask( @@ -80,6 +85,7 @@ describe('WS task', () => { null, null, null, + LexoRank.parse(middleRankValue).genNext().genNext().toString(), ); await testCreateTask( @@ -91,6 +97,11 @@ describe('WS task', () => { null, null, null, + LexoRank.parse(middleRankValue) + .genNext() + .genNext() + .genNext() + .toString(), ); socket1.close(); @@ -106,6 +117,7 @@ describe('WS task', () => { expectedTime, actualTime, assignedMemberId, + rankValue, ) => { const requestData = { action: 'create', @@ -116,6 +128,7 @@ describe('WS task', () => { expectedTime, actualTime, assignedMemberId, + rankValue, }, }; socket1.emit('task', requestData); @@ -129,6 +142,7 @@ describe('WS task', () => { expectedTime, actualTime, assignedMemberId, + rankValue, ), expectCreateTask( socket2, @@ -138,6 +152,7 @@ describe('WS task', () => { expectedTime, actualTime, assignedMemberId, + rankValue, ), ]); }; @@ -150,6 +165,7 @@ describe('WS task', () => { expectedTime, actualTime, assignedMemberId, + rankValue, ) => { return new Promise((resolve) => { socket.once('backlog', async (data) => { @@ -164,10 +180,75 @@ describe('WS task', () => { expect(content?.actualTime).toBe(actualTime); expect(content?.expectedTime).toBe(expectedTime); expect(content?.assignedMemberId).toBe(assignedMemberId); + expect(content?.rankValue).toBe(rankValue); resolve(); }); }); }; + + 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', () => { @@ -178,9 +259,11 @@ describe('WS task', () => { const name = '회원'; const color = 'yellow'; + const middleRankValue = LexoRank.middle().toString(); + let requestData: any = { action: 'create', - content: { name, color }, + content: { name, color, rankValue: middleRankValue }, }; socket.emit('epic', requestData); const [epicId] = await Promise.all([getEpicId(socket)]); @@ -190,7 +273,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); @@ -209,6 +292,7 @@ describe('WS task', () => { expectedTime, actualTime, assignedMemberId, + rankValue: middleRankValue, }, }; socket.emit('task', requestData); @@ -245,9 +329,10 @@ describe('WS task', () => { const name = '회원'; const color = 'yellow'; + const middleRankValue = LexoRank.middle().toString(); let requestData: any = { action: 'create', - content: { name, color }, + content: { name, color, rankValue: middleRankValue }, }; socket.emit('epic', requestData); const [epicId] = await Promise.all([getEpicId(socket)]); @@ -257,7 +342,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); @@ -276,6 +361,7 @@ describe('WS task', () => { expectedTime, actualTime, assignedMemberId, + rankValue: middleRankValue, }, }; socket.emit('task', requestData); @@ -353,9 +439,10 @@ describe('WS task', () => { const name = '회원'; const color = 'yellow'; + const middleRankValue = LexoRank.middle().toString(); let requestData: any = { action: 'create', - content: { name, color }, + content: { name, color, rankValue: middleRankValue }, }; socket.emit('epic', requestData); const [epicId] = await Promise.all([getEpicId(socket)]); @@ -365,7 +452,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); @@ -384,6 +471,7 @@ describe('WS task', () => { expectedTime, actualTime, assignedMemberId, + rankValue: middleRankValue, }, }; socket.emit('task', requestData); @@ -415,6 +503,267 @@ 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(); + }); + + 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(); + } + }); + }); + }; }); });