diff --git a/BE/src/auth/auth.controller.ts b/BE/src/auth/auth.controller.ts index a32084a..5273780 100644 --- a/BE/src/auth/auth.controller.ts +++ b/BE/src/auth/auth.controller.ts @@ -9,7 +9,7 @@ import { } from "@nestjs/common"; import { AuthService } from "./auth.service"; import { AuthCredentialsDto } from "./dto/auth-credential.dto"; -import { AccessTokenDto } from "./dto/auth-access-token.dto"; +import { LoginResponseDto } from "./dto/login-response.dto"; import { ExpiredOrNotGuard, NoDuplicateLoginGuard, @@ -30,7 +30,7 @@ export class AuthController { async kakaoSignIn( @GetUser() user: User, @Req() request: Request, - ): Promise { + ): Promise { return await this.authService.kakaoSignIn(user, request); } @@ -40,7 +40,7 @@ export class AuthController { async naverSignIn( @GetUser() user: User, @Req() request: Request, - ): Promise { + ): Promise { return await this.authService.naverSignIn(user, request); } @@ -56,7 +56,7 @@ export class AuthController { async signIn( @Body() authCredentialsDto: AuthCredentialsDto, @Req() request: Request, - ): Promise { + ): Promise { return await this.authService.signIn(authCredentialsDto, request); } @@ -70,7 +70,7 @@ export class AuthController { @Post("/reissue") @UseGuards(ExpiredOrNotGuard) @HttpCode(201) - async reissueAccessToken(@Req() request: Request): Promise { + async reissueAccessToken(@Req() request: Request): Promise { return await this.authService.reissueAccessToken(request); } } diff --git a/BE/src/auth/auth.service.ts b/BE/src/auth/auth.service.ts index 08eedca..26a1dda 100644 --- a/BE/src/auth/auth.service.ts +++ b/BE/src/auth/auth.service.ts @@ -3,7 +3,7 @@ import { JwtService } from "@nestjs/jwt"; import { UsersRepository } from "src/auth/users.repository"; import { AuthCredentialsDto } from "./dto/auth-credential.dto"; import * as bcrypt from "bcryptjs"; -import { AccessTokenDto } from "./dto/auth-access-token.dto"; +import { LoginResponseDto } from "./dto/login-response.dto"; import { CreateUserDto } from "./dto/users.dto"; import { User } from "./users.entity"; import { Redis } from "ioredis"; @@ -26,7 +26,7 @@ export class AuthService { async signIn( authCredentialsDto: AuthCredentialsDto, request: Request, - ): Promise { + ): Promise { const { userId, password } = authCredentialsDto; const user = await this.usersRepository.getUserByUserId(userId); if (!user) { @@ -37,14 +37,16 @@ export class AuthService { throw new NotFoundException("올바르지 않은 비밀번호입니다."); } - return this.createUserTokens(userId, user.nickname, request.ip); + const { nickname, premium } = user; + const accessToken = await this.createUserTokens(userId, request.ip); + return new LoginResponseDto(accessToken, nickname, premium); } async signOut(user: User): Promise { await this.redisClient.del(user.userId); } - async reissueAccessToken(request: Request): Promise { + async reissueAccessToken(request: Request): Promise { const expiredAccessToken = request.headers.authorization.split(" ")[1]; // 만료된 액세스 토큰을 직접 디코딩 @@ -54,38 +56,41 @@ export class AuthService { const userId = expiredResult.userId; - const userNickname = (await User.findOne({ where: { userId: userId } })) - .nickname; - return this.createUserTokens(userId, userNickname, request.ip); + const { nickname, premium } = await User.findOne({ + where: { userId }, + }); + const accessToken = await this.createUserTokens(userId, request.ip); + return new LoginResponseDto(accessToken, nickname, premium); } - async naverSignIn(user: User, request: Request): Promise { - const userId = user.userId; + async naverSignIn(user: User, request: Request): Promise { + const { userId, nickname, premium } = user; const provider = providerEnum.NAVER; if (!(await User.findOne({ where: { userId, provider } }))) { await user.save(); } - return this.createUserTokens(userId, user.nickname, request.ip); + const accessToken = await this.createUserTokens(userId, request.ip); + return new LoginResponseDto(accessToken, nickname, premium); } - async kakaoSignIn(user: User, request: Request): Promise { - const userId = user.userId; + async kakaoSignIn(user: User, request: Request): Promise { + const { userId, nickname, premium } = user; const provider = providerEnum.KAKAO; if (!(await User.findOne({ where: { userId, provider } }))) { await user.save(); } - return this.createUserTokens(userId, user.nickname, request.ip); + const accessToken = await this.createUserTokens(userId, request.ip); + return new LoginResponseDto(accessToken, nickname, premium); } private async createUserTokens( userId: string, - nickname: string, requestIp: string, - ): Promise { + ): Promise { const accessTokenPayload = { userId }; const accessToken = await this.jwtService.sign(accessTokenPayload, { expiresIn: "1h", @@ -102,6 +107,6 @@ export class AuthService { // 86000s = 24h await this.redisClient.set(userId, refreshToken, "EX", 86400); - return new AccessTokenDto(accessToken, nickname); + return accessToken; } } diff --git a/BE/src/auth/dto/auth-access-token.dto.ts b/BE/src/auth/dto/auth-access-token.dto.ts deleted file mode 100644 index 9884334..0000000 --- a/BE/src/auth/dto/auth-access-token.dto.ts +++ /dev/null @@ -1,9 +0,0 @@ -export class AccessTokenDto { - accessToken: string; - nickname: string; - - constructor(accessToken: string, nickname: string) { - this.accessToken = accessToken; - this.nickname = nickname; - } -} diff --git a/BE/src/auth/dto/login-response.dto.ts b/BE/src/auth/dto/login-response.dto.ts new file mode 100644 index 0000000..8e49f75 --- /dev/null +++ b/BE/src/auth/dto/login-response.dto.ts @@ -0,0 +1,13 @@ +import { premiumStatus } from "src/utils/enum"; + +export class LoginResponseDto { + accessToken: string; + nickname: string; + premium: premiumStatus; + + constructor(accessToken: string, nickname: string, premium: premiumStatus) { + this.accessToken = accessToken; + this.nickname = nickname; + this.premium = premium; + } +} diff --git a/BE/src/purchase/dto/purchase.credit.dto.ts b/BE/src/purchase/dto/purchase.credit.dto.ts new file mode 100644 index 0000000..57e2284 --- /dev/null +++ b/BE/src/purchase/dto/purchase.credit.dto.ts @@ -0,0 +1,7 @@ +export class CreditDto { + credit: number; + + constructor(credit: number) { + this.credit = credit; + } +} diff --git a/BE/src/purchase/purchase.controller.ts b/BE/src/purchase/purchase.controller.ts index a7726e8..fbddb35 100644 --- a/BE/src/purchase/purchase.controller.ts +++ b/BE/src/purchase/purchase.controller.ts @@ -11,6 +11,7 @@ import { PurchaseService } from "./purchase.service"; import { PurchaseDesignDto, PurchaseListDto } from "./dto/purchase.design.dto"; import { GetUser } from "src/auth/get-user.decorator"; import { User } from "src/auth/users.entity"; +import { CreditDto } from "./dto/purchase.credit.dto"; @Controller("purchase") @UseGuards(JwtAuthGuard) @@ -18,12 +19,11 @@ export class PurchaseController { constructor(private purchaseService: PurchaseService) {} @Post("/design") - @HttpCode(204) async purchaseDesign( @GetUser() user: User, @Body() purchaseDesignDto: PurchaseDesignDto, - ): Promise { - await this.purchaseService.purchaseDesign(user, purchaseDesignDto); + ): Promise { + return this.purchaseService.purchaseDesign(user, purchaseDesignDto); } @Get("/design") @@ -31,4 +31,9 @@ export class PurchaseController { async getDesignPurchaseList(@GetUser() user: User): Promise { return await this.purchaseService.getDesignPurchaseList(user); } + + @Post("/premium") + async purchasePremium(@GetUser() user: User): Promise { + return this.purchaseService.purchasePremium(user); + } } diff --git a/BE/src/purchase/purchase.repository.ts b/BE/src/purchase/purchase.repository.ts index 3fbdba6..4749de1 100644 --- a/BE/src/purchase/purchase.repository.ts +++ b/BE/src/purchase/purchase.repository.ts @@ -15,7 +15,7 @@ export class PurchaseRepository { purchase.design = design; purchase.user = user; - purchase.save(); + await purchase.save(); } async getDesignPurchaseList(user: User) { diff --git a/BE/src/purchase/purchase.service.ts b/BE/src/purchase/purchase.service.ts index 18b6658..cbdbf51 100644 --- a/BE/src/purchase/purchase.service.ts +++ b/BE/src/purchase/purchase.service.ts @@ -3,7 +3,8 @@ import { PurchaseDesignDto, PurchaseListDto } from "./dto/purchase.design.dto"; import { User } from "src/auth/users.entity"; import { PurchaseRepository } from "./purchase.repository"; import { Purchase } from "./purchase.entity"; -import { designEnum, domainEnum } from "src/utils/enum"; +import { designEnum, domainEnum, premiumStatus } from "src/utils/enum"; +import { CreditDto } from "./dto/purchase.credit.dto"; @Injectable() export class PurchaseService { @@ -12,32 +13,59 @@ export class PurchaseService { async purchaseDesign( user: User, purchaseDesignDto: PurchaseDesignDto, - ): Promise { + ): Promise { + const DESIGN_PRICE = 500; const domain = domainEnum[purchaseDesignDto.domain]; const design = designEnum[purchaseDesignDto.design]; - if (user.credit < 500) { + if (await this.isDesignAlreadyPurchased(user.userId, design, design)) { + throw new BadRequestException(`이미 구매한 디자인입니다.`); + } + + if (user.credit < DESIGN_PRICE) { throw new BadRequestException( `보유한 별가루가 부족합니다. 현재 ${user.credit} 별가루`, ); } - if ( - await Purchase.findOne({ - where: { - user: { userId: user.userId }, - domain: domain, - design: design, - }, - }) - ) { - throw new BadRequestException(`이미 구매한 디자인입니다.`); + user.credit -= DESIGN_PRICE; + await user.save(); + + await this.purchaseRepository.purchaseDesign(user, domain, design); + + return new CreditDto(user.credit); + } + + private async isDesignAlreadyPurchased( + userId: string, + domain: domainEnum, + design: designEnum, + ): Promise { + const found = await Purchase.findOne({ + where: { design, domain, user: { userId } }, + }); + + return !!found; + } + + async purchasePremium(user: User): Promise { + const PREMIUM_VERSION_PRICE = 350; + + if (user.premium === premiumStatus.TRUE) { + throw new BadRequestException(`이미 프리미엄 사용자입니다.`); + } + + if (user.credit < PREMIUM_VERSION_PRICE) { + throw new BadRequestException( + `보유한 별가루가 부족합니다. 현재 ${user.credit} 별가루`, + ); } - user.credit -= 500; - user.save(); + user.credit -= PREMIUM_VERSION_PRICE; + user.premium = premiumStatus.TRUE; + await user.save(); - await this.purchaseRepository.purchaseDesign(user, domain, design); + return new CreditDto(user.credit); } async getDesignPurchaseList(user: User): Promise { @@ -46,7 +74,7 @@ export class PurchaseService { const groundPurchase = []; const skyPurchase = []; - await purchaseList.forEach((purchase) => { + purchaseList.forEach((purchase) => { if (purchase.domain === "ground") { groundPurchase.push(purchase.design); } diff --git a/BE/test/int/auth.service.int-spec.ts b/BE/test/int/auth.service.int-spec.ts index 4fd821f..94f55f5 100644 --- a/BE/test/int/auth.service.int-spec.ts +++ b/BE/test/int/auth.service.int-spec.ts @@ -11,7 +11,7 @@ import { clearUserDb } from "src/utils/clearDb"; import { CreateUserDto } from "src/auth/dto/users.dto"; import { AuthCredentialsDto } from "src/auth/dto/auth-credential.dto"; import { Request } from "express"; -import { AccessTokenDto } from "src/auth/dto/auth-access-token.dto"; +import { LoginResponseDto } from "src/auth/dto/login-response.dto"; import { NotFoundException } from "@nestjs/common"; import { providerEnum } from "src/utils/enum"; @@ -71,7 +71,7 @@ describe("AuthService 통합 테스트", () => { const result = await authService.signIn(authCredentialsDto, request); - expect(result).toBeInstanceOf(AccessTokenDto); + expect(result).toBeInstanceOf(LoginResponseDto); }); it("존재하지 않는 아이디로 요청 시 실패", async () => { @@ -130,7 +130,7 @@ describe("AuthService 통합 테스트", () => { await authService.signIn(authCredentialsDto, request); const result = await authService.reissueAccessToken(user, request); - expect(result).toBeInstanceOf(AccessTokenDto); + expect(result).toBeInstanceOf(LoginResponseDto); }); }); @@ -147,7 +147,7 @@ describe("AuthService 통합 테스트", () => { const result = await authService.naverSignIn(user, request); - expect(result).toBeInstanceOf(AccessTokenDto); + expect(result).toBeInstanceOf(LoginResponseDto); }); }); }); diff --git a/BE/test/int/purchase.service.int-spec.ts b/BE/test/int/purchase.service.int-spec.ts index 4050f2f..7e7d2af 100644 --- a/BE/test/int/purchase.service.int-spec.ts +++ b/BE/test/int/purchase.service.int-spec.ts @@ -7,14 +7,20 @@ import { PurchaseDesignDto } from "src/purchase/dto/purchase.design.dto"; import { Purchase } from "src/purchase/purchase.entity"; import { PurchaseRepository } from "src/purchase/purchase.repository"; import { PurchaseService } from "src/purchase/purchase.service"; -import { clearUserDb } from "src/utils/clearDb"; -import { DataSource } from "typeorm"; +import { premiumStatus } from "src/utils/enum"; +import { DataSource, QueryRunner } from "typeorm"; describe("PurchaseService 통합 테스트", () => { let purchaseService: PurchaseService; - let user: User; - let usersRepository: UsersRepository; let dataSource: DataSource; + let queryRunner: QueryRunner; + + const userMockData = { + userId: "PurchaseServiceTest", + password: "PurchaseServiceTest", + nickname: "PurchaseServiceTest", + email: "test@test.com", + }; beforeAll(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ @@ -22,49 +28,125 @@ describe("PurchaseService 통합 테스트", () => { providers: [PurchaseService, PurchaseRepository, UsersRepository], }).compile(); - dataSource = moduleFixture.get(DataSource); purchaseService = moduleFixture.get(PurchaseService); + dataSource = moduleFixture.get(DataSource); + queryRunner = dataSource.createQueryRunner(); + await queryRunner.connect(); + }); - usersRepository = await moduleFixture.get(UsersRepository); - await clearUserDb(moduleFixture, usersRepository); + beforeEach(async () => { + await queryRunner.startTransaction(); + }); - user = new User(); - user.userId = "purchaseTest"; - user.password = "purchaseTest"; - user.nickname = "purchaseTest"; - user.email = "email@email.com"; - await user.save(); + afterEach(async () => { + await queryRunner.rollbackTransaction(); + jest.restoreAllMocks(); }); afterAll(async () => { - await dataSource.destroy(); + await queryRunner.release(); }); - describe("purchaseDesign 메서드", () => { + // // 별가루가 부족한 경우 테스트 + // // 이미 존재하는 디자인에 대한 경우 테스트 + // // 위 두 테스트는 테스트 DB 데이터 삭제 오류의 문제로 테스트 DB 독립화 이후 구현 예정 + + describe("purchaseDesign & getDesignPurchaseList 메서드", () => { it("메서드 정상 요청", async () => { + // 테스트 DB 독립 시 수정 필요 + const user = User.create({ + ...userMockData, + premium: premiumStatus.FALSE, + credit: 500, + }); + await queryRunner.manager.save(user); + + jest.spyOn(user, "save").mockImplementation(async () => { + return queryRunner.manager.save(user); + }); + + const purchase = new Purchase(); + jest.spyOn(Purchase, "create").mockReturnValue(purchase); + jest.spyOn(purchase, "save").mockImplementation(async () => { + return queryRunner.manager.save(purchase); + }); + jest.spyOn(Purchase, "find").mockImplementation(async (options) => { + return queryRunner.manager.find(Purchase, options); + }); + jest.spyOn(Purchase, "findOne").mockImplementation(async (options) => { + return queryRunner.manager.findOne(Purchase, options); + }); + const purchaseDesignDto = new PurchaseDesignDto(); purchaseDesignDto.domain = "GROUND"; purchaseDesignDto.design = "GROUND_GREEN"; - user.credit = 500; - await user.save(); - - await purchaseService.purchaseDesign(user, purchaseDesignDto); - }); - // 별가루가 부족한 경우 테스트 - // 이미 존재하는 디자인에 대한 경우 테스트 - // 위 두 테스트는 테스트 DB 데이터 삭제 오류의 문제로 테스트 DB 독립화 이후 구현 예정 - }); + const { credit } = await purchaseService.purchaseDesign( + user, + purchaseDesignDto, + ); + expect(credit).toBe(0); - describe("getDesignPurchaseList 메서드", () => { - it("메서드 정상 요청", async () => { - // 테스트 DB 독립 시 수정 필요 const result = await purchaseService.getDesignPurchaseList(user); - expect(result).toStrictEqual({ ground: ["#254117"], sky: [], }); }); }); + + describe("purchasePremium 메서드", () => { + it("프리미엄 구매 성공", async () => { + const user = User.create({ + ...userMockData, + premium: premiumStatus.FALSE, + credit: 500, + }); + + await queryRunner.manager.save(user); + + jest.spyOn(user, "save").mockImplementation(async () => { + return queryRunner.manager.save(user); + }); + + const result = await purchaseService.purchasePremium(user); + + expect(result.credit).toBe(150); + expect(user.premium).toBe(premiumStatus.TRUE); + }); + + it("크레딧 부족으로 구매 실패", async () => { + const user = User.create({ + ...userMockData, + premium: premiumStatus.FALSE, + credit: 300, + }); + await queryRunner.manager.save(user); + + jest.spyOn(user, "save").mockImplementation(async () => { + return queryRunner.manager.save(user); + }); + + await expect(purchaseService.purchasePremium(user)).rejects.toThrow( + `보유한 별가루가 부족합니다. 현재 ${user.credit} 별가루`, + ); + }); + + it("프리미엄 중복 구매시 실패", async () => { + const user = User.create({ + ...userMockData, + premium: premiumStatus.TRUE, + credit: 500, + }); + await queryRunner.manager.save(user); + + jest.spyOn(user, "save").mockImplementation(async () => { + return queryRunner.manager.save(user); + }); + + await expect(purchaseService.purchasePremium(user)).rejects.toThrow( + "이미 프리미엄 사용자입니다.", + ); + }); + }); });