Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/common/constants/errorMessage.enum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,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사에서 해당 결제 정보를 찾는 데 오류가 발생했습니다.',
Expand Down
4 changes: 0 additions & 4 deletions src/modules/chatRoom/chatRoom.e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -34,7 +31,6 @@ describe('ChatRoom Test (e2e)', () => {
const authService = app.get<AuthService>(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;
});
Expand Down
12 changes: 7 additions & 5 deletions src/modules/payment/domain/payment.mapper.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
<<<<<<< HEAD:src/common/domains/payment/payment.mapper.ts
=======
import { PaymentProperties } from 'src/modules/payment/types/payment.type';
>>>>>>> dev:src/modules/payment/domain/payment.mapper.ts
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,
Expand Down
141 changes: 141 additions & 0 deletions src/modules/payment/payment.e2e.spec.ts
Original file line number Diff line number Diff line change
@@ -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>(AuthService);
paymentService = app.get<PaymentService>(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);
});
});
});
3 changes: 1 addition & 2 deletions src/modules/payment/payment.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,7 @@ export default class PaymentRepository {
constructor(@InjectModel(Payment.name) private payment: Model<Payment>) {}

async findById(id: string): Promise<IPayment> {
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();
}
Expand Down
9 changes: 7 additions & 2 deletions src/modules/payment/payment.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,18 @@ import Payment from './domain/payment.domain';
import UnauthorizedError from 'src/common/errors/unauthorizedError';
import ErrorMessage from 'src/common/constants/errorMessage.enum';
import NotFoundError from 'src/common/errors/notFoundError';
<<<<<<< HEAD
import { SavePaymentDTO } from 'src/common/types/payment/payment.dto';
=======
import InternalServerError from 'src/common/errors/internalServerError';
import BadRequestError from 'src/common/errors/badRequestError';
import { SavePaymentDTO } from 'src/modules/payment/types/payment.dto';
>>>>>>> dev
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 {
Expand Down Expand Up @@ -50,15 +55,15 @@ 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 결제 정보 검증 및 동기화
const isValidPayment = actualPayment.amount.total === payment.getAmount();
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);

Expand Down
16 changes: 16 additions & 0 deletions src/providers/database/mongoose/mock/payment.mock.ts
Original file line number Diff line number Diff line change
@@ -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;
4 changes: 4 additions & 0 deletions src/providers/database/mongoose/mongoose.seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
Expand Down
9 changes: 8 additions & 1 deletion src/providers/database/mongoose/payment.schema.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
<<<<<<< HEAD
import mongoose, { HydratedDocument, Types } from 'mongoose';
import { PaymentStatusEnum } from 'src/common/types/payment/payment.type';
=======
import { HydratedDocument, Types } from 'mongoose';
import { PaymentStatusEnum } from 'src/modules/payment/types/payment.type';
>>>>>>> dev

@Schema({ timestamps: true })
export class Payment {
Expand Down Expand Up @@ -29,7 +34,9 @@ export class Payment {
status: PaymentStatusEnum;
}

export type PaymentDocument = HydratedDocument<Payment>;
export type PaymentDocument = HydratedDocument<Payment> & { createdAt?: Date; updatedAt?: Date };

const PaymentSchema = SchemaFactory.createForClass(Payment);
export const PaymentModel = mongoose.model('Payment', PaymentSchema);

export default PaymentSchema;
3 changes: 1 addition & 2 deletions src/providers/queue/points.processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}