From a129a0a8286c3396a5d9d6a5948f0e2d00585189 Mon Sep 17 00:00:00 2001 From: Maybeiley <2784519@gmail.com> Date: Thu, 20 Feb 2025 21:11:30 +0900 Subject: [PATCH] =?UTF-8?q?Feat:=20#216=20Payment=20=EB=AA=A8=EB=93=88=20e?= =?UTF-8?q?2e=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/common/constants/errorMessage.enum.ts | 2 +- src/common/domains/payment/payment.mapper.ts | 10 +- src/modules/chatRoom/chatRoom.e2e.spec.ts | 4 - src/modules/payment/payment.e2e.spec.ts | 141 ++++++++++++++++++ src/modules/payment/payment.repository.ts | 3 +- src/modules/payment/payment.service.ts | 7 +- .../database/mongoose/mock/payment.mock.ts | 16 ++ .../database/mongoose/mongoose.seed.ts | 4 + .../database/mongoose/payment.schema.ts | 6 +- src/providers/queue/points.processor.ts | 3 +- 10 files changed, 175 insertions(+), 21 deletions(-) create mode 100644 src/modules/payment/payment.e2e.spec.ts create mode 100644 src/providers/database/mongoose/mock/payment.mock.ts diff --git a/src/common/constants/errorMessage.enum.ts b/src/common/constants/errorMessage.enum.ts index 0c7c891..de919c6 100644 --- a/src/common/constants/errorMessage.enum.ts +++ b/src/common/constants/errorMessage.enum.ts @@ -55,7 +55,7 @@ export enum ErrorMessage { PAYMENT_BAD_REQUEST = '결제 정보가 잘못되어 결제를 완료할 수 없습니다.', PAYMENT_AMOUNT_ERROR = '실제 결제한 금액이 결제 요청한 금액과 맞지 않습니다.', - PAYMENT_STATUS_BAD_REQUEST = 'PG사에서 결제가 아직 진행 중이거나 문제가 발생했습니다.', + PAYMENT_STATUS_CONFLICT = 'PG사에서 결제가 아직 진행 중이거나 문제가 발생했습니다.', PAYMENT_NOT_FOUND = '해당 결제 정보를 찾을 수 없습니다', PAYMENT_CANCEL_SERVER_ERROR = 'PG사에서 결제를 취소하지 못했습니다.', PAYMENT_SERVER_ERROR = 'PG사에서 해당 결제 정보를 찾는 데 오류가 발생했습니다.', diff --git a/src/common/domains/payment/payment.mapper.ts b/src/common/domains/payment/payment.mapper.ts index 309ba15..236f7b7 100644 --- a/src/common/domains/payment/payment.mapper.ts +++ b/src/common/domains/payment/payment.mapper.ts @@ -1,16 +1,14 @@ -import { PaymentProperties } from 'src/common/types/payment/payment.type'; import Payment from './payment.domain'; +import { PaymentDocument } from 'src/providers/database/mongoose/payment.schema'; export default class PaymentMapper { - constructor(private readonly payment: PaymentProperties) {} + constructor(private readonly payment: PaymentDocument) {} toDomain() { - if (!this.payment) { - return null; - } + if (!this.payment) return null; return new Payment({ - id: this.payment.id, + id: this.payment._id.toString(), paymentId: this.payment.paymentId, userId: this.payment.userId, orderName: this.payment.orderName, diff --git a/src/modules/chatRoom/chatRoom.e2e.spec.ts b/src/modules/chatRoom/chatRoom.e2e.spec.ts index d985949..205ec17 100644 --- a/src/modules/chatRoom/chatRoom.e2e.spec.ts +++ b/src/modules/chatRoom/chatRoom.e2e.spec.ts @@ -10,12 +10,9 @@ import { mongooseSeed } from 'src/providers/database/mongoose/mongoose.seed'; describe('ChatRoom Test (e2e)', () => { let app: INestApplication; - const makerId = process.env.MAKER1_ID; - const dreamerId1 = process.env.DREAMER1_ID; const dreamerId2 = process.env.DREAMER2_ID; - let makerToken: string; let dreamerToken1: string; let dreamerToken2: string; let chatRoomId: string; @@ -34,7 +31,6 @@ describe('ChatRoom Test (e2e)', () => { const authService = app.get(AuthService); await mongooseSeed(); - makerToken = authService.createTokens({ userId: makerId, role: RoleValues.MAKER }).accessToken; dreamerToken1 = authService.createTokens({ userId: dreamerId1, role: RoleValues.DREAMER }).accessToken; dreamerToken2 = authService.createTokens({ userId: dreamerId2, role: RoleValues.DREAMER }).accessToken; }); diff --git a/src/modules/payment/payment.e2e.spec.ts b/src/modules/payment/payment.e2e.spec.ts new file mode 100644 index 0000000..41e606e --- /dev/null +++ b/src/modules/payment/payment.e2e.spec.ts @@ -0,0 +1,141 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { HttpStatus, INestApplication, ValidationPipe } from '@nestjs/common'; +import request from 'supertest'; +import AppModule from 'src/app.module'; +import GlobalExceptionFilter from 'src/common/filters/globalExceptionFilter'; +import { RoleValues } from 'src/common/constants/role.type'; +import AuthService from '../auth/auth.service'; +import { mongooseSeed } from 'src/providers/database/mongoose/mongoose.seed'; +import PaymentService from './payment.service'; +import { PaymentStatusEnum } from 'src/common/types/payment/payment.type'; + +describe('Payment Test (e2e)', () => { + let app: INestApplication; + let paymentService: PaymentService; + + const dreamerId1 = process.env.DREAMER1_ID; + const dreamerId2 = process.env.DREAMER2_ID; + const paymentId = process.env.PAYMENT_ID; + + let dreamerToken1: string; + let dreamerToken2: string; + + jest.setTimeout(100000); + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule] + }).compile(); + + app = moduleFixture.createNestApplication(); + app.useGlobalPipes(new ValidationPipe({ transform: true })).useGlobalFilters(new GlobalExceptionFilter()); + await app.init(); + + const authService = app.get(AuthService); + paymentService = app.get(PaymentService); + await mongooseSeed(); + + dreamerToken1 = authService.createTokens({ userId: dreamerId1, role: RoleValues.DREAMER }).accessToken; + dreamerToken2 = authService.createTokens({ userId: dreamerId2, role: RoleValues.DREAMER }).accessToken; + }); + + afterAll(async () => { + await new Promise((resolve) => setTimeout(resolve, 500)); + await app.close(); + }); + + describe('[GET /payments/{paymentId}]', () => { + it('결제 상태 조회', async () => { + const { body, statusCode } = await request(app.getHttpServer()) + .get(`/payments/${paymentId}`) + .set('authorization', `Bearer ${dreamerToken1}`); + + expect(statusCode).toBe(HttpStatus.OK); + expect(body).toBeDefined(); + }); + + it('결제 상태 조회, 본인의 결제가 아닐 경우 401에러', async () => { + const { statusCode } = await request(app.getHttpServer()) + .get(`/payments/${paymentId}`) + .set('authorization', `Bearer ${dreamerToken2}`); + + expect(statusCode).toBe(HttpStatus.UNAUTHORIZED); + }); + }); + + describe('[POST /payments]', () => { + const dto = { + paymentId: '1', + orderName: 'test', + amount: 10, + method: 'CARD', + currency: 'KRW' + }; + + it('결제 데이터 저장', async () => { + const { body, statusCode } = await request(app.getHttpServer()) + .post(`/payments`) + .set('authorization', `Bearer ${dreamerToken1}`) + .send(dto); + + expect(statusCode).toBe(HttpStatus.CREATED); + expect(body).toBeDefined(); + }); + }); + + describe('[POST /payments/complete]', () => { + const dto = { paymentId }; + const wrongDto = { paymentId: '67918968c27c4c4cfe5c47fe' }; + + it('결제 완료', async () => { + jest + .spyOn(paymentService['pg'], 'getPayment') + .mockResolvedValue({ status: PaymentStatusEnum.PAID, amount: { total: 1000 } } as any); + + const { body, statusCode } = await request(app.getHttpServer()) + .post(`/payments/complete`) + .set('authorization', `Bearer ${dreamerToken1}`) + .send(dto); + + expect(statusCode).toBe(HttpStatus.CREATED); + expect(body).toBeDefined(); + }); + + it('결제 완료, 결제 데이터가 없는 경우 404에러', async () => { + const { statusCode } = await request(app.getHttpServer()) + .post(`/payments/complete`) + .set('authorization', `Bearer ${dreamerToken1}`) + .send(wrongDto); + + expect(statusCode).toBe(HttpStatus.NOT_FOUND); + }); + + it('결제 완료, PG사 결제 상태가 PAID가 아닌 경우 409에러', async () => { + jest + .spyOn(paymentService['pg'], 'getPayment') + .mockResolvedValue({ status: PaymentStatusEnum.PENDING, amount: { total: 1000 } } as any); + + const { statusCode } = await request(app.getHttpServer()) + .post(`/payments/complete`) + .set('authorization', `Bearer ${dreamerToken1}`) + .send(dto); + + expect(statusCode).toBe(HttpStatus.CONFLICT); + }); + + it('결제 완료, 결제 금액이 불일치할 경우 409에러', async () => { + jest.spyOn(paymentService['pg'], 'cancelPayment').mockResolvedValue(undefined); + + jest + .spyOn(paymentService['pg'], 'getPayment') + .mockResolvedValue({ status: PaymentStatusEnum.PAID, amount: { total: 2000 } } as any); + + const { statusCode } = await request(app.getHttpServer()) + .post(`/payments/complete`) + .set('authorization', `Bearer ${dreamerToken1}`) + .send(dto); + + expect(statusCode).toBe(HttpStatus.CONFLICT); + }); + }); +}); diff --git a/src/modules/payment/payment.repository.ts b/src/modules/payment/payment.repository.ts index 91c2fb5..555efab 100644 --- a/src/modules/payment/payment.repository.ts +++ b/src/modules/payment/payment.repository.ts @@ -11,8 +11,7 @@ export default class PaymentRepository { constructor(@InjectModel(Payment.name) private payment: Model) {} async findById(id: string): Promise { - const data = await this.payment.findById(id).exec(); - const payment = { ...data.toObject(), id: data._id.toString() }; + const payment = await this.payment.findById(id).exec(); return new PaymentMapper(payment).toDomain(); } diff --git a/src/modules/payment/payment.service.ts b/src/modules/payment/payment.service.ts index a14a5d0..f5ba419 100644 --- a/src/modules/payment/payment.service.ts +++ b/src/modules/payment/payment.service.ts @@ -5,13 +5,12 @@ import Payment from 'src/common/domains/payment/payment.domain'; import UnauthorizedError from 'src/common/errors/unauthorizedError'; import ErrorMessage from 'src/common/constants/errorMessage.enum'; import NotFoundError from 'src/common/errors/notFoundError'; -import InternalServerError from 'src/common/errors/internalServerError'; -import BadRequestError from 'src/common/errors/badRequestError'; import { SavePaymentDTO } from 'src/common/types/payment/payment.dto'; import { PGService } from 'src/providers/pg/pg.service'; import { Queue } from 'bullmq'; import { InjectQueue } from '@nestjs/bullmq'; import { PointEventEnum } from 'src/common/constants/pointEvent.type'; +import ConflictError from 'src/common/errors/conflictError'; @Injectable() export default class PaymentService { @@ -50,7 +49,7 @@ export default class PaymentService { const actualPayment = await this.pg.getPayment(paymentId); if (actualPayment.status !== PaymentStatusEnum.PAID) { - throw new BadRequestError(ErrorMessage.PAYMENT_STATUS_BAD_REQUEST); + throw new ConflictError(ErrorMessage.PAYMENT_STATUS_CONFLICT); } // 실결제 정보와 DB 결제 정보 검증 및 동기화 @@ -58,7 +57,7 @@ export default class PaymentService { if (!isValidPayment) { const reason = ErrorMessage.PAYMENT_AMOUNT_ERROR; await this.pg.cancelPayment(paymentId, reason); - throw new InternalServerError(ErrorMessage.PAYMENT_AMOUNT_ERROR); + throw new ConflictError(reason); } payment.update(PaymentStatusEnum.PAID); diff --git a/src/providers/database/mongoose/mock/payment.mock.ts b/src/providers/database/mongoose/mock/payment.mock.ts new file mode 100644 index 0000000..7791487 --- /dev/null +++ b/src/providers/database/mongoose/mock/payment.mock.ts @@ -0,0 +1,16 @@ +const TEST_PAYMENTS = [ + { + _id: '67b6d2c35a22e6739da2ada4', + paymentId: '93f2d138644b7943', + userId: '66885a3c-50f4-427b-8a92-3702c6976fb0', + orderName: 'test', + amount: 1000, + method: 'CARD', + currency: 'KRW', + status: 'PENDING' + } +]; +const PRODUCTION_PAYMENTS = [{}]; + +const PAYMENTS = process.env.ENV === 'test' ? TEST_PAYMENTS : PRODUCTION_PAYMENTS; +export default PAYMENTS; diff --git a/src/providers/database/mongoose/mongoose.seed.ts b/src/providers/database/mongoose/mongoose.seed.ts index dec6e05..95ba0c7 100644 --- a/src/providers/database/mongoose/mongoose.seed.ts +++ b/src/providers/database/mongoose/mongoose.seed.ts @@ -6,6 +6,8 @@ import CHAT_ROOMS from './mock/chatRoom.mock'; import { NotificationModel } from './notification.schema'; import NOTIFICATIONS from './mock/notification.mock'; import CHATS from './mock/chat.mock'; +import { PaymentModel } from './payment.schema'; +import PAYMENTS from './mock/payment.mock'; async function connectDB() { await mongoose.connect(process.env.MONGO_URI); @@ -19,10 +21,12 @@ export async function mongooseSeed() { await ChatModel.deleteMany(); await ChatRoomModel.deleteMany(); await NotificationModel.deleteMany(); + await PaymentModel.deleteMany(); await ChatRoomModel.insertMany(CHAT_ROOMS); await ChatModel.insertMany(CHATS); await NotificationModel.insertMany(NOTIFICATIONS); + await PaymentModel.insertMany(PAYMENTS); console.log('🌱 Mongoose Seeding completed!'); isSeeded = true; diff --git a/src/providers/database/mongoose/payment.schema.ts b/src/providers/database/mongoose/payment.schema.ts index 62089ba..8e19ff9 100644 --- a/src/providers/database/mongoose/payment.schema.ts +++ b/src/providers/database/mongoose/payment.schema.ts @@ -1,5 +1,5 @@ import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; -import { HydratedDocument, Types } from 'mongoose'; +import mongoose, { HydratedDocument, Types } from 'mongoose'; import { PaymentStatusEnum } from 'src/common/types/payment/payment.type'; @Schema({ timestamps: true }) @@ -29,7 +29,9 @@ export class Payment { status: PaymentStatusEnum; } -export type PaymentDocument = HydratedDocument; +export type PaymentDocument = HydratedDocument & { createdAt?: Date; updatedAt?: Date }; const PaymentSchema = SchemaFactory.createForClass(Payment); +export const PaymentModel = mongoose.model('Payment', PaymentSchema); + export default PaymentSchema; diff --git a/src/providers/queue/points.processor.ts b/src/providers/queue/points.processor.ts index 5c3c9a2..1fb3691 100644 --- a/src/providers/queue/points.processor.ts +++ b/src/providers/queue/points.processor.ts @@ -37,12 +37,11 @@ export class PointsProcessor extends WorkerHost { attempt++; } - console.error(ErrorMessage.QUEUE_MAX_RETRY_EXCEEDED); - // 자정에 다시 시도하는 scheduler에서 실패한 작업만 실행하도록 큐에 추가 if (jobs.length > 0) { const failedJobKeys = jobs.map((job) => job.key); await job.updateData({ ...job.data, retry: failedJobKeys }); + console.error(ErrorMessage.QUEUE_MAX_RETRY_EXCEEDED); } } }