diff --git a/.github/workflows/BE-deploy.yml b/.github/workflows/BE-deploy.yml index 0ae4aeef..292b9a27 100644 --- a/.github/workflows/BE-deploy.yml +++ b/.github/workflows/BE-deploy.yml @@ -2,7 +2,9 @@ name: BE-deploy on: push: branches: - - "BE-develop" + - "release" + paths: + - "nestjs-BE/**" jobs: build: runs-on: ubuntu-latest @@ -29,7 +31,6 @@ jobs: echo "BASE_IMAGE_URL=$BASE_IMAGE_URL" >> ./nestjs-BE/server/.env echo "BUCKET_NAME=$BUCKET_NAME" >> ./nestjs-BE/server/.env echo "APP_ICON_URL=$APP_ICON_URL" >> ./nestjs-BE/server/.env - echo "CSV_FOLDER=$CSV_FOLDER" >> ./nestjs-BE/server/.env docker build -t ghcr.io/${{ secrets.PACKAGE_USERNAME }}/mindsync ./nestjs-BE/server docker push ghcr.io/${{ secrets.PACKAGE_USERNAME }}/mindsync:latest env: @@ -47,7 +48,6 @@ jobs: BASE_IMAGE_URL: ${{ secrets.BASE_IMAGE_URL }} BUCKET_NAME: ${{ secrets.BUCKET_NAME }} APP_ICON_URL: ${{ secrets.APP_ICON_URL }} - CSV_FOLDER: ${{ secrets.CSV_FOLDER }} deploy: needs: build @@ -61,12 +61,12 @@ jobs: username: ${{ secrets.REMOTE_USER }} key: ${{ secrets.REMOTE_SSH_KEY }} script: | - echo ${{ secrets.PACKAGE_ACCESS_TOKEN }} | docker login ghcr.io -u ${{ secrets.PACKAGE_USERNAME }} --password-stdin - docker pull ghcr.io/${{ secrets.PACKAGE_USERNAME }}/mindsync - docker stop mindsync_server || true - docker rm mindsync_server || true - docker run -d \ + echo ${{ secrets.PACKAGE_ACCESS_TOKEN }} | sudo docker login ghcr.io -u ${{ secrets.PACKAGE_USERNAME }} --password-stdin + sudo docker pull ghcr.io/${{ secrets.PACKAGE_USERNAME }}/mindsync + sudo docker stop mindsync_server || true + sudo docker rm mindsync_server || true + sudo docker run -d \ --name mindsync_server \ -p ${{ secrets.SERVER_PORT }}:${{ secrets.CONTAINER_PORT }} \ - -v temporary-volume:${{ secrets.CSV_FOLDER }} \ + --net mybridge \ ghcr.io/${{ secrets.PACKAGE_USERNAME }}/mindsync diff --git a/nestjs-BE/server/.eslintrc.js b/nestjs-BE/server/.eslintrc.js index 5d07f54b..3b453c1b 100644 --- a/nestjs-BE/server/.eslintrc.js +++ b/nestjs-BE/server/.eslintrc.js @@ -23,7 +23,6 @@ module.exports = { '@typescript-eslint/no-explicit-any': 'off', 'max-depth': ['error', 3], 'no-magic-numbers': ['error', { ignore: [-1, 0, 1] }], - 'curly': ['error', 'multi-line', 'consistent'], - 'max-params': ['error', 3], + curly: ['error', 'multi-line', 'consistent'], }, }; diff --git a/nestjs-BE/server/.gitignore b/nestjs-BE/server/.gitignore index 4af1e623..2d2fb1cc 100644 --- a/nestjs-BE/server/.gitignore +++ b/nestjs-BE/server/.gitignore @@ -32,7 +32,3 @@ lerna-debug.log* # Environment Variable File .env - -# csv, prisma -/operations -/prisma/generated diff --git a/nestjs-BE/server/Dockerfile b/nestjs-BE/server/Dockerfile index 26d49afc..cf5094f8 100644 --- a/nestjs-BE/server/Dockerfile +++ b/nestjs-BE/server/Dockerfile @@ -2,16 +2,16 @@ FROM node:20.4.0-alpine WORKDIR /server +ENV NODE_OPTIONS="--max-old-space-size=2048" + COPY package.json package-lock.json ./ RUN npm ci COPY ./ ./ -RUN npx prisma generate --schema=./prisma/mysql.schema.prisma - -RUN npx prisma generate --schema=./prisma/mongodb.schema.prisma - EXPOSE 3000 -CMD ["npm", "start"] +RUN chmod +x ./entrypoint.sh + +ENTRYPOINT ["./entrypoint.sh"] diff --git a/nestjs-BE/server/entrypoint.sh b/nestjs-BE/server/entrypoint.sh new file mode 100644 index 00000000..f158f6fb --- /dev/null +++ b/nestjs-BE/server/entrypoint.sh @@ -0,0 +1,3 @@ +#!/bin/sh +npx prisma migrate deploy +npm start diff --git a/nestjs-BE/server/package-lock.json b/nestjs-BE/server/package-lock.json index 9419e02f..60809c68 100644 --- a/nestjs-BE/server/package-lock.json +++ b/nestjs-BE/server/package-lock.json @@ -47,6 +47,7 @@ "eslint-config-prettier": "^9.0.0", "eslint-plugin-prettier": "^5.0.0", "jest": "^29.5.0", + "jest-mock-extended": "^3.0.7", "prettier": "^3.0.0", "prisma": "^5.6.0", "source-map-support": "^0.5.21", @@ -6225,6 +6226,19 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/jest-mock-extended": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/jest-mock-extended/-/jest-mock-extended-3.0.7.tgz", + "integrity": "sha512-7lsKdLFcW9B9l5NzZ66S/yTQ9k8rFtnwYdCNuRU/81fqDWicNDVhitTSPnrGmNeNm0xyw0JHexEOShrIKRCIRQ==", + "dev": true, + "dependencies": { + "ts-essentials": "^10.0.0" + }, + "peerDependencies": { + "jest": "^24.0.0 || ^25.0.0 || ^26.0.0 || ^27.0.0 || ^28.0.0 || ^29.0.0", + "typescript": "^3.0.0 || ^4.0.0 || ^5.0.0" + } + }, "node_modules/jest-pnp-resolver": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", @@ -9039,6 +9053,20 @@ "typescript": ">=4.2.0" } }, + "node_modules/ts-essentials": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/ts-essentials/-/ts-essentials-10.0.0.tgz", + "integrity": "sha512-77FHNJEyysF9+1s4G6eejuA1lxw7uMchT3ZPy3CIbh7GIunffpshtM8pTe5G6N5dpOzNevqRHew859ceLWVBfw==", + "dev": true, + "peerDependencies": { + "typescript": ">=4.5.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/ts-jest": { "version": "29.1.1", "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.1.1.tgz", diff --git a/nestjs-BE/server/package.json b/nestjs-BE/server/package.json index 4abe9280..adc41086 100644 --- a/nestjs-BE/server/package.json +++ b/nestjs-BE/server/package.json @@ -58,6 +58,7 @@ "eslint-config-prettier": "^9.0.0", "eslint-plugin-prettier": "^5.0.0", "jest": "^29.5.0", + "jest-mock-extended": "^3.0.7", "prettier": "^3.0.0", "prisma": "^5.6.0", "source-map-support": "^0.5.21", diff --git a/nestjs-BE/server/prisma/migrations/20231206093559_init/migration.sql b/nestjs-BE/server/prisma/migrations/20231213081134_init/migration.sql similarity index 94% rename from nestjs-BE/server/prisma/migrations/20231206093559_init/migration.sql rename to nestjs-BE/server/prisma/migrations/20231213081134_init/migration.sql index 72079c55..8f3b2597 100644 --- a/nestjs-BE/server/prisma/migrations/20231206093559_init/migration.sql +++ b/nestjs-BE/server/prisma/migrations/20231213081134_init/migration.sql @@ -11,11 +11,9 @@ CREATE TABLE `USER_TB` ( -- CreateTable CREATE TABLE `REFRESH_TOKEN_TB` ( `uuid` VARCHAR(32) NOT NULL, - `token` VARCHAR(191) NOT NULL, `expiry_date` DATETIME(3) NOT NULL, `user_id` VARCHAR(191) NOT NULL, - UNIQUE INDEX `REFRESH_TOKEN_TB_token_key`(`token`), PRIMARY KEY (`uuid`) ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; @@ -44,7 +42,7 @@ CREATE TABLE `PROFILE_SPACE_TB` ( `space_uuid` VARCHAR(32) NOT NULL, `profile_uuid` VARCHAR(32) NOT NULL, - PRIMARY KEY (`space_uuid`, `profile_uuid`) + UNIQUE INDEX `PROFILE_SPACE_TB_space_uuid_profile_uuid_key`(`space_uuid`, `profile_uuid`) ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; -- CreateTable diff --git a/nestjs-BE/server/prisma/migrations/20240430062129_set_max_length/migration.sql b/nestjs-BE/server/prisma/migrations/20240430062129_set_max_length/migration.sql new file mode 100644 index 00000000..1d5ef029 --- /dev/null +++ b/nestjs-BE/server/prisma/migrations/20240430062129_set_max_length/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - You are about to alter the column `nickname` on the `PROFILE_TB` table. The data in that column could be lost. The data in that column will be cast from `VarChar(191)` to `VarChar(20)`. + - You are about to alter the column `name` on the `SPACE_TB` table. The data in that column could be lost. The data in that column will be cast from `VarChar(191)` to `VarChar(20)`. + +*/ +-- AlterTable +ALTER TABLE `PROFILE_TB` MODIFY `nickname` VARCHAR(20) NOT NULL; + +-- AlterTable +ALTER TABLE `SPACE_TB` MODIFY `name` VARCHAR(20) NOT NULL; diff --git a/nestjs-BE/server/prisma/migrations/20240430064956_rename_user_model/migration.sql b/nestjs-BE/server/prisma/migrations/20240430064956_rename_user_model/migration.sql new file mode 100644 index 00000000..b03db2d1 --- /dev/null +++ b/nestjs-BE/server/prisma/migrations/20240430064956_rename_user_model/migration.sql @@ -0,0 +1,5 @@ +-- RenameIndex +ALTER TABLE `USER_TB` RENAME INDEX `USER_TB_email_provider_key` TO `User_email_provider_key`; + +-- RenameTable +ALTER TABLE `USER_TB` RENAME `User`; diff --git a/nestjs-BE/server/prisma/migrations/20240430082846_rename_profile_model/migration.sql b/nestjs-BE/server/prisma/migrations/20240430082846_rename_profile_model/migration.sql new file mode 100644 index 00000000..67df03ad --- /dev/null +++ b/nestjs-BE/server/prisma/migrations/20240430082846_rename_profile_model/migration.sql @@ -0,0 +1,11 @@ +-- DropForeignKey +ALTER TABLE `PROFILE_TB` DROP FOREIGN KEY `PROFILE_TB_user_id_fkey`; + +-- RenameIndex +ALTER TABLE `PROFILE_TB` RENAME INDEX `PROFILE_TB_user_id_key` TO `Profile_user_id_key`; + +-- RenameTable +ALTER TABLE `PROFILE_TB` RENAME `Profile`; + +-- AddForeignKey +ALTER TABLE `Profile` ADD CONSTRAINT `Profile_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `User`(`uuid`) ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/nestjs-BE/server/prisma/migrations/20240509025953_rename_models/migration.sql b/nestjs-BE/server/prisma/migrations/20240509025953_rename_models/migration.sql new file mode 100644 index 00000000..0c76981a --- /dev/null +++ b/nestjs-BE/server/prisma/migrations/20240509025953_rename_models/migration.sql @@ -0,0 +1,26 @@ +-- DropForeignKey +ALTER TABLE `INVITE_CODE_TB` DROP FOREIGN KEY `INVITE_CODE_TB_space_uuid_fkey`; + +-- DropForeignKey +ALTER TABLE `PROFILE_SPACE_TB` DROP FOREIGN KEY `PROFILE_SPACE_TB_profile_uuid_fkey`; + +-- DropForeignKey +ALTER TABLE `PROFILE_SPACE_TB` DROP FOREIGN KEY `PROFILE_SPACE_TB_space_uuid_fkey`; + +-- RenameIndex +ALTER TABLE `PROFILE_SPACE_TB` RENAME INDEX `PROFILE_SPACE_TB_space_uuid_profile_uuid_key` TO `Profile_space_space_uuid_profile_uuid_key`; + +-- RenameTable +ALTER TABLE `PROFILE_SPACE_TB` RENAME `Profile_space`; + +-- RenameTable +ALTER TABLE `SPACE_TB` RENAME `Space`; + +-- AddForeignKey +ALTER TABLE `Profile_space` ADD CONSTRAINT `Profile_space_space_uuid_fkey` FOREIGN KEY (`space_uuid`) REFERENCES `Space`(`uuid`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `Profile_space` ADD CONSTRAINT `Profile_space_profile_uuid_fkey` FOREIGN KEY (`profile_uuid`) REFERENCES `Profile`(`uuid`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `INVITE_CODE_TB` ADD CONSTRAINT `INVITE_CODE_TB_space_uuid_fkey` FOREIGN KEY (`space_uuid`) REFERENCES `Space`(`uuid`) ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/nestjs-BE/server/prisma/migrations/20240511061416_rename_refresh_token_invite_code/migration.sql b/nestjs-BE/server/prisma/migrations/20240511061416_rename_refresh_token_invite_code/migration.sql new file mode 100644 index 00000000..56982598 --- /dev/null +++ b/nestjs-BE/server/prisma/migrations/20240511061416_rename_refresh_token_invite_code/migration.sql @@ -0,0 +1,20 @@ +-- DropForeignKey +ALTER TABLE `INVITE_CODE_TB` DROP FOREIGN KEY `INVITE_CODE_TB_space_uuid_fkey`; + +-- DropForeignKey +ALTER TABLE `REFRESH_TOKEN_TB` DROP FOREIGN KEY `REFRESH_TOKEN_TB_user_id_fkey`; + +-- RenameIndex +ALTER TABLE `INVITE_CODE_TB` RENAME INDEX `INVITE_CODE_TB_invite_code_key` TO `InviteCode_invite_code_key`; + +-- RenameTable +ALTER TABLE `INVITE_CODE_TB` RENAME `InviteCode`; + +-- RenameTable +ALTER TABLE `REFRESH_TOKEN_TB` RENAME `RefreshToken`; + +-- AddForeignKey +ALTER TABLE `RefreshToken` ADD CONSTRAINT `RefreshToken_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `User`(`uuid`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `InviteCode` ADD CONSTRAINT `InviteCode_space_uuid_fkey` FOREIGN KEY (`space_uuid`) REFERENCES `Space`(`uuid`) ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/nestjs-BE/server/prisma/migrations/20240513072450_fix_refresh_token/migration.sql b/nestjs-BE/server/prisma/migrations/20240513072450_fix_refresh_token/migration.sql new file mode 100644 index 00000000..b533af71 --- /dev/null +++ b/nestjs-BE/server/prisma/migrations/20240513072450_fix_refresh_token/migration.sql @@ -0,0 +1,17 @@ +/* + Warnings: + + - The primary key for the `RefreshToken` table will be changed. If it partially fails, the table could be left without primary key constraint. + - Added the required column `id` to the `RefreshToken` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE `RefreshToken` DROP PRIMARY KEY, + ADD COLUMN `id` INTEGER NOT NULL AUTO_INCREMENT, + RENAME COLUMN `uuid` TO `token`, + ADD PRIMARY KEY (`id`); + +ALTER TABLE `RefreshToken` MODIFY `token` VARCHAR(210) NOT NULL; + +-- CreateIndex +CREATE INDEX `RefreshToken_token_idx` ON `RefreshToken`(`token`); diff --git a/nestjs-BE/server/prisma/migrations/20240513082711_alter_index/migration.sql b/nestjs-BE/server/prisma/migrations/20240513082711_alter_index/migration.sql new file mode 100644 index 00000000..0ef3796d --- /dev/null +++ b/nestjs-BE/server/prisma/migrations/20240513082711_alter_index/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - A unique constraint covering the columns `[token]` on the table `RefreshToken` will be added. If there are existing duplicate values, this will fail. + +*/ +-- DropIndex +DROP INDEX `RefreshToken_token_idx` ON `RefreshToken`; + +-- CreateIndex +CREATE UNIQUE INDEX `RefreshToken_token_key` ON `RefreshToken`(`token`); diff --git a/nestjs-BE/server/prisma/mongodb.schema.prisma b/nestjs-BE/server/prisma/mongodb.schema.prisma deleted file mode 100644 index 7fbb0264..00000000 --- a/nestjs-BE/server/prisma/mongodb.schema.prisma +++ /dev/null @@ -1,14 +0,0 @@ -generator client { - provider = "prisma-client-js" - output = "./generated/mongodb" -} - -datasource db { - provider = "mongodb" - url = env("MONGODB_DATABASE_URL") -} - -model BoardCollection { - uuid String @id @map("_id") - data Json -} \ No newline at end of file diff --git a/nestjs-BE/server/prisma/mysql.schema.prisma b/nestjs-BE/server/prisma/schema.prisma similarity index 51% rename from nestjs-BE/server/prisma/mysql.schema.prisma rename to nestjs-BE/server/prisma/schema.prisma index 30b53a6e..cd971414 100644 --- a/nestjs-BE/server/prisma/mysql.schema.prisma +++ b/nestjs-BE/server/prisma/schema.prisma @@ -1,6 +1,5 @@ generator client { provider = "prisma-client-js" - output = "./generated/mysql" } datasource db { @@ -8,53 +7,53 @@ datasource db { url = env("MYSQL_DATABASE_URL") } -model USER_TB { +model User { uuid String @id @db.VarChar(32) email String provider String - profiles PROFILE_TB[] - refresh_tokens REFRESH_TOKEN_TB[] + profiles Profile[] + refresh_tokens RefreshToken[] @@unique([email, provider]) } -model REFRESH_TOKEN_TB { - uuid String @id @db.VarChar(32) - token String @unique +model RefreshToken { + id Int @id @default(autoincrement()) + token String @db.VarChar(210) expiry_date DateTime user_id String - user USER_TB @relation(fields: [user_id], references: [uuid], onDelete: Cascade) + user User @relation(fields: [user_id], references: [uuid], onDelete: Cascade) + @@unique([token]) } -model PROFILE_TB { +model Profile { uuid String @id @db.VarChar(32) user_id String @unique @db.VarChar(32) image String - nickname String - user USER_TB @relation(fields: [user_id], references: [uuid], onDelete: Cascade) - spaces PROFILE_SPACE_TB[] + nickname String @db.VarChar(20) + user User @relation(fields: [user_id], references: [uuid], onDelete: Cascade) + spaces Profile_space[] } -model SPACE_TB { +model Space { uuid String @id @db.VarChar(32) - name String + name String @db.VarChar(20) icon String - profiles PROFILE_SPACE_TB[] - invite_codes INVITE_CODE_TB[] + profiles Profile_space[] + invite_codes InviteCode[] } -model PROFILE_SPACE_TB { +model Profile_space { space_uuid String @db.VarChar(32) profile_uuid String @db.VarChar(32) - space SPACE_TB @relation(fields: [space_uuid], references: [uuid], onDelete: Cascade) - profile PROFILE_TB @relation(fields: [profile_uuid], references: [uuid], onDelete: Cascade) + space Space @relation(fields: [space_uuid], references: [uuid], onDelete: Cascade) + profile Profile @relation(fields: [profile_uuid], references: [uuid], onDelete: Cascade) @@unique([space_uuid, profile_uuid]) } -model INVITE_CODE_TB { +model InviteCode { uuid String @id @db.VarChar(32) invite_code String @unique @db.VarChar(10) space_uuid String @db.VarChar(32) expiry_date DateTime - space SPACE_TB @relation(fields: [space_uuid], references: [uuid], onDelete: Cascade) + space Space @relation(fields: [space_uuid], references: [uuid], onDelete: Cascade) } - diff --git a/nestjs-BE/server/src/app.module.ts b/nestjs-BE/server/src/app.module.ts index 59b27d08..bb479ceb 100644 --- a/nestjs-BE/server/src/app.module.ts +++ b/nestjs-BE/server/src/app.module.ts @@ -4,7 +4,6 @@ import { AppService } from './app.service'; import { AuthModule } from './auth/auth.module'; import { UsersModule } from './users/users.module'; import { PrismaModule } from './prisma/prisma.module'; -import { TemporaryDatabaseModule } from './temporary-database/temporary-database.module'; import { ProfilesModule } from './profiles/profiles.module'; import { SpacesModule } from './spaces/spaces.module'; import { BoardsModule } from './boards/boards.module'; @@ -21,7 +20,6 @@ import customEnv from './config/env'; AuthModule, UsersModule, PrismaModule, - TemporaryDatabaseModule, ScheduleModule.forRoot(), ProfilesModule, SpacesModule, diff --git a/nestjs-BE/server/src/auth/auth.controller.spec.ts b/nestjs-BE/server/src/auth/auth.controller.spec.ts new file mode 100644 index 00000000..af77ba17 --- /dev/null +++ b/nestjs-BE/server/src/auth/auth.controller.spec.ts @@ -0,0 +1,143 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AuthController } from './auth.controller'; +import { PrismaService } from '../prisma/prisma.service'; +import { DeepMockProxy, mockDeep } from 'jest-mock-extended'; +import { PrismaClient, RefreshToken, User } from '@prisma/client'; +import { AuthService } from './auth.service'; +import { UsersService } from '../users/users.service'; +import { RefreshTokensService } from './refresh-tokens.service'; +import { ProfilesService } from '../profiles/profiles.service'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; + +describe('AuthController', () => { + let controller: AuthController; + let refreshTokensService: DeepMockProxy; + let usersService: DeepMockProxy; + let authService: DeepMockProxy; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [AuthController], + providers: [ + AuthService, + PrismaService, + UsersService, + ProfilesService, + RefreshTokensService, + ], + }) + .overrideProvider(AuthService) + .useValue(mockDeep()) + .overrideProvider(PrismaService) + .useValue(mockDeep()) + .overrideProvider(UsersService) + .useValue(mockDeep()) + .overrideProvider(ProfilesService) + .useValue(mockDeep()) + .overrideProvider(RefreshTokensService) + .useValue(mockDeep()) + .compile(); + + controller = module.get(AuthController); + refreshTokensService = module.get(RefreshTokensService); + authService = module.get(AuthService); + usersService = module.get(UsersService); + }); + + it('kakaoLogin user have been logged in', async () => { + const requestMock = { kakaoUserId: 0 }; + const kakaoUserAccountMock = { email: 'kakao email' }; + const tokenMock = { + refresh_token: 'refresh token', + access_token: 'access token', + }; + authService.getKakaoAccount.mockResolvedValue(kakaoUserAccountMock); + usersService.findUserByEmailAndProvider.mockResolvedValue({ + uuid: 'user uuid', + } as User); + authService.login.mockResolvedValue(tokenMock); + + const response = controller.kakaoLogin(requestMock); + + await expect(response).resolves.toEqual({ + statusCode: 200, + message: 'Success', + data: tokenMock, + }); + expect(usersService.createUser).not.toHaveBeenCalled(); + }); + + it('kakaoLogin user login first time', async () => { + const requestMock = { kakaoUserId: 0 }; + const kakaoUserAccountMock = { email: 'kakao email' }; + const tokenMock = { + refresh_token: 'refresh token', + access_token: 'access token', + }; + authService.getKakaoAccount.mockResolvedValue(kakaoUserAccountMock); + usersService.createUser.mockResolvedValue({ uuid: 'user uuid' } as User); + authService.login.mockResolvedValue(tokenMock); + + const response = controller.kakaoLogin(requestMock); + + await expect(response).resolves.toEqual({ + statusCode: 200, + message: 'Success', + data: tokenMock, + }); + expect(usersService.createUser).toHaveBeenCalled(); + }); + + it('kakaoLogin kakao login fail', async () => { + const requestMock = { kakaoUserId: 0 }; + authService.getKakaoAccount.mockResolvedValue(null); + + const response = controller.kakaoLogin(requestMock); + + await expect(response).rejects.toThrow(NotFoundException); + }); + + it('renewAccessToken respond new access token', async () => { + const requestMock = { refresh_token: 'refresh token' }; + authService.renewAccessToken.mockResolvedValue('new access token'); + + const response = controller.renewAccessToken(requestMock); + + await expect(response).resolves.toEqual({ + statusCode: 200, + message: 'Success', + data: { access_token: 'new access token' }, + }); + }); + + it('renewAccessToken received expired token', async () => { + const requestMock = { refresh_token: 'refresh token' }; + authService.renewAccessToken.mockRejectedValue(new Error()); + + const response = controller.renewAccessToken(requestMock); + + await expect(response).rejects.toThrow(Error); + }); + + it('logout received token deleted', async () => { + const requestMock = { refresh_token: 'refresh token' }; + const token = {} as RefreshToken; + refreshTokensService.deleteRefreshToken.mockResolvedValue(token); + + const response = controller.logout(requestMock); + + await expect(response).resolves.toEqual({ + statusCode: 204, + message: 'No Content', + }); + }); + + it('logout received token not found', async () => { + const requestMock = { refresh_token: 'bad refresh token' }; + refreshTokensService.deleteRefreshToken.mockResolvedValue(null); + + const response = controller.logout(requestMock); + + await expect(response).rejects.toThrow(BadRequestException); + }); +}); diff --git a/nestjs-BE/server/src/auth/auth.controller.ts b/nestjs-BE/server/src/auth/auth.controller.ts index aad67c36..c31533fd 100644 --- a/nestjs-BE/server/src/auth/auth.controller.ts +++ b/nestjs-BE/server/src/auth/auth.controller.ts @@ -1,11 +1,19 @@ -import { Controller, Post, Body, NotFoundException } from '@nestjs/common'; +import { + Controller, + Post, + Body, + NotFoundException, + BadRequestException, +} from '@nestjs/common'; import { AuthService } from './auth.service'; import { Public } from './public.decorator'; import { KakaoUserDto } from './dto/kakao-user.dto'; -import { UsersService } from 'src/users/users.service'; +import { UsersService } from '../users/users.service'; import { RefreshTokenDto } from './dto/refresh-token.dto'; -import { ProfilesService } from 'src/profiles/profiles.service'; +import { ProfilesService } from '../profiles/profiles.service'; import { ApiTags, ApiResponse, ApiOperation } from '@nestjs/swagger'; +import customEnv from '../config/env'; +import { RefreshTokensService } from './refresh-tokens.service'; @Controller('auth') @ApiTags('auth') @@ -14,6 +22,7 @@ export class AuthController { private authService: AuthService, private usersService: UsersService, private profilesService: ProfilesService, + private refreshTokensService: RefreshTokensService, ) {} @Post('kakao-oauth') @@ -33,20 +42,24 @@ export class AuthController { ); if (!kakaoUserAccount) throw new NotFoundException(); const email = kakaoUserAccount.email; - let userUuid = await this.authService.findUser( - this.usersService, + const user = await this.usersService.findUserByEmailAndProvider( email, 'kakao', ); + let userUuid = user?.uuid; if (!userUuid) { const data = { email, provider: 'kakao' }; - userUuid = await this.authService.createUser( - data, - this.usersService, - this.profilesService, - ); + const createdUser = await this.usersService.createUser(data); + userUuid = createdUser.uuid; + const profileData = { + user_id: createdUser.uuid, + image: customEnv.BASE_IMAGE_URL, + nickname: '익명의 사용자', + }; + await this.profilesService.createProfile(profileData); } - return this.authService.login(userUuid); + const tokenData = await this.authService.login(userUuid); + return { statusCode: 200, message: 'Success', data: tokenData }; } @Post('token') @@ -60,15 +73,23 @@ export class AuthController { status: 401, description: 'Refresh token expired. Please log in again.', }) - renewAccessToken(@Body() refreshTokenDto: RefreshTokenDto) { + async renewAccessToken(@Body() refreshTokenDto: RefreshTokenDto) { const refreshToken = refreshTokenDto.refresh_token; - return this.authService.renewAccessToken(refreshToken); + const accessToken = await this.authService.renewAccessToken(refreshToken); + return { + statusCode: 200, + message: 'Success', + data: { access_token: accessToken }, + }; } @Post('logout') @Public() - logout(@Body() refreshTokenDto: RefreshTokenDto) { + async logout(@Body() refreshTokenDto: RefreshTokenDto) { const refreshToken = refreshTokenDto.refresh_token; - return this.authService.remove(refreshToken); + const token = + await this.refreshTokensService.deleteRefreshToken(refreshToken); + if (!token) throw new BadRequestException(); + return { statusCode: 204, message: 'No Content' }; } } diff --git a/nestjs-BE/server/src/auth/auth.module.ts b/nestjs-BE/server/src/auth/auth.module.ts index 14b7cccd..027a13dc 100644 --- a/nestjs-BE/server/src/auth/auth.module.ts +++ b/nestjs-BE/server/src/auth/auth.module.ts @@ -8,6 +8,7 @@ import { JwtStrategy } from './jwt.strategy'; import { APP_GUARD } from '@nestjs/core'; import { JwtAuthGuard } from './jwt-auth.guard'; import { ProfilesModule } from 'src/profiles/profiles.module'; +import { RefreshTokensService } from './refresh-tokens.service'; @Module({ imports: [UsersModule, PassportModule, JwtModule, ProfilesModule], @@ -16,6 +17,7 @@ import { ProfilesModule } from 'src/profiles/profiles.module'; AuthService, JwtStrategy, { provide: APP_GUARD, useClass: JwtAuthGuard }, + RefreshTokensService, ], exports: [AuthService], }) diff --git a/nestjs-BE/server/src/auth/auth.service.spec.ts b/nestjs-BE/server/src/auth/auth.service.spec.ts new file mode 100644 index 00000000..60f044f3 --- /dev/null +++ b/nestjs-BE/server/src/auth/auth.service.spec.ts @@ -0,0 +1,75 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AuthService } from './auth.service'; +import { DeepMockProxy, mockDeep } from 'jest-mock-extended'; +import { PrismaClient, RefreshToken } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; +import { JwtModule, JwtService } from '@nestjs/jwt'; +import { RefreshTokensService } from './refresh-tokens.service'; + +const fetchSpy = jest.spyOn(global, 'fetch'); + +describe('AuthService', () => { + let service: AuthService; + let prisma: DeepMockProxy; + let jwtService: DeepMockProxy; + let refreshTokensService: DeepMockProxy; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [JwtModule], + providers: [AuthService, PrismaService, RefreshTokensService], + }) + .overrideProvider(PrismaService) + .useValue(mockDeep()) + .overrideProvider(JwtService) + .useValue(mockDeep()) + .overrideProvider(RefreshTokensService) + .useValue(mockDeep()) + .compile(); + + service = module.get(AuthService); + prisma = module.get(PrismaService); + jwtService = module.get(JwtService); + refreshTokensService = module.get(RefreshTokensService); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + }); + + it('login success', async () => { + jwtService.signAsync.mockResolvedValue('access token'); + refreshTokensService.createRefreshToken.mockResolvedValue({ + token: 'refresh token', + } as unknown as RefreshToken); + + const tokens = service.login('user uuid'); + + await expect(tokens).resolves.toEqual({ + access_token: 'access token', + refresh_token: 'refresh token', + }); + }); + + it('renewAccessToken success', async () => { + jwtService.verify.mockReturnValue({}); + jwtService.signAsync.mockResolvedValue('access token'); + refreshTokensService.findRefreshToken.mockResolvedValue({ + user_id: 'user uuid', + } as RefreshToken); + + const token = service.renewAccessToken('refresh token'); + + await expect(token).resolves.toBe('access token'); + }); + + it('renewAccessToken fail', async () => { + jwtService.verify.mockImplementation(() => { + throw new Error(); + }); + + const token = service.renewAccessToken('refresh token'); + + await expect(token).rejects.toThrow(); + }); +}); diff --git a/nestjs-BE/server/src/auth/auth.service.ts b/nestjs-BE/server/src/auth/auth.service.ts index 87e931fc..57543bfb 100644 --- a/nestjs-BE/server/src/auth/auth.service.ts +++ b/nestjs-BE/server/src/auth/auth.service.ts @@ -1,48 +1,17 @@ -import { Injectable, UnauthorizedException, HttpStatus } from '@nestjs/common'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { jwtConstants, kakaoOauthConstants } from './constants'; import { stringify } from 'qs'; -import { PrismaServiceMySQL } from 'src/prisma/prisma.service'; -import { TemporaryDatabaseService } from 'src/temporary-database/temporary-database.service'; -import { BaseService } from 'src/base/base.service'; -import { - REFRESH_TOKEN_CACHE_SIZE, - REFRESH_TOKEN_EXPIRY_DAYS, -} from 'src/config/magic-number'; -import generateUuid from 'src/utils/uuid'; -import { UsersService } from 'src/users/users.service'; -import { ProfilesService } from 'src/profiles/profiles.service'; -import { CreateUserDto } from 'src/users/dto/create-user.dto'; -import customEnv from 'src/config/env'; -import { ResponseUtils } from 'src/utils/response'; -const { BASE_IMAGE_URL } = customEnv; - -export interface TokenData { - uuid?: string; - token: string; - expiry_date: Date; - user_id: string; -} +import { PrismaService } from '../prisma/prisma.service'; +import { RefreshTokensService } from './refresh-tokens.service'; @Injectable() -export class AuthService extends BaseService { +export class AuthService { constructor( private jwtService: JwtService, - protected prisma: PrismaServiceMySQL, - protected temporaryDatabaseService: TemporaryDatabaseService, - ) { - super({ - prisma, - temporaryDatabaseService, - cacheSize: REFRESH_TOKEN_CACHE_SIZE, - className: 'REFRESH_TOKEN_TB', - field: 'token', - }); - } - - generateKey(data: TokenData): string { - return data.token; - } + private refreshTokensService: RefreshTokensService, + protected prisma: PrismaService, + ) {} async getKakaoAccount(kakaoUserId: number) { const url = `https://kapi.kakao.com/v2/user/me`; @@ -60,7 +29,7 @@ export class AuthService extends BaseService { return responseBody.kakao_account; } - async createAccessToken(userUuid: string): Promise { + private async createAccessToken(userUuid: string): Promise { const payload = { sub: userUuid }; const accessToken = await this.jwtService.signAsync(payload, { secret: jwtConstants.accessSecret, @@ -69,79 +38,34 @@ export class AuthService extends BaseService { return accessToken; } - async createRefreshToken(): Promise { - const refreshTokenPayload = { uuid: generateUuid() }; - const refreshToken = await this.jwtService.signAsync(refreshTokenPayload, { - secret: jwtConstants.refreshSecret, - expiresIn: '14d', - }); - return refreshToken; - } - - createRefreshTokenData(refreshToken: string, userUuid: string) { - const currentDate = new Date(); - const expiryDate = new Date(currentDate); - expiryDate.setDate(currentDate.getDate() + REFRESH_TOKEN_EXPIRY_DAYS); - const refreshTokenData: TokenData = { - token: refreshToken, - expiry_date: expiryDate, - user_id: userUuid, - }; - return refreshTokenData; - } - async login(userUuid: string) { - const refreshToken = await this.createRefreshToken(); const accessToken = await this.createAccessToken(userUuid); - const refreshTokenData = this.createRefreshTokenData( - refreshToken, - userUuid, - ); - super.create(refreshTokenData); - const tokenData = { + const refreshToken = + await this.refreshTokensService.createRefreshToken(userUuid); + return { access_token: accessToken, - refresh_token: refreshToken, + refresh_token: refreshToken.token, }; - return ResponseUtils.createResponse(HttpStatus.OK, tokenData); } - async renewAccessToken(refreshToken: string) { + async renewAccessToken(refreshToken: string): Promise { try { this.jwtService.verify(refreshToken, { secret: jwtConstants.refreshSecret, }); - const { data: tokenData } = await this.findOne(refreshToken); - const accessToken = await this.createAccessToken(tokenData.user_id); - return ResponseUtils.createResponse(HttpStatus.OK, { - access_token: accessToken, - }); + const token = + await this.refreshTokensService.findRefreshToken(refreshToken); + if (!token) { + throw new UnauthorizedException( + 'Refresh token expired. Please log in again.', + ); + } + const accessToken = await this.createAccessToken(token.user_id); + return accessToken; } catch (error) { - super.remove(refreshToken); throw new UnauthorizedException( 'Refresh token expired. Please log in again.', ); } } - - async findUser(usersService: UsersService, email: string, provider: string) { - const key = `email:${email}+provider:${provider}`; - const findUserData = await usersService.getDataFromCacheOrDB(key); - return findUserData?.uuid; - } - - async createUser( - data: CreateUserDto, - usersService: UsersService, - profilesService: ProfilesService, - ) { - const createdData = await usersService.create(data); - const userUuid = createdData.data.uuid; - const profileData = { - user_id: userUuid, - image: BASE_IMAGE_URL, - nickname: '익명의 사용자', - }; - profilesService.create(profileData); - return userUuid; - } } diff --git a/nestjs-BE/server/src/auth/constants.ts b/nestjs-BE/server/src/auth/constants.ts index bdb08300..fbe8acf1 100644 --- a/nestjs-BE/server/src/auth/constants.ts +++ b/nestjs-BE/server/src/auth/constants.ts @@ -1,4 +1,4 @@ -import customEnv from 'src/config/env'; +import customEnv from '../config/env'; export const jwtConstants = { accessSecret: customEnv.JWT_ACCESS_SECRET, diff --git a/nestjs-BE/server/src/auth/refresh-tokens.service.spec.ts b/nestjs-BE/server/src/auth/refresh-tokens.service.spec.ts new file mode 100644 index 00000000..0aae4537 --- /dev/null +++ b/nestjs-BE/server/src/auth/refresh-tokens.service.spec.ts @@ -0,0 +1,119 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { RefreshTokensService } from './refresh-tokens.service'; +import { DeepMockProxy, mockDeep } from 'jest-mock-extended'; +import { PrismaClient } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; +import { JwtModule, JwtService } from '@nestjs/jwt'; +import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library'; + +jest.useFakeTimers(); + +describe('RefreshTokensService', () => { + let service: RefreshTokensService; + let prisma: DeepMockProxy; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [JwtModule], + providers: [RefreshTokensService, PrismaService], + }) + .overrideProvider(PrismaService) + .useValue(mockDeep()) + .overrideProvider(JwtService) + .useValue(mockDeep()) + .compile(); + + service = module.get(RefreshTokensService); + prisma = module.get(PrismaService); + }); + + afterEach(() => { + jest.clearAllTimers(); + }); + + it('getExpiryDate check time diff', () => { + const currentDate = new Date(); + const expiryDate = service.getExpiryDate(); + const twoWeeksInMilliseconds = 2 * 7 * 24 * 60 * 60 * 1000; + + const timeDiff = expiryDate.getTime() - currentDate.getTime(); + + expect(twoWeeksInMilliseconds == timeDiff).toBeTruthy(); + }); + + it('findRefreshToken found token', async () => { + const testToken = { + id: 0, + token: 'Token', + expiry_date: service.getExpiryDate(), + user_id: 'UserId', + }; + prisma.refreshToken.findUnique.mockResolvedValue(testToken); + + const token = service.findRefreshToken(testToken.token); + + await expect(token).resolves.toEqual(testToken); + }); + + it('findRefreshToken not found token', async () => { + prisma.refreshToken.findUnique.mockResolvedValue(null); + + const token = service.findRefreshToken('Token'); + + await expect(token).resolves.toBeNull(); + }); + + it('createRefreshToken created', async () => { + const testToken = { + id: 0, + token: 'Token', + expiry_date: service.getExpiryDate(), + user_id: 'userId', + }; + prisma.refreshToken.create.mockResolvedValue(testToken); + + const token = service.createRefreshToken('userId'); + + await expect(token).resolves.toEqual(testToken); + }); + + it('createUser user already exists', async () => { + prisma.refreshToken.create.mockRejectedValue( + new PrismaClientKnownRequestError( + 'Unique constraint failed on the constraint: `RefreshToken_token_key`', + { code: 'P2025', clientVersion: '' }, + ), + ); + + const token = service.createRefreshToken('userId'); + + await expect(token).rejects.toThrow(PrismaClientKnownRequestError); + }); + + it('deleteRefreshToken deleted', async () => { + const testToken = { + id: 0, + token: 'Token', + expiry_date: service.getExpiryDate(), + user_id: 'userId', + }; + prisma.refreshToken.delete.mockResolvedValue(testToken); + + const token = service.deleteRefreshToken(testToken.token); + + await expect(token).resolves.toEqual(testToken); + }); + + it('deleteRefreshToken not found', async () => { + prisma.refreshToken.delete.mockRejectedValue( + new PrismaClientKnownRequestError( + 'An operation failed because it depends on one or more records that were required but not found. Record to update not found.', + { code: 'P2025', clientVersion: '' }, + ), + ); + + const token = service.deleteRefreshToken('Token'); + + await expect(token).resolves.toBeNull(); + }); +}); diff --git a/nestjs-BE/server/src/auth/refresh-tokens.service.ts b/nestjs-BE/server/src/auth/refresh-tokens.service.ts new file mode 100644 index 00000000..de0081b9 --- /dev/null +++ b/nestjs-BE/server/src/auth/refresh-tokens.service.ts @@ -0,0 +1,60 @@ +import { Injectable } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { PrismaService } from '../prisma/prisma.service'; +import generateUuid from '../utils/uuid'; +import { jwtConstants } from './constants'; +import { Prisma, RefreshToken } from '@prisma/client'; +import { REFRESH_TOKEN_EXPIRY_DAYS } from '../config/magic-number'; + +@Injectable() +export class RefreshTokensService { + constructor( + private prisma: PrismaService, + private jwtService: JwtService, + ) {} + + async createRefreshToken(userUuid: string): Promise { + return this.prisma.refreshToken.create({ + data: { + token: this.createToken(), + expiry_date: this.getExpiryDate(), + user_id: userUuid, + }, + }); + } + + async findRefreshToken(refreshToken: string): Promise { + return this.prisma.refreshToken.findUnique({ + where: { token: refreshToken }, + }); + } + + async deleteRefreshToken(refreshToken: string): Promise { + try { + return await this.prisma.refreshToken.delete({ + where: { token: refreshToken }, + }); + } catch (err) { + if (err instanceof Prisma.PrismaClientKnownRequestError) { + return null; + } else { + throw err; + } + } + } + + createToken(): string { + const refreshToken = this.jwtService.sign( + { uuid: generateUuid() }, + { secret: jwtConstants.refreshSecret, expiresIn: '14d' }, + ); + return refreshToken; + } + + getExpiryDate(): Date { + const currentDate = new Date(); + const expiryDate = new Date(currentDate); + expiryDate.setDate(currentDate.getDate() + REFRESH_TOKEN_EXPIRY_DAYS); + return expiryDate; + } +} diff --git a/nestjs-BE/server/src/base/base.service.ts b/nestjs-BE/server/src/base/base.service.ts deleted file mode 100644 index b0aa14fa..00000000 --- a/nestjs-BE/server/src/base/base.service.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { Injectable, HttpException, HttpStatus } from '@nestjs/common'; -import { - PrismaServiceMySQL, - PrismaServiceMongoDB, -} from '../prisma/prisma.service'; -import { TemporaryDatabaseService } from '../temporary-database/temporary-database.service'; -import LRUCache from '../utils/lru-cache'; -import generateUuid from '../utils/uuid'; -import { ResponseUtils } from 'src/utils/response'; - -interface BaseServiceOptions { - prisma: PrismaServiceMySQL | PrismaServiceMongoDB; - temporaryDatabaseService: TemporaryDatabaseService; - cacheSize: number; - className: string; - field: string; -} - -export interface HasUuid { - uuid?: string; -} - -@Injectable() -export abstract class BaseService { - protected cache: LRUCache; - protected className: string; - protected field: string; - protected prisma: PrismaServiceMySQL | PrismaServiceMongoDB; - protected temporaryDatabaseService: TemporaryDatabaseService; - - constructor(options: BaseServiceOptions) { - this.cache = new LRUCache(options.cacheSize); - this.className = options.className; - this.field = options.field; - this.prisma = options.prisma; - this.temporaryDatabaseService = options.temporaryDatabaseService; - } - - abstract generateKey(data: T): string; - - async create(data: T, generateUuidFlag: boolean = true) { - if (generateUuidFlag) data.uuid = generateUuid(); - const key = this.generateKey(data); - const storeData = await this.getDataFromCacheOrDB(key); - if (storeData) { - throw new HttpException('Data already exists.', HttpStatus.CONFLICT); - } - - this.temporaryDatabaseService.create(this.className, key, data); - this.cache.put(key, data); - return ResponseUtils.createResponse(HttpStatus.CREATED, data); - } - - async findOne(key: string) { - const data = await this.getDataFromCacheOrDB(key); - const deleteCommand = this.temporaryDatabaseService.get( - this.className, - key, - 'delete', - ); - if (deleteCommand) { - throw new HttpException('Not Found', HttpStatus.NOT_FOUND); - } - if (data) { - const mergedData = this.mergeWithUpdateCommand(data, key); - this.cache.put(key, mergedData); - return ResponseUtils.createResponse(HttpStatus.OK, mergedData); - } else { - throw new HttpException('Not Found', HttpStatus.NOT_FOUND); - } - } - - async update(key: string, updateData: T) { - const data = await this.getDataFromCacheOrDB(key); - if (data) { - const updatedData = { - field: this.field, - value: { ...data, ...updateData }, - }; - if (this.temporaryDatabaseService.get(this.className, key, 'insert')) { - this.temporaryDatabaseService.create( - this.className, - key, - updatedData.value, - ); - } else { - this.temporaryDatabaseService.update(this.className, key, updatedData); - } - this.cache.put(key, updatedData.value); - return ResponseUtils.createResponse(HttpStatus.OK, updatedData.value); - } else { - return ResponseUtils.createResponse(HttpStatus.NOT_FOUND); - } - } - - async remove(key: string) { - const storeData = await this.getDataFromCacheOrDB(key); - if (!storeData) return; - this.cache.delete(key); - const insertTemporaryData = this.temporaryDatabaseService.get( - this.className, - key, - 'insert', - ); - const updateTemporaryData = this.temporaryDatabaseService.get( - this.className, - key, - 'update', - ); - if (updateTemporaryData) { - this.temporaryDatabaseService.delete(this.className, key, 'update'); - } - if (insertTemporaryData) { - this.temporaryDatabaseService.delete(this.className, key, 'insert'); - } else { - this.temporaryDatabaseService.remove(this.className, key, { - field: this.field, - value: key, - }); - } - return ResponseUtils.createResponse(HttpStatus.NO_CONTENT); - } - - async getDataFromCacheOrDB(key: string): Promise { - if (!key) throw new HttpException('Bad Request', HttpStatus.BAD_REQUEST); - const cacheData = this.cache.get(key); - if (cacheData) return cacheData; - const temporaryDatabaseData = this.temporaryDatabaseService.get( - this.className, - key, - 'insert', - ); - if (temporaryDatabaseData) return temporaryDatabaseData; - const databaseData = await this.prisma[this.className].findUnique({ - where: { - [this.field]: key.includes('+') ? this.stringToObject(key) : key, - }, - }); - return databaseData; - } - - stringToObject(key: string) { - const obj = {}; - const keyValuePairs = key.split('+'); - - keyValuePairs.forEach((keyValue) => { - const [key, value] = keyValue.split(':'); - obj[key] = value; - }); - - return obj; - } - - private mergeWithUpdateCommand(data: T, key: string): T { - const updateCommand = this.temporaryDatabaseService.get( - this.className, - key, - 'update', - ); - if (updateCommand) return { ...data, ...updateCommand.value }; - - return data; - } -} diff --git a/nestjs-BE/server/src/board-trees/board-trees.gateway.ts b/nestjs-BE/server/src/board-trees/board-trees.gateway.ts index 502e7182..c7578d13 100644 --- a/nestjs-BE/server/src/board-trees/board-trees.gateway.ts +++ b/nestjs-BE/server/src/board-trees/board-trees.gateway.ts @@ -23,7 +23,10 @@ export class BoardTreesGateway { async handleJoinBoard(client: Socket, payload: string) { const payloadObject = JSON.parse(payload); if (!this.boardTreesService.hasTree(payloadObject.boardId)) { - await this.boardTreesService.initBoardTree(payloadObject.boardId); + await this.boardTreesService.initBoardTree( + payloadObject.boardId, + payloadObject.boardName, + ); } client.join(payloadObject.boardId); client.emit( diff --git a/nestjs-BE/server/src/board-trees/board-trees.service.ts b/nestjs-BE/server/src/board-trees/board-trees.service.ts index 06750d73..9d85fd22 100644 --- a/nestjs-BE/server/src/board-trees/board-trees.service.ts +++ b/nestjs-BE/server/src/board-trees/board-trees.service.ts @@ -30,12 +30,13 @@ export class BoardTreesService { return JSON.stringify(boardTree); } - async initBoardTree(boardId: string) { + async initBoardTree(boardId: string, boardName: string) { const existingTree = await this.findByBoardId(boardId); if (existingTree) { this.boardTrees.set(boardId, CrdtTree.parse(existingTree.tree)); } else { const newTree = new CrdtTree(boardId); + newTree.tree.get('root').description = boardName; this.create(boardId, JSON.stringify(newTree)); this.boardTrees.set(boardId, newTree); } diff --git a/nestjs-BE/server/src/boards/boards.controller.spec.ts b/nestjs-BE/server/src/boards/boards.controller.spec.ts index f6de507d..cca601c2 100644 --- a/nestjs-BE/server/src/boards/boards.controller.spec.ts +++ b/nestjs-BE/server/src/boards/boards.controller.spec.ts @@ -1,20 +1,135 @@ import { Test, TestingModule } from '@nestjs/testing'; import { BoardsController } from './boards.controller'; import { BoardsService } from './boards.service'; +import { UploadService } from '../upload/upload.service'; +import { DeepMockProxy, mockDeep } from 'jest-mock-extended'; +import { Board } from './schemas/board.schema'; +import { CreateBoardDto } from './dto/create-board.dto'; +import { HttpStatus, NotFoundException } from '@nestjs/common'; +import customEnv from '../config/env'; +import { UpdateWriteOpResult } from 'mongoose'; describe('BoardsController', () => { let controller: BoardsController; + let boardsService: DeepMockProxy; + let uploadService: DeepMockProxy; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [BoardsController], - providers: [BoardsService], - }).compile(); + providers: [BoardsService, UploadService], + }) + .overrideProvider(BoardsService) + .useValue(mockDeep()) + .overrideProvider(UploadService) + .useValue(mockDeep()) + .compile(); controller = module.get(BoardsController); + boardsService = module.get(BoardsService); + uploadService = module.get(UploadService); }); - it('should be defined', () => { - expect(controller).toBeDefined(); + it('createBoard created board', async () => { + const bodyMock = { + boardName: 'board name', + spaceId: 'space uuid', + } as CreateBoardDto; + const imageMock = { filename: 'image' } as Express.Multer.File; + uploadService.uploadFile.mockResolvedValue('image url'); + boardsService.create.mockResolvedValue({ + uuid: 'board uuid', + createdAt: 'created date' as unknown as Date, + } as Board); + + const board = controller.createBoard(bodyMock, imageMock); + + await expect(board).resolves.toEqual({ + statusCode: HttpStatus.CREATED, + message: 'Board created.', + data: { + boardId: 'board uuid', + date: 'created date', + imageUrl: 'image url', + }, + }); + expect(uploadService.uploadFile).toHaveBeenCalled(); + }); + + it('createBoard request does not have image file', async () => { + const bodyMock = { + boardName: 'board name', + spaceId: 'space uuid', + } as CreateBoardDto; + boardsService.create.mockResolvedValue({ + uuid: 'board uuid', + createdAt: 'created date' as unknown as Date, + } as Board); + + const response = controller.createBoard( + bodyMock, + null as unknown as Express.Multer.File, + ); + + await expect(response).resolves.toEqual({ + statusCode: HttpStatus.CREATED, + message: 'Board created.', + data: { + boardId: 'board uuid', + date: 'created date', + imageUrl: customEnv.APP_ICON_URL, + }, + }); + expect(uploadService.uploadFile).not.toHaveBeenCalled(); + }); + + it('deleteBoard success', async () => { + const bodyMock = { boardId: 'board uuid' }; + boardsService.deleteBoard.mockResolvedValue({ + matchedCount: 1, + } as UpdateWriteOpResult); + + const response = controller.deleteBoard(bodyMock); + + await expect(response).resolves.toEqual({ + statusCode: HttpStatus.OK, + message: 'Board deleted.', + }); + }); + + it('deleteBoard fail', async () => { + const bodyMock = { boardId: 'board uuid' }; + boardsService.deleteBoard.mockResolvedValue({ + matchedCount: 0, + } as UpdateWriteOpResult); + + const response = controller.deleteBoard(bodyMock); + + await expect(response).rejects.toThrow(NotFoundException); + }); + + it('restoreBoard success', async () => { + const bodyMock = { boardId: 'board uuid' }; + boardsService.restoreBoard.mockResolvedValue({ + matchedCount: 1, + } as UpdateWriteOpResult); + + const response = controller.restoreBoard(bodyMock); + + await expect(response).resolves.toEqual({ + statusCode: HttpStatus.OK, + message: 'Board restored.', + }); + }); + + it('restoreBoard fail', async () => { + const bodyMock = { boardId: 'board uuid' }; + boardsService.restoreBoard.mockResolvedValue({ + matchedCount: 0, + } as UpdateWriteOpResult); + + const response = controller.restoreBoard(bodyMock); + + await expect(response).rejects.toThrow(NotFoundException); }); }); diff --git a/nestjs-BE/server/src/boards/boards.controller.ts b/nestjs-BE/server/src/boards/boards.controller.ts index 4abf9c5e..fc3347f8 100644 --- a/nestjs-BE/server/src/boards/boards.controller.ts +++ b/nestjs-BE/server/src/boards/boards.controller.ts @@ -15,7 +15,6 @@ import { BoardsService } from './boards.service'; import { CreateBoardDto } from './dto/create-board.dto'; import { ApiBody, - ApiConflictResponse, ApiConsumes, ApiCreatedResponse, ApiNotFoundResponse, @@ -24,12 +23,11 @@ import { ApiQuery, ApiTags, } from '@nestjs/swagger'; -import { Public } from 'src/auth/public.decorator'; +import { Public } from '../auth/public.decorator'; import { DeleteBoardDto } from './dto/delete-board.dto'; import { RestoreBoardDto } from './dto/restore-board.dto'; import { BoardListSuccess, - CreateBoardFailure, CreateBoardSuccess, DeleteBoardFailure, DeleteBoardSuccess, @@ -37,8 +35,8 @@ import { RestoreBoardSuccess, } from './swagger/boards.type'; import { FileInterceptor } from '@nestjs/platform-express'; -import { UploadService } from 'src/upload/upload.service'; -import customEnv from 'src/config/env'; +import { UploadService } from '../upload/upload.service'; +import customEnv from '../config/env'; const BOARD_EXPIRE_DAY = 7; @@ -59,10 +57,6 @@ export class BoardsController { type: CreateBoardSuccess, description: '보드 생성 완료', }) - @ApiConflictResponse({ - type: CreateBoardFailure, - description: '보드가 이미 존재함', - }) @Public() @Post('create') @UseInterceptors(FileInterceptor('image')) @@ -72,10 +66,6 @@ export class BoardsController { @Body() createBoardDto: CreateBoardDto, @UploadedFile() image: Express.Multer.File, ) { - await this.boardsService.findByNameAndSpaceId( - createBoardDto.boardName, - createBoardDto.spaceId, - ); const imageUrl = image ? await this.uploadService.uploadFile(image) : customEnv.APP_ICON_URL; @@ -109,7 +99,7 @@ export class BoardsController { @Get('list') async findBySpaceId(@Query('spaceId') spaceId: string) { const boardList = await this.boardsService.findBySpaceId(spaceId); - const responseData = boardList.reduce((list, board) => { + const responseData = boardList.reduce>((list, board) => { let isDeleted = false; if (board.deletedAt && board.deletedAt > board.restoredAt) { diff --git a/nestjs-BE/server/src/boards/boards.service.spec.ts b/nestjs-BE/server/src/boards/boards.service.spec.ts index a1da1cdb..67ef5ffd 100644 --- a/nestjs-BE/server/src/boards/boards.service.spec.ts +++ b/nestjs-BE/server/src/boards/boards.service.spec.ts @@ -1,18 +1,55 @@ import { Test, TestingModule } from '@nestjs/testing'; import { BoardsService } from './boards.service'; +import { getModelToken } from '@nestjs/mongoose'; +import { Board, BoardDocument } from './schemas/board.schema'; +import { Model, Query } from 'mongoose'; +import { DeepMockProxy, mockDeep } from 'jest-mock-extended'; +import { CreateBoardDto } from './dto/create-board.dto'; describe('BoardsService', () => { let service: BoardsService; + let model: DeepMockProxy>; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [BoardsService], + providers: [ + BoardsService, + { + provide: getModelToken(Board.name), + useValue: mockDeep>(), + }, + ], }).compile(); service = module.get(BoardsService); + model = module.get(getModelToken(Board.name)); }); - it('should be defined', () => { - expect(service).toBeDefined(); + it('create', async () => { + const data = { + boardName: 'board name', + spaceId: 'space uuid', + } as CreateBoardDto; + const imageMock = 'www.test.com/image'; + model.create.mockResolvedValue( + 'created board' as unknown as BoardDocument[], + ); + + const board = service.create(data, imageMock); + + await expect(board).resolves.toBe('created board'); + expect(model.create).toHaveBeenCalled(); + }); + + it('findBySpaceId', async () => { + model.find.mockReturnValue({ + exec: async () => { + return 'board list' as unknown as Board[]; + }, + } as Query); + + const boards = service.findBySpaceId('space uuid'); + + await expect(boards).resolves.toBe('board list'); }); }); diff --git a/nestjs-BE/server/src/boards/boards.service.ts b/nestjs-BE/server/src/boards/boards.service.ts index 5034d71c..7d134f36 100644 --- a/nestjs-BE/server/src/boards/boards.service.ts +++ b/nestjs-BE/server/src/boards/boards.service.ts @@ -1,4 +1,4 @@ -import { Injectable, ConflictException } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; import { Board } from './schemas/board.schema'; import { Model } from 'mongoose'; @@ -16,7 +16,7 @@ export class BoardsService { const { boardName, spaceId } = createBoardDto; const uuid = v4(); const now = new Date(); - const createdBoard = new this.boardModel({ + const board = this.boardModel.create({ boardName, imageUrl, spaceId, @@ -24,14 +24,7 @@ export class BoardsService { createdAt: now, restoredAt: now, }); - return createdBoard.save(); - } - - async findByNameAndSpaceId(boardName: string, spaceId: string) { - const existingBoard = await this.boardModel - .findOne({ boardName, spaceId }) - .exec(); - if (existingBoard) throw new ConflictException('Board already exist.'); + return board; } async findBySpaceId(spaceId: string): Promise { diff --git a/nestjs-BE/server/src/boards/dto/create-board.dto.ts b/nestjs-BE/server/src/boards/dto/create-board.dto.ts index a958215b..a6e8c0c5 100644 --- a/nestjs-BE/server/src/boards/dto/create-board.dto.ts +++ b/nestjs-BE/server/src/boards/dto/create-board.dto.ts @@ -1,10 +1,12 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsNotEmpty, IsString } from 'class-validator'; +import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; +import { MAX_NAME_LENGTH } from '../../config/magic-number'; export class CreateBoardDto { @ApiProperty({ description: '보드 이름' }) @IsString() @IsNotEmpty() + @MaxLength(MAX_NAME_LENGTH) boardName: string; @ApiProperty({ description: '스페이스 id' }) diff --git a/nestjs-BE/server/src/boards/swagger/boards.type.ts b/nestjs-BE/server/src/boards/swagger/boards.type.ts index 9a36321c..f02fa2af 100644 --- a/nestjs-BE/server/src/boards/swagger/boards.type.ts +++ b/nestjs-BE/server/src/boards/swagger/boards.type.ts @@ -23,17 +23,6 @@ export class CreateBoardSuccess { data: CreatedBoard; } -export class CreateBoardFailure { - @ApiProperty({ example: HttpStatus.CONFLICT, description: '응답 코드' }) - statusCode: number; - - @ApiProperty({ example: 'Board already exist.', description: '응답 메세지' }) - message: string; - - @ApiProperty({ example: 'Conflict', description: '응답 메세지' }) - error: string; -} - export class BoardInSpace { @ApiProperty({ description: '보드 id' }) boardId: string; diff --git a/nestjs-BE/server/src/config/magic-number.ts b/nestjs-BE/server/src/config/magic-number.ts index 75e72de4..00635ab5 100644 --- a/nestjs-BE/server/src/config/magic-number.ts +++ b/nestjs-BE/server/src/config/magic-number.ts @@ -10,3 +10,4 @@ export const REFRESH_TOKEN_EXPIRY_DAYS = 14; export const INVITE_CODE_CACHE_SIZE = 5000; export const INVITE_CODE_LENGTH = 10; export const INVITE_CODE_EXPIRY_HOURS = 6; +export const MAX_NAME_LENGTH = 20; diff --git a/nestjs-BE/server/src/crdt/clock.spec.ts b/nestjs-BE/server/src/crdt/clock.spec.ts new file mode 100644 index 00000000..82fb0243 --- /dev/null +++ b/nestjs-BE/server/src/crdt/clock.spec.ts @@ -0,0 +1,55 @@ +import { COMPARE, Clock } from './clock'; + +it('clock 역직렬화', () => { + const clockCount = 10; + + const clock = new Clock('123', clockCount); + const parsedClock = Clock.parse(JSON.stringify(clock)); + + expect(JSON.stringify(clock)).toEqual(JSON.stringify(parsedClock)); +}); + +it('copy', () => { + const clockCount = 10; + + const clock = new Clock('123', clockCount); + const clockCopy = clock.copy(); + + expect(clock === clockCopy).toBeFalsy(); + expect(JSON.stringify(clock)).toEqual(JSON.stringify(clockCopy)); +}); + +it('increment', () => { + const clockCount = 10; + const incrementCount = 5; + const expectedNumber = 15; + + const clock = new Clock('123', clockCount); + + for (let i = 0; i < incrementCount; i++) clock.increment(); + + expect(clock.counter).toEqual(expectedNumber); +}); + +it('merge', () => { + const clockCount10 = 10; + const clockCount15 = 15; + + const clock1 = new Clock('123', clockCount10); + const clock2 = new Clock('124', clockCount15); + + clock1.merge(clock2); + clock2.merge(clock1); +}); + +it('compare', () => { + const clockCount10 = 10; + const clockCount25 = 25; + + const clock1 = new Clock('123', clockCount10); + const clock2 = new Clock('124', clockCount25); + const clock3 = new Clock('126', clockCount25); + + expect(clock1.compare(clock2)).toEqual(COMPARE.LESS); + expect(clock2.compare(clock3)).toEqual(COMPARE.LESS); +}); diff --git a/nestjs-BE/server/src/crdt/crdt-tree.spec.ts b/nestjs-BE/server/src/crdt/crdt-tree.spec.ts new file mode 100644 index 00000000..7adf6936 --- /dev/null +++ b/nestjs-BE/server/src/crdt/crdt-tree.spec.ts @@ -0,0 +1,76 @@ +import { CrdtTree } from './crdt-tree'; +import { Node } from './node'; + +it('crdt tree 동기화', () => { + const tree1 = new CrdtTree('1'); + const tree2 = new CrdtTree('2'); + + const op_1_1 = tree1.generateOperationAdd('a', 'root', 'hello'); + const op_1_2 = tree1.generateOperationAdd('b', 'root', 'hi'); + + const op_2_1 = tree2.generateOperationAdd('c', 'root', 'good'); + const op_2_2 = tree2.generateOperationAdd('d', 'c', 'bad'); + + tree1.applyOperations([op_1_1, op_1_2]); + tree2.applyOperations([op_2_1, op_2_2]); + + tree2.applyOperations([op_1_1, op_1_2]); + tree1.applyOperations([op_2_1, op_2_2]); + + const op_1_3 = tree1.generateOperationUpdate('a', 'updatedByTree1'); + const op_1_4 = tree1.generateOperationMove('d', 'b'); + + const op_2_3 = tree2.generateOperationUpdate('a', 'updatedByTree2'); + const op_2_4 = tree2.generateOperationMove('a', 'c'); + + tree1.applyOperations([op_1_3, op_1_4]); + tree2.applyOperations([op_2_3, op_2_4]); + + tree2.applyOperations([op_1_3, op_1_4]); + tree1.applyOperations([op_2_3, op_2_4]); + + const op_1_5 = tree1.generateOperationDelete('b'); + + const op_2_5 = tree2.generateOperationUpdate('b', 'updatedByTree2'); + + tree1.applyOperations([op_1_5]); + tree2.applyOperations([op_2_5]); + + tree2.applyOperations([op_1_5]); + tree1.applyOperations([op_2_5]); + + tree1.clock.id = tree2.clock.id; + + expect(tree1).toMatchObject(tree2); +}); + +it('crdt tree 역직렬화', () => { + const tree = new CrdtTree('1'); + + const op1 = tree.generateOperationAdd('a', 'root', 'hello'); + const op2 = tree.generateOperationAdd('b', 'root', 'hi'); + const op3 = tree.generateOperationAdd('c', 'root', 'good'); + const op4 = tree.generateOperationAdd('d', 'c', 'bad'); + + tree.applyOperations([op1, op2, op3, op4]); + + expect(JSON.stringify(tree)); + + const parsedTree = CrdtTree.parse(JSON.stringify(tree)); + + expect(JSON.stringify(tree)).toEqual(JSON.stringify(parsedTree)); +}); + +it('crdt tree 순환', () => { + const tree = new CrdtTree('1'); + + const op1 = tree.generateOperationAdd('a', 'root', 'hello'); + const op2 = tree.generateOperationAdd('b', 'root', 'hi'); + const op3 = tree.generateOperationAdd('c', 'a', 'good'); + const op4 = tree.generateOperationAdd('d', 'b', 'bad'); + const op5 = tree.generateOperationMove('a', 'b'); + const op6 = tree.generateOperationMove('b', 'a'); + + tree.applyOperations([op1, op2, op3, op4, op5, op6]); + expect((tree.tree.get('b') as Node).parentId).toEqual('root'); +}); diff --git a/nestjs-BE/server/src/crdt/crdt-tree.ts b/nestjs-BE/server/src/crdt/crdt-tree.ts index d8791b93..4400f116 100644 --- a/nestjs-BE/server/src/crdt/crdt-tree.ts +++ b/nestjs-BE/server/src/crdt/crdt-tree.ts @@ -94,7 +94,7 @@ export class CrdtTree { const lastOperation = this.operationLogs[this.operationLogs.length - 1].operation; if (operation.clock.compare(lastOperation.clock) === COMPARE.LESS) { - const prevLog = this.operationLogs.pop(); + const prevLog = this.operationLogs.pop() as OperationLog; prevLog.operation.undoOperation(this.tree, prevLog); this.applyOperation(operation); const redoLog = prevLog.operation.redoOperation(this.tree, prevLog); diff --git a/nestjs-BE/server/src/crdt/node.spec.ts b/nestjs-BE/server/src/crdt/node.spec.ts new file mode 100644 index 00000000..61e6c470 --- /dev/null +++ b/nestjs-BE/server/src/crdt/node.spec.ts @@ -0,0 +1,8 @@ +import { Node } from './node'; + +it('node 역직렬화', () => { + const node = new Node('1', 'root', 'hello'); + const parsedNode = Node.parse(JSON.stringify(node)); + + expect(JSON.stringify(node)).toEqual(JSON.stringify(parsedNode)); +}); diff --git a/nestjs-BE/server/src/crdt/node.ts b/nestjs-BE/server/src/crdt/node.ts index 78d825f3..7e0303cb 100644 --- a/nestjs-BE/server/src/crdt/node.ts +++ b/nestjs-BE/server/src/crdt/node.ts @@ -1,7 +1,7 @@ export class Node { targetId: string; parentId: string; - description: T; + description: T | null; children = new Array(); constructor( diff --git a/nestjs-BE/server/src/crdt/operation.ts b/nestjs-BE/server/src/crdt/operation.ts index ef2704c7..42836c8e 100644 --- a/nestjs-BE/server/src/crdt/operation.ts +++ b/nestjs-BE/server/src/crdt/operation.ts @@ -1,10 +1,11 @@ import { Clock } from './clock'; +import { Node } from './node'; import { Tree } from './tree'; export interface OperationLog { operation: Operation; oldParentId?: string; - oldDescription?: T; + oldDescription?: T | null; } export interface OperationInput { @@ -83,8 +84,8 @@ export class OperationAdd extends Operation { ): OperationAdd { const input: OperationAddInput = { id: serializedOperation.id, - parentId: serializedOperation.parentId, - description: serializedOperation.description, + parentId: serializedOperation.parentId as string, + description: serializedOperation.description as T, clock: new Clock( serializedOperation.clock.id, serializedOperation.clock.counter, @@ -100,14 +101,14 @@ export class OperationDelete extends Operation { } doOperation(tree: Tree): OperationLog { - const node = tree.get(this.id); + const node = tree.get(this.id) as Node; const oldParentId = node.parentId; tree.removeNode(this.id); return { operation: this, oldParentId: oldParentId }; } undoOperation(tree: Tree, log: OperationLog): void { - tree.attachNode(log.operation.id, log.oldParentId); + tree.attachNode(log.operation.id, log.oldParentId as string); } redoOperation(tree: Tree, log: OperationLog): OperationLog { @@ -138,9 +139,13 @@ export class OperationMove extends Operation { } doOperation(tree: Tree): OperationLog { - const node = tree.get(this.id); + const node = tree.get(this.id) as Node; const oldParentId = node.parentId; + if (tree.isAncestor(this.parentId, this.id)) { + return { operation: this, oldParentId }; + } + tree.removeNode(this.id); tree.attachNode(this.id, this.parentId); return { operation: this, oldParentId }; @@ -148,7 +153,7 @@ export class OperationMove extends Operation { undoOperation(tree: Tree, log: OperationLog): void { tree.removeNode(log.operation.id); - tree.attachNode(log.operation.id, log.oldParentId); + tree.attachNode(log.operation.id, log.oldParentId as string); } redoOperation(tree: Tree, log: OperationLog): OperationLog { @@ -161,7 +166,7 @@ export class OperationMove extends Operation { ): OperationMove { const input: OperationMoveInput = { id: serializedOperation.id, - parentId: serializedOperation.parentId, + parentId: serializedOperation.parentId as string, clock: new Clock( serializedOperation.clock.id, serializedOperation.clock.counter, @@ -180,14 +185,14 @@ export class OperationUpdate extends Operation { } doOperation(tree: Tree): OperationLog { - const node = tree.get(this.id); + const node = tree.get(this.id) as Node; const oldDescription = node.description; tree.updateNode(this.id, this.description); return { operation: this, oldDescription: oldDescription }; } undoOperation(tree: Tree, log: OperationLog): void { - tree.updateNode(log.operation.id, log.oldDescription); + tree.updateNode(log.operation.id, log.oldDescription as T); } redoOperation(tree: Tree, log: OperationLog): OperationLog { @@ -200,7 +205,7 @@ export class OperationUpdate extends Operation { ): OperationUpdate { const input: OperationUpdateInput = { id: serializedOperation.id, - description: serializedOperation.description, + description: serializedOperation.description as T, clock: new Clock( serializedOperation.clock.id, serializedOperation.clock.counter, diff --git a/nestjs-BE/server/src/crdt/tree.spec.ts b/nestjs-BE/server/src/crdt/tree.spec.ts new file mode 100644 index 00000000..cf9da0ce --- /dev/null +++ b/nestjs-BE/server/src/crdt/tree.spec.ts @@ -0,0 +1,19 @@ +import { Tree } from './tree'; + +it('isAncestor', () => { + const tree = new Tree(); + + tree.addNode('a', 'root', 'test'); + tree.addNode('b', 'a', 'test'); + tree.addNode('c', 'b', 'test'); + tree.addNode('d', 'a', 'test'); + tree.addNode('e', 'b', 'test'); + + expect(tree.isAncestor('c', 'a')).toBeTruthy(); + expect(tree.isAncestor('c', 'root')).toBeTruthy(); + expect(tree.isAncestor('d', 'root')).toBeTruthy(); + expect(tree.isAncestor('d', 'a')).toBeTruthy(); + expect(tree.isAncestor('c', 'e')).toBeFalsy(); + expect(tree.isAncestor('e', 'c')).toBeFalsy(); + expect(tree.isAncestor('c', 'd')).toBeFalsy(); +}); diff --git a/nestjs-BE/server/src/crdt/tree.ts b/nestjs-BE/server/src/crdt/tree.ts index f0ea9066..a39cc063 100644 --- a/nestjs-BE/server/src/crdt/tree.ts +++ b/nestjs-BE/server/src/crdt/tree.ts @@ -32,7 +32,7 @@ export class Tree { targetNode.parentId = parentId; } - removeNode(targetId: string): Node { + removeNode(targetId: string): Node | undefined { const targetNode = this.nodes.get(targetId); if (!targetNode) return; @@ -41,6 +41,7 @@ export class Tree { const targetIndex = parentNode.children.indexOf(targetId); if (targetIndex !== -1) parentNode.children.splice(targetIndex, 1); + targetNode.parentId = '0'; return this.nodes.get(targetId); } @@ -56,6 +57,15 @@ export class Tree { return { nodes: Array.from(this.nodes.values()) }; } + isAncestor(targetId: string, ancestorId: string) { + let curNode = this.nodes.get(targetId); + while (curNode) { + if (curNode.parentId === ancestorId) return true; + curNode = this.nodes.get(curNode.parentId); + } + return false; + } + static parse(json: string) { const { nodes } = JSON.parse(json); const tree = new Tree(); diff --git a/nestjs-BE/server/src/invite-codes/invite-codes.controller.ts b/nestjs-BE/server/src/invite-codes/invite-codes.controller.ts index f6e10e3d..1a125b1c 100644 --- a/nestjs-BE/server/src/invite-codes/invite-codes.controller.ts +++ b/nestjs-BE/server/src/invite-codes/invite-codes.controller.ts @@ -1,12 +1,25 @@ -import { Controller, Get, Post, Body, Param } from '@nestjs/common'; +import { + Controller, + Get, + Post, + Body, + Param, + NotFoundException, + HttpException, + HttpStatus, +} from '@nestjs/common'; import { InviteCodesService } from './invite-codes.service'; import { CreateInviteCodeDto } from './dto/create-invite-code.dto'; import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { SpacesService } from 'src/spaces/spaces.service'; @Controller('inviteCodes') @ApiTags('inviteCodes') export class InviteCodesController { - constructor(private readonly inviteCodesService: InviteCodesService) {} + constructor( + private readonly inviteCodesService: InviteCodesService, + private readonly spacesService: SpacesService, + ) {} @Post() @ApiOperation({ summary: 'Create invite code' }) @@ -22,8 +35,17 @@ export class InviteCodesController { status: 404, description: 'Space not found.', }) - create(@Body() createInviteCodeDto: CreateInviteCodeDto) { - return this.inviteCodesService.createCode(createInviteCodeDto); + async create(@Body() createInviteCodeDto: CreateInviteCodeDto) { + const spaceUuid = createInviteCodeDto.space_uuid; + const space = await this.spacesService.findSpace(spaceUuid); + if (!space) throw new NotFoundException(); + const inviteCode = + await this.inviteCodesService.createInviteCode(spaceUuid); + return { + statusCode: 201, + message: 'Created', + data: { invite_code: inviteCode.invite_code }, + }; } @Get(':inviteCode') @@ -40,7 +62,15 @@ export class InviteCodesController { status: 410, description: 'Invite code has expired', }) - findSpace(@Param('inviteCode') inviteCode: string) { - return this.inviteCodesService.findSpace(inviteCode); + async findSpace(@Param('inviteCode') inviteCode: string) { + const inviteCodeData = + await this.inviteCodesService.findInviteCode(inviteCode); + if (!inviteCodeData) throw new NotFoundException(); + if (this.inviteCodesService.checkExpiry(inviteCodeData.expiry_date)) { + this.inviteCodesService.deleteInviteCode(inviteCode); + throw new HttpException('Invite code has expired.', HttpStatus.GONE); + } + const space = await this.spacesService.findSpace(inviteCodeData.space_uuid); + return { statusCode: 200, message: 'Success', data: space }; } } diff --git a/nestjs-BE/server/src/invite-codes/invite-codes.service.ts b/nestjs-BE/server/src/invite-codes/invite-codes.service.ts index 8c8f54e7..c48722bf 100644 --- a/nestjs-BE/server/src/invite-codes/invite-codes.service.ts +++ b/nestjs-BE/server/src/invite-codes/invite-codes.service.ts @@ -1,70 +1,47 @@ -import { Injectable, HttpException, HttpStatus } from '@nestjs/common'; -import { CreateInviteCodeDto } from './dto/create-invite-code.dto'; -import { BaseService } from 'src/base/base.service'; -import { PrismaServiceMySQL } from 'src/prisma/prisma.service'; -import { TemporaryDatabaseService } from 'src/temporary-database/temporary-database.service'; +import { Injectable } from '@nestjs/common'; +import { PrismaService } from 'src/prisma/prisma.service'; import { - INVITE_CODE_CACHE_SIZE, INVITE_CODE_EXPIRY_HOURS, INVITE_CODE_LENGTH, } from 'src/config/magic-number'; -import { SpacesService } from 'src/spaces/spaces.service'; -import { ResponseUtils } from 'src/utils/response'; - -export interface InviteCodeData extends CreateInviteCodeDto { - uuid?: string; - invite_code: string; - expiry_date: Date; -} +import { InviteCode, Prisma } from '@prisma/client'; +import generateUuid from 'src/utils/uuid'; @Injectable() -export class InviteCodesService extends BaseService { - constructor( - protected prisma: PrismaServiceMySQL, - protected temporaryDatabaseService: TemporaryDatabaseService, - protected spacesService: SpacesService, - ) { - super({ - prisma, - temporaryDatabaseService, - cacheSize: INVITE_CODE_CACHE_SIZE, - className: 'INVITE_CODE_TB', - field: 'invite_code', - }); - } +export class InviteCodesService { + constructor(protected prisma: PrismaService) {} - generateKey(data: InviteCodeData): string { - return data.invite_code; - } - - async createCode(createInviteCodeDto: CreateInviteCodeDto) { - const { space_uuid: spaceUuid } = createInviteCodeDto; - await this.spacesService.findOne(spaceUuid); - const inviteCodeData = await this.generateInviteCode(createInviteCodeDto); - super.create(inviteCodeData); - const { invite_code } = inviteCodeData; - return ResponseUtils.createResponse(HttpStatus.CREATED, { invite_code }); + async findInviteCode(inviteCode: string): Promise { + return this.prisma.inviteCode.findUnique({ + where: { invite_code: inviteCode }, + }); } - async findSpace(inviteCode: string) { - const inviteCodeData = await this.getInviteCodeData(inviteCode); - this.checkExpiry(inviteCode, inviteCodeData.expiry_date); - const spaceResponse = await this.spacesService.findOne( - inviteCodeData.space_uuid, - ); - return spaceResponse; + async createInviteCode(spaceUuid: string): Promise { + return this.prisma.inviteCode.create({ + data: { + uuid: generateUuid(), + invite_code: await this.generateUniqueInviteCode(INVITE_CODE_LENGTH), + space_uuid: spaceUuid, + expiry_date: this.calculateExpiryDate(), + }, + }); } - private async generateInviteCode(createInviteCodeDto: CreateInviteCodeDto) { - const uniqueInviteCode = - await this.generateUniqueInviteCode(INVITE_CODE_LENGTH); - const expiryDate = this.calculateExpiryDate(); - - return { - ...createInviteCodeDto, - invite_code: uniqueInviteCode, - expiry_date: expiryDate, - }; + async deleteInviteCode(inviteCode: string): Promise { + try { + return await this.prisma.inviteCode.delete({ + where: { + invite_code: inviteCode, + }, + }); + } catch (err) { + if (err instanceof Prisma.PrismaClientKnownRequestError) { + return null; + } else { + throw err; + } + } } private calculateExpiryDate(): Date { @@ -74,19 +51,9 @@ export class InviteCodesService extends BaseService { return expiryDate; } - private async getInviteCodeData(inviteCode: string) { - const inviteCodeResponse = await super.findOne(inviteCode); - const { data: inviteCodeData } = inviteCodeResponse; - return inviteCodeData; - } - - private checkExpiry(inviteCode: string, expiryDate: Date) { - const currentTimestamp = new Date().getTime(); - const expiryTimestamp = new Date(expiryDate).getTime(); - if (expiryTimestamp < currentTimestamp) { - super.remove(inviteCode); - throw new HttpException('Invite code has expired.', HttpStatus.GONE); - } + checkExpiry(expiryDate: Date) { + const currentTimestamp = new Date(); + return expiryDate < currentTimestamp ? true : false; } private generateShortInviteCode(length: number) { @@ -103,11 +70,11 @@ export class InviteCodesService extends BaseService { private async generateUniqueInviteCode(length: number): Promise { let inviteCode: string; - let inviteCodeData: InviteCodeData; + let inviteCodeData: InviteCode; do { inviteCode = this.generateShortInviteCode(length); - inviteCodeData = await super.getDataFromCacheOrDB(inviteCode); + inviteCodeData = await this.findInviteCode(inviteCode); } while (inviteCodeData !== null); return inviteCode; diff --git a/nestjs-BE/server/src/prisma/prisma.module.ts b/nestjs-BE/server/src/prisma/prisma.module.ts index 01b27f2d..7207426f 100644 --- a/nestjs-BE/server/src/prisma/prisma.module.ts +++ b/nestjs-BE/server/src/prisma/prisma.module.ts @@ -1,10 +1,9 @@ import { Global, Module } from '@nestjs/common'; -import { PrismaServiceMySQL } from './prisma.service'; -import { PrismaServiceMongoDB } from './prisma.service'; +import { PrismaService } from './prisma.service'; @Global() @Module({ - providers: [PrismaServiceMySQL, PrismaServiceMongoDB], - exports: [PrismaServiceMySQL, PrismaServiceMongoDB], + providers: [PrismaService], + exports: [PrismaService], }) export class PrismaModule {} diff --git a/nestjs-BE/server/src/prisma/prisma.service.spec.ts b/nestjs-BE/server/src/prisma/prisma.service.spec.ts deleted file mode 100644 index a68cb9e3..00000000 --- a/nestjs-BE/server/src/prisma/prisma.service.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { PrismaService } from './prisma.service'; - -describe('PrismaService', () => { - let service: PrismaService; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [PrismaService], - }).compile(); - - service = module.get(PrismaService); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); -}); diff --git a/nestjs-BE/server/src/prisma/prisma.service.ts b/nestjs-BE/server/src/prisma/prisma.service.ts index 5559b4ea..359f950b 100644 --- a/nestjs-BE/server/src/prisma/prisma.service.ts +++ b/nestjs-BE/server/src/prisma/prisma.service.ts @@ -1,22 +1,8 @@ import { Injectable, OnModuleInit } from '@nestjs/common'; -import { PrismaClient as PrismaClientMySQL } from '../../prisma/generated/mysql'; -import { PrismaClient as PrismaClientMongoDB } from '../../prisma/generated/mongodb'; +import { PrismaClient } from '@prisma/client'; @Injectable() -export class PrismaServiceMySQL - extends PrismaClientMySQL - implements OnModuleInit -{ - async onModuleInit() { - await this.$connect(); - } -} - -@Injectable() -export class PrismaServiceMongoDB - extends PrismaClientMongoDB - implements OnModuleInit -{ +export class PrismaService extends PrismaClient implements OnModuleInit { async onModuleInit() { await this.$connect(); } diff --git a/nestjs-BE/server/src/profile-space/profile-space.controller.ts b/nestjs-BE/server/src/profile-space/profile-space.controller.ts index e10e3642..f4df1f0b 100644 --- a/nestjs-BE/server/src/profile-space/profile-space.controller.ts +++ b/nestjs-BE/server/src/profile-space/profile-space.controller.ts @@ -6,12 +6,17 @@ import { Delete, Param, Request as Req, + NotFoundException, + HttpException, + HttpStatus, + ConflictException, } from '@nestjs/common'; import { ProfileSpaceService } from './profile-space.service'; import { CreateProfileSpaceDto } from './dto/create-profile-space.dto'; import { RequestWithUser } from 'src/utils/interface'; import { SpacesService } from 'src/spaces/spaces.service'; import { ApiTags, ApiResponse, ApiOperation } from '@nestjs/swagger'; +import { ProfilesService } from 'src/profiles/profiles.service'; @Controller('profileSpace') @ApiTags('profileSpace') @@ -19,6 +24,7 @@ export class ProfileSpaceController { constructor( private readonly profileSpaceService: ProfileSpaceService, private readonly spacesService: SpacesService, + private readonly profilesService: ProfilesService, ) {} @Post('join') @@ -35,14 +41,16 @@ export class ProfileSpaceController { @Body() createProfileSpaceDto: CreateProfileSpaceDto, @Req() req: RequestWithUser, ) { - const userUuid = req.user.uuid; - const { space_uuid } = createProfileSpaceDto; - const { joinData, profileData } = - await this.profileSpaceService.processData(userUuid, space_uuid); - const responseData = await this.profileSpaceService.create(joinData); - const data = await this.spacesService.processData(space_uuid, profileData); - await this.profileSpaceService.put(userUuid, space_uuid, data); - return responseData; + const profile = await this.profilesService.findProfile(req.user.uuid); + if (!profile) throw new NotFoundException(); + const profileSpace = await this.profileSpaceService.joinSpace( + profile.uuid, + createProfileSpaceDto.space_uuid, + ); + if (!profileSpace) { + throw new HttpException('Data already exists.', HttpStatus.CONFLICT); + } + return { statusCode: 201, message: 'Created', data: profileSpace }; } @Delete('leave/:space_uuid') @@ -58,29 +66,40 @@ export class ProfileSpaceController { @Param('space_uuid') spaceUuid: string, @Req() req: RequestWithUser, ) { - const userUuid = req.user.uuid; - const { joinData, profileData } = - await this.profileSpaceService.processData(userUuid, spaceUuid); - await this.spacesService.processData(spaceUuid, profileData); - const isSpaceEmpty = await this.profileSpaceService.delete( - userUuid, + const profile = await this.profilesService.findProfile(req.user.uuid); + if (!profile) throw new NotFoundException(); + const space = await this.spacesService.findSpace(spaceUuid); + if (!space) throw new NotFoundException(); + const profileSpace = await this.profileSpaceService.leaveSpace( + profile.uuid, spaceUuid, - profileData, ); - if (isSpaceEmpty) this.spacesService.remove(spaceUuid); - const key = this.profileSpaceService.generateKey(joinData); - return this.profileSpaceService.remove(key); + if (!profileSpace) throw new ConflictException(); + const isSpaceEmpty = await this.profileSpaceService.isSpaceEmpty(spaceUuid); + if (isSpaceEmpty) { + await this.spacesService.deleteSpace(spaceUuid); + } + return { statusCode: 204, message: 'No Content' }; } @Get('spaces') - @ApiOperation({ summary: 'Get user’s spaces' }) + @ApiOperation({ summary: "Get user's spaces" }) @ApiResponse({ status: 200, description: 'Returns a list of spaces.', }) - getSpaces(@Req() req: RequestWithUser) { - const userUuid = req.user.uuid; - return this.profileSpaceService.retrieveUserSpaces(userUuid); + async getSpaces(@Req() req: RequestWithUser) { + const profile = await this.profilesService.findProfile(req.user.uuid); + if (!profile) throw new NotFoundException(); + const profileSpaces = + await this.profileSpaceService.findProfileSpacesByProfileUuid( + profile.uuid, + ); + const spaceUuids = profileSpaces.map( + (profileSpace) => profileSpace.space_uuid, + ); + const spaces = await this.spacesService.findSpaces(spaceUuids); + return { statusCode: 200, message: 'Success', data: spaces }; } @Get('users/:space_uuid') @@ -93,7 +112,15 @@ export class ProfileSpaceController { status: 404, description: 'Space not found.', }) - getUsers(@Param('space_uuid') spaceUuid: string) { - return this.profileSpaceService.retrieveSpaceUsers(spaceUuid); + async getProfiles(@Param('space_uuid') spaceUuid: string) { + const space = await this.spacesService.findSpace(spaceUuid); + if (!space) throw new NotFoundException(); + const profileSpaces = + await this.profileSpaceService.findProfileSpacesBySpaceUuid(space.uuid); + const profileUuids = profileSpaces.map( + (profileSpace) => profileSpace.profile_uuid, + ); + const profiles = await this.profilesService.findProfiles(profileUuids); + return { statusCode: 200, message: 'Success', data: profiles }; } } diff --git a/nestjs-BE/server/src/profile-space/profile-space.service.ts b/nestjs-BE/server/src/profile-space/profile-space.service.ts index a451a220..39e6291e 100644 --- a/nestjs-BE/server/src/profile-space/profile-space.service.ts +++ b/nestjs-BE/server/src/profile-space/profile-space.service.ts @@ -1,164 +1,72 @@ -import { Injectable, HttpStatus } from '@nestjs/common'; -import { UpdateProfileSpaceDto } from './dto/update-profile-space.dto'; -import { BaseService } from 'src/base/base.service'; -import { PrismaServiceMySQL } from 'src/prisma/prisma.service'; -import { TemporaryDatabaseService } from 'src/temporary-database/temporary-database.service'; -import { - PROFILE_SPACE_CACHE_SIZE, - SPACE_USER_CACHE_SIZE, - USER_SPACE_CACHE_SIZE, -} from 'src/config/magic-number'; -import { CreateProfileSpaceDto } from './dto/create-profile-space.dto'; -import { ProfilesService } from 'src/profiles/profiles.service'; -import { UpdateProfileDto } from 'src/profiles/dto/update-profile.dto'; -import { UpdateSpaceDto } from 'src/spaces/dto/update-space.dto'; -import LRUCache from 'src/utils/lru-cache'; -import { ResponseUtils } from 'src/utils/response'; - -interface UpdateProfileAndSpaceDto { - profileData: UpdateProfileDto; - spaceData: UpdateSpaceDto; -} +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { Prisma, Profile_space } from '@prisma/client'; @Injectable() -export class ProfileSpaceService extends BaseService { - private readonly userCache: LRUCache; - private readonly spaceCache: LRUCache; - constructor( - protected prisma: PrismaServiceMySQL, - protected temporaryDatabaseService: TemporaryDatabaseService, - private readonly profilesService: ProfilesService, - ) { - super({ - prisma, - temporaryDatabaseService, - cacheSize: PROFILE_SPACE_CACHE_SIZE, - className: 'PROFILE_SPACE_TB', - field: 'space_uuid_profile_uuid', - }); - this.userCache = new LRUCache(USER_SPACE_CACHE_SIZE); - this.spaceCache = new LRUCache(SPACE_USER_CACHE_SIZE); - } - - generateKey(data: CreateProfileSpaceDto) { - return `space_uuid:${data.space_uuid}+profile_uuid:${data.profile_uuid}`; - } +export class ProfileSpaceService { + constructor(private readonly prisma: PrismaService) {} - async create(data: CreateProfileSpaceDto) { - const response = await super.create(data, false); - return response; - } - - async processData(userUuid: string, spaceUuid: string) { - const profileResponse = await this.profilesService.findOne(userUuid); - const profileUuid = profileResponse.data?.uuid; - const joinData = { - profile_uuid: profileUuid, - space_uuid: spaceUuid, - }; - return { joinData, profileData: profileResponse.data }; + async findProfileSpacesByProfileUuid( + profileUuid: string, + ): Promise { + return this.prisma.profile_space.findMany({ + where: { profile_uuid: profileUuid }, + }); } - async put( - userUuid: string, + async findProfileSpacesBySpaceUuid( spaceUuid: string, - data: UpdateProfileAndSpaceDto, - ) { - const { spaceData, profileData } = data; - const userSpaces = await this.fetchUserSpacesFromCacheOrDB( - userUuid, - profileData.uuid, - ); - userSpaces.push(spaceData); - this.userCache.put(userUuid, userSpaces); - const spaceProfiles = await this.fetchSpaceUsersFromCacheOrDB(spaceUuid); - spaceProfiles.push(profileData); - this.spaceCache.put(spaceUuid, spaceProfiles); + ): Promise { + return this.prisma.profile_space.findMany({ + where: { space_uuid: spaceUuid }, + }); } - async delete( - userUuid: string, + async joinSpace( + profileUuid: string, spaceUuid: string, - profileData: UpdateProfileDto, - ) { - const userSpaces = await this.fetchUserSpacesFromCacheOrDB( - userUuid, - profileData.uuid, - ); - const filterUserSpaces = userSpaces.filter( - (space) => space.uuid !== spaceUuid, - ); - this.userCache.put(userUuid, filterUserSpaces); - const spaceUsers = await this.fetchSpaceUsersFromCacheOrDB(spaceUuid); - const filterSpaceUsers = spaceUsers.filter( - (profile) => profile.uuid !== profileData.uuid, - ); - this.spaceCache.put(spaceUuid, filterSpaceUsers); - return filterSpaceUsers.length === 0; + ): Promise { + try { + return await this.prisma.profile_space.create({ + data: { space_uuid: spaceUuid, profile_uuid: profileUuid }, + }); + } catch (err) { + if (err instanceof Prisma.PrismaClientKnownRequestError) { + return null; + } else { + throw err; + } + } } - async fetchUserSpacesFromCacheOrDB( - userUuid: string, + async leaveSpace( profileUuid: string, - ): Promise { - const cacheUserSpaces = this.userCache.get(userUuid); - if (cacheUserSpaces) return cacheUserSpaces; - const profileResponse = await this.prisma['PROFILE_TB'].findUnique({ - where: { uuid: profileUuid }, - include: { - spaces: { - include: { - space: true, + spaceUuid: string, + ): Promise { + try { + return await this.prisma.profile_space.delete({ + where: { + space_uuid_profile_uuid: { + space_uuid: spaceUuid, + profile_uuid: profileUuid, }, }, - }, - }); - const storeUserSpaces = - profileResponse?.spaces.map((profileSpace) => profileSpace.space) || []; - return storeUserSpaces; + }); + } catch (err) { + if (err instanceof Prisma.PrismaClientKnownRequestError) { + return null; + } else { + throw err; + } + } } - async fetchSpaceUsersFromCacheOrDB( - spaceUuid: string, - ): Promise { - const cacheSpaceProfiles = this.spaceCache.get(spaceUuid); - if (cacheSpaceProfiles) return cacheSpaceProfiles; - - const spaceResponse = await this.prisma['SPACE_TB'].findUnique({ - where: { uuid: spaceUuid }, - include: { - profiles: { - include: { - profile: true, - }, - }, + async isSpaceEmpty(spaceUuid: string) { + const first = await this.prisma.profile_space.findFirst({ + where: { + space_uuid: spaceUuid, }, }); - - const storeSpaceProfiles = - spaceResponse?.profiles.map((profileSpace) => profileSpace.profile) || []; - return storeSpaceProfiles; - } - - async retrieveUserSpaces(userUuid: string) { - const profileResponse = await this.profilesService.findOne(userUuid); - const profileUuid = profileResponse.data?.uuid; - const spaces = await this.fetchUserSpacesFromCacheOrDB( - userUuid, - profileUuid, - ); - this.userCache.put(userUuid, spaces); - return ResponseUtils.createResponse(HttpStatus.OK, spaces); - } - - async retrieveSpaceUsers(spaceUuid: string) { - const users = await this.fetchSpaceUsersFromCacheOrDB(spaceUuid); - const usersData = await Promise.all( - users.map(async (user) => { - return await this.profilesService.findOne(user.user_id); - }), - ); - this.spaceCache.put(spaceUuid, usersData); - return ResponseUtils.createResponse(HttpStatus.OK, usersData); + return first ? false : true; } } diff --git a/nestjs-BE/server/src/profiles/dto/create-profile.dto.ts b/nestjs-BE/server/src/profiles/dto/create-profile.dto.ts index 905a78e3..9599e029 100644 --- a/nestjs-BE/server/src/profiles/dto/create-profile.dto.ts +++ b/nestjs-BE/server/src/profiles/dto/create-profile.dto.ts @@ -1,4 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; +import { MaxLength } from 'class-validator'; +import { MAX_NAME_LENGTH } from '../../config/magic-number'; export class CreateProfileDto { user_id: string; @@ -9,6 +11,7 @@ export class CreateProfileDto { }) image: string; + @MaxLength(MAX_NAME_LENGTH) @ApiProperty({ example: 'Sample nickname', description: 'Nickname for the profile', diff --git a/nestjs-BE/server/src/profiles/dto/update-profile.dto.ts b/nestjs-BE/server/src/profiles/dto/update-profile.dto.ts index c6e4c50e..a69ec111 100644 --- a/nestjs-BE/server/src/profiles/dto/update-profile.dto.ts +++ b/nestjs-BE/server/src/profiles/dto/update-profile.dto.ts @@ -1,8 +1,11 @@ import { PartialType } from '@nestjs/mapped-types'; import { CreateProfileDto } from './create-profile.dto'; import { ApiProperty } from '@nestjs/swagger'; +import { MaxLength } from 'class-validator'; +import { MAX_NAME_LENGTH } from '../../config/magic-number'; export class UpdateProfileDto extends PartialType(CreateProfileDto) { + @MaxLength(MAX_NAME_LENGTH) @ApiProperty({ example: 'new nickname', description: 'Updated nickname of the profile', diff --git a/nestjs-BE/server/src/profiles/profiles.controller.spec.ts b/nestjs-BE/server/src/profiles/profiles.controller.spec.ts index 752ac281..7af28683 100644 --- a/nestjs-BE/server/src/profiles/profiles.controller.spec.ts +++ b/nestjs-BE/server/src/profiles/profiles.controller.spec.ts @@ -1,20 +1,106 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ProfilesController } from './profiles.controller'; import { ProfilesService } from './profiles.service'; +import { DeepMockProxy, mockDeep } from 'jest-mock-extended'; +import { UploadService } from '../upload/upload.service'; +import { RequestWithUser } from '../utils/interface'; +import { NotFoundException } from '@nestjs/common'; describe('ProfilesController', () => { let controller: ProfilesController; + let profilesService: DeepMockProxy; + let uploadService: DeepMockProxy; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [ProfilesController], - providers: [ProfilesService], - }).compile(); + providers: [ProfilesService, UploadService], + }) + .overrideProvider(ProfilesService) + .useValue(mockDeep()) + .overrideProvider(UploadService) + .useValue(mockDeep()) + .compile(); controller = module.get(ProfilesController); + profilesService = module.get(ProfilesService); + uploadService = module.get(UploadService); }); - it('should be defined', () => { - expect(controller).toBeDefined(); + it('findProfile found profile', async () => { + const requestMock = { user: { uuid: 'user test uuid' } } as RequestWithUser; + const testProfile = { + uuid: 'profile test uuid', + user_id: requestMock.user.uuid, + image: 'www.test.com/image', + nickname: 'test nickname', + }; + profilesService.findProfile.mockResolvedValue(testProfile); + + const response = controller.findProfile(requestMock); + + await expect(response).resolves.toEqual({ + statusCode: 200, + message: 'Success', + data: testProfile, + }); + expect(profilesService.findProfile).toHaveBeenCalledWith( + requestMock.user.uuid, + ); + }); + + it('findProfile not found profile', async () => { + const requestMock = { user: { uuid: 'test uuid' } } as RequestWithUser; + profilesService.findProfile.mockResolvedValue(null); + + const response = controller.findProfile(requestMock); + + await expect(response).rejects.toThrow(NotFoundException); + }); + + it('update updated profile', async () => { + const imageMock = {} as Express.Multer.File; + const requestMock = { user: { uuid: 'test uuid' } } as RequestWithUser; + const bodyMock = { + nickname: 'test nickname', + }; + const testImageUrl = 'www.test.com/image'; + const testProfile = { + uuid: 'profile test uuid', + user_id: requestMock.user.uuid, + image: 'www.test.com/image', + nickname: 'test nickname', + }; + uploadService.uploadFile.mockResolvedValue(testImageUrl); + profilesService.updateProfile.mockResolvedValue(testProfile); + + const response = controller.update(imageMock, requestMock, bodyMock); + + await expect(response).resolves.toEqual({ + statusCode: 200, + message: 'Success', + data: testProfile, + }); + expect(uploadService.uploadFile).toHaveBeenCalled(); + expect(uploadService.uploadFile).toHaveBeenCalledWith(imageMock); + expect(profilesService.updateProfile).toHaveBeenCalledWith( + requestMock.user.uuid, + { nickname: bodyMock.nickname, image: testImageUrl }, + ); + }); + + it('update not found user', async () => { + const imageMock = {} as Express.Multer.File; + const requestMock = { user: { uuid: 'test uuid' } } as RequestWithUser; + const bodyMock = { + nickname: 'test nickname', + }; + const testImageUrl = 'www.test.com/image'; + uploadService.uploadFile.mockResolvedValue(testImageUrl); + profilesService.updateProfile.mockResolvedValue(null); + + const response = controller.update(imageMock, requestMock, bodyMock); + + await expect(response).rejects.toThrow(NotFoundException); }); }); diff --git a/nestjs-BE/server/src/profiles/profiles.controller.ts b/nestjs-BE/server/src/profiles/profiles.controller.ts index c59bff3b..93067f57 100644 --- a/nestjs-BE/server/src/profiles/profiles.controller.ts +++ b/nestjs-BE/server/src/profiles/profiles.controller.ts @@ -6,13 +6,15 @@ import { UseInterceptors, UploadedFile, Request as Req, + ValidationPipe, + NotFoundException, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import { ProfilesService } from './profiles.service'; import { UpdateProfileDto } from './dto/update-profile.dto'; import { ApiTags, ApiResponse, ApiOperation } from '@nestjs/swagger'; -import { UploadService } from 'src/upload/upload.service'; -import { RequestWithUser } from 'src/utils/interface'; +import { UploadService } from '../upload/upload.service'; +import { RequestWithUser } from '../utils/interface'; @Controller('profiles') @ApiTags('profiles') @@ -32,8 +34,10 @@ export class ProfilesController { status: 401, description: 'Unauthorized.', }) - findOne(@Req() req: RequestWithUser) { - return this.profilesService.findOne(req.user.uuid); + async findProfile(@Req() req: RequestWithUser) { + const profile = await this.profilesService.findProfile(req.user.uuid); + if (!profile) throw new NotFoundException(); + return { statusCode: 200, message: 'Success', data: profile }; } @Patch() @@ -50,11 +54,17 @@ export class ProfilesController { async update( @UploadedFile() image: Express.Multer.File, @Req() req: RequestWithUser, - @Body() updateProfileDto: UpdateProfileDto, + @Body(new ValidationPipe({ whitelist: true })) + updateProfileDto: UpdateProfileDto, ) { if (image) { updateProfileDto.image = await this.uploadService.uploadFile(image); } - return this.profilesService.update(req.user.uuid, updateProfileDto); + const profile = await this.profilesService.updateProfile( + req.user.uuid, + updateProfileDto, + ); + if (!profile) throw new NotFoundException(); + return { statusCode: 200, message: 'Success', data: profile }; } } diff --git a/nestjs-BE/server/src/profiles/profiles.module.ts b/nestjs-BE/server/src/profiles/profiles.module.ts index 97674fa9..1a060fd9 100644 --- a/nestjs-BE/server/src/profiles/profiles.module.ts +++ b/nestjs-BE/server/src/profiles/profiles.module.ts @@ -1,11 +1,13 @@ import { Module } from '@nestjs/common'; import { ProfilesService } from './profiles.service'; import { ProfilesController } from './profiles.controller'; -import { UploadService } from 'src/upload/upload.service'; +import { UploadModule } from '../upload/upload.module'; +import { PrismaModule } from '../prisma/prisma.module'; @Module({ controllers: [ProfilesController], - providers: [ProfilesService, UploadService], + providers: [ProfilesService], exports: [ProfilesService], + imports: [UploadModule, PrismaModule], }) export class ProfilesModule {} diff --git a/nestjs-BE/server/src/profiles/profiles.service.spec.ts b/nestjs-BE/server/src/profiles/profiles.service.spec.ts index 29cbe78f..2ead69de 100644 --- a/nestjs-BE/server/src/profiles/profiles.service.spec.ts +++ b/nestjs-BE/server/src/profiles/profiles.service.spec.ts @@ -1,18 +1,140 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ProfilesService } from './profiles.service'; +import { DeepMockProxy, mockDeep } from 'jest-mock-extended'; +import { PrismaClient } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; +import generateUuid from '../utils/uuid'; +import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library'; describe('ProfilesService', () => { - let service: ProfilesService; + let profilesService: ProfilesService; + let prisma: DeepMockProxy; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [ProfilesService], - }).compile(); + providers: [ProfilesService, PrismaService], + }) + .overrideProvider(PrismaService) + .useValue(mockDeep()) + .compile(); - service = module.get(ProfilesService); + profilesService = module.get(ProfilesService); + prisma = module.get(PrismaService); }); - it('should be defined', () => { - expect(service).toBeDefined(); + it('findProfile found profile', async () => { + const userId = generateUuid(); + const testProfile = { + uuid: generateUuid(), + user_id: userId, + image: 'www.test.com/image', + nickname: 'test nickname', + }; + prisma.profile.findUnique.mockResolvedValue(testProfile); + + const user = profilesService.findProfile(userId); + + await expect(user).resolves.toEqual(testProfile); + }); + + it('findProfile not found profile', async () => { + const userId = generateUuid(); + prisma.profile.findUnique.mockResolvedValue(null); + + const user = profilesService.findProfile(userId); + + await expect(user).resolves.toBeNull(); + }); + + it('findProfiles found profiles', async () => { + const ARRAY_SIZE = 5; + const profileUuids = Array(ARRAY_SIZE) + .fill(null) + .map(() => generateUuid()); + const testProfiles = profileUuids.map((uuid, index) => { + return { + uuid, + user_id: generateUuid(), + image: 'www.test.com/image', + nickname: `nickname${index}`, + }; + }); + prisma.profile.findMany.mockResolvedValue(testProfiles); + + const profiles = profilesService.findProfiles(profileUuids); + + await expect(profiles).resolves.toEqual(testProfiles); + }); + + it('findProfiles not found profiles', async () => { + const profileUuids = []; + prisma.profile.findMany.mockResolvedValue([]); + + const profiles = profilesService.findProfiles(profileUuids); + + await expect(profiles).resolves.toEqual([]); + }); + + it('createProfile created', async () => { + const data = { + user_id: generateUuid(), + image: 'www.test.com/image', + nickname: 'test nickname', + }; + const testProfile = { uuid: generateUuid(), ...data }; + prisma.profile.create.mockResolvedValue(testProfile); + + const profile = profilesService.createProfile(data); + + await expect(profile).resolves.toEqual(testProfile); + }); + + it("createProfile user_id doesn't exists", async () => { + const data = { + user_id: generateUuid(), + image: 'www.test.com/image', + nickname: 'test nickname', + }; + prisma.profile.create.mockRejectedValue( + new PrismaClientKnownRequestError( + 'Foreign key constraint failed on the field: `user_id`', + { code: 'P2003', clientVersion: '' }, + ), + ); + + const profile = profilesService.createProfile(data); + + await expect(profile).rejects.toThrow(PrismaClientKnownRequestError); + }); + + it('updateProfile updated', async () => { + const data = { + image: 'www.test.com', + nickname: 'test nickname', + }; + const uuid = generateUuid(); + const testProfile = { uuid: generateUuid(), user_id: uuid, ...data }; + prisma.profile.update.mockResolvedValue(testProfile); + + const profile = profilesService.updateProfile(uuid, data); + + await expect(profile).resolves.toEqual(testProfile); + }); + + it("updateProfile user_id doesn't exists", async () => { + const data = { + image: 'www.test.com', + nickname: 'test nickname', + }; + prisma.profile.update.mockRejectedValue( + new PrismaClientKnownRequestError( + 'An operation failed because it depends on one or more records that were required but not found. Record to update not found.', + { code: 'P2025', clientVersion: '' }, + ), + ); + + const profile = profilesService.updateProfile(generateUuid(), data); + + await expect(profile).resolves.toBeNull(); }); }); diff --git a/nestjs-BE/server/src/profiles/profiles.service.ts b/nestjs-BE/server/src/profiles/profiles.service.ts index 0a1027fb..5fdc1483 100644 --- a/nestjs-BE/server/src/profiles/profiles.service.ts +++ b/nestjs-BE/server/src/profiles/profiles.service.ts @@ -1,26 +1,50 @@ import { Injectable } from '@nestjs/common'; -import { PrismaServiceMySQL } from '../prisma/prisma.service'; -import { TemporaryDatabaseService } from '../temporary-database/temporary-database.service'; -import { BaseService } from '../base/base.service'; -import { PROFILE_CACHE_SIZE } from 'src/config/magic-number'; +import { PrismaService } from '../prisma/prisma.service'; import { UpdateProfileDto } from './dto/update-profile.dto'; +import { CreateProfileDto } from './dto/create-profile.dto'; +import { Profile, Prisma } from '@prisma/client'; +import generateUuid from '../utils/uuid'; @Injectable() -export class ProfilesService extends BaseService { - constructor( - protected prisma: PrismaServiceMySQL, - protected temporaryDatabaseService: TemporaryDatabaseService, - ) { - super({ - prisma, - temporaryDatabaseService, - cacheSize: PROFILE_CACHE_SIZE, - className: 'PROFILE_TB', - field: 'user_id', +export class ProfilesService { + constructor(private prisma: PrismaService) {} + + async findProfile(userUuid: string): Promise { + return this.prisma.profile.findUnique({ where: { user_id: userUuid } }); + } + + async findProfiles(profileUuids: string[]): Promise { + return this.prisma.profile.findMany({ + where: { uuid: { in: profileUuids } }, + }); + } + + async createProfile(data: CreateProfileDto): Promise { + return this.prisma.profile.create({ + data: { + uuid: generateUuid(), + user_id: data.user_id, + image: data.image, + nickname: data.nickname, + }, }); } - generateKey(data: UpdateProfileDto): string { - return data.user_id; + async updateProfile( + userUuid: string, + updateProfileDto: UpdateProfileDto, + ): Promise { + try { + return await this.prisma.profile.update({ + where: { user_id: userUuid }, + data: { ...updateProfileDto }, + }); + } catch (err) { + if (err instanceof Prisma.PrismaClientKnownRequestError) { + return null; + } else { + throw err; + } + } } } diff --git a/nestjs-BE/server/src/spaces/dto/create-space.dto.ts b/nestjs-BE/server/src/spaces/dto/create-space.dto.ts index 23317491..7620e5ef 100644 --- a/nestjs-BE/server/src/spaces/dto/create-space.dto.ts +++ b/nestjs-BE/server/src/spaces/dto/create-space.dto.ts @@ -1,9 +1,11 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsNotEmpty, IsString } from 'class-validator'; +import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; +import { MAX_NAME_LENGTH } from '../../config/magic-number'; export class CreateSpaceDto { @IsString() @IsNotEmpty() + @MaxLength(MAX_NAME_LENGTH) @ApiProperty({ example: 'Sample Space', description: 'Name of the space' }) name: string; diff --git a/nestjs-BE/server/src/spaces/dto/update-space.dto.ts b/nestjs-BE/server/src/spaces/dto/update-space.dto.ts index 017ec463..0a510d83 100644 --- a/nestjs-BE/server/src/spaces/dto/update-space.dto.ts +++ b/nestjs-BE/server/src/spaces/dto/update-space.dto.ts @@ -1,21 +1,22 @@ -import { PartialType } from '@nestjs/mapped-types'; -import { CreateSpaceDto } from './create-space.dto'; import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; +import { MAX_NAME_LENGTH } from '../../config/magic-number'; -export class UpdateSpaceDto extends PartialType(CreateSpaceDto) { +export class UpdateSpaceDto { + @IsString() + @IsNotEmpty() + @MaxLength(MAX_NAME_LENGTH) @ApiProperty({ example: 'new space', description: 'Updated space name', required: false, }) - name?: string; + name: string; @ApiProperty({ example: 'new image', description: 'Updated space icon', required: false, }) - icon?: string; - - uuid?: string; + icon: string; } diff --git a/nestjs-BE/server/src/spaces/spaces.controller.spec.ts b/nestjs-BE/server/src/spaces/spaces.controller.spec.ts index bc7556ae..f5e97988 100644 --- a/nestjs-BE/server/src/spaces/spaces.controller.spec.ts +++ b/nestjs-BE/server/src/spaces/spaces.controller.spec.ts @@ -1,20 +1,178 @@ import { Test, TestingModule } from '@nestjs/testing'; import { SpacesController } from './spaces.controller'; import { SpacesService } from './spaces.service'; +import { DeepMockProxy, mockDeep } from 'jest-mock-extended'; +import { ProfileSpaceService } from '../profile-space/profile-space.service'; +import { UploadService } from '../upload/upload.service'; +import { ProfilesService } from '../profiles/profiles.service'; +import { Profile, Space } from '@prisma/client'; +import { NotFoundException } from '@nestjs/common'; +import { UpdateSpaceDto } from './dto/update-space.dto'; +import { CreateSpaceDto } from './dto/create-space.dto'; +import { RequestWithUser } from '../utils/interface'; +import customEnv from '../config/env'; +const { APP_ICON_URL } = customEnv; describe('SpacesController', () => { let controller: SpacesController; + let spacesService: DeepMockProxy; + let uploadService: DeepMockProxy; + let profilesService: DeepMockProxy; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [SpacesController], - providers: [SpacesService], - }).compile(); + providers: [ + SpacesService, + UploadService, + ProfileSpaceService, + ProfilesService, + ], + }) + .overrideProvider(SpacesService) + .useValue(mockDeep()) + .overrideProvider(UploadService) + .useValue(mockDeep()) + .overrideProvider(ProfileSpaceService) + .useValue(mockDeep()) + .overrideProvider(ProfilesService) + .useValue(mockDeep()) + .compile(); controller = module.get(SpacesController); + spacesService = module.get(SpacesService); + uploadService = module.get(UploadService); + profilesService = module.get(ProfilesService); }); - it('should be defined', () => { - expect(controller).toBeDefined(); + it('create created', async () => { + const iconMock = { filename: 'icon' } as Express.Multer.File; + const bodyMock = { name: 'new space name' } as CreateSpaceDto; + const requestMock = { user: { uuid: 'user uuid' } } as RequestWithUser; + const profileMock = { uuid: 'profile uuid' } as Profile; + const spaceMock = { uuid: 'space uuid' } as Space; + profilesService.findProfile.mockResolvedValue(profileMock); + uploadService.uploadFile.mockResolvedValue('www.test.com/image'); + spacesService.createSpace.mockResolvedValue(spaceMock); + + const response = controller.create(iconMock, bodyMock, requestMock); + + await expect(response).resolves.toEqual({ + statusCode: 201, + message: 'Created', + data: spaceMock, + }); + expect(uploadService.uploadFile).toHaveBeenCalled(); + expect(spacesService.createSpace).toHaveBeenCalledWith({ + ...bodyMock, + icon: 'www.test.com/image', + }); + }); + + it('create not found profile', async () => { + const bodyMock = { name: 'new space name' } as CreateSpaceDto; + const requestMock = { user: { uuid: 'user uuid' } } as RequestWithUser; + profilesService.findProfile.mockResolvedValue(null); + + const response = controller.create( + null as unknown as Express.Multer.File, + bodyMock, + requestMock, + ); + + await expect(response).rejects.toThrow(NotFoundException); + }); + + it('create icon not requested', async () => { + const bodyMock = { name: 'new space name' } as CreateSpaceDto; + const requestMock = { user: { uuid: 'user uuid' } } as RequestWithUser; + const profileMock = { uuid: 'profile uuid' } as Profile; + const spaceMock = { uuid: 'space uuid' } as Space; + profilesService.findProfile.mockResolvedValue(profileMock); + spacesService.createSpace.mockResolvedValue(spaceMock); + + const response = controller.create( + null as unknown as Express.Multer.File, + bodyMock, + requestMock, + ); + + await expect(response).resolves.toEqual({ + statusCode: 201, + message: 'Created', + data: spaceMock, + }); + expect(uploadService.uploadFile).not.toHaveBeenCalled(); + expect(spacesService.createSpace).toHaveBeenCalledWith({ + ...bodyMock, + icon: APP_ICON_URL, + }); + }); + + it('findOne found space', async () => { + const spaceMock = { uuid: 'space uuid' } as Space; + spacesService.findSpace.mockResolvedValue(spaceMock); + + const response = controller.findOne('space uuid'); + + await expect(response).resolves.toEqual({ + statusCode: 200, + message: 'Success', + data: spaceMock, + }); + }); + + it('findOne not found space', async () => { + spacesService.findSpace.mockResolvedValue(null); + + const response = controller.findOne('space uuid'); + + await expect(response).rejects.toThrow(NotFoundException); + }); + + it('update update space', async () => { + const iconMock = { filename: 'icon' } as Express.Multer.File; + const bodyMock = { name: 'new space name' } as UpdateSpaceDto; + const spaceMock = { uuid: 'space uuid' } as Space; + spacesService.updateSpace.mockResolvedValue(spaceMock); + uploadService.uploadFile.mockResolvedValue('www.test.com/image'); + + const response = controller.update(iconMock, 'space uuid', bodyMock); + + await expect(response).resolves.toEqual({ + statusCode: 200, + message: 'Success', + data: spaceMock, + }); + expect(uploadService.uploadFile).toHaveBeenCalled(); + }); + + it('update icon not requested', async () => { + const bodyMock = { name: 'new space name' } as UpdateSpaceDto; + const spaceMock = { uuid: 'space uuid' } as Space; + spacesService.updateSpace.mockResolvedValue(spaceMock); + + const response = controller.update( + null as unknown as Express.Multer.File, + 'space uuid', + bodyMock, + ); + + await expect(response).resolves.toEqual({ + statusCode: 200, + message: 'Success', + data: spaceMock, + }); + expect(uploadService.uploadFile).not.toHaveBeenCalled(); + }); + + it('update fail', async () => { + const iconMock = { filename: 'icon' } as Express.Multer.File; + const bodyMock = { name: 'new space name' } as UpdateSpaceDto; + spacesService.updateSpace.mockResolvedValue(null); + + const response = controller.update(iconMock, 'space uuid', bodyMock); + + await expect(response).rejects.toThrow(NotFoundException); }); }); diff --git a/nestjs-BE/server/src/spaces/spaces.controller.ts b/nestjs-BE/server/src/spaces/spaces.controller.ts index 39c025fd..90022959 100644 --- a/nestjs-BE/server/src/spaces/spaces.controller.ts +++ b/nestjs-BE/server/src/spaces/spaces.controller.ts @@ -8,16 +8,19 @@ import { UseInterceptors, UploadedFile, Request as Req, + NotFoundException, + ValidationPipe, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import { SpacesService } from './spaces.service'; import { CreateSpaceDto } from './dto/create-space.dto'; import { UpdateSpaceDto } from './dto/update-space.dto'; import { ApiTags, ApiResponse, ApiOperation } from '@nestjs/swagger'; -import { UploadService } from 'src/upload/upload.service'; -import { ProfileSpaceService } from 'src/profile-space/profile-space.service'; -import { RequestWithUser } from 'src/utils/interface'; -import customEnv from 'src/config/env'; +import { UploadService } from '../upload/upload.service'; +import { ProfileSpaceService } from '../profile-space/profile-space.service'; +import { RequestWithUser } from '../utils/interface'; +import customEnv from '../config/env'; +import { ProfilesService } from '../profiles/profiles.service'; const { APP_ICON_URL } = customEnv; @Controller('spaces') @@ -27,6 +30,7 @@ export class SpacesController { private readonly spacesService: SpacesService, private readonly uploadService: UploadService, private readonly profileSpaceService: ProfileSpaceService, + private readonly profilesService: ProfilesService, ) {} @Post() @@ -41,20 +45,15 @@ export class SpacesController { @Body() createSpaceDto: CreateSpaceDto, @Req() req: RequestWithUser, ) { + const profile = await this.profilesService.findProfile(req.user.uuid); + if (!profile) throw new NotFoundException(); const iconUrl = icon ? await this.uploadService.uploadFile(icon) : APP_ICON_URL; createSpaceDto.icon = iconUrl; - const response = await this.spacesService.create(createSpaceDto); - const { uuid: spaceUuid } = response.data; - const userUuid = req.user.uuid; - const { joinData, profileData } = - await this.profileSpaceService.processData(userUuid, spaceUuid); - this.profileSpaceService.create(joinData); - const spaceData = response.data; - const data = { profileData, spaceData }; - await this.profileSpaceService.put(userUuid, spaceUuid, data); - return response; + const space = await this.spacesService.createSpace(createSpaceDto); + await this.profileSpaceService.joinSpace(profile.uuid, space.uuid); + return { statusCode: 201, message: 'Created', data: space }; } @Get(':space_uuid') @@ -67,8 +66,10 @@ export class SpacesController { status: 404, description: 'Space not found.', }) - findOne(@Param('space_uuid') spaceUuid: string) { - return this.spacesService.findOne(spaceUuid); + async findOne(@Param('space_uuid') spaceUuid: string) { + const space = await this.spacesService.findSpace(spaceUuid); + if (!space) throw new NotFoundException(); + return { statusCode: 200, message: 'Success', data: space }; } @Patch(':space_uuid') @@ -89,11 +90,17 @@ export class SpacesController { async update( @UploadedFile() icon: Express.Multer.File, @Param('space_uuid') spaceUuid: string, - @Body() updateSpaceDto: UpdateSpaceDto, + @Body(new ValidationPipe({ whitelist: true })) + updateSpaceDto: UpdateSpaceDto, ) { if (icon) { updateSpaceDto.icon = await this.uploadService.uploadFile(icon); } - return this.spacesService.update(spaceUuid, updateSpaceDto); + const space = await this.spacesService.updateSpace( + spaceUuid, + updateSpaceDto, + ); + if (!space) throw new NotFoundException(); + return { statusCode: 200, message: 'Success', data: space }; } } diff --git a/nestjs-BE/server/src/spaces/spaces.module.ts b/nestjs-BE/server/src/spaces/spaces.module.ts index 2964682c..d208085c 100644 --- a/nestjs-BE/server/src/spaces/spaces.module.ts +++ b/nestjs-BE/server/src/spaces/spaces.module.ts @@ -3,9 +3,10 @@ import { SpacesService } from './spaces.service'; import { SpacesController } from './spaces.controller'; import { UploadService } from 'src/upload/upload.service'; import { ProfileSpaceModule } from 'src/profile-space/profile-space.module'; +import { ProfilesModule } from 'src/profiles/profiles.module'; @Module({ - imports: [forwardRef(() => ProfileSpaceModule)], + imports: [forwardRef(() => ProfileSpaceModule), ProfilesModule], controllers: [SpacesController], providers: [SpacesService, UploadService], exports: [SpacesService], diff --git a/nestjs-BE/server/src/spaces/spaces.service.spec.ts b/nestjs-BE/server/src/spaces/spaces.service.spec.ts index 6b9d4876..10b03afb 100644 --- a/nestjs-BE/server/src/spaces/spaces.service.spec.ts +++ b/nestjs-BE/server/src/spaces/spaces.service.spec.ts @@ -1,18 +1,116 @@ import { Test, TestingModule } from '@nestjs/testing'; import { SpacesService } from './spaces.service'; +import { DeepMockProxy, mockDeep } from 'jest-mock-extended'; +import { PrismaClient, Space } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; +import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library'; describe('SpacesService', () => { - let service: SpacesService; + let spacesService: SpacesService; + let prisma: DeepMockProxy; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [SpacesService], - }).compile(); + providers: [SpacesService, PrismaService], + }) + .overrideProvider(PrismaService) + .useValue(mockDeep()) + .compile(); - service = module.get(SpacesService); + spacesService = module.get(SpacesService); + prisma = module.get(PrismaService); }); - it('should be defined', () => { - expect(service).toBeDefined(); + it('findSpace found space', async () => { + const spaceMock = { uuid: 'space uuid' } as Space; + prisma.space.findUnique.mockResolvedValue(spaceMock); + + const space = spacesService.findSpace('space uuid'); + + await expect(space).resolves.toEqual(spaceMock); + }); + + it('findSpace not found space', async () => { + prisma.space.findUnique.mockResolvedValue(null); + + const space = spacesService.findSpace('bad space uuid'); + + await expect(space).resolves.toBeNull(); + }); + + it('findSpaces found spaces', async () => { + const spaceUuidsMock = [ + { uuid: 'space uuid 1' }, + { uuid: 'space uuid 2' }, + ] as Space[]; + prisma.space.findMany.mockResolvedValue(spaceUuidsMock); + + const spaces = spacesService.findSpaces(['space uuid 1', 'space uuid 2']); + + await expect(spaces).resolves.toEqual(spaceUuidsMock); + }); + + it('findSpaces not found spaces', async () => { + prisma.space.findMany.mockResolvedValue([]); + + const spaces = spacesService.findSpaces(['space uuid 1', 'space uuid 2']); + + await expect(spaces).resolves.toHaveLength(0); + }); + + it('createSpace created space', async () => { + const data = { name: 'new space name', icon: 'new icon' }; + const spaceMock = { uuid: 'space uuid', ...data }; + prisma.space.create.mockResolvedValue(spaceMock); + + const space = spacesService.createSpace(data); + + await expect(space).resolves.toEqual(spaceMock); + }); + + it('updateSpace updated space', async () => { + const data = { name: 'new space name', icon: 'new space icon' }; + const spaceMock = { uuid: 'space uuid', ...data }; + prisma.space.update.mockResolvedValue(spaceMock); + + const space = spacesService.updateSpace('space uuid', data); + + await expect(space).resolves.toEqual(spaceMock); + }); + + it('updateSpace fail', async () => { + const data = { name: 'new space name', icon: 'new space icon' }; + prisma.space.update.mockRejectedValue( + new PrismaClientKnownRequestError( + 'An operation failed because it depends on one or more records that were required but not found. Record to update not found.', + { code: 'P2025', clientVersion: '' }, + ), + ); + + const space = spacesService.updateSpace('space uuid', data); + + await expect(space).resolves.toBeNull(); + }); + + it('deleteSpace deleted spaced', async () => { + const spaceMock = { uuid: 'space uuid' } as Space; + prisma.space.delete.mockResolvedValue(spaceMock); + + const space = spacesService.deleteSpace('space uuid'); + + await expect(space).resolves.toEqual(spaceMock); + }); + + it('deleteSpace fail', async () => { + prisma.space.delete.mockRejectedValue( + new PrismaClientKnownRequestError( + 'An operation failed because it depends on one or more records that were required but not found. Record to delete not found.', + { code: 'P2025', clientVersion: '' }, + ), + ); + + const space = spacesService.deleteSpace('space uuid'); + + await expect(space).rejects.toThrow(PrismaClientKnownRequestError); }); }); diff --git a/nestjs-BE/server/src/spaces/spaces.service.ts b/nestjs-BE/server/src/spaces/spaces.service.ts index 4eba8ff0..2cd59aae 100644 --- a/nestjs-BE/server/src/spaces/spaces.service.ts +++ b/nestjs-BE/server/src/spaces/spaces.service.ts @@ -1,34 +1,51 @@ import { Injectable } from '@nestjs/common'; -import { PrismaServiceMySQL } from '../prisma/prisma.service'; -import { TemporaryDatabaseService } from '../temporary-database/temporary-database.service'; -import { BaseService } from '../base/base.service'; -import { SPACE_CACHE_SIZE } from 'src/config/magic-number'; +import { PrismaService } from '../prisma/prisma.service'; import { UpdateSpaceDto } from './dto/update-space.dto'; -import { UpdateProfileDto } from 'src/profiles/dto/update-profile.dto'; +import { Prisma, Space } from '@prisma/client'; +import { CreateSpaceDto } from './dto/create-space.dto'; +import generateUuid from '../utils/uuid'; @Injectable() -export class SpacesService extends BaseService { - constructor( - protected prisma: PrismaServiceMySQL, - protected temporaryDatabaseService: TemporaryDatabaseService, - ) { - super({ - prisma, - temporaryDatabaseService, - cacheSize: SPACE_CACHE_SIZE, - className: 'SPACE_TB', - field: 'uuid', +export class SpacesService { + constructor(protected prisma: PrismaService) {} + + async findSpace(spaceUuid: string): Promise { + return this.prisma.space.findUnique({ where: { uuid: spaceUuid } }); + } + + async findSpaces(spaceUuids: string[]): Promise { + return this.prisma.space.findMany({ where: { uuid: { in: spaceUuids } } }); + } + + async createSpace(createSpaceDto: CreateSpaceDto): Promise { + return this.prisma.space.create({ + data: { + uuid: generateUuid(), + name: createSpaceDto.name, + icon: createSpaceDto.icon, + }, }); } - generateKey(data: UpdateSpaceDto): string { - return data.uuid; + async updateSpace( + spaceUuid: string, + updateSpaceDto: UpdateSpaceDto, + ): Promise { + try { + return await this.prisma.space.update({ + where: { uuid: spaceUuid }, + data: { ...updateSpaceDto }, + }); + } catch (err) { + if (err instanceof Prisma.PrismaClientKnownRequestError) { + return null; + } else { + throw err; + } + } } - async processData(spaceUuid: string, profileData: UpdateProfileDto) { - const spaceResponseData = await super.findOne(spaceUuid); - const spaceData = spaceResponseData.data; - const data = { profileData, spaceData }; - return data; + async deleteSpace(spaceUuid: string): Promise { + return this.prisma.space.delete({ where: { uuid: spaceUuid } }); } } diff --git a/nestjs-BE/server/src/temporary-database/temporary-database.module.ts b/nestjs-BE/server/src/temporary-database/temporary-database.module.ts deleted file mode 100644 index d3a2f58a..00000000 --- a/nestjs-BE/server/src/temporary-database/temporary-database.module.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Global, Module } from '@nestjs/common'; -import { TemporaryDatabaseService } from './temporary-database.service'; - -@Global() -@Module({ - providers: [TemporaryDatabaseService], - exports: [TemporaryDatabaseService], -}) -export class TemporaryDatabaseModule {} diff --git a/nestjs-BE/server/src/temporary-database/temporary-database.service.spec.ts b/nestjs-BE/server/src/temporary-database/temporary-database.service.spec.ts deleted file mode 100644 index b76bc582..00000000 --- a/nestjs-BE/server/src/temporary-database/temporary-database.service.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { TemporaryDatabaseService } from './temporary-database.service'; - -describe('TemporaryDatabaseService', () => { - let service: TemporaryDatabaseService; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [TemporaryDatabaseService], - }).compile(); - - service = module.get(TemporaryDatabaseService); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); -}); diff --git a/nestjs-BE/server/src/temporary-database/temporary-database.service.ts b/nestjs-BE/server/src/temporary-database/temporary-database.service.ts deleted file mode 100644 index 4330e949..00000000 --- a/nestjs-BE/server/src/temporary-database/temporary-database.service.ts +++ /dev/null @@ -1,229 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { - PrismaServiceMySQL, - PrismaServiceMongoDB, -} from '../prisma/prisma.service'; -import { Cron } from '@nestjs/schedule'; -import { promises as fs } from 'fs'; -import { join } from 'path'; -import { TokenData } from 'src/auth/auth.service'; -import { InviteCodeData } from 'src/invite-codes/invite-codes.service'; -import { CreateProfileSpaceDto } from 'src/profile-space/dto/create-profile-space.dto'; -import { UpdateProfileDto } from 'src/profiles/dto/update-profile.dto'; -import { UpdateSpaceDto } from 'src/spaces/dto/update-space.dto'; -import { UpdateUserDto } from 'src/users/dto/update-user.dto'; -import costomEnv from 'src/config/env'; -const { CSV_FOLDER } = costomEnv; - -type DeleteDataType = { - field: string; - value: string; -}; - -export type InsertDataType = - | TokenData - | InviteCodeData - | CreateProfileSpaceDto - | UpdateProfileDto - | UpdateSpaceDto - | UpdateUserDto; - -type UpdateDataType = { - field: string; - value: InsertDataType; -}; -type DataType = InsertDataType | UpdateDataType | DeleteDataType; - -interface OperationData { - service: string; - uniqueKey: string; - command: string; - data: DataType; -} - -@Injectable() -export class TemporaryDatabaseService { - private database: Map>> = new Map(); - private readonly FOLDER_NAME = CSV_FOLDER; - - constructor( - private readonly prismaMysql: PrismaServiceMySQL, - private readonly prismaMongoDB: PrismaServiceMongoDB, - ) { - this.init(); - } - - async init() { - this.initializeDatabase(); - await this.readDataFromFiles(); - await this.executeBulkOperations(); - } - - private initializeDatabase() { - const services = [ - 'USER_TB', - 'PROFILE_TB', - 'SPACE_TB', - 'BoardCollection', - 'PROFILE_SPACE_TB', - 'REFRESH_TOKEN_TB', - 'INVITE_CODE_TB', - ]; - const operations = ['insert', 'update', 'delete']; - - services.forEach((service) => { - const serviceMap = new Map(); - this.database.set(service, serviceMap); - operations.forEach((operation) => { - serviceMap.set(operation, new Map()); - }); - }); - } - - private async readDataFromFiles() { - const files = await fs.readdir(this.FOLDER_NAME); - return Promise.all( - files - .filter((file) => file.endsWith('.csv')) - .map((file) => this.readDataFromFile(file)), - ); - } - - private async readDataFromFile(file: string) { - const [service, commandWithExtension] = file.split('-'); - const command = commandWithExtension.replace('.csv', ''); - const fileData = await fs.readFile(join(this.FOLDER_NAME, file), 'utf8'); - fileData.split('\n').forEach((line) => { - if (line.trim() !== '') { - const [uniqueKey, ...dataParts] = line.split(','); - const data = dataParts.join(','); - this.database - .get(service) - .get(command) - .set(uniqueKey, JSON.parse(data)); - } - }); - } - - get(service: string, uniqueKey: string, command: string): any { - return this.database.get(service).get(command).get(uniqueKey); - } - - create(service: string, uniqueKey: string, data: InsertDataType) { - this.operation({ service, uniqueKey, command: 'insert', data }); - } - - update(service: string, uniqueKey: string, data: UpdateDataType) { - this.operation({ service, uniqueKey, command: 'update', data }); - } - - remove(service: string, uniqueKey: string, data: DeleteDataType) { - this.operation({ service, uniqueKey, command: 'delete', data }); - } - - delete(service: string, uniqueKey: string, command: string) { - this.database.get(service).get(command).delete(uniqueKey); - const filePath = join(this.FOLDER_NAME, `${service}-${command}.csv`); - fs.readFile(filePath, 'utf8').then((fileData) => { - const lines = fileData.split('\n'); - const updatedFileData = lines - .filter((line) => !line.startsWith(`${uniqueKey},`)) - .join('\n'); - fs.writeFile(filePath, updatedFileData); - }); - } - - operation({ service, uniqueKey, command, data }: OperationData) { - const filePath = join(this.FOLDER_NAME, `${service}-${command}.csv`); - fs.appendFile(filePath, `${uniqueKey},${JSON.stringify(data)}\n`, 'utf8'); - this.database.get(service).get(command).set(uniqueKey, data); - } - - @Cron('0 */10 * * * *') - async executeBulkOperations() { - for (const service of this.database.keys()) { - const serviceMap = this.database.get(service); - const prisma = - service === 'BoardCollection' ? this.prismaMongoDB : this.prismaMysql; - await this.performInsert(service, serviceMap.get('insert'), prisma); - await this.performUpdate(service, serviceMap.get('update'), prisma); - await this.performDelete(service, serviceMap.get('delete'), prisma); - } - } - - private async performInsert( - service: string, - dataMap: Map, - prisma: PrismaServiceMongoDB | PrismaServiceMySQL, - ) { - const data = this.prepareData(service, 'insert', dataMap); - if (!data.length) return; - if (prisma instanceof PrismaServiceMySQL) { - await prisma[service].createMany({ - data: data, - skipDuplicates: true, - }); - } else { - await prisma[service].createMany({ - data: data, - }); - } - } - - private async performUpdate( - service: string, - dataMap: Map, - prisma: PrismaServiceMongoDB | PrismaServiceMySQL, - ) { - const data = this.prepareData(service, 'update', dataMap); - if (!data.length) return; - await Promise.all( - data.map((item) => { - const keyField = item.field; - const keyValue = item.value[keyField]; - const updatedValue = Object.fromEntries( - Object.entries(item.value).filter(([key]) => key !== 'uuid'), - ); - return prisma[service].update({ - where: { [keyField]: keyValue }, - data: updatedValue, - }); - }), - ); - } - - private async performDelete( - service: string, - dataMap: Map, - prisma: PrismaServiceMongoDB | PrismaServiceMySQL, - ) { - const data = this.prepareData(service, 'delete', dataMap); - if (!data.length) return; - await Promise.all( - data.map(async (item) => { - try { - await prisma[service].delete({ - where: { [item.field]: item.value }, - }); - } finally { - return; - } - }), - ); - } - - private prepareData( - service: string, - operation: string, - dataMap: Map, - ) { - const data = Array.from(dataMap.values()); - this.clearFile(`${service}-${operation}.csv`); - dataMap.clear(); - return data; - } - - private clearFile(filename: string) { - fs.writeFile(join(this.FOLDER_NAME, filename), '', 'utf8'); - } -} diff --git a/nestjs-BE/server/src/upload/upload.service.spec.ts b/nestjs-BE/server/src/upload/upload.service.spec.ts deleted file mode 100644 index 7b83db6a..00000000 --- a/nestjs-BE/server/src/upload/upload.service.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { UploadService } from './upload.service'; - -describe('UploadService', () => { - let service: UploadService; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [UploadService], - }).compile(); - - service = module.get(UploadService); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); -}); diff --git a/nestjs-BE/server/src/upload/upload.service.ts b/nestjs-BE/server/src/upload/upload.service.ts index 12a0fb28..f5e2afee 100644 --- a/nestjs-BE/server/src/upload/upload.service.ts +++ b/nestjs-BE/server/src/upload/upload.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import customEnv from 'src/config/env'; +import customEnv from '../config/env'; import { S3, Endpoint } from 'aws-sdk'; import uuid from '../utils/uuid'; const { diff --git a/nestjs-BE/server/src/users/users.module.ts b/nestjs-BE/server/src/users/users.module.ts index 153ff941..8fa904f1 100644 --- a/nestjs-BE/server/src/users/users.module.ts +++ b/nestjs-BE/server/src/users/users.module.ts @@ -1,9 +1,8 @@ import { Module } from '@nestjs/common'; import { UsersService } from './users.service'; -import { SpacesService } from 'src/spaces/spaces.service'; @Module({ - providers: [UsersService, SpacesService], + providers: [UsersService], exports: [UsersService], }) export class UsersModule {} diff --git a/nestjs-BE/server/src/users/users.service.spec.ts b/nestjs-BE/server/src/users/users.service.spec.ts index 62815ba6..5a76966a 100644 --- a/nestjs-BE/server/src/users/users.service.spec.ts +++ b/nestjs-BE/server/src/users/users.service.spec.ts @@ -1,18 +1,83 @@ import { Test, TestingModule } from '@nestjs/testing'; import { UsersService } from './users.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { DeepMockProxy, mockDeep } from 'jest-mock-extended'; +import { PrismaClient } from '@prisma/client'; +import generateUuid from '../utils/uuid'; +import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library'; describe('UsersService', () => { - let service: UsersService; + let usersService: UsersService; + let prisma: DeepMockProxy; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [UsersService], - }).compile(); + providers: [UsersService, PrismaService], + }) + .overrideProvider(PrismaService) + .useValue(mockDeep()) + .compile(); - service = module.get(UsersService); + usersService = module.get(UsersService); + prisma = module.get(PrismaService); }); - it('should be defined', () => { - expect(service).toBeDefined(); + it('findUserByEmailAndProvider found user', async () => { + const testUser = { + uuid: generateUuid(), + email: 'test@test.com', + provider: 'kakao', + }; + prisma.user.findUnique.mockResolvedValue(testUser); + + const user = usersService.findUserByEmailAndProvider( + 'test@test.com', + 'kakao', + ); + + await expect(user).resolves.toEqual(testUser); + }); + + it('findUserByEmailAndProvider not found user', async () => { + prisma.user.findUnique.mockResolvedValue(null); + + const user = usersService.findUserByEmailAndProvider( + 'test@test.com', + 'kakao', + ); + + await expect(user).resolves.toBeNull(); + }); + + it('createUser created', async () => { + const testUser = { + uuid: generateUuid(), + email: 'test@test.com', + provider: 'kakao', + }; + prisma.user.create.mockResolvedValue(testUser); + + const user = usersService.createUser({ + email: 'test@test.com', + provider: 'kakao', + }); + + await expect(user).resolves.toEqual(testUser); + }); + + it('createUser user already exists', async () => { + prisma.user.create.mockRejectedValue( + new PrismaClientKnownRequestError( + 'Unique constraint failed on the constraint: `User_email_provider_key`', + { code: 'P2025', clientVersion: '' }, + ), + ); + + const user = usersService.createUser({ + email: 'test@test.com', + provider: 'kakao', + }); + + await expect(user).rejects.toThrow(PrismaClientKnownRequestError); }); }); diff --git a/nestjs-BE/server/src/users/users.service.ts b/nestjs-BE/server/src/users/users.service.ts index be26c750..eb2f1f16 100644 --- a/nestjs-BE/server/src/users/users.service.ts +++ b/nestjs-BE/server/src/users/users.service.ts @@ -1,26 +1,29 @@ import { Injectable } from '@nestjs/common'; -import { PrismaServiceMySQL } from '../prisma/prisma.service'; -import { TemporaryDatabaseService } from '../temporary-database/temporary-database.service'; -import { BaseService } from '../base/base.service'; -import { USER_CACHE_SIZE } from '../config/magic-number'; -import { UpdateUserDto } from './dto/update-user.dto'; +import { PrismaService } from '../prisma/prisma.service'; +import { CreateUserDto } from './dto/create-user.dto'; +import { User } from '@prisma/client'; +import generateUuid from '../utils/uuid'; @Injectable() -export class UsersService extends BaseService { - constructor( - protected prisma: PrismaServiceMySQL, - protected temporaryDatabaseService: TemporaryDatabaseService, - ) { - super({ - prisma, - temporaryDatabaseService, - cacheSize: USER_CACHE_SIZE, - className: 'USER_TB', - field: 'email_provider', +export class UsersService { + constructor(private prisma: PrismaService) {} + + async findUserByEmailAndProvider( + email: string, + provider: string, + ): Promise { + return this.prisma.user.findUnique({ + where: { email_provider: { email, provider } }, }); } - generateKey(data: UpdateUserDto) { - return `email:${data.email}+provider:${data.provider}`; + async createUser(data: CreateUserDto): Promise { + return this.prisma.user.create({ + data: { + uuid: generateUuid(), + email: data.email, + provider: data.provider, + }, + }); } } diff --git a/nestjs-BE/server/src/utils/response.ts b/nestjs-BE/server/src/utils/response.ts index a3c01ef4..291d40b9 100644 --- a/nestjs-BE/server/src/utils/response.ts +++ b/nestjs-BE/server/src/utils/response.ts @@ -1,5 +1,5 @@ import { HttpStatus } from '@nestjs/common'; -import { InsertDataType } from 'src/temporary-database/temporary-database.service'; + type TokenDataType = { access_token: string; refresh_token?: string; @@ -8,11 +8,7 @@ type InviteDataType = { invite_code: string; }; -type ExtendedDataType = - | InsertDataType - | TokenDataType - | InviteDataType - | InsertDataType[]; +type ExtendedDataType = TokenDataType | InviteDataType; export class ResponseUtils { private static messages = new Map([