diff --git a/.gitignore b/.gitignore index 29c67c1a..76a2b59f 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,9 @@ node_modules # husky 관련 .husky/_ -.husky/.gitignore \ No newline at end of file +.husky/.gitignore + +# docker-compose 관련 파일 + +mysql-data +redis-data \ No newline at end of file diff --git a/backend/package.json b/backend/package.json index a3918ab8..aef7d802 100644 --- a/backend/package.json +++ b/backend/package.json @@ -22,22 +22,37 @@ "dependencies": { "@nestjs/common": "^10.0.0", "@nestjs/core": "^10.0.0", + "@nestjs/jwt": "^10.2.0", + "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.0.0", "@nestjs/platform-socket.io": "^10.4.6", + "@nestjs/typeorm": "^10.0.2", "@nestjs/websockets": "^10.4.6", + "@types/passport-jwt": "^4.0.1", + "axios": "^1.7.7", + "cookie-parser": "^1.4.7", "dotenv": "^16.4.5", "ioredis": "^5.4.1", + "mysql2": "^3.11.4", + "passport": "^0.7.0", + "passport-custom": "^1.1.1", + "passport-jwt": "^4.0.1", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", - "socket.io": "^4.8.1" + "socket.io": "^4.8.1", + "typeorm": "^0.3.20", + "typeorm-naming-strategies": "^4.1.0", + "typeorm-transactional": "^0.5.0" }, "devDependencies": { "@nestjs/cli": "^10.0.0", "@nestjs/schematics": "^10.0.0", "@nestjs/testing": "^10.0.0", + "@types/cookie-parser": "^1.4.7", "@types/express": "^5.0.0", "@types/jest": "^29.5.2", "@types/node": "^20.3.1", + "@types/passport-github": "^1.1.12", "@types/supertest": "^6.0.0", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 2e4f16bc..f50c98af 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -1,14 +1,33 @@ import { Module } from "@nestjs/common"; + import { AppController } from "./app.controller"; import { AppService } from "./app.service"; + import { SocketModule } from "./signaling-server/socket.module"; import { RoomModule } from "./room/room.module"; import { RedisModule } from "./redis/redis.module"; +import { AuthModule } from "./auth/auth.module"; +import { UserModule } from "./user/user.module"; +import { TypeOrmModule } from "@nestjs/typeorm"; import "dotenv/config"; +import { createDataSource, typeOrmConfig } from "./config/typeorm.config"; +import { QuestionListModule } from "./question-list/question-list.module"; + @Module({ - imports: [SocketModule, RoomModule, RedisModule], + imports: [ + TypeOrmModule.forRootAsync({ + useFactory: async () => typeOrmConfig, // 설정 객체를 직접 반환 + dataSourceFactory: async () => await createDataSource(), // 분리된 데이터소스 생성 함수 사용 + }), + SocketModule, + RoomModule, + RedisModule, + AuthModule, + UserModule, + QuestionListModule, + ], controllers: [AppController], providers: [AppService], }) diff --git a/backend/src/auth/auth.controller.spec.ts b/backend/src/auth/auth.controller.spec.ts new file mode 100644 index 00000000..901d3e88 --- /dev/null +++ b/backend/src/auth/auth.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { AuthController } from "./auth.controller"; + +describe("AuthController", () => { + let controller: AuthController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [AuthController], + }).compile(); + + controller = module.get(AuthController); + }); + + it("should be defined", () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts new file mode 100644 index 00000000..fe56476c --- /dev/null +++ b/backend/src/auth/auth.controller.ts @@ -0,0 +1,65 @@ +import { Controller, Get, Post, Res, Req, UseGuards } from "@nestjs/common"; +import { AuthGuard } from "@nestjs/passport"; +import { Request, Response } from "express"; +import { AuthService } from "./auth.service"; +import { GithubProfile } from "./github/gitub-profile.decorator"; +import { Profile } from "passport-github"; +import { setCookieConfig } from "../config/cookie.config"; +import { JwtPayload, JwtTokenPair } from "./jwt/jwt.decorator"; +import { IJwtPayload, IJwtTokenPair } from "./jwt/jwt.model"; + +@Controller("auth") +export class AuthController { + private static ACCESS_TOKEN = "accessToken"; + private static REFRESH_TOKEN = "refreshToken"; + + constructor(private readonly authService: AuthService) {} + + @Post("github") + @UseGuards(AuthGuard("github")) + async githubCallback( + @Req() req: Request, + @Res({ passthrough: true }) res: Response, + @GithubProfile() profile: Profile + ) { + const id = parseInt(profile.id); + + const result = await this.authService.getTokenByGithubId(id); + + res.cookie("accessToken", result.accessToken.token, { + maxAge: result.accessToken.expireTime, + ...setCookieConfig, + }); + + res.cookie("refreshToken", result.refreshToken.token, { + maxAge: result.refreshToken.expireTime, + ...setCookieConfig, + }); + + return { + success: true, + }; + } + + @Get("whoami") + @UseGuards(AuthGuard("jwt")) + async handleWhoami(@Req() req: Request, @JwtPayload() token: IJwtPayload) { + return token; + } + + @Get("refresh") + @UseGuards(AuthGuard("jwt-refresh")) + async handleRefresh( + @Res({ passthrough: true }) res: Response, + @JwtTokenPair() token: IJwtTokenPair + ) { + res.cookie("accessToken", token.accessToken.token, { + maxAge: token.accessToken.expireTime, + ...setCookieConfig, + }); + + return { + success: true, + }; + } +} diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts new file mode 100644 index 00000000..83ac1574 --- /dev/null +++ b/backend/src/auth/auth.module.ts @@ -0,0 +1,13 @@ +import { Module } from "@nestjs/common"; +import { AuthController } from "./auth.controller"; +import { AuthService } from "./auth.service"; +import { GithubStrategy } from "./github/github.strategy"; +import { UserRepository } from "../user/user.repository"; +import { JwtModule } from "./jwt/jwt.module"; + +@Module({ + imports: [JwtModule], + controllers: [AuthController], + providers: [AuthService, GithubStrategy, UserRepository], +}) +export class AuthModule {} diff --git a/backend/src/auth/auth.service.spec.ts b/backend/src/auth/auth.service.spec.ts new file mode 100644 index 00000000..042f61ea --- /dev/null +++ b/backend/src/auth/auth.service.spec.ts @@ -0,0 +1,129 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { AuthService } from "./auth.service"; +import { UserRepository } from "../user/user.repository"; +import { Profile } from "passport-github"; + +// typeorm-transactional 모킹 +jest.mock("typeorm-transactional", () => ({ + Transactional: () => () => ({}), + runOnTransactionCommit: () => () => ({}), + runOnTransactionRollback: () => () => ({}), + runOnTransactionComplete: () => () => ({}), + initializeTransactionalContext: () => ({}), +})); + +describe("AuthService", () => { + let authService: AuthService; + let userRepository: UserRepository; + + // Mock GitHub 프로필 데이터 + const mockGithubProfile: Profile = { + id: "12345", + displayName: "Test User", + username: "testuser", + profileUrl: "https://abcd/", + photos: [], + provider: "github", + _raw: "", + _json: {}, + }; + + // Mock 유저 데이터 + const mockUser = { + id: 1, + loginId: null, + passwordHash: null, + githubId: 12345, + username: "camper_12345", + }; + + beforeEach(async () => { + // Mock Repository 생성 + const mockUserRepository = { + getUserByGithubId: jest.fn(), + createUser: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AuthService, + { + provide: UserRepository, + useValue: mockUserRepository, + }, + ], + }).compile(); + + authService = module.get(AuthService); + userRepository = module.get(UserRepository); + }); + + describe("githubLogin", () => { + it("기존 사용자가 있을 경우 해당 사용자를 반환해야 한다", async () => { + // Given + jest.spyOn(userRepository, "getUserByGithubId").mockResolvedValue( + mockUser + ); + + // When + const result = await authService.githubLogin(mockGithubProfile); + + // Then + expect(userRepository.getUserByGithubId).toHaveBeenCalledWith( + parseInt(mockGithubProfile.id) + ); + expect(result).toEqual(mockUser); + expect(userRepository.createUser).not.toHaveBeenCalled(); + }); + + it("새로운 사용자의 경우 새 계정을 생성해야 한다", async () => { + // Given + jest.spyOn(userRepository, "getUserByGithubId").mockResolvedValue( + null + ); + jest.spyOn(userRepository, "createUser").mockResolvedValue( + mockUser + ); + + // When + const result = await authService.githubLogin(mockGithubProfile); + + // Then + expect(userRepository.getUserByGithubId).toHaveBeenCalledWith( + parseInt(mockGithubProfile.id) + ); + expect(userRepository.createUser).toHaveBeenCalledWith({ + githubId: parseInt(mockGithubProfile.id), + username: `camper_${mockGithubProfile.id}`, + }); + expect(result).toEqual(mockUser); + }); + + it("getUserByGithubId 에러 발생 시 예외를 던져야 한다", async () => { + // Given + const error = new Error("Database error"); + jest.spyOn(userRepository, "getUserByGithubId").mockRejectedValue( + error + ); + + // When & Then + await expect( + authService.githubLogin(mockGithubProfile) + ).rejects.toThrow(error); + }); + + it("createUser 에러 발생 시 예외를 던져야 한다", async () => { + // Given + const error = new Error("Database error"); + jest.spyOn(userRepository, "getUserByGithubId").mockResolvedValue( + null + ); + jest.spyOn(userRepository, "createUser").mockRejectedValue(error); + + // When & Then + await expect( + authService.githubLogin(mockGithubProfile) + ).rejects.toThrow(error); + }); + }); +}); diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts new file mode 100644 index 00000000..1620988a --- /dev/null +++ b/backend/src/auth/auth.service.ts @@ -0,0 +1,31 @@ +import { Injectable, UnauthorizedException } from "@nestjs/common"; +import { UserRepository } from "../user/user.repository"; +import { Transactional } from "typeorm-transactional"; +import "dotenv/config"; +import { DAY, HOUR } from "../utils/time"; +import { JwtService } from "./jwt/jwt.service"; + +@Injectable() +export class AuthService { + private static ACCESS_TOKEN_EXPIRATION_TIME = 3 * HOUR; + private static ACCESS_TOKEN_EXPIRATION = 30 * DAY; + + constructor( + private readonly userRepository: UserRepository, + private readonly jwtService: JwtService + ) {} + + @Transactional() + public async getTokenByGithubId(id: number) { + let user = await this.userRepository.getUserByGithubId(id); + + if (!user) { + user = await this.userRepository.createUser({ + githubId: id, + username: `camper_${id}`, + }); + } + + return await this.jwtService.createJwtToken(user.id); + } +} diff --git a/backend/src/auth/github/github.strategy.ts b/backend/src/auth/github/github.strategy.ts new file mode 100644 index 00000000..87791b24 --- /dev/null +++ b/backend/src/auth/github/github.strategy.ts @@ -0,0 +1,51 @@ +// github-auth.strategy.ts +import { Injectable, UnauthorizedException } from "@nestjs/common"; +import { PassportStrategy } from "@nestjs/passport"; +import { Strategy } from "passport-custom"; +import { Request } from "express"; +import axios from "axios"; +import "dotenv/config"; + +@Injectable() +export class GithubStrategy extends PassportStrategy(Strategy, "github") { + private static REQUEST_ACCESS_TOKEN_URL = + "https://github.com/login/oauth/access_token"; + private static REQUEST_USER_URL = "https://api.github.com/user"; + + constructor() { + super(); + } + + async validate(req: Request, done: any) { + const { code } = req.body; + + if (!code) { + throw new UnauthorizedException("Authorization code not found"); + } + + const tokenResponse = await axios.post( + GithubStrategy.REQUEST_ACCESS_TOKEN_URL, + { + client_id: process.env.OAUTH_GITHUB_ID, + client_secret: process.env.OAUTH_GITHUB_SECRET, + code: code, + }, + { + headers: { Accept: "application/json" }, + } + ); + + const { access_token } = tokenResponse.data; + + // GitHub 사용자 정보 조회 + const userResponse = await axios.get(GithubStrategy.REQUEST_USER_URL, { + headers: { + Authorization: `Bearer ${access_token}`, + }, + }); + + return done(null, { + profile: userResponse.data, + }); + } +} diff --git a/backend/src/auth/github/gitub-profile.decorator.ts b/backend/src/auth/github/gitub-profile.decorator.ts new file mode 100644 index 00000000..f25fa13a --- /dev/null +++ b/backend/src/auth/github/gitub-profile.decorator.ts @@ -0,0 +1,24 @@ +import { + createParamDecorator, + ExecutionContext, + BadRequestException, +} from "@nestjs/common"; +import { Profile } from "passport-github"; + +export const GithubProfile = createParamDecorator( + (data: unknown, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); + const user = request.user.profile; + + // GitHub Profile 타입 검증 + if (!isGithubProfile(user)) { + throw new BadRequestException("Invalid GitHub profile"); + } + + return user; + } +); + +function isGithubProfile(user: any): user is Profile { + return user && typeof user.id === "number"; +} diff --git a/backend/src/auth/jwt/jwt.decorator.ts b/backend/src/auth/jwt/jwt.decorator.ts new file mode 100644 index 00000000..f96f461b --- /dev/null +++ b/backend/src/auth/jwt/jwt.decorator.ts @@ -0,0 +1,60 @@ +import { + createParamDecorator, + ExecutionContext, + InternalServerErrorException, + UnauthorizedException, +} from "@nestjs/common"; +import { + IJwtPayload as IJwtPayload, + IJwtTokenPair as IJwtTokenPair, + IJwtToken as IJwtToken, +} from "./jwt.model"; + +export const JwtPayload = createParamDecorator( + (data: unknown, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); + const payload = request.user.jwtToken; + + if (!isJwtTokenPayload(payload)) { + throw new UnauthorizedException("Invalid jwt token payload"); + } + + return payload; + } +); + +export const JwtTokenPair = createParamDecorator( + (data: unknown, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); + const payload = request.user.jwtToken; + + if (!isJwtTokenPair(payload)) { + throw new InternalServerErrorException("Invalid jwt token"); + } + + return payload; + } +); + +function isJwtTokenPayload(payload: any): payload is IJwtPayload { + return ( + payload && + typeof payload.userId === "number" && + typeof payload.username === "string" + ); +} + +function isJwtTokenPair(payload: any): payload is IJwtTokenPair { + if (!payload.accessToken || !payload.refreshToken) return false; + if (!isJwtToken(payload.accessToken)) return false; + if (!isJwtToken(payload.refreshToken)) return false; + return true; +} + +function isJwtToken(token: any): token is IJwtToken { + return ( + token && + typeof token.token === "string" && + typeof token.expireTime === "number" + ); +} diff --git a/backend/src/auth/jwt/jwt.model.ts b/backend/src/auth/jwt/jwt.model.ts new file mode 100644 index 00000000..6f010ba5 --- /dev/null +++ b/backend/src/auth/jwt/jwt.model.ts @@ -0,0 +1,14 @@ +export interface IJwtPayload { + userId: number; + username: string; +} + +export interface IJwtToken { + token: string; + expireTime: number; +} + +export interface IJwtTokenPair { + accessToken: IJwtToken; + refreshToken: IJwtToken; +} diff --git a/backend/src/auth/jwt/jwt.module.ts b/backend/src/auth/jwt/jwt.module.ts new file mode 100644 index 00000000..232ef011 --- /dev/null +++ b/backend/src/auth/jwt/jwt.module.ts @@ -0,0 +1,18 @@ +import { Module } from "@nestjs/common"; +import { JwtModule as ParentJwtModule } from "@nestjs/jwt"; +import { JwtService } from "./jwt.service"; +import { UserRepository } from "../../user/user.repository"; +import { AccessTokenStrategy } from "./strategy/access-token.strategy"; +import { RefreshTokenStrategy } from "./strategy/refresh-token.strategy"; + +@Module({ + imports: [ParentJwtModule.register({})], + providers: [ + JwtService, + UserRepository, + AccessTokenStrategy, + RefreshTokenStrategy, + ], + exports: [JwtService, AccessTokenStrategy, RefreshTokenStrategy], +}) +export class JwtModule {} diff --git a/backend/src/auth/jwt/jwt.service.ts b/backend/src/auth/jwt/jwt.service.ts new file mode 100644 index 00000000..29784fba --- /dev/null +++ b/backend/src/auth/jwt/jwt.service.ts @@ -0,0 +1,79 @@ +import { JwtService as ParentService } from "@nestjs/jwt"; +import { Injectable, UnauthorizedException } from "@nestjs/common"; +import { Transactional } from "typeorm-transactional"; +import { UserRepository } from "../../user/user.repository"; +import { DAY, HOUR } from "../../utils/time"; +import { User } from "../../user/user.entity"; +import { IJwtToken } from "./jwt.model"; + +@Injectable() +export class JwtService { + private static ACCESS_TOKEN_TIME = 3 * HOUR; + private static REFRESH_TOKEN_TIME = 10 * DAY; + + constructor( + private readonly parent: ParentService, + private readonly userRepository: UserRepository + ) {} + + @Transactional() + public async createJwtToken(id: number) { + const user = await this.userRepository.getUserByUserId(id); + + const accessToken = await this.createAccessToken(user); + const refreshToken = await this.createRefreshToken(id); + + user.refreshToken = refreshToken.token; + + await this.userRepository.updateUser(user); + + return { + accessToken, + refreshToken, + }; + } + + public async createAccessToken(user: User): Promise { + const accessToken = this.parent.sign( + { + userId: user.id, + username: user.username, + }, + { + secret: process.env.JWT_ACCESS_TOKEN_SECRET_KEY, + expiresIn: process.env.JWT_ACCESS_TOKEN_EXPIRATION_TIME, + } + ); + + return { + token: accessToken, + expireTime: Date.now() + JwtService.ACCESS_TOKEN_TIME, + }; + } + + public async createRefreshToken(id: number): Promise { + const refreshToken = this.parent.sign( + {}, + { + secret: process.env.JWT_REFRESH_TOKEN_SECRET_KEY, + expiresIn: process.env.JWT_REFRESH_TOKEN_EXPIRATION_TIME, + audience: String(id), + } + ); + + return { + token: refreshToken, + expireTime: Date.now() + JwtService.REFRESH_TOKEN_TIME, + }; + } + + public async getNewAccessToken(id: number, refreshToken: string) { + const user = await this.userRepository.getUserByUserId(id); + + if (!user) throw new UnauthorizedException("Invalid refresh token"); + if (user.refreshToken !== refreshToken) + throw new UnauthorizedException("Refresh token expired!"); + + return this.createAccessToken(user); + } +} diff --git a/backend/src/auth/jwt/strategy/access-token.strategy.ts b/backend/src/auth/jwt/strategy/access-token.strategy.ts new file mode 100644 index 00000000..a2aab9d8 --- /dev/null +++ b/backend/src/auth/jwt/strategy/access-token.strategy.ts @@ -0,0 +1,30 @@ +import { Injectable } from "@nestjs/common"; +import { PassportStrategy } from "@nestjs/passport"; +import { Strategy } from "passport-jwt"; +import { Request } from "express"; +import "dotenv/config"; + +@Injectable() +export class AccessTokenStrategy extends PassportStrategy(Strategy, "jwt") { + constructor() { + super({ + jwtFromRequest: (req: Request) => { + if (!req || !req.cookies) return null; + return req.cookies["accessToken"]; + }, + secretOrKey: process.env.JWT_ACCESS_TOKEN_SECRET_KEY, + passReqToCallback: true, + }); + } + + async validate(req: Request, payload: any) { + const { userId, username } = payload; + + return { + jwtToken: { + userId, + username, + }, + }; + } +} diff --git a/backend/src/auth/jwt/strategy/refresh-token.strategy.ts b/backend/src/auth/jwt/strategy/refresh-token.strategy.ts new file mode 100644 index 00000000..04dafdf6 --- /dev/null +++ b/backend/src/auth/jwt/strategy/refresh-token.strategy.ts @@ -0,0 +1,44 @@ +import { Injectable } from "@nestjs/common"; +import { PassportStrategy } from "@nestjs/passport"; +import { Strategy } from "passport-jwt"; +import { Request } from "express"; +import "dotenv/config"; +import { JwtService } from "../jwt.service"; + +@Injectable() +export class RefreshTokenStrategy extends PassportStrategy( + Strategy, + "jwt-refresh" +) { + constructor(private readonly jwtService: JwtService) { + super({ + jwtFromRequest: (req: Request) => { + if (!req || !req.cookies) return null; + return req.cookies["refreshToken"]; + }, + ignoreExpiration: true, + secretOrKey: process.env.JWT_REFRESH_TOKEN_SECRET_KEY, + passReqToCallback: true, + }); + } + + async validate(req: Request, payload: any) { + const { aud, exp } = payload; + const refreshToken = req.cookies["refreshToken"]; + + const accessToken = await this.jwtService.getNewAccessToken( + parseInt(aud), + refreshToken + ); + + return { + jwtToken: { + accessToken, + refreshToken: { + token: refreshToken, + expireTime: exp, + }, + }, + }; + } +} diff --git a/backend/src/config/cookie.config.ts b/backend/src/config/cookie.config.ts new file mode 100644 index 00000000..b6fab013 --- /dev/null +++ b/backend/src/config/cookie.config.ts @@ -0,0 +1,4 @@ +export const setCookieConfig = { + httpOnly: true, + secure: true, +}; diff --git a/backend/src/config/typeorm.config.ts b/backend/src/config/typeorm.config.ts new file mode 100644 index 00000000..ea1d12a3 --- /dev/null +++ b/backend/src/config/typeorm.config.ts @@ -0,0 +1,65 @@ +import { DataSource, DataSourceOptions } from "typeorm"; +import { SnakeNamingStrategy } from "typeorm-naming-strategies"; +import { User } from "../user/user.entity"; // 엔티티 경로를 수정하세요. +import "dotenv/config"; +import { addTransactionalDataSource } from "typeorm-transactional"; +import { QuestionList } from "../question-list/question-list.entity"; +import { Question } from "../question-list/question.entity"; +import { Category } from "../question-list/category.entity"; + +export const typeOrmConfig: DataSourceOptions = { + type: "mysql", + host: process.env.MYSQL_HOST, + port: parseInt(process.env.MYSQL_PORT) ?? 3306, + username: process.env.MYSQL_USERNAME, + password: process.env.MYSQL_PASSWORD, + database: process.env.MYSQL_DATABASE, + entities: [User, QuestionList, Question, Category], + namingStrategy: new SnakeNamingStrategy(), + synchronize: true, +}; + +let transactionalDataSource: DataSource | null = null; + +export const createDataSource = async (): Promise => { + if (!transactionalDataSource) { + transactionalDataSource = addTransactionalDataSource( + new DataSource(typeOrmConfig) + ); + + await transactionalDataSource.initialize(); + await seedDatabase(transactionalDataSource); + } + return transactionalDataSource; +}; + +const seedDatabase = async (dataSource: DataSource) => { + const categoryRepository = dataSource.getRepository(Category); + + const categories = [ + "자료구조", + "운영체제", + "데이터베이스", + "컴퓨터구조", + "네트워크", + "백엔드", + "프론트엔드", + "알고리즘", + "보안", + ]; + + // 이미 데이터가 있을 경우 시딩하지 않음 + const existingCount = await categoryRepository.count(); + if (existingCount > 0) { + return; + } + + // 카테고리 데이터 삽입 + const categoryEntities = categories.map((name) => { + const category = new Category(); + category.name = name; + return category; + }); + + await categoryRepository.save(categoryEntities); +}; \ No newline at end of file diff --git a/backend/src/main.ts b/backend/src/main.ts index b5c531a1..7fe63a87 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -1,8 +1,25 @@ import { NestFactory } from "@nestjs/core"; import { AppModule } from "./app.module"; +import { + initializeTransactionalContext, + StorageDriver, +} from "typeorm-transactional"; +import * as cookieParser from "cookie-parser"; async function bootstrap() { + initializeTransactionalContext({ + storageDriver: StorageDriver.AUTO, // 명시적으로 storage driver 설정 + }); + const app = await NestFactory.create(AppModule); + + app.enableCors({ + credentials: true, + }); + + app.use(cookieParser()); + await app.listen(process.env.PORT ?? 3000); } + bootstrap(); diff --git a/backend/src/question-list/category.entity.ts b/backend/src/question-list/category.entity.ts new file mode 100644 index 00000000..a56882da --- /dev/null +++ b/backend/src/question-list/category.entity.ts @@ -0,0 +1,16 @@ +import { Entity, PrimaryGeneratedColumn, Column, ManyToMany } from "typeorm"; +import { QuestionList } from "./question-list.entity"; + +@Entity() +export class Category { + private static NAME_MAX_LEN = 20; + + @PrimaryGeneratedColumn() + id: number; + + @Column({ length: Category.NAME_MAX_LEN }) + name: string; + + @ManyToMany(() => QuestionList, (questionList) => questionList.categories) + questionLists: QuestionList[]; +} diff --git a/backend/src/question-list/dto/create-question-list.dto.ts b/backend/src/question-list/dto/create-question-list.dto.ts new file mode 100644 index 00000000..4c4f075a --- /dev/null +++ b/backend/src/question-list/dto/create-question-list.dto.ts @@ -0,0 +1,7 @@ +export interface CreateQuestionListDto { + title: string; + contents: string[]; + categoryNames: string[]; + isPublic: boolean; + userId: number; +} diff --git a/backend/src/question-list/dto/create-question.dto.ts b/backend/src/question-list/dto/create-question.dto.ts new file mode 100644 index 00000000..3916d486 --- /dev/null +++ b/backend/src/question-list/dto/create-question.dto.ts @@ -0,0 +1,4 @@ +export interface CreateQuestionDto { + contents: string[]; + questionListId: number; +} diff --git a/backend/src/question-list/dto/get-all-question-list.dto.ts b/backend/src/question-list/dto/get-all-question-list.dto.ts new file mode 100644 index 00000000..9d87dfad --- /dev/null +++ b/backend/src/question-list/dto/get-all-question-list.dto.ts @@ -0,0 +1,7 @@ +export interface GetAllQuestionListDto { + id: number; + title: string; + categoryNames: string[]; + usage: number; + questionCount: number; +} diff --git a/backend/src/question-list/dto/my-question-list.dto.ts b/backend/src/question-list/dto/my-question-list.dto.ts new file mode 100644 index 00000000..bc8ff9eb --- /dev/null +++ b/backend/src/question-list/dto/my-question-list.dto.ts @@ -0,0 +1,10 @@ +import { Question } from "../question.entity"; + +export interface MyQuestionListDto { + id: number; + title: string; + contents: Question[]; + categoryNames: string[]; + isPublic: boolean; + usage: number; +} \ No newline at end of file diff --git a/backend/src/question-list/dto/question-list-contents.dto.ts b/backend/src/question-list/dto/question-list-contents.dto.ts new file mode 100644 index 00000000..58494169 --- /dev/null +++ b/backend/src/question-list/dto/question-list-contents.dto.ts @@ -0,0 +1,10 @@ +import { Question } from "../question.entity"; + +export interface QuestionListContentsDto { + id: number; + title: string; + categoryNames: string[]; + contents: Question[]; + usage: number; + username: string; +} diff --git a/backend/src/question-list/question-list.controller.spec.ts b/backend/src/question-list/question-list.controller.spec.ts new file mode 100644 index 00000000..b460c0fa --- /dev/null +++ b/backend/src/question-list/question-list.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { QuestionListController } from "./question-list.controller"; + +describe("QuestionController", () => { + let controller: QuestionListController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [QuestionListController], + }).compile(); + + controller = module.get(QuestionListController); + }); + + it("should be defined", () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/backend/src/question-list/question-list.controller.ts b/backend/src/question-list/question-list.controller.ts new file mode 100644 index 00000000..64dbb3b7 --- /dev/null +++ b/backend/src/question-list/question-list.controller.ts @@ -0,0 +1,176 @@ +import { + Body, + Controller, + Get, + Post, + Req, + Res, + UseGuards, +} from "@nestjs/common"; +import { QuestionListService } from "./question-list.service"; +import { CreateQuestionListDto } from "./dto/create-question-list.dto"; +import { CreateQuestionDto } from "./dto/create-question.dto"; +import { GetAllQuestionListDto } from "./dto/get-all-question-list.dto"; +import { QuestionListContentsDto } from "./dto/question-list-contents.dto"; +import { AuthGuard } from "@nestjs/passport"; +import { JwtPayload } from "../auth/jwt/jwt.decorator"; +import { IJwtPayload } from "../auth/jwt/jwt.model"; +import { MyQuestionListDto } from "./dto/my-question-list.dto"; + +@Controller("question-list") +export class QuestionListController { + constructor(private readonly questionListService: QuestionListService) {} + + @Get() + async getAllQuestionLists(@Res() res) { + try { + const allQuestionLists: GetAllQuestionListDto[] = + await this.questionListService.getAllQuestionLists(); + return res.send({ + success: true, + message: "All question lists received successfully.", + data: { + allQuestionLists, + }, + }); + } catch (error) { + return res.send({ + success: false, + message: "Failed to get all question lists.", + error: error.message, + }); + } + } + + @Post() + @UseGuards(AuthGuard("jwt")) + async createQuestionList( + @JwtPayload() token: IJwtPayload, + @Req() req, + @Res() res, + @Body() + body: { + title: string; + contents: string[]; + categoryNames: string[]; + isPublic: boolean; + } + ) { + try { + const { title, contents, categoryNames, isPublic } = body; + + // 질문지 DTO 준비 + const createQuestionListDto: CreateQuestionListDto = { + title, + contents, + categoryNames, + isPublic, + userId: token.userId, + }; + + // 질문지 생성 + const { createdQuestionList, createdQuestions } = + await this.questionListService.createQuestionList( + createQuestionListDto + ); + + return res.send({ + success: true, + message: "Question list created successfully.", + data: { + createdQuestionList, + createdQuestions, + }, + }); + } catch (error) { + return res.send({ + success: false, + message: "Failed to create question list.", + error: error.message, + }); + } + } + + @Post("category") + async getAllQuestionListsByCategoryName( + @Res() res, + @Body() + body: { + categoryName: string; + } + ) { + try { + const { categoryName } = body; + const allQuestionLists: GetAllQuestionListDto[] = + await this.questionListService.getAllQuestionListsByCategoryName( + categoryName + ); + return res.send({ + success: true, + message: "All question lists received successfully.", + data: { + allQuestionLists, + }, + }); + } catch (error) { + return res.send({ + success: false, + message: "Failed to get all question lists.", + error: error.message, + }); + } + } + + @Post("contents") + async getQuestionListContents( + @Res() res, + @Body() + body: { + questionListId: number; + } + ) { + try { + const { questionListId } = body; + const questionListContents: QuestionListContentsDto = + await this.questionListService.getQuestionListContents( + questionListId + ); + return res.send({ + success: true, + message: "Question list contents received successfully.", + data: { + questionListContents, + }, + }); + } catch (error) { + return res.send({ + success: false, + message: "Failed to get question list contents.", + error: error.message, + }); + } + } + + @Get("my") + @UseGuards(AuthGuard("jwt")) + async getMyQuestionLists(@Res() res, @JwtPayload() token: IJwtPayload) { + try { + const userId = token.userId; + const myQuestionLists: MyQuestionListDto[] = + await this.questionListService.getMyQuestionLists(userId); + return res.send({ + success: true, + message: "My question lists received successfully.", + data: { + myQuestionLists, + }, + }); + } catch (error) { + return res.send({ + success: false, + message: "Failed to get my question lists.", + error: error.message, + }); + } + } +} diff --git a/backend/src/question-list/question-list.entity.ts b/backend/src/question-list/question-list.entity.ts new file mode 100644 index 00000000..829469a7 --- /dev/null +++ b/backend/src/question-list/question-list.entity.ts @@ -0,0 +1,50 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + OneToMany, + ManyToMany, + JoinTable, +} from "typeorm"; +import { User } from "../user/user.entity"; +import { Question } from "./question.entity"; +import { Category } from "./category.entity"; + +@Entity() +export class QuestionList { + private static QUESTION_LIST_TITLE_MAX_LEN = 100; + + @PrimaryGeneratedColumn() + id: number; + + @Column({ length: QuestionList.QUESTION_LIST_TITLE_MAX_LEN }) + title: string; + + @Column() + isPublic: boolean; + + @Column({ default: 0 }) + usage: number; + + @Column() + userId: number; + + @ManyToOne(() => User, (user) => user.questionLists) + user: User; + + @OneToMany(() => Question, (question) => question.questionList) + questions: Question[]; + + @ManyToMany(() => Category, (category) => category.questionLists, { + cascade: true, + }) + @JoinTable({ + name: "question_list_category", + joinColumn: { + name: "questionListId", + referencedColumnName: "id", + }, + }) + categories: Category[]; +} diff --git a/backend/src/question-list/question-list.module.ts b/backend/src/question-list/question-list.module.ts new file mode 100644 index 00000000..14b7a61b --- /dev/null +++ b/backend/src/question-list/question-list.module.ts @@ -0,0 +1,11 @@ +import { Module } from "@nestjs/common"; +import { QuestionListController } from "./question-list.controller"; +import { QuestionListService } from "./question-list.service"; +import { QuestionListRepository } from "./question-list.repository"; + +@Module({ + controllers: [QuestionListController], + providers: [QuestionListService, QuestionListRepository], + exports: [QuestionListRepository], +}) +export class QuestionListModule {} diff --git a/backend/src/question-list/question-list.repository.ts b/backend/src/question-list/question-list.repository.ts new file mode 100644 index 00000000..f3e4470a --- /dev/null +++ b/backend/src/question-list/question-list.repository.ts @@ -0,0 +1,93 @@ +import { Injectable } from "@nestjs/common"; +import { DataSource, In } from "typeorm"; +import { QuestionList } from "./question-list.entity"; +import { Question } from "./question.entity"; +import { Category } from "./category.entity"; +import { User } from "../user/user.entity"; + +@Injectable() +export class QuestionListRepository { + constructor(private dataSource: DataSource) {} + + findPublicQuestionLists() { + return this.dataSource.getRepository(QuestionList).find({ + where: { isPublic: true }, + }); + } + + async getCategoryIdByName(categoryName: string) { + const category = await this.dataSource.getRepository(Category).findOne({ + where: { name: categoryName }, + select: ["id"], + }); + + return category?.id || null; + } + + findPublicQuestionListsByCategoryId(categoryId: number) { + return this.dataSource.getRepository(QuestionList).find({ + where: { + isPublic: true, + categories: { id: categoryId }, + }, + relations: ["categories"], + }); + } + + async findCategoryNamesByQuestionListId(questionListId: number) { + const questionList = await this.dataSource + .getRepository(QuestionList) + .findOne({ + where: { id: questionListId }, + relations: ["categories"], // 질문지와 관련된 카테고리도 함께 조회 + }); + console.log(questionList); + return questionList + ? questionList.categories.map((category) => category.name) + : []; + } + + async findCategoriesByNames(categoryNames: string[]) { + return this.dataSource.getRepository(Category).find({ + where: { + name: In(categoryNames), + }, + }); + } + + getQuestionListById(questionListId: number) { + return this.dataSource.getRepository(QuestionList).findOne({ + where: { id: questionListId }, + }); + } + + getContentsByQuestionListId(questionListId: number) { + return this.dataSource.getRepository(Question).find({ + where: { questionListId }, + }); + } + + async getUsernameById(userId: number) { + const user = await this.dataSource.getRepository(User).findOne({ + where: { id: userId }, + }); + + return user?.username || null; + } + + getQuestionListsByUserId(userId: number) { + return this.dataSource.getRepository(QuestionList).find({ + where: { userId }, + }); + } + + getQuestionCountByQuestionListId(questionListId: number) { + return this.dataSource + .getRepository(Question) + .createQueryBuilder("question") + .where("question.questionListId = :questionListId", { + questionListId, + }) + .getCount(); + } +} diff --git a/backend/src/question-list/question-list.service.spec.ts b/backend/src/question-list/question-list.service.spec.ts new file mode 100644 index 00000000..4928726d --- /dev/null +++ b/backend/src/question-list/question-list.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { QuestionListService } from "./question-list.service"; + +describe("QuestionService", () => { + let service: QuestionListService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [QuestionListService], + }).compile(); + + service = module.get(QuestionListService); + }); + + it("should be defined", () => { + expect(service).toBeDefined(); + }); +}); diff --git a/backend/src/question-list/question-list.service.ts b/backend/src/question-list/question-list.service.ts new file mode 100644 index 00000000..6d185067 --- /dev/null +++ b/backend/src/question-list/question-list.service.ts @@ -0,0 +1,204 @@ +import { Injectable } from "@nestjs/common"; +import { QuestionListRepository } from "./question-list.repository"; +import { CreateQuestionListDto } from "./dto/create-question-list.dto"; +import { GetAllQuestionListDto } from "./dto/get-all-question-list.dto"; +import { QuestionListContentsDto } from "./dto/question-list-contents.dto"; +import { MyQuestionListDto } from "./dto/my-question-list.dto"; +import { DataSource } from "typeorm"; +import { QuestionList } from "./question-list.entity"; +import { Question } from "./question.entity"; + +@Injectable() +export class QuestionListService { + constructor( + private readonly dataSource: DataSource, + private readonly questionListRepository: QuestionListRepository + ) {} + + async getAllQuestionLists() { + const allQuestionLists: GetAllQuestionListDto[] = []; + + const publicQuestionLists = + await this.questionListRepository.findPublicQuestionLists(); + + for (const publicQuestionList of publicQuestionLists) { + const { id, title, usage } = publicQuestionList; + const categoryNames: string[] = + await this.questionListRepository.findCategoryNamesByQuestionListId( + id + ); + + const questionCount = + await this.questionListRepository.getQuestionCountByQuestionListId( + id + ); + + const questionList: GetAllQuestionListDto = { + id, + title, + categoryNames, + usage, + questionCount, + }; + allQuestionLists.push(questionList); + } + return allQuestionLists; + } + + async getAllQuestionListsByCategoryName(categoryName: string) { + const allQuestionLists: GetAllQuestionListDto[] = []; + + const categoryId = + await this.questionListRepository.getCategoryIdByName(categoryName); + + if (!categoryId) { + return []; + } + + const publicQuestionLists = + await this.questionListRepository.findPublicQuestionListsByCategoryId( + categoryId + ); + + for (const publicQuestionList of publicQuestionLists) { + const { id, title, usage } = publicQuestionList; + const categoryNames: string[] = + await this.questionListRepository.findCategoryNamesByQuestionListId( + id + ); + + const questionCount = + await this.questionListRepository.getQuestionCountByQuestionListId( + id + ); + + const questionList: GetAllQuestionListDto = { + id, + title, + categoryNames, + usage, + questionCount, + }; + allQuestionLists.push(questionList); + } + return allQuestionLists; + } + + // 질문 생성 메서드 + async createQuestionList(createQuestionListDto: CreateQuestionListDto) { + const { title, contents, categoryNames, isPublic, userId } = + createQuestionListDto; + + const categories = await this.findCategoriesByNames(categoryNames); + + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.startTransaction(); + + try { + const questionListDto = new QuestionList(); + questionListDto.title = title; + questionListDto.categories = categories; + questionListDto.isPublic = isPublic; + questionListDto.userId = userId; + + const createdQuestionList = + await queryRunner.manager.save(questionListDto); + + const questions = contents.map((content, index) => { + const question = new Question(); + question.content = content; + question.index = index; + question.questionList = createdQuestionList; + + return question; + }); + + const createdQuestions = + await queryRunner.manager.save(questions); + + await queryRunner.commitTransaction(); + + return { createdQuestionList, createdQuestions }; + } catch (error) { + await queryRunner.rollbackTransaction(); + throw new Error(error.message); + } finally { + await queryRunner.release(); + } + } + + async getQuestionListContents(questionListId: number) { + const questionList = + await this.questionListRepository.getQuestionListById( + questionListId + ); + const { id, title, usage, userId } = questionList; + + const contents = + await this.questionListRepository.getContentsByQuestionListId( + questionListId + ); + + const categoryNames = + await this.questionListRepository.findCategoryNamesByQuestionListId( + questionListId + ); + + const username = + await this.questionListRepository.getUsernameById(userId); + + const questionListContents: QuestionListContentsDto = { + id, + title, + contents, + categoryNames, + usage, + username, + }; + + return questionListContents; + } + + async getMyQuestionLists(userId: number) { + const questionLists = + await this.questionListRepository.getQuestionListsByUserId(userId); + + const myQuestionLists: MyQuestionListDto[] = []; + for (const myQuestionList of questionLists) { + const { id, title, isPublic, usage } = myQuestionList; + const categoryNames: string[] = + await this.questionListRepository.findCategoryNamesByQuestionListId( + id + ); + + const contents = + await this.questionListRepository.getContentsByQuestionListId( + id + ); + + const questionList: MyQuestionListDto = { + id, + title, + contents, + categoryNames, + isPublic, + usage, + }; + myQuestionLists.push(questionList); + } + return myQuestionLists; + } + + async findCategoriesByNames(categoryNames: string[]) { + const categories = + await this.questionListRepository.findCategoriesByNames( + categoryNames + ); + + if (categories.length !== categoryNames.length) { + throw new Error("Some category names were not found."); + } + + return categories; + } +} diff --git a/backend/src/question-list/question.entity.ts b/backend/src/question-list/question.entity.ts new file mode 100644 index 00000000..b92b98d4 --- /dev/null +++ b/backend/src/question-list/question.entity.ts @@ -0,0 +1,22 @@ +import { Entity, PrimaryGeneratedColumn, Column, ManyToOne } from "typeorm"; +import { QuestionList } from "./question-list.entity"; + +@Entity() +export class Question { + private static CONTENT_MAX_LEN = 200; + + @PrimaryGeneratedColumn() + id: number; + + @Column({ length: Question.CONTENT_MAX_LEN }) + content: string; + + @Column() + index: number; + + @Column() + questionListId: number; + + @ManyToOne(() => QuestionList, (questionList) => questionList.questions) + questionList: QuestionList; +} diff --git a/backend/src/redis/redis.service.spec.ts b/backend/src/redis/redis.service.spec.ts index 19faa0c1..66a5e277 100644 --- a/backend/src/redis/redis.service.spec.ts +++ b/backend/src/redis/redis.service.spec.ts @@ -1,18 +1,251 @@ import { Test, TestingModule } from "@nestjs/testing"; import { RedisService } from "./redis.service"; +// Mock 함수들 생성 +const mockSet = jest.fn().mockResolvedValue("OK"); +const mockGet = jest.fn(); +const mockTtl = jest.fn(); +const mockExpire = jest.fn(); +const mockHget = jest.fn(); +const mockHset = jest.fn(); +const mockDel = jest.fn(); + +const mockScan = jest.fn().mockImplementation(() => { + return Promise.resolve(["0", ["key1", "key2"]]); // 배열 형태로 반환 +}); + +const mockMget = jest.fn().mockImplementation(() => { + return Promise.resolve(["value1", "value2"]); // 배열 형태로 반환 +}); + +// Redis 모듈 모킹 +jest.mock("ioredis", () => { + return { + default: jest.fn().mockImplementation(() => ({ + set: mockSet, + get: mockGet, + ttl: mockTtl, + expire: mockExpire, + scan: mockScan, + hget: mockHget, + hset: mockHset, + del: mockDel, + mget: mockMget, + })), + }; +}); + describe("RedisService", () => { - let service: RedisService; + let redisService: RedisService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [RedisService], }).compile(); - service = module.get(RedisService); + redisService = module.get(RedisService); + + // 각 테스트 전에 모든 mock 함수 초기화 + jest.clearAllMocks(); + }); + + describe("set", () => { + it("문자열 값을 저장해야 한다", async () => { + const key = "test-key"; + const value = "test-value"; + const ttl = 3600; + + await redisService.set(key, value, ttl); + + expect(mockSet).toHaveBeenCalledWith(key, value, "KEEPTTL"); + expect(mockExpire).toHaveBeenCalledWith(key, ttl); + }); + + it("객체를 JSON 문자열로 변환하여 저장해야 한다", async () => { + const key = "test-key"; + const value = { name: "test" }; + const ttl = 3600; + + await redisService.set(key, value, ttl); + + expect(mockSet).toHaveBeenCalledWith( + key, + JSON.stringify(value), + "KEEPTTL" + ); + expect(mockExpire).toHaveBeenCalledWith(key, ttl); + }); + }); + + describe("get", () => { + it("저장된 값을 조회해야 한다", async () => { + const key = "test-key"; + const value = "test-value"; + mockGet.mockResolvedValue(value); + + const result = await redisService.get(key); + + expect(mockGet).toHaveBeenCalledWith(key); + expect(result).toBe(value); + }); + }); + + describe("getTTL", () => { + it("키의 TTL을 반환해야 한다", async () => { + const key = "test-key"; + const ttlValue = 3600; + mockTtl.mockResolvedValue(ttlValue); + + const result = await redisService.getTTL(key); + + expect(mockTtl).toHaveBeenCalledWith(key); + expect(result).toBe(ttlValue); + }); + }); + + describe("getKeys", () => { + it("패턴에 맞는 모든 키를 반환해야 한다", async () => { + const query = "test*"; + mockScan + .mockResolvedValueOnce(["1", ["key1", "key2"]]) + .mockResolvedValueOnce(["0", ["key3"]]); + + const result = await redisService.getKeys(query); + + expect(result).toEqual(["key1", "key2", "key3"]); + expect(mockScan).toHaveBeenCalledWith( + "0", + "MATCH", + query, + "COUNT", + "100" + ); + }); + }); + + describe("getHashValueByField", () => { + it("해시 필드의 값을 반환해야 한다", async () => { + const key = "hash-key"; + const field = "field1"; + const value = "value1"; + mockHget.mockResolvedValue(value); + + const result = await redisService.getHashValueByField(key, field); + + expect(mockHget).toHaveBeenCalledWith(key, field); + expect(result).toBe(value); + }); }); - it("should be defined", () => { - expect(service).toBeDefined(); + describe("setHashValueByField", () => { + it("해시 필드에 문자열 값을 저장해야 한다", async () => { + const key = "hash-key"; + const field = "field1"; + const value = "test-value"; + + await redisService.setHashValueByField(key, field, value); + + expect(mockHset).toHaveBeenCalledWith(key, field, value); + }); + + it("해시 필드에 객체를 JSON 문자열로 변환하여 저장해야 한다", async () => { + const key = "hash-key"; + const field = "field1"; + const value = { test: "value" }; + + await redisService.setHashValueByField(key, field, value); + + expect(mockHset).toHaveBeenCalledWith( + key, + field, + JSON.stringify(value) + ); + }); + }); + + describe("delete", () => { + it("키들을 삭제해야 한다", async () => { + const keys = ["key1", "key2"]; + + await redisService.delete(...keys); + + expect(mockDel).toHaveBeenCalledWith(...keys); + }); + }); + + describe("getValues", () => { + it("키 패턴에 해당하는 모든 값을 반환해야 한다", async () => { + const query = "test*"; + const keys = ["key1", "key2"]; + const values = ["value1", "value2"]; + + mockScan.mockResolvedValueOnce(["0", keys]); + mockMget.mockResolvedValue(values); + + const result = await redisService.getValues(query); + + expect(result).toEqual(values); + }); + + it("키가 없을 경우 null을 반환해야 한다", async () => { + const query = "test*"; + mockScan.mockResolvedValueOnce(["0", []]); + + const result = await redisService.getValues(query); + + expect(result).toBeNull(); + }); + }); + + describe("getMap", () => { + it("객체 타입으로 맵을 반환해야 한다", async () => { + const query = "test*"; + const keys = ["key1", "key2"]; + const values = ['{"value":1}', '{"value":2}']; + + // getKeys 모킹 + mockScan.mockResolvedValueOnce(["0", keys]); + // getValues를 위한 mget 모킹 + mockMget.mockResolvedValueOnce(values); + + const result = await redisService.getMap(query); + + expect(result).toEqual({ + key1: { value: 1 }, + key2: { value: 2 }, + }); + }); + + it("primitive 타입으로 맵을 반환해야 한다", async () => { + const query = "test*"; + const keys = ["key1", "key2"]; + const values = ["value1", "value2"]; + + // getKeys 모킹 + mockScan.mockResolvedValueOnce(["0", keys]); + // getValues를 위한 mget 모킹 + mockMget.mockResolvedValueOnce(values); + + const result = await redisService.getMap(query, "primitive"); + + expect(result).toEqual({ + key1: "value1", + key2: "value2", + }); + }); + + it("값이 없을 경우 null을 반환해야 한다", async () => { + const query = "test*"; + + // getKeys가 빈 배열을 반환하도록 모킹 + mockScan.mockResolvedValueOnce(["0", []]); + + // getValues가 null을 반환하도록 모킹 + mockMget.mockResolvedValueOnce(null); + + const result = await redisService.getMap(query); + + expect(result).toBeNull(); + }); }); }); diff --git a/backend/src/room/dto/create-room.dto.ts b/backend/src/room/dto/create-room.dto.ts index 50f0dc0b..af5d2e9f 100644 --- a/backend/src/room/dto/create-room.dto.ts +++ b/backend/src/room/dto/create-room.dto.ts @@ -4,4 +4,5 @@ export interface CreateRoomDto { nickname: string; socketId: string; maxParticipants?: number; + questionListId: number; } diff --git a/backend/src/room/room.controller.spec.ts b/backend/src/room/room.controller.spec.ts index 7536f2ec..51f10620 100644 --- a/backend/src/room/room.controller.spec.ts +++ b/backend/src/room/room.controller.spec.ts @@ -1,18 +1,76 @@ import { Test, TestingModule } from "@nestjs/testing"; import { RoomController } from "./room.controller"; +import { RoomService } from "./room.service"; describe("RoomController", () => { let controller: RoomController; + let roomService: RoomService; + + // RoomService Mock 생성 + const mockRoomService = { + getPublicRoom: jest.fn(), + }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [RoomController], + providers: [ + { + provide: RoomService, + useValue: mockRoomService, + }, + ], }).compile(); controller = module.get(RoomController); + roomService = module.get(RoomService); }); - it("should be defined", () => { - expect(controller).toBeDefined(); + describe("getPublicRooms", () => { + it("공개방 목록을 반환해야 한다", async () => { + // Given + const mockRooms = { + room1: { + title: "Room 1", + status: "PUBLIC", + maxParticipants: 5, + }, + room2: { + title: "Room 2", + status: "PUBLIC", + maxParticipants: 3, + }, + }; + mockRoomService.getPublicRoom.mockResolvedValue(mockRooms); + + // When + const result = await controller.getPublicRooms(); + + // Then + expect(roomService.getPublicRoom).toHaveBeenCalled(); + expect(result).toEqual(mockRooms); + }); + + it("빈 방 목록을 반환해야 한다", async () => { + // Given + const mockEmptyRooms = {}; + mockRoomService.getPublicRoom.mockResolvedValue(mockEmptyRooms); + + // When + const result = await controller.getPublicRooms(); + + // Then + expect(roomService.getPublicRoom).toHaveBeenCalled(); + expect(result).toEqual({}); + }); + + it("서비스 에러 발생 시 예외를 던져야 한다", async () => { + // Given + const error = new Error("Service error"); + mockRoomService.getPublicRoom.mockRejectedValue(error); + + // When & Then + await expect(controller.getPublicRooms()).rejects.toThrow(error); + }); }); }); diff --git a/backend/src/room/room.gateway.spec.ts b/backend/src/room/room.gateway.spec.ts index 446b302b..52ab83da 100644 --- a/backend/src/room/room.gateway.spec.ts +++ b/backend/src/room/room.gateway.spec.ts @@ -1,18 +1,176 @@ import { Test, TestingModule } from "@nestjs/testing"; import { RoomGateway } from "./room.gateway"; +import { RoomService } from "./room.service"; describe("RoomGateway", () => { let gateway: RoomGateway; + let roomService: RoomService; + + // Mock Socket.io 서버와 클라이언트 + let mockServer: any; + let mockClient: any; beforeEach(async () => { + // Mock Service 생성 + const mockRoomService = { + createRoom: jest.fn(), + joinRoom: jest.fn(), + getRoomId: jest.fn(), + checkHost: jest.fn(), + leaveRoom: jest.fn(), + deleteRoom: jest.fn(), + delegateHost: jest.fn(), + finishRoom: jest.fn(), + checkAvailable: jest.fn(), + getRoomMemberConnection: jest.fn(), + }; + const module: TestingModule = await Test.createTestingModule({ - providers: [RoomGateway], + providers: [ + RoomGateway, + { + provide: RoomService, + useValue: mockRoomService, + }, + ], }).compile(); gateway = module.get(RoomGateway); + roomService = module.get(RoomService); + + // Mock Socket.io 서버 설정 + mockServer = { + to: jest.fn().mockReturnThis(), + emit: jest.fn(), + }; + gateway.server = mockServer as any; + + // Mock 클라이언트 설정 + mockClient = { + id: "test-client-id", + join: jest.fn(), + emit: jest.fn(), + }; + }); + + describe("handleCreateRoom", () => { + it("방 생성 성공시 이벤트를 발생시켜야 한다", async () => { + const createRoomData = { + title: "Test Room", + nickname: "Test User", + status: "PUBLIC", + maxParticipants: 5, + }; + + const mockRoomData = { + roomId: "test-room-id", + roomMetadata: { + title: createRoomData.title, + status: createRoomData.status, + maxParticipants: createRoomData.maxParticipants, + }, + }; + + roomService.createRoom = jest.fn().mockResolvedValue(mockRoomData); + + await gateway.handleCreateRoom(mockClient, createRoomData); + + expect(mockClient.join).toHaveBeenCalledWith(mockRoomData.roomId); + expect(mockServer.to).toHaveBeenCalledWith(mockRoomData.roomId); + expect(mockServer.emit).toHaveBeenCalledWith( + "room_created", + mockRoomData + ); + }); }); - it("should be defined", () => { - expect(gateway).toBeDefined(); + describe("handleJoinRoom", () => { + it("방이 가득 찼을 경우 ROOM_FULL 이벤트를 발생시켜야 한다", async () => { + const joinRoomData = { + roomId: "test-room-id", + nickname: "Test User", + }; + + roomService.checkAvailable = jest.fn().mockResolvedValue(false); + + await gateway.handleJoinRoom(mockClient, joinRoomData); + + expect(mockClient.emit).toHaveBeenCalledWith("room_full"); + }); + + it("방 참가 성공시 ALL_USERS 이벤트를 발생시켜야 한다", async () => { + const joinRoomData = { + roomId: "test-room-id", + nickname: "Test User", + }; + + const mockRoom = { + id: "test-room-id", + title: "Test Room", + }; + + const mockUsers = { + "user-1": { nickname: "User 1" }, + }; + + roomService.checkAvailable = jest.fn().mockResolvedValue(true); + roomService.joinRoom = jest.fn().mockResolvedValue(mockRoom); + roomService.getRoomMemberConnection = jest + .fn() + .mockResolvedValue(mockUsers); + + await gateway.handleJoinRoom(mockClient, joinRoomData); + + expect(mockClient.join).toHaveBeenCalledWith(joinRoomData.roomId); + expect(mockClient.emit).toHaveBeenCalledWith("all_users", { + roomMetadata: mockRoom, + users: mockUsers, + }); + }); + }); + + describe("handleLeaveRoom", () => { + it("마지막 사용자가 나갈 경우 방을 삭제해야 한다", async () => { + roomService.getRoomId = jest.fn().mockResolvedValue("test-room-id"); + roomService.checkHost = jest.fn().mockResolvedValue(false); + roomService.leaveRoom = jest.fn().mockResolvedValue(0); + + await gateway.handleLeaveRoom(mockClient); + + expect(roomService.deleteRoom).toHaveBeenCalled(); + }); + + it("호스트가 나갈 경우 새로운 호스트를 지정해야 한다", async () => { + const mockRoomId = "test-room-id"; + const mockNewHost = { + socketId: "new-host-id", + nickname: "New Host", + }; + + roomService.getRoomId = jest.fn().mockResolvedValue(mockRoomId); + roomService.checkHost = jest.fn().mockResolvedValue(true); + roomService.leaveRoom = jest.fn().mockResolvedValue(1); + roomService.delegateHost = jest.fn().mockResolvedValue(mockNewHost); + + await gateway.handleLeaveRoom(mockClient); + + expect(mockServer.to).toHaveBeenCalledWith(mockRoomId); + expect(mockServer.emit).toHaveBeenCalledWith("master_changed", { + masterSocketId: mockNewHost.socketId, + masterNickname: mockNewHost.nickname, + }); + }); + }); + + describe("handleFinishRoom", () => { + it("방 종료시 ROOM_FINISHED 이벤트를 발생시켜야 한다", async () => { + const mockRoomId = "test-room-id"; + roomService.finishRoom = jest.fn().mockResolvedValue(mockRoomId); + + await gateway.handleFinishRoom(mockClient); + + expect(mockServer.to).toHaveBeenCalledWith(mockRoomId); + expect(mockServer.emit).toHaveBeenCalledWith("room_finished"); + }); }); }); diff --git a/backend/src/room/room.gateway.ts b/backend/src/room/room.gateway.ts index acdbda46..093d2857 100644 --- a/backend/src/room/room.gateway.ts +++ b/backend/src/room/room.gateway.ts @@ -51,7 +51,8 @@ export class RoomGateway implements OnGatewayConnection, OnGatewayDisconnect { @SubscribeMessage(EVENT_NAME.CREATE_ROOM) async handleCreateRoom(client: Socket, data: any) { - const { title, nickname, status, maxParticipants } = data; // unknown 으로 받고, Dto와 Pipe로 검증받기 + const { title, nickname, status, maxParticipants, questionListId } = + data; // unknown 으로 받고, Dto와 Pipe로 검증받기 try { const roomData = await this.roomService.createRoom({ title, @@ -59,6 +60,7 @@ export class RoomGateway implements OnGatewayConnection, OnGatewayDisconnect { socketId: client.id, nickname, maxParticipants, + questionListId, }); client.join(roomData.roomId); diff --git a/backend/src/room/room.model.ts b/backend/src/room/room.model.ts index 1db80a32..fd834d3a 100644 --- a/backend/src/room/room.model.ts +++ b/backend/src/room/room.model.ts @@ -6,6 +6,7 @@ export interface Room { maxParticipants: number; createdAt: number; host: string; + questionListId; } export interface MemberConnection { diff --git a/backend/src/room/room.module.ts b/backend/src/room/room.module.ts index 4bf3e9c1..d775a309 100644 --- a/backend/src/room/room.module.ts +++ b/backend/src/room/room.module.ts @@ -4,8 +4,10 @@ import { RoomGateway } from "./room.gateway"; import { RedisService } from "../redis/redis.service"; import { RoomRepository } from "./room.repository"; import { RoomController } from "./room.controller"; +import { QuestionListModule } from "../question-list/question-list.module"; @Module({ + imports: [QuestionListModule], providers: [RoomService, RoomGateway, RedisService, RoomRepository], controllers: [RoomController], }) diff --git a/backend/src/room/room.repository.ts b/backend/src/room/room.repository.ts index 52c0ecfb..e7747ac5 100644 --- a/backend/src/room/room.repository.ts +++ b/backend/src/room/room.repository.ts @@ -15,9 +15,8 @@ export class RoomRepository { async getAllRoom(): Promise> { const redisMap = await this.redisService.getMap("room:*"); - console.log(redisMap); - return Object.entries(redisMap).reduce( + return Object.entries(redisMap ?? {}).reduce( (acc, [roomId, room]) => { acc[roomId.split(":")[1]] = room as Room; return acc; @@ -115,7 +114,8 @@ export class RoomRepository { } async createRoom(dto: CreateRoomDto) { - const { title, socketId, maxParticipants, status } = dto; + const { title, socketId, maxParticipants, status, questionListId } = + dto; const roomId = generateRoomId(); await this.redisService.set( @@ -126,6 +126,7 @@ export class RoomRepository { host: socketId, maxParticipants, status, + questionListId, } as Room, 6 * HOUR ); diff --git a/backend/src/room/room.service.spec.ts b/backend/src/room/room.service.spec.ts index 0ab7dff4..fe752666 100644 --- a/backend/src/room/room.service.spec.ts +++ b/backend/src/room/room.service.spec.ts @@ -1,18 +1,135 @@ import { Test, TestingModule } from "@nestjs/testing"; import { RoomService } from "./room.service"; +import { RoomRepository } from "./room.repository"; +import { CreateRoomDto } from "./dto/create-room.dto"; describe("RoomService", () => { - let service: RoomService; + let roomService: RoomService; + + // Mock Repository 생성 + const mockRoomRepository = { + getAllRoom: jest.fn(), + findMyRoomId: jest.fn(), + createRoom: jest.fn(), + addUser: jest.fn(), + getRoomById: jest.fn(), + getRoomMemberConnection: jest.fn(), + checkHost: jest.fn(), + deleteUser: jest.fn(), + getRoomMemberCount: jest.fn(), + getNewHost: jest.fn(), + setNewHost: jest.fn(), + deleteRoom: jest.fn(), + }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [RoomService], + providers: [ + RoomService, + { + provide: RoomRepository, + useValue: mockRoomRepository, + }, + ], }).compile(); - service = module.get(RoomService); + roomService = module.get(RoomService); + }); + + describe("getPublicRoom", () => { + it("공개방만 반환해야 한다", async () => { + const mockRooms = { + room1: { status: "PUBLIC", title: "Room 1" }, + room2: { status: "PRIVATE", title: "Room 2" }, + }; + mockRoomRepository.getAllRoom.mockResolvedValue(mockRooms); + + const result = await roomService.getPublicRoom(); + + expect(result.room1).toBeDefined(); + expect(result.room2).toBeUndefined(); + }); + }); + + describe("createRoom", () => { + it("새로운 방을 생성해야 한다", async () => { + const createRoomDto: CreateRoomDto = { + status: "PUBLIC", + title: "Test Room", + socketId: "socket-123", + nickname: "User1", + questionListId: 1, + }; + const mockRoomId = "room-123"; + + mockRoomRepository.createRoom.mockResolvedValue(mockRoomId); + + const result = await roomService.createRoom(createRoomDto); + + expect(result.roomId).toBe(mockRoomId); + expect(result.roomMetadata.title).toBe(createRoomDto.title); + expect(mockRoomRepository.addUser).toHaveBeenCalled(); + }); }); - it("should be defined", () => { - expect(service).toBeDefined(); + describe("joinRoom", () => { + it("존재하는 방에 참가할 수 있어야 한다", async () => { + const mockRoom = { id: "room-123", title: "Test Room" }; + mockRoomRepository.getRoomById.mockResolvedValue(mockRoom); + + const result = await roomService.joinRoom( + "socket-123", + "room-123", + "User1" + ); + + expect(result).toBe(mockRoom); + expect(mockRoomRepository.addUser).toHaveBeenCalled(); + }); + + it("존재하지 않는 방에 참가할 수 없어야 한다", async () => { + mockRoomRepository.getRoomById.mockResolvedValue(null); + + const result = await roomService.joinRoom( + "socket-123", + "invalid-room", + "User1" + ); + + expect(result).toBeNull(); + }); + }); + + describe("leaveRoom", () => { + it("방을 떠날 수 있어야 한다", async () => { + const mockRoomId = "room-123"; + mockRoomRepository.findMyRoomId.mockResolvedValue(mockRoomId); + mockRoomRepository.getRoomMemberCount.mockResolvedValue(2); + + const result = await roomService.leaveRoom("socket-123"); + + expect(mockRoomRepository.deleteUser).toHaveBeenCalled(); + expect(result).toBe(2); + }); + }); + + describe("getRoomMemberConnection", () => { + it("자신을 제외한 멤버 연결 정보를 반환해야 한다", async () => { + const mockConnections = { + "socket-123": { nickname: "User1" }, + "socket-456": { nickname: "User2" }, + }; + mockRoomRepository.getRoomMemberConnection.mockResolvedValue( + mockConnections + ); + + const result = await roomService.getRoomMemberConnection( + "socket-123", + "room-123" + ); + + expect(result["socket-123"]).toBeUndefined(); + expect(result["socket-456"]).toBeDefined(); + }); }); }); diff --git a/backend/src/room/room.service.ts b/backend/src/room/room.service.ts index dc7a413e..20b001c4 100644 --- a/backend/src/room/room.service.ts +++ b/backend/src/room/room.service.ts @@ -2,6 +2,9 @@ import { Injectable } from "@nestjs/common"; import { RoomRepository } from "./room.repository"; import { CreateRoomDto } from "./dto/create-room.dto"; import { MemberConnection } from "./room.model"; +import { QuestionListRepository } from "../question-list/question-list.repository"; +import { InjectRepository } from "@nestjs/typeorm"; +import { QuestionList } from "../question-list/question-list.entity"; /** * 비즈니스 로직 처리를 좀 더 하게 하기 위한 클래스로 설정 @@ -12,15 +15,32 @@ import { MemberConnection } from "./room.model"; export class RoomService { private static MAX_MEMBERS = 5; - constructor(private readonly roomRepository: RoomRepository) {} + constructor( + private readonly roomRepository: RoomRepository, + private readonly questionListRepository: QuestionListRepository + ) {} async getPublicRoom() { const rooms = await this.roomRepository.getAllRoom(); - - Object.keys(rooms).forEach((roomId) => { - if (rooms[roomId].status === "PRIVATE") rooms[roomId] = undefined; + const roomList = []; + Object.entries(rooms).forEach(([roomId, roomData]) => { + if (roomData.status === "PRIVATE") return; + roomList.push({ + id: roomId, + title: roomData.title, + category: "프론트엔드", + inProgress: false, + host: { + nickname: "방장", + socketId: roomData.host, + }, + participant: 1, + maxParticipant: roomData.maxParticipants, + createAt: roomData.createdAt, + }); }); - return rooms; + + return roomList.sort((a, b) => b.createAt - a.createAt); } async getRoomId(socketId: string) { @@ -28,15 +48,29 @@ export class RoomService { } async createRoom(dto: CreateRoomDto) { - const { title, status, maxParticipants, socketId, nickname } = dto; + const { + title, + status, + maxParticipants, + socketId, + nickname, + questionListId, + } = dto; const roomId = await this.roomRepository.createRoom({ title, status: status ?? "PUBLIC", maxParticipants: maxParticipants ?? RoomService.MAX_MEMBERS, socketId, nickname: nickname ?? "Master", + questionListId, }); + await this.roomRepository.addUser(roomId, dto.socketId, dto.nickname); + const questionListContents = + await this.questionListRepository.getContentsByQuestionListId( + questionListId + ); + return { roomId, roomMetadata: { @@ -45,6 +79,7 @@ export class RoomService { maxParticipants: maxParticipants ?? RoomService.MAX_MEMBERS, host: socketId, nickname: nickname ?? "Master", + questionListContents, }, }; } diff --git a/backend/src/user/dto/create-user.dto.ts b/backend/src/user/dto/create-user.dto.ts new file mode 100644 index 00000000..61c14de2 --- /dev/null +++ b/backend/src/user/dto/create-user.dto.ts @@ -0,0 +1,6 @@ +export interface CreateUserDto { + loginId?: string; + passwordHash?: string; + githubId?: number; + username: string; +} diff --git a/backend/src/user/dto/user.dto.ts b/backend/src/user/dto/user.dto.ts new file mode 100644 index 00000000..002ce6f9 --- /dev/null +++ b/backend/src/user/dto/user.dto.ts @@ -0,0 +1,7 @@ +export interface UserDto { + loginId?: string; + passwordHash?: string; + githubId?: number; + username: string; + refreshToken: string; +} diff --git a/backend/src/user/user.entity.ts b/backend/src/user/user.entity.ts new file mode 100644 index 00000000..6fbcc9e1 --- /dev/null +++ b/backend/src/user/user.entity.ts @@ -0,0 +1,31 @@ +import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from "typeorm"; +import { QuestionList } from "../question-list/question-list.entity"; + +@Entity() +export class User { + private static LOGIN_ID_MAX_LEN = 20; + private static PASSWORD_HASH_MAX_LEN = 256; + private static USERNAME_MAX_LEN = 20; + private static REFRESH_TOKEN_MAX_LEN = 200; + + @PrimaryGeneratedColumn() + id: number; + + @Column({ length: User.LOGIN_ID_MAX_LEN, nullable: true, unique: true }) + loginId: string; + + @Column({ length: User.PASSWORD_HASH_MAX_LEN, nullable: true }) + passwordHash: string; + + @Column({ length: User.USERNAME_MAX_LEN, unique: true }) + username: string; + + @Column({ length: User.REFRESH_TOKEN_MAX_LEN, nullable: true }) + refreshToken: string; + + @Column({ nullable: true, unique: true }) + githubId: number; + + @OneToMany(() => QuestionList, (questionList) => questionList.user) + questionLists: QuestionList[]; +} diff --git a/backend/src/user/user.module.ts b/backend/src/user/user.module.ts new file mode 100644 index 00000000..196852ba --- /dev/null +++ b/backend/src/user/user.module.ts @@ -0,0 +1,7 @@ +import { Module } from "@nestjs/common"; +import { UserRepository } from "./user.repository"; + +@Module({ + providers: [UserRepository], +}) +export class UserModule {} diff --git a/backend/src/user/user.repository.ts b/backend/src/user/user.repository.ts new file mode 100644 index 00000000..88a56c6b --- /dev/null +++ b/backend/src/user/user.repository.ts @@ -0,0 +1,34 @@ +import { Injectable } from "@nestjs/common"; +import { User } from "./user.entity"; +import { DataSource } from "typeorm"; +import { CreateUserDto } from "./dto/create-user.dto"; +import { UserDto } from "./dto/user.dto"; + +@Injectable() +export class UserRepository { + constructor(private dataSource: DataSource) {} + + getUserByGithubId(githubId: number) { + return this.dataSource + .getRepository(User) + .createQueryBuilder("user") + .where("user.github_id = :id", { id: githubId }) + .getOne(); + } + + getUserByUserId(userId: number) { + return this.dataSource + .getRepository(User) + .createQueryBuilder("user") + .where("user.id = :id", { id: userId }) + .getOne(); + } + + createUser(createUserDto: CreateUserDto) { + return this.dataSource.getRepository(User).save(createUserDto); + } + + updateUser(userDto: UserDto) { + return this.dataSource.getRepository(User).save(userDto); + } +} diff --git a/backend/src/utils/time.ts b/backend/src/utils/time.ts index 655f5325..27ed6fd1 100644 --- a/backend/src/utils/time.ts +++ b/backend/src/utils/time.ts @@ -1,3 +1,4 @@ export const SEC = 1; export const MIN = 60 * SEC; export const HOUR = 60 * MIN; +export const DAY = 24 * HOUR; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..9370a4b2 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,26 @@ +version: '3' + +services: + mysql: + image: mysql:latest + container_name: mysql + ports: + - '3306:3306' + environment: + MYSQL_ROOT_PASSWORD: dbcadmium + MYSQL_DATABASE: preview + TZ: Asia/Seoul + command: + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_unicode_ci + volumes: + - ./mysql-data:/var/lib/mysql + + redis: + image: redis:latest + container_name: redis + ports: + - '6379:6379' + command: redis-server --port 6379 + volumes: + - ./redis-data:/data \ No newline at end of file diff --git a/frontend/.gitignore b/frontend/.gitignore index 50c8dda2..1a30e0e9 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -24,3 +24,4 @@ dist-ssr *.sw? .env +test \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html index e4b78eae..234dd5e9 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,10 +1,10 @@ - + - Vite + React + TS + 면접 스터디 Preview
diff --git a/frontend/jest.config.ts b/frontend/jest.config.ts new file mode 100644 index 00000000..02175d0e --- /dev/null +++ b/frontend/jest.config.ts @@ -0,0 +1,23 @@ +import type { Config } from "jest"; + +const config: Config = { + preset: "ts-jest", + testEnvironment: "jsdom", + setupFilesAfterEnv: ["/src/setupTests.ts"], + moduleNameMapper: { + "^@/(.*)$": "/src/$1", + "^@components/(.*)$": "/src/components/$1", + "^@hooks/(.*)$": "/src/hooks/$1", + "^@stores/(.*)$": "/src/stores/$1", + }, + transform: { + "^.+\\.tsx?$": [ + "ts-jest", + { + tsconfig: "tsconfig.test.json", + }, + ], + }, +}; + +export default config; diff --git a/frontend/package.json b/frontend/package.json index 943e72f1..8861278a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,19 +9,31 @@ "lint": "eslint .", "format": "prettier --write \"**/*.{ts,tsx}\"", "preview": "vite preview", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "jest", + "test:watch": "jest --watch" }, "dependencies": { + "@dotlottie/react-player": "^1.6.19", + "@tanstack/react-query": "^5.60.6", + "@tanstack/react-query-devtools": "^5.60.6", + "@types/react-lottie": "^1.2.10", "@types/socket.io-client": "^3.0.0", + "axios": "^1.7.7", "react": "^18.3.1", "react-dom": "^18.3.1", "react-icons": "^5.3.0", + "react-lottie": "^1.2.7", "react-router-dom": "^6.27.0", "socket.io-client": "^4.8.1", "zustand": "^5.0.1" }, "devDependencies": { "@eslint/js": "^9.13.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^15.0.6", + "@testing-library/react-hooks": "^8.0.1", + "@testing-library/user-event": "^14.5.2", + "@types/jest": "^29.5.14", "@types/node": "^20.3.1", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", @@ -31,8 +43,11 @@ "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-refresh": "^0.4.14", "globals": "^15.11.0", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", "postcss": "^8.4.47", "tailwindcss": "^3.4.14", + "ts-jest": "^29.1.0", "typescript": "~5.6.2", "typescript-eslint": "^8.11.0", "vite": "^5.4.10", diff --git a/frontend/public/assets/loadingIndicator.lottie b/frontend/public/assets/loadingIndicator.lottie new file mode 100644 index 00000000..4c8f1a12 Binary files /dev/null and b/frontend/public/assets/loadingIndicator.lottie differ diff --git a/frontend/public/assets/noondeumyum.lottie b/frontend/public/assets/noondeumyum.lottie new file mode 100644 index 00000000..d83a4ffc Binary files /dev/null and b/frontend/public/assets/noondeumyum.lottie differ diff --git a/frontend/public/assets/snowman.lottie b/frontend/public/assets/snowman.lottie new file mode 100644 index 00000000..733ebc21 Binary files /dev/null and b/frontend/public/assets/snowman.lottie differ diff --git a/frontend/src/api/questions/create.ts b/frontend/src/api/questions/create.ts new file mode 100644 index 00000000..65fe0487 --- /dev/null +++ b/frontend/src/api/questions/create.ts @@ -0,0 +1,14 @@ +import axios from "axios"; + +interface QuestionListRequest { + title: string; + contents: string[]; + categoryNames: string[]; + isPublic: boolean; +} + +export const createQuestionList = async (data: QuestionListRequest) => { + const response = await axios.post("/api/question-list", data); + + return response.data; +}; diff --git a/frontend/src/api/questions/getQuestion.ts b/frontend/src/api/questions/getQuestion.ts new file mode 100644 index 00000000..e112e50e --- /dev/null +++ b/frontend/src/api/questions/getQuestion.ts @@ -0,0 +1,15 @@ +import axios from "axios"; + +const fetchQuestion = async (questionListId: number) => { + const { data } = await axios.post("/api/question-list/contents", { + questionListId + }); + + if (!data.success) { + throw new Error(data.message); + } + + return data.data.questionListContents; +}; + +export default fetchQuestion; diff --git a/frontend/src/components/common/AccessButton/index.tsx b/frontend/src/components/common/AccessButton/index.tsx new file mode 100644 index 00000000..ee1eb337 --- /dev/null +++ b/frontend/src/components/common/AccessButton/index.tsx @@ -0,0 +1,35 @@ +interface AccessProps { + access: string; + onClick: (access: "PUBLIC" | "PRIVATE") => void; +} + +const AccessButton = ({ access, onClick }: AccessProps) => { + return ( +
+ + +
+ ); +}; + +export default AccessButton; diff --git a/frontend/src/components/common/CategorySelector/index.tsx b/frontend/src/components/common/CategorySelector/index.tsx new file mode 100644 index 00000000..84b524a7 --- /dev/null +++ b/frontend/src/components/common/CategorySelector/index.tsx @@ -0,0 +1,48 @@ +import { IoChevronDownSharp } from "react-icons/io5"; + +interface Option { + value: string; + label: string; +} + +interface CategoryProps { + title: string; + options: Option[]; + value: string; + onChange: (value: string) => void; +} + +const CategorySelector = ({ + title, + options, + value, + onChange, +}: CategoryProps) => { + const changeHandler = (event: React.ChangeEvent) => { + onChange(event.target.value); + }; + + return ( +
+ + + + +
+ ); +}; + +export default CategorySelector; diff --git a/frontend/src/components/common/LoadingIndicator.tsx b/frontend/src/components/common/LoadingIndicator.tsx new file mode 100644 index 00000000..306da5d1 --- /dev/null +++ b/frontend/src/components/common/LoadingIndicator.tsx @@ -0,0 +1,26 @@ +import { DotLottiePlayer } from "@dotlottie/react-player"; + +interface LoadingIndicator { + loadingState: boolean; + text?: string; +} + +const LoadingIndicator = ({ loadingState, text }: LoadingIndicator) => { + return ( + loadingState && ( +
+ + {text && ( +

{text}

+ )} +
+ ) + ); +}; + +export default LoadingIndicator; diff --git a/frontend/src/components/common/SearchBar.tsx b/frontend/src/components/common/SearchBar.tsx index ad104517..3b14ceac 100644 --- a/frontend/src/components/common/SearchBar.tsx +++ b/frontend/src/components/common/SearchBar.tsx @@ -5,11 +5,11 @@ interface Props { const SearchBar = ({ text }: Props) => { return ( -
+
{ + return ( +
+ + + + +
+ ); +}; + +export default Select; diff --git a/frontend/src/components/sessions/create/SelectTitle.tsx b/frontend/src/components/common/SelectTitle.tsx similarity index 66% rename from frontend/src/components/sessions/create/SelectTitle.tsx rename to frontend/src/components/common/SelectTitle.tsx index 026e61d0..0eb48e9f 100644 --- a/frontend/src/components/sessions/create/SelectTitle.tsx +++ b/frontend/src/components/common/SelectTitle.tsx @@ -3,7 +3,7 @@ interface Props { } const SelectTitle = ({ title }: Props) => { - return

{title}

; + return

{title}

; }; export default SelectTitle; diff --git a/frontend/src/components/common/Sidebar.tsx b/frontend/src/components/common/Sidebar.tsx new file mode 100644 index 00000000..66b00b0c --- /dev/null +++ b/frontend/src/components/common/Sidebar.tsx @@ -0,0 +1,134 @@ +import { Link } from "react-router-dom"; +import { ReactElement, useEffect, useState } from "react"; +import { FaClipboardList, FaLayerGroup } from "react-icons/fa"; +import { MdDarkMode, MdLightMode, MdLogout } from "react-icons/md"; +import { IoPersonSharp, IoHomeSharp } from "react-icons/io5"; +import { FaGithub } from "react-icons/fa6"; +import useTheme from "@hooks/useTheme.ts"; + +const Sidebar = () => { + const routes = [ + { + path: "/", + label: "홈", + icon: , + }, + { + path: "/questions", + label: "질문지 리스트", + icon: , + }, + { + path: "/sessions", + label: "스터디 세션 목록", + icon: , + }, + { + path: "/mypage", + label: "마이페이지", + icon: , + }, + { + path: "/logout", + label: "로그아웃", + icon: , + }, + ]; + + const [selected, setSelected] = useState(""); + const { theme, toggleTheme } = useTheme(); + + useEffect(() => { + setSelected(window.location.pathname); + }, []); + return ( + + ); +}; + +interface SidebarMenuProps { + path: string; + label: string; + icon?: ReactElement; + isSelected?: boolean; +} + +const SidebarMenu = ({ + path, + label, + icon, + isSelected = false, +}: SidebarMenuProps) => { + const activeClass = isSelected + ? "bg-green-100 dark:text-gray-black text-white text-semibold-m" + : "bg-transparent dark:text-white text-gray-black text-medium-l transition-color duration-300 hover:bg-gray-200/30"; + + return ( +
  • + + {icon} + {label} + +
  • + ); +}; + +export default Sidebar; diff --git a/frontend/src/components/common/TitleInput/index.tsx b/frontend/src/components/common/TitleInput/index.tsx new file mode 100644 index 00000000..7355fe32 --- /dev/null +++ b/frontend/src/components/common/TitleInput/index.tsx @@ -0,0 +1,60 @@ +import { useState } from "react"; + +interface TitleProps { + placeholder: string; + onChange: (title: string) => void; + minLength?: number; + maxLength?: number; +} + +const TitleInput = ({ + placeholder, + onChange, + minLength = 5, + maxLength = 20, +}: TitleProps) => { + const [charCount, setCharCount] = useState(0); + const [value, setValue] = useState(""); + + const changeHandler = (event: React.ChangeEvent) => { + const newValue = event.target.value; + if (newValue.length <= maxLength) { + setValue(newValue); + setCharCount(newValue.length); + onChange(newValue); + } + }; + + const getCounterColor = () => { + if (charCount === 0) return "text-gray-500"; + if (charCount < minLength) return "text-point-1"; + if (charCount === maxLength) return "text-point-1"; + return "text-gray-500"; + }; + + const getMessage = () => { + return `${charCount}/${maxLength}`; + }; + + return ( +
    + +
    + {getMessage()} +
    +
    + ); +}; + +export default TitleInput; diff --git a/frontend/src/components/common/Toast.tsx b/frontend/src/components/common/Toast.tsx index ef98581f..00a301e6 100644 --- a/frontend/src/components/common/Toast.tsx +++ b/frontend/src/components/common/Toast.tsx @@ -11,14 +11,19 @@ const Toast = ({ message, type, removeToast }: ToastProps) => { return (
    -

    +

    {message}

    diff --git a/frontend/src/components/common/ToastProvider.tsx b/frontend/src/components/common/ToastProvider.tsx index 6486404e..3c36cce7 100644 --- a/frontend/src/components/common/ToastProvider.tsx +++ b/frontend/src/components/common/ToastProvider.tsx @@ -11,7 +11,7 @@ const ToastProvider = () => { const { toasts, removeToast } = useToastStore(); return ( -
    +
    {toasts.map((toast: Toast) => { return ( void; +} + +const QuestionCard = ({ + title, + questionCount, + usage, + isStarred = false, + category, + onClick, +}: QuestionCardProps) => { + return ( +
    +
    + + {category} + + +
    + +

    + {title} +

    + +
    +
    + {questionCount} + 문항 +
    +
    + + {usage} +
    +
    +
    + ); +}; + +export default QuestionCard; diff --git a/frontend/src/components/questions/create/QuestionForm/AccessSection/index.tsx b/frontend/src/components/questions/create/QuestionForm/AccessSection/index.tsx new file mode 100644 index 00000000..28b352cc --- /dev/null +++ b/frontend/src/components/questions/create/QuestionForm/AccessSection/index.tsx @@ -0,0 +1,16 @@ +import SelectTitle from "@/components/common/SelectTitle"; +import AccessButton from "@/components/common/AccessButton"; +import useQuestionFormStore from "@/stores/useQuestionFormStore"; + +const AccessSection = () => { + const { access, setAccess } = useQuestionFormStore(); + + return ( +
    + + +
    + ); +}; + +export default AccessSection; diff --git a/frontend/src/components/questions/create/QuestionForm/CategorySection/data.ts b/frontend/src/components/questions/create/QuestionForm/CategorySection/data.ts new file mode 100644 index 00000000..f4a814b4 --- /dev/null +++ b/frontend/src/components/questions/create/QuestionForm/CategorySection/data.ts @@ -0,0 +1,14 @@ +export const options = [ + { + value: "운영체제", + label: "운영체제", + }, + { + value: "네트워크", + label: "네트워크", + }, + { + value: "프론트엔드", + label: "프론트엔드", + }, +]; diff --git a/frontend/src/components/questions/create/QuestionForm/CategorySection/index.tsx b/frontend/src/components/questions/create/QuestionForm/CategorySection/index.tsx new file mode 100644 index 00000000..2ddebccf --- /dev/null +++ b/frontend/src/components/questions/create/QuestionForm/CategorySection/index.tsx @@ -0,0 +1,22 @@ +import SelectTitle from "@/components/common/SelectTitle"; +import CategorySelector from "@/components/common/CategorySelector"; +import { options } from "./data"; +import useQuestionFormStore from "@/stores/useQuestionFormStore"; + +const CategorySection = () => { + const { category, setCategory } = useQuestionFormStore(); + + return ( +
    + + +
    + ); +}; + +export default CategorySection; diff --git a/frontend/src/components/questions/create/QuestionForm/QuestionInputSection/EditInput.tsx b/frontend/src/components/questions/create/QuestionForm/QuestionInputSection/EditInput.tsx new file mode 100644 index 00000000..11f11049 --- /dev/null +++ b/frontend/src/components/questions/create/QuestionForm/QuestionInputSection/EditInput.tsx @@ -0,0 +1,50 @@ +import { useEffect, useRef } from "react"; +import { adjustHeight } from "../utils/textarea"; + +interface EditInputProps { + value: string; + onChange: (value: string) => void; + onSave: () => void; + onCancel: () => void; +} + +const EditInput = ({ value, onChange, onSave, onCancel }: EditInputProps) => { + const textareaRef = useRef(null); + + const changeHandler = (e: React.ChangeEvent) => { + const newValue = e.target.value.slice(0, 100); + onChange(newValue); + }; + + useEffect(() => { + adjustHeight(textareaRef, value); + }, [value]); + + return ( +
    +