From 3efa3a12d1855d2eae6dabecff7ef76fca7826d9 Mon Sep 17 00:00:00 2001 From: takch02 Date: Thu, 14 May 2026 10:23:50 +0900 Subject: [PATCH 1/6] =?UTF-8?q?refactor:=20EscrowPaymentRepository,=20User?= =?UTF-8?q?Facade=20=EA=B3=84=EC=B8=B5=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?=EB=AA=A8=EB=93=88=20=EB=93=B1=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../escrow-payments/escrow-payments.module.ts | 8 +- .../repositories/escrow-payment.repository.ts | 192 ++++++++++++++++++ .../repositories/user.facade.ts | 41 ++++ 3 files changed, 239 insertions(+), 2 deletions(-) create mode 100644 src/modules/escrow-payments/repositories/escrow-payment.repository.ts create mode 100644 src/modules/escrow-payments/repositories/user.facade.ts diff --git a/src/modules/escrow-payments/escrow-payments.module.ts b/src/modules/escrow-payments/escrow-payments.module.ts index ee4ce37..a614a0e 100644 --- a/src/modules/escrow-payments/escrow-payments.module.ts +++ b/src/modules/escrow-payments/escrow-payments.module.ts @@ -8,26 +8,30 @@ import { EscrowPayment, EscrowPaymentSchema, } from "./schemas/escrow-payment.schema"; +import { User, UserSchema } from "../users/schemas/user.schema"; import { XrplModule } from "../xrpl/xrpl.module"; -import { UsersModule } from "../users/users.module"; import { ESCROW_CREATE_QUEUE } from "./escrow-create.constants"; import { EscrowCreateProcessor } from "./escrow-create.processor"; import { EscrowCancelScheduler } from "./escrow-cancel.scheduler"; import { EscrowSubmitRecoveryScheduler } from "./escrow-submit-recovery.scheduler"; import { OutboxModule } from "../outbox/outbox.module"; +import { EscrowPaymentRepository } from "./repositories/escrow-payment.repository"; +import { UserFacade } from "./repositories/user.facade"; @Module({ imports: [ MongooseModule.forFeature([ { name: EscrowPayment.name, schema: EscrowPaymentSchema }, + { name: User.name, schema: UserSchema }, ]), BullModule.registerQueue({ name: ESCROW_CREATE_QUEUE }), XrplModule, - UsersModule, OutboxModule, ], controllers: [EscrowPaymentsController], providers: [ + EscrowPaymentRepository, + UserFacade, EscrowPaymentsCrudService, EscrowPaymentsService, EscrowCreateProcessor, diff --git a/src/modules/escrow-payments/repositories/escrow-payment.repository.ts b/src/modules/escrow-payments/repositories/escrow-payment.repository.ts new file mode 100644 index 0000000..6fe379b --- /dev/null +++ b/src/modules/escrow-payments/repositories/escrow-payment.repository.ts @@ -0,0 +1,192 @@ +import { Injectable } from "@nestjs/common"; +import { InjectModel } from "@nestjs/mongoose"; +import { ClientSession, Model, Types } from "mongoose"; +import { + EscrowPayment, + EscrowPaymentDocument, +} from "../schemas/escrow-payment.schema"; + +@Injectable() +export class EscrowPaymentRepository { + constructor( + @InjectModel(EscrowPayment.name) + private readonly model: Model, + ) {} + + findById(id: string): Promise { + return this.model.findById(id).exec(); + } + + findByIdWithFulfillment(id: string): Promise { + return this.model.findById(id).select("+escrows.fulfillment").exec(); + } + + findByIdLean(id: string): Promise { + return this.model.findById(id).lean().exec(); + } + + save(doc: EscrowPaymentDocument): Promise { + return doc.save(); + } + + create(data: Record): Promise { + return new this.model(data).save(); + } + + findMany( + filter: Record, + skip: number, + limit: number, + ): Promise { + return this.model + .find(filter) + .sort({ createdAt: -1 }) + .skip(skip) + .limit(limit) + .lean() + .exec(); + } + + countDocuments(filter: Record): Promise { + return this.model.countDocuments(filter).exec(); + } + + findCancelling(): Promise { + return this.model.find({ "escrows.status": "CANCELLING" }).exec(); + } + + findStuckSubmitting(cutoff: Date): Promise { + return this.model + .find({ + escrows: { + $elemMatch: { status: "SUBMITTING", submittingAt: { $lt: cutoff } }, + }, + }) + .exec(); + } + + startSession() { + return this.model.db.startSession(); + } + + // APPROVED → PROCESSING (트랜잭션 세션 사용) + markProcessing( + paymentId: string, + session: ClientSession, + ): Promise { + return this.model + .findOneAndUpdate( + { _id: paymentId, status: "APPROVED" }, + { $set: { status: "PROCESSING" } }, + { session, new: true }, + ) + .exec(); + } + + // payment → ACTIVE + async markActive(paymentId: string): Promise { + await this.model + .findByIdAndUpdate(paymentId, { $set: { status: "ACTIVE" } }) + .exec(); + } + + // PENDING_ESCROW → SUBMITTING + condition/fulfillment 원자적 저장 + preflight( + paymentId: string, + escrowId: string, + condition: string, + fulfillment: string, + ): Promise { + return this.model + .findOneAndUpdate( + { + _id: paymentId, + escrows: { + $elemMatch: { + _id: new Types.ObjectId(escrowId), + status: "PENDING_ESCROW", + }, + }, + }, + { + $set: { + "escrows.$.status": "SUBMITTING", + "escrows.$.condition": condition, + "escrows.$.fulfillment": fulfillment, + "escrows.$.submittingAt": new Date(), + }, + }, + ) + .exec(); + } + + // SUBMITTING → PENDING_ESCROW (XRPL 제출 실패 즉시 복구) + async revertSubmitting(paymentId: string, escrowId: string): Promise { + await this.model + .findOneAndUpdate( + { + _id: paymentId, + escrows: { + $elemMatch: { + _id: new Types.ObjectId(escrowId), + status: "SUBMITTING", + }, + }, + }, + { $set: { "escrows.$.status": "PENDING_ESCROW" } }, + ) + .exec(); + } + + // SUBMITTING → ESCROWED + markEscrowed( + paymentId: string, + escrowId: string, + sequence: number, + txHash: string, + ): Promise { + return this.model + .findOneAndUpdate( + { + _id: paymentId, + escrows: { + $elemMatch: { + _id: new Types.ObjectId(escrowId), + status: "SUBMITTING", + }, + }, + }, + { + $set: { + "escrows.$.status": "ESCROWED", + "escrows.$.xrplSequence": sequence, + "escrows.$.txHashCreate": txHash, + "escrows.$.escrowedAt": new Date(), + }, + }, + { new: true }, + ) + .exec(); + } + + // SUBMITTING → CANCELLED (recovery: XRPL에 에스크로 없음 확정) + async cancelSubmittingEscrow( + paymentId: string, + escrowId: string, + ): Promise { + await this.model + .findOneAndUpdate( + { + _id: paymentId, + escrows: { + $elemMatch: { + _id: new Types.ObjectId(escrowId), + status: "SUBMITTING", + }, + }, + }, + { $set: { "escrows.$.status": "CANCELLED" } }, + ) + .exec(); + } +} diff --git a/src/modules/escrow-payments/repositories/user.facade.ts b/src/modules/escrow-payments/repositories/user.facade.ts new file mode 100644 index 0000000..171124a --- /dev/null +++ b/src/modules/escrow-payments/repositories/user.facade.ts @@ -0,0 +1,41 @@ +import { Injectable } from "@nestjs/common"; +import { InjectModel } from "@nestjs/mongoose"; +import { Model } from "mongoose"; +import { User, UserDocument } from "../../users/schemas/user.schema"; + +@Injectable() +export class UserFacade { + constructor( + @InjectModel(User.name) + private readonly model: Model, + ) {} + + findById(id: string | object): Promise { + return this.model.findById(id).exec(); + } + + findByIdLean(id: string | object): Promise { + return this.model.findById(id).lean().exec(); + } + + findByIdWithSeed(id: string | object): Promise { + return this.model.findById(id).select("+wallet.seed").exec(); + } + + findByWalletAddressAndType( + address: string, + type: "buyer" | "seller", + ): Promise { + return this.model + .findOne( + { "wallet.address": address, type }, + { _id: 1, name: 1, wallet: 1 }, + ) + .lean() + .exec(); + } + + findByWalletAddress(address: string): Promise { + return this.model.findOne({ "wallet.address": address }).lean().exec(); + } +} From 49d3f49cfbc1eb3628685f9ce3b9e7a757ed969e Mon Sep 17 00:00:00 2001 From: takch02 Date: Thu, 14 May 2026 10:24:10 +0900 Subject: [PATCH 2/6] =?UTF-8?q?refactor:=20=EC=84=9C=EB=B9=84=EC=8A=A4?= =?UTF-8?q?=C2=B7=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC=EA=B0=80=20Mongoose?= =?UTF-8?q?=20=EB=AA=A8=EB=8D=B8=20=EC=A7=81=EC=A0=91=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=20=E2=86=92=20=EB=A0=88=ED=8F=AC=EC=A7=80=ED=86=A0=EB=A6=AC?= =?UTF-8?q?=C2=B7=ED=8C=8C=EC=82=AC=EB=93=9C=20=EC=82=AC=EC=9A=A9=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../escrow-cancel.scheduler.ts | 25 ++----- .../escrow-create.processor.ts | 73 ++++++------------- .../escrow-payments-crud.service.ts | 73 +++++++------------ .../escrow-payments.service.ts | 67 ++++++++--------- .../escrow-submit-recovery.scheduler.ts | 61 ++++------------ 5 files changed, 97 insertions(+), 202 deletions(-) diff --git a/src/modules/escrow-payments/escrow-cancel.scheduler.ts b/src/modules/escrow-payments/escrow-cancel.scheduler.ts index 847d22a..5be8a96 100644 --- a/src/modules/escrow-payments/escrow-cancel.scheduler.ts +++ b/src/modules/escrow-payments/escrow-cancel.scheduler.ts @@ -1,23 +1,16 @@ import { Injectable, Logger } from "@nestjs/common"; import { Cron, CronExpression } from "@nestjs/schedule"; -import { InjectModel } from "@nestjs/mongoose"; -import { Model } from "mongoose"; -import { - EscrowPayment, - EscrowPaymentDocument, -} from "./schemas/escrow-payment.schema"; -import { User, UserDocument } from "../users/schemas/user.schema"; import { XrplService, XrplWallet } from "../xrpl/xrpl.service"; +import { EscrowPaymentRepository } from "./repositories/escrow-payment.repository"; +import { UserFacade } from "./repositories/user.facade"; @Injectable() export class EscrowCancelScheduler { private readonly logger = new Logger(EscrowCancelScheduler.name); constructor( - @InjectModel(EscrowPayment.name) - private readonly escrowPaymentModel: Model, - @InjectModel(User.name) - private readonly userModel: Model, + private readonly escrowPaymentRepo: EscrowPaymentRepository, + private readonly userFacade: UserFacade, private readonly xrplService: XrplService, ) {} @@ -27,9 +20,7 @@ export class EscrowCancelScheduler { */ @Cron(CronExpression.EVERY_MINUTE) async processCancellingEscrows(): Promise { - const payments = await this.escrowPaymentModel.find({ - "escrows.status": "CANCELLING", - }); + const payments = await this.escrowPaymentRepo.findCancelling(); if (payments.length === 0) return; @@ -38,9 +29,7 @@ export class EscrowCancelScheduler { ); for (const payment of payments) { - const buyerUser = await this.userModel - .findById(payment.buyerId) - .select("+wallet.seed"); + const buyerUser = await this.userFacade.findByIdWithSeed(payment.buyerId); if (!buyerUser?.wallet?.seed) { this.logger.warn( @@ -67,7 +56,7 @@ export class EscrowCancelScheduler { ); escrow.status = "CANCELLED"; - await payment.save(); + await this.escrowPaymentRepo.save(payment); this.logger.log( `EscrowCancel success: seq=${escrow.xrplSequence} payment=${payment._id.toString()}`, ); diff --git a/src/modules/escrow-payments/escrow-create.processor.ts b/src/modules/escrow-payments/escrow-create.processor.ts index 2cd51dd..47a9bcb 100644 --- a/src/modules/escrow-payments/escrow-create.processor.ts +++ b/src/modules/escrow-payments/escrow-create.processor.ts @@ -1,18 +1,12 @@ import { Processor, Process, OnQueueFailed } from "@nestjs/bull"; import { Logger } from "@nestjs/common"; -import { InjectModel } from "@nestjs/mongoose"; -import { Model } from "mongoose"; import type { Job } from "bull"; import { EscrowPaymentsService } from "./escrow-payments.service"; import { ESCROW_CREATE_QUEUE, EscrowCreateJobData, } from "./escrow-create.constants"; -import { - EscrowPayment, - EscrowPaymentDocument, -} from "./schemas/escrow-payment.schema"; -import { User, UserDocument } from "../users/schemas/user.schema"; +import { EscrowPaymentDocument } from "./schemas/escrow-payment.schema"; import { XrplService, XrplWallet } from "../xrpl/xrpl.service"; import { EscrowItemNotFoundException, @@ -21,6 +15,8 @@ import { PaymentNotActiveException, WalletNotAvailableException, } from "../../common/exceptions"; +import { EscrowPaymentRepository } from "./repositories/escrow-payment.repository"; +import { UserFacade } from "./repositories/user.facade"; // 재시도해도 해결되지 않는 XRPL 오류 코드 const NON_RETRYABLE_CODES = [ @@ -46,10 +42,8 @@ export class EscrowCreateProcessor { private readonly logger = new Logger(EscrowCreateProcessor.name); constructor( - @InjectModel(EscrowPayment.name) - private readonly escrowPaymentModel: Model, - @InjectModel(User.name) - private readonly userModel: Model, + private readonly escrowPaymentRepo: EscrowPaymentRepository, + private readonly userFacade: UserFacade, private readonly xrplService: XrplService, private readonly escrowPaymentsService: EscrowPaymentsService, ) {} @@ -132,7 +126,7 @@ export class EscrowCreateProcessor { paymentId: string, escrowId: string, ): Promise { - const payment = await this.escrowPaymentModel.findById(paymentId); + const payment = await this.escrowPaymentRepo.findById(paymentId); if (!payment) throw new EscrowPaymentNotFoundException(); if (payment.status !== "PROCESSING") { @@ -148,14 +142,12 @@ export class EscrowCreateProcessor { ); } - const buyerUser = await this.userModel - .findById(payment.buyerId) - .select("+wallet.seed"); + const buyerUser = await this.userFacade.findByIdWithSeed(payment.buyerId); if (!buyerUser?.wallet?.seed) { throw new WalletNotAvailableException("Buyer"); } - const sellerUser = await this.userModel.findById(payment.sellerId); + const sellerUser = await this.userFacade.findById(payment.sellerId); if (!sellerUser?.wallet?.address) { throw new WalletNotAvailableException("Seller"); } @@ -174,19 +166,11 @@ export class EscrowCreateProcessor { // Pre-flight: PENDING_ESCROW → SUBMITTING + condition/fulfillment 원자적 저장 // XRPL 제출 전에 상태를 선점해 재시도 시 중복 EscrowCreate 방지 - const preFlighted = await this.escrowPaymentModel.findOneAndUpdate( - { - _id: paymentId, - escrows: { $elemMatch: { _id: escrow._id, status: "PENDING_ESCROW" } }, - }, - { - $set: { - "escrows.$.status": "SUBMITTING", - "escrows.$.condition": condition, - "escrows.$.fulfillment": encryptedFulfillment, - "escrows.$.submittingAt": new Date(), - }, - }, + const preFlighted = await this.escrowPaymentRepo.preflight( + paymentId, + escrowId, + condition, + encryptedFulfillment, ); if (!preFlighted) { throw new InvalidEscrowItemStatusException( @@ -211,13 +195,7 @@ export class EscrowCreateProcessor { )); } catch (err) { // Case A: XRPL 제출 실패 — 에스크로 미생성 확정, SUBMITTING → PENDING_ESCROW 즉시 복구 - await this.escrowPaymentModel.findOneAndUpdate( - { - _id: paymentId, - escrows: { $elemMatch: { _id: escrow._id, status: "SUBMITTING" } }, - }, - { $set: { "escrows.$.status": "PENDING_ESCROW" } }, - ); + await this.escrowPaymentRepo.revertSubmitting(paymentId, escrowId); throw err; } @@ -225,28 +203,23 @@ export class EscrowCreateProcessor { `Submitting EscrowCreate: buyer=${buyerUser.wallet.address} ` + `seller=${sellerUser.wallet.address} amount=${escrow.amountXrp} ${currency}`, ); + // Case B: post-flight DB 저장 실패 시 최대 3회 재시도 (메모리의 txHash/sequence 활용) // 재시도 소진 시 SUBMITTING 유지 → 스케줄러가 XRPL 조회로 복구 let result: EscrowPaymentDocument | null = null; for (let attempt = 1; attempt <= 3; attempt++) { try { - result = await this.escrowPaymentModel.findOneAndUpdate( - { _id: paymentId, "escrows._id": escrow._id }, - { - $set: { - "escrows.$.status": "ESCROWED", - "escrows.$.xrplSequence": sequence, - "escrows.$.txHashCreate": txHash, - "escrows.$.escrowedAt": new Date(), - }, - }, - { new: true }, + result = await this.escrowPaymentRepo.markEscrowed( + paymentId, + escrowId, + sequence, + txHash, ); if (result) break; // null 반환 = 다른 경로가 상태를 변경한 경우 (동시성 충돌) if (attempt === 3) { this.logger.error( - `Post-flight DB update missed for paymentId=${paymentId} escrowId=${escrow._id.toString()}; recovery scheduler must reconcile via condition lookup`, + `Post-flight DB update missed for paymentId=${paymentId} escrowId=${escrowId}; recovery scheduler must reconcile via condition lookup`, ); throw new Error("Post-flight DB update did not match"); } @@ -264,9 +237,7 @@ export class EscrowCreateProcessor { e.status === "CANCELLED", ); if (allEscrowed) { - await this.escrowPaymentModel.findByIdAndUpdate(paymentId, { - $set: { status: "ACTIVE" }, - }); + await this.escrowPaymentRepo.markActive(paymentId); result!.status = "ACTIVE"; } diff --git a/src/modules/escrow-payments/escrow-payments-crud.service.ts b/src/modules/escrow-payments/escrow-payments-crud.service.ts index 5bc19fe..6eb9fa0 100644 --- a/src/modules/escrow-payments/escrow-payments-crud.service.ts +++ b/src/modules/escrow-payments/escrow-payments-crud.service.ts @@ -1,6 +1,5 @@ import { Injectable } from "@nestjs/common"; -import { InjectModel } from "@nestjs/mongoose"; -import { Model, Types } from "mongoose"; +import { Types } from "mongoose"; import { BuyerWalletNotFoundException, EscrowPaymentNotFoundException, @@ -8,26 +7,21 @@ import { UnauthorizedPaymentActionException, WalletUserNotFoundException, } from "../../common/exceptions"; -import { - EscrowPayment, - EscrowPaymentDocument, -} from "./schemas/escrow-payment.schema"; -import { User, UserDocument } from "../users/schemas/user.schema"; +import { UserDocument } from "../users/schemas/user.schema"; import { CreateEscrowPaymentDto } from "./dto/create-escrow-payment.dto"; import { QueryEscrowPaymentDto } from "./dto/query-escrow-payment.dto"; - import { EscrowPaymentListResponse, EscrowPaymentResponse, } from "./dto/escrow-payment-response.dto"; +import { EscrowPaymentRepository } from "./repositories/escrow-payment.repository"; +import { UserFacade } from "./repositories/user.facade"; @Injectable() export class EscrowPaymentsCrudService { constructor( - @InjectModel(EscrowPayment.name) - private readonly escrowPaymentModel: Model, - @InjectModel(User.name) - private readonly userModel: Model, + private readonly escrowPaymentRepo: EscrowPaymentRepository, + private readonly userFacade: UserFacade, ) {} /** @@ -37,9 +31,7 @@ export class EscrowPaymentsCrudService { dto: CreateEscrowPaymentDto, userId: string, ): Promise { - const creator = await this.userModel - .findById(userId, { type: 1, name: 1, wallet: 1 }) - .lean(); + const creator = await this.userFacade.findByIdLean(userId); if (!creator) throw new UnauthorizedPaymentActionException(); const now = new Date(); @@ -55,34 +47,30 @@ export class EscrowPaymentsCrudService { let sellerApprovedAt: Date | undefined; if (creator.type === "buyer") { - const seller = await this.userModel - .findOne( - { "wallet.address": dto.counterpartyWalletAddress, type: "seller" }, - { _id: 1, name: 1, wallet: 1 }, - ) - .lean(); + const seller = await this.userFacade.findByWalletAddressAndType( + dto.counterpartyWalletAddress, + "seller", + ); if (!seller) throw new SellerWalletNotFoundException(dto.counterpartyWalletAddress); buyerId = userId; buyerName = creator.name; buyerWalletAddress = creator.wallet?.address; - sellerId = seller._id.toString(); + sellerId = (seller as any)._id.toString(); sellerName = seller.name; sellerWalletAddress = seller.wallet?.address; buyerApproved = true; buyerApprovedAt = now; } else if (creator.type === "seller") { - const buyer = await this.userModel - .findOne( - { "wallet.address": dto.counterpartyWalletAddress, type: "buyer" }, - { _id: 1, name: 1, wallet: 1 }, - ) - .lean(); + const buyer = await this.userFacade.findByWalletAddressAndType( + dto.counterpartyWalletAddress, + "buyer", + ); if (!buyer) throw new BuyerWalletNotFoundException(dto.counterpartyWalletAddress); - buyerId = buyer._id.toString(); + buyerId = (buyer as any)._id.toString(); buyerName = buyer.name; buyerWalletAddress = buyer.wallet?.address; sellerId = userId; @@ -100,7 +88,7 @@ export class EscrowPaymentsCrudService { const totalAmountXrp = dto.escrows.reduce((sum, e) => sum + e.amountXrp, 0); - const doc = new this.escrowPaymentModel({ + const saved = await this.escrowPaymentRepo.create({ buyerId: new Types.ObjectId(buyerId), buyerName, buyerWalletAddress, @@ -128,7 +116,6 @@ export class EscrowPaymentsCrudService { })), }); - const saved = await doc.save(); return this.mapToResponse(saved.toObject(), userId); } @@ -158,21 +145,12 @@ export class EscrowPaymentsCrudService { const skip = (page - 1) * limit; const [data, total] = await Promise.all([ - this.escrowPaymentModel - .find(filter) - .sort({ createdAt: -1 }) - .skip(skip) - .limit(limit) - .lean(), - this.escrowPaymentModel.countDocuments(filter), + this.escrowPaymentRepo.findMany(filter, skip, limit), + this.escrowPaymentRepo.countDocuments(filter), ]); - const mappedData = data.map((item: any) => - this.mapToResponse(item, userId), - ); - return { - data: mappedData, + data: data.map((item: any) => this.mapToResponse(item, userId)), total, page, limit, @@ -180,11 +158,12 @@ export class EscrowPaymentsCrudService { } async findById(id: string, userId: string): Promise { - const doc = await this.escrowPaymentModel.findById(id).lean(); + const doc = await this.escrowPaymentRepo.findByIdLean(id); if (!doc) throw new EscrowPaymentNotFoundException(); const isParticipant = - doc.buyerId.toString() === userId || doc.sellerId.toString() === userId; + (doc as any).buyerId.toString() === userId || + (doc as any).sellerId.toString() === userId; if (!isParticipant) { throw new UnauthorizedPaymentActionException(); } @@ -196,9 +175,7 @@ export class EscrowPaymentsCrudService { * XRPL 지갑 주소로 사용자 조회 */ async findUserByWalletAddress(walletAddress: string): Promise { - const user = await this.userModel - .findOne({ "wallet.address": walletAddress }) - .lean(); + const user = await this.userFacade.findByWalletAddress(walletAddress); if (!user) throw new WalletUserNotFoundException(walletAddress); return user as UserDocument; } diff --git a/src/modules/escrow-payments/escrow-payments.service.ts b/src/modules/escrow-payments/escrow-payments.service.ts index 5f748d4..214359b 100644 --- a/src/modules/escrow-payments/escrow-payments.service.ts +++ b/src/modules/escrow-payments/escrow-payments.service.ts @@ -1,6 +1,4 @@ import { Injectable, Logger } from "@nestjs/common"; -import { InjectModel } from "@nestjs/mongoose"; -import { Model } from "mongoose"; import { AlreadyApprovedEventException, AlreadyApprovedPaymentException, @@ -17,23 +15,21 @@ import { WalletSeedUnavailableException, } from "../../common/exceptions"; import { - EscrowPayment, EscrowPaymentDocument, EscrowItem, } from "./schemas/escrow-payment.schema"; import { XrplService, XrplWallet } from "../xrpl/xrpl.service"; -import { User, UserDocument } from "../users/schemas/user.schema"; import { OutboxService } from "../outbox/outbox.service"; +import { EscrowPaymentRepository } from "./repositories/escrow-payment.repository"; +import { UserFacade } from "./repositories/user.facade"; @Injectable() export class EscrowPaymentsService { private readonly logger = new Logger(EscrowPaymentsService.name); constructor( - @InjectModel(EscrowPayment.name) - private readonly escrowPaymentModel: Model, - @InjectModel(User.name) - private readonly userModel: Model, + private readonly escrowPaymentRepo: EscrowPaymentRepository, + private readonly userFacade: UserFacade, private readonly xrplService: XrplService, private readonly outboxService: OutboxService, ) {} @@ -47,7 +43,7 @@ export class EscrowPaymentsService { paymentId: string, userId: string, ): Promise { - const payment = await this.escrowPaymentModel.findById(paymentId); + const payment = await this.escrowPaymentRepo.findById(paymentId); if (!payment) throw new EscrowPaymentNotFoundException(); const isBuyer = payment.buyerId.toString() === userId; @@ -75,7 +71,7 @@ export class EscrowPaymentsService { const bothApproved = payment.buyerApproved && payment.sellerApproved; payment.status = bothApproved ? "APPROVED" : "PENDING_APPROVAL"; - await payment.save(); + await this.escrowPaymentRepo.save(payment); return payment; } @@ -88,7 +84,7 @@ export class EscrowPaymentsService { paymentId: string, userId: string, ): Promise { - const payment = await this.escrowPaymentModel.findById(paymentId); + const payment = await this.escrowPaymentRepo.findById(paymentId); if (!payment) throw new EscrowPaymentNotFoundException(); if (payment.status !== "APPROVED") { @@ -108,13 +104,14 @@ export class EscrowPaymentsService { // RLUSD는 TrustSet 서명에 seed 필요 → +wallet.seed 포함 조회 const withSeed = payment.currency === "RLUSD"; const [buyerUser, sellerUser] = await Promise.all([ - this.userModel - .findById(payment.buyerId) - .select(withSeed ? "+wallet.seed" : ""), - this.userModel - .findById(payment.sellerId) - .select(withSeed ? "+wallet.seed" : ""), + withSeed + ? this.userFacade.findByIdWithSeed(payment.buyerId) + : this.userFacade.findById(payment.buyerId), + withSeed + ? this.userFacade.findByIdWithSeed(payment.sellerId) + : this.userFacade.findById(payment.sellerId), ]); + if (!buyerUser?.wallet?.address) { throw new WalletNotAvailableException("Buyer"); } @@ -145,13 +142,12 @@ export class EscrowPaymentsService { } let initiated: EscrowPaymentDocument | null = null; - const session = await this.escrowPaymentModel.db.startSession(); + const session = await this.escrowPaymentRepo.startSession(); try { await session.withTransaction(async () => { - initiated = await this.escrowPaymentModel.findOneAndUpdate( - { _id: paymentId, status: "APPROVED" }, - { $set: { status: "PROCESSING" } }, - { session, new: true }, + initiated = await this.escrowPaymentRepo.markProcessing( + paymentId, + session, ); if (!initiated) throw new PaymentNotApprovedForPayException("APPROVED"); await this.outboxService.createPendingEvent( @@ -182,7 +178,7 @@ export class EscrowPaymentsService { * PENDING_ESCROW → CANCELLED, ESCROWED/SUBMITTING → CANCELLING */ async rollbackAllEscrows(paymentId: string): Promise { - const payment = await this.escrowPaymentModel.findById(paymentId); + const payment = await this.escrowPaymentRepo.findById(paymentId); if (!payment) { this.logger.error(`rollbackAllEscrows: payment ${paymentId} not found`); return; @@ -202,7 +198,7 @@ export class EscrowPaymentsService { } payment.status = "CANCELLED"; - await payment.save(); + await this.escrowPaymentRepo.save(payment); this.logger.warn(`Payment ${paymentId} rolled back to CANCELLED`); } @@ -215,9 +211,8 @@ export class EscrowPaymentsService { eventType: string, userId: string, ): Promise { - const payment = await this.escrowPaymentModel - .findById(paymentId) - .select("+escrows.fulfillment"); + const payment = + await this.escrowPaymentRepo.findByIdWithFulfillment(paymentId); if (!payment) throw new EscrowPaymentNotFoundException(); const isBuyer = payment.buyerId.toString() === userId; @@ -262,7 +257,7 @@ export class EscrowPaymentsService { if (allEventsComplete) { await this.releaseEscrow(payment, escrow); } else { - await payment.save(); + await this.escrowPaymentRepo.save(payment); } return payment; @@ -273,12 +268,10 @@ export class EscrowPaymentsService { escrow: EscrowItem, ): Promise { escrow.status = "RELEASING"; - await payment.save(); + await this.escrowPaymentRepo.save(payment); try { - const buyerUser = await this.userModel - .findById(payment.buyerId) - .select("+wallet.seed"); + const buyerUser = await this.userFacade.findByIdWithSeed(payment.buyerId); if (!buyerUser?.wallet?.seed) { throw new WalletSeedUnavailableException(); @@ -317,12 +310,12 @@ export class EscrowPaymentsService { payment.status = "COMPLETED"; } - await payment.save(); + await this.escrowPaymentRepo.save(payment); this.logger.log(`EscrowFinish success: txHash=${txHash}`); } catch (err: any) { this.logger.error(`EscrowFinish failed: ${err.message}`, err.stack); escrow.status = "ESCROWED"; - await payment.save(); + await this.escrowPaymentRepo.save(payment); throw err; } } @@ -331,7 +324,7 @@ export class EscrowPaymentsService { paymentId: string, escrowId: string, ): Promise { - const payment = await this.escrowPaymentModel.findById(paymentId); + const payment = await this.escrowPaymentRepo.findById(paymentId); if (!payment) throw new EscrowPaymentNotFoundException(); const escrow = payment.escrows.find((e) => e._id.toString() === escrowId); @@ -342,14 +335,14 @@ export class EscrowPaymentsService { } escrow.status = "CANCELLED"; - return payment.save(); + return this.escrowPaymentRepo.save(payment); } async getEscrowStatus( paymentId: string, escrowId: string, ): Promise { - const payment = await this.escrowPaymentModel.findById(paymentId).lean(); + const payment = await this.escrowPaymentRepo.findByIdLean(paymentId); if (!payment) throw new EscrowPaymentNotFoundException(); const escrow = payment.escrows.find((e) => e._id.toString() === escrowId); diff --git a/src/modules/escrow-payments/escrow-submit-recovery.scheduler.ts b/src/modules/escrow-payments/escrow-submit-recovery.scheduler.ts index 66fa679..cd265aa 100644 --- a/src/modules/escrow-payments/escrow-submit-recovery.scheduler.ts +++ b/src/modules/escrow-payments/escrow-submit-recovery.scheduler.ts @@ -1,14 +1,9 @@ import { Injectable, Logger } from "@nestjs/common"; import { Cron, CronExpression } from "@nestjs/schedule"; -import { InjectModel } from "@nestjs/mongoose"; -import { Model, Types } from "mongoose"; -import { - EscrowPayment, - EscrowPaymentDocument, -} from "./schemas/escrow-payment.schema"; -import { User, UserDocument } from "../users/schemas/user.schema"; import { EscrowPaymentsService } from "./escrow-payments.service"; import { XrplService } from "../xrpl/xrpl.service"; +import { EscrowPaymentRepository } from "./repositories/escrow-payment.repository"; +import { UserFacade } from "./repositories/user.facade"; // 이 시간보다 오래된 SUBMITTING만 처리 — 정상 진행 중인 요청과 구분 const SUBMITTING_TIMEOUT_MS = 5 * 60 * 1000; @@ -18,10 +13,8 @@ export class EscrowSubmitRecoveryScheduler { private readonly logger = new Logger(EscrowSubmitRecoveryScheduler.name); constructor( - @InjectModel(EscrowPayment.name) - private readonly escrowPaymentModel: Model, - @InjectModel(User.name) - private readonly userModel: Model, + private readonly escrowPaymentRepo: EscrowPaymentRepository, + private readonly userFacade: UserFacade, private readonly xrplService: XrplService, private readonly escrowPaymentsService: EscrowPaymentsService, ) {} @@ -33,12 +26,7 @@ export class EscrowSubmitRecoveryScheduler { @Cron(CronExpression.EVERY_MINUTE) async recoverStuckSubmittingEscrows(): Promise { const cutoff = new Date(Date.now() - SUBMITTING_TIMEOUT_MS); - - const payments = await this.escrowPaymentModel.find({ - escrows: { - $elemMatch: { status: "SUBMITTING", submittingAt: { $lt: cutoff } }, - }, - }); + const payments = await this.escrowPaymentRepo.findStuckSubmitting(cutoff); if (payments.length === 0) return; @@ -47,7 +35,7 @@ export class EscrowSubmitRecoveryScheduler { ); for (const payment of payments) { - const buyerUser = await this.userModel.findById(payment.buyerId); + const buyerUser = await this.userFacade.findById(payment.buyerId); if (!buyerUser?.wallet?.address) { this.logger.warn( `Buyer wallet unavailable for payment ${payment._id.toString()}`, @@ -118,25 +106,11 @@ export class EscrowSubmitRecoveryScheduler { if (xrplResult) { // XRPL에 에스크로 존재 → DB만 업데이트하면 복구 완료 - const result = await this.escrowPaymentModel.findOneAndUpdate( - { - _id: paymentId, - escrows: { - $elemMatch: { - _id: new Types.ObjectId(escrowId), - status: "SUBMITTING", - }, - }, - }, - { - $set: { - "escrows.$.status": "ESCROWED", - "escrows.$.xrplSequence": xrplResult.sequence, - "escrows.$.txHashCreate": xrplResult.txHash, - "escrows.$.escrowedAt": new Date(), - }, - }, - { new: true }, + const result = await this.escrowPaymentRepo.markEscrowed( + paymentId, + escrowId, + xrplResult.sequence, + xrplResult.txHash, ); if (!result) return "recovered"; @@ -147,9 +121,7 @@ export class EscrowSubmitRecoveryScheduler { e.status === "CANCELLED", ); if (allEscrowed) { - await this.escrowPaymentModel.findByIdAndUpdate(paymentId, { - $set: { status: "ACTIVE" }, - }); + await this.escrowPaymentRepo.markActive(paymentId); } return "recovered"; } @@ -165,14 +137,7 @@ export class EscrowSubmitRecoveryScheduler { ): Promise { // XRPL에 없음이 확정된 SUBMITTING 에스크로를 CANCELLED로 직접 전환 // (rollbackAllEscrows는 SUBMITTING → CANCELLING으로 처리하므로 별도 처리) - await this.escrowPaymentModel.findOneAndUpdate( - { - _id: paymentId, - "escrows._id": new Types.ObjectId(escrowId), - "escrows.status": "SUBMITTING", - }, - { $set: { "escrows.$.status": "CANCELLED" } }, - ); + await this.escrowPaymentRepo.cancelSubmittingEscrow(paymentId, escrowId); await this.escrowPaymentsService.rollbackAllEscrows(paymentId); } } From 3409f7b666a5aa57ba48981a1aa45443f945297c Mon Sep 17 00:00:00 2001 From: takch02 Date: Thu, 14 May 2026 10:24:22 +0900 Subject: [PATCH 3/6] =?UTF-8?q?test:=20=EB=A0=88=ED=8F=AC=EC=A7=80?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=C2=B7=ED=8C=8C=EC=82=AC=EB=93=9C=20=EA=B3=84?= =?UTF-8?q?=EC=B8=B5=20=EB=8F=84=EC=9E=85=EC=97=90=20=EB=94=B0=EB=A5=B8=20?= =?UTF-8?q?=EC=9C=A0=EB=8B=9B=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=AA=A9=20?= =?UTF-8?q?=EC=A0=84=EB=A9=B4=20=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../__tests__/approval.spec.ts | 37 +++-- .../escrow-payments/__tests__/create.spec.ts | 99 ++++++------- .../escrow-payments/__tests__/helpers.ts | 111 +++++++-------- .../__tests__/initiate.spec.ts | 66 ++++----- .../escrow-payments/__tests__/query.spec.ts | 40 +++--- .../__tests__/recovery-scheduler.spec.ts | 132 +++++++----------- .../escrow-payments/__tests__/xrpl.spec.ts | 127 +++++++---------- 7 files changed, 254 insertions(+), 358 deletions(-) diff --git a/src/modules/escrow-payments/__tests__/approval.spec.ts b/src/modules/escrow-payments/__tests__/approval.spec.ts index b88290f..6aafb52 100644 --- a/src/modules/escrow-payments/__tests__/approval.spec.ts +++ b/src/modules/escrow-payments/__tests__/approval.spec.ts @@ -13,7 +13,6 @@ import { makeEscrowItem, makeApproval, makeBuyerUser, - makeQueryChain, makeServiceTestingModule, } from "./helpers"; import { @@ -44,7 +43,7 @@ describe("EscrowPaymentsService › approvePayment", () => { buyerApproved: true, buyerApprovedAt: new Date(), }); - ctx.escrowPaymentModel.findById.mockReturnValue(makeQueryChain(payment)); + ctx.escrowPaymentRepo.findById.mockResolvedValue(payment); await ctx.service.approvePayment( PAYMENT_ID.toString(), @@ -54,7 +53,7 @@ describe("EscrowPaymentsService › approvePayment", () => { expect(payment.sellerApproved).toBe(true); expect(payment.sellerApprovedAt).toBeInstanceOf(Date); expect(payment.status).toBe("APPROVED"); - expect(payment.save).toHaveBeenCalled(); + expect(ctx.escrowPaymentRepo.save).toHaveBeenCalledWith(payment); }); it("buyer 중복 승인 → AlreadyApprovedPaymentException, save 미호출", async () => { @@ -62,12 +61,12 @@ describe("EscrowPaymentsService › approvePayment", () => { status: "PENDING_APPROVAL", buyerApproved: true, }); - ctx.escrowPaymentModel.findById.mockReturnValue(makeQueryChain(payment)); + ctx.escrowPaymentRepo.findById.mockResolvedValue(payment); await expect( ctx.service.approvePayment(PAYMENT_ID.toString(), BUYER_ID.toString()), ).rejects.toThrow(AlreadyApprovedPaymentException); - expect(payment.save).not.toHaveBeenCalled(); + expect(ctx.escrowPaymentRepo.save).not.toHaveBeenCalled(); }); }); @@ -80,7 +79,7 @@ describe("EscrowPaymentsService › approvePayment", () => { sellerApproved: true, sellerApprovedAt: new Date(), }); - ctx.escrowPaymentModel.findById.mockReturnValue(makeQueryChain(payment)); + ctx.escrowPaymentRepo.findById.mockResolvedValue(payment); await ctx.service.approvePayment( PAYMENT_ID.toString(), @@ -90,7 +89,7 @@ describe("EscrowPaymentsService › approvePayment", () => { expect(payment.buyerApproved).toBe(true); expect(payment.buyerApprovedAt).toBeInstanceOf(Date); expect(payment.status).toBe("APPROVED"); - expect(payment.save).toHaveBeenCalled(); + expect(ctx.escrowPaymentRepo.save).toHaveBeenCalledWith(payment); }); it("seller 중복 승인 → AlreadyApprovedPaymentException, save 미호출", async () => { @@ -98,12 +97,12 @@ describe("EscrowPaymentsService › approvePayment", () => { status: "PENDING_APPROVAL", sellerApproved: true, }); - ctx.escrowPaymentModel.findById.mockReturnValue(makeQueryChain(payment)); + ctx.escrowPaymentRepo.findById.mockResolvedValue(payment); await expect( ctx.service.approvePayment(PAYMENT_ID.toString(), SELLER_ID.toString()), ).rejects.toThrow(AlreadyApprovedPaymentException); - expect(payment.save).not.toHaveBeenCalled(); + expect(ctx.escrowPaymentRepo.save).not.toHaveBeenCalled(); }); }); @@ -111,7 +110,7 @@ describe("EscrowPaymentsService › approvePayment", () => { it("ACTIVE 상태에서 승인 시도 → InvalidPaymentStatusException", async () => { const payment = makePayment({ status: "ACTIVE" }); - ctx.escrowPaymentModel.findById.mockReturnValue(makeQueryChain(payment)); + ctx.escrowPaymentRepo.findById.mockResolvedValue(payment); await expect( ctx.service.approvePayment(PAYMENT_ID.toString(), BUYER_ID.toString()), @@ -120,7 +119,7 @@ describe("EscrowPaymentsService › approvePayment", () => { it("buyer도 seller도 아닌 제3자 → UnauthorizedPaymentActionException", async () => { const payment = makePayment({ status: "PENDING_APPROVAL" }); - ctx.escrowPaymentModel.findById.mockReturnValue(makeQueryChain(payment)); + ctx.escrowPaymentRepo.findById.mockResolvedValue(payment); await expect( ctx.service.approvePayment( @@ -131,7 +130,7 @@ describe("EscrowPaymentsService › approvePayment", () => { }); it("존재하지 않는 결제 → EscrowPaymentNotFoundException", async () => { - ctx.escrowPaymentModel.findById.mockReturnValue(makeQueryChain(null)); + ctx.escrowPaymentRepo.findById.mockResolvedValue(null); await expect( ctx.service.approvePayment(PAYMENT_ID.toString(), BUYER_ID.toString()), @@ -157,7 +156,7 @@ describe("EscrowPaymentsService › approveEvent", () => { }), ], }); - ctx.escrowPaymentModel.findById.mockReturnValue(makeQueryChain(payment)); + ctx.escrowPaymentRepo.findByIdWithFulfillment.mockResolvedValue(payment); return payment; } @@ -169,7 +168,7 @@ describe("EscrowPaymentsService › approveEvent", () => { const payment = makePayment({ escrows: [makeEscrowItem({ status: "PENDING_ESCROW" })], }); - ctx.escrowPaymentModel.findById.mockReturnValue(makeQueryChain(payment)); + ctx.escrowPaymentRepo.findByIdWithFulfillment.mockResolvedValue(payment); await expect( ctx.service.approveEvent( @@ -247,7 +246,7 @@ describe("EscrowPaymentsService › approveEvent", () => { expect(approval.buyerApproved).toBe(true); expect(approval.buyerApprovedAt).toBeInstanceOf(Date); expect(approval.completedAt).toBeUndefined(); - expect(payment.save).toHaveBeenCalled(); + expect(ctx.escrowPaymentRepo.save).toHaveBeenCalled(); }); it("양측 모두 승인 → completedAt 설정, EscrowFinish 자동 제출", async () => { @@ -255,7 +254,7 @@ describe("EscrowPaymentsService › approveEvent", () => { buyerApproved: true, buyerApprovedAt: new Date(), }); - ctx.userModel.findById.mockReturnValue(makeQueryChain(makeBuyerUser())); + ctx.userFacade.findByIdWithSeed.mockResolvedValue(makeBuyerUser()); await ctx.service.approveEvent( PAYMENT_ID.toString(), @@ -280,7 +279,7 @@ describe("EscrowPaymentsService › approveEvent", () => { buyerApproved: true, buyerApprovedAt: new Date(), }); - ctx.userModel.findById.mockReturnValue(makeQueryChain(makeBuyerUser())); + ctx.userFacade.findByIdWithSeed.mockResolvedValue(makeBuyerUser()); await ctx.service.approveEvent( PAYMENT_ID.toString(), @@ -300,7 +299,7 @@ describe("EscrowPaymentsService › approveEvent", () => { buyerApproved: true, buyerApprovedAt: new Date(), }); - ctx.userModel.findById.mockReturnValue(makeQueryChain(makeBuyerUser())); + ctx.userFacade.findByIdWithSeed.mockResolvedValue(makeBuyerUser()); await ctx.service.approveEvent( PAYMENT_ID.toString(), @@ -317,7 +316,7 @@ describe("EscrowPaymentsService › approveEvent", () => { buyerApproved: true, buyerApprovedAt: new Date(), }); - ctx.userModel.findById.mockReturnValue(makeQueryChain(makeBuyerUser())); + ctx.userFacade.findByIdWithSeed.mockResolvedValue(makeBuyerUser()); ctx.xrplService.finishEscrow.mockRejectedValue( new Error("XRPL network error"), ); diff --git a/src/modules/escrow-payments/__tests__/create.spec.ts b/src/modules/escrow-payments/__tests__/create.spec.ts index cda0f3a..20ab5e5 100644 --- a/src/modules/escrow-payments/__tests__/create.spec.ts +++ b/src/modules/escrow-payments/__tests__/create.spec.ts @@ -1,9 +1,4 @@ -import { - BUYER_ID, - SELLER_ID, - makeCrudServiceTestingModule, - makeQueryChain, -} from "./helpers"; +import { BUYER_ID, SELLER_ID, makeCrudServiceTestingModule } from "./helpers"; import { BuyerWalletNotFoundException, SellerWalletNotFoundException, @@ -42,26 +37,22 @@ describe("EscrowPaymentsCrudService › create", () => { beforeEach(async () => { ctx = await makeCrudServiceTestingModule(); - ctx.userModel.findById.mockReturnValue( - makeQueryChain({ - type: "buyer", - name: "Buyer Corp", - wallet: { address: BUYER_WALLET_ADDRESS }, - }), - ); - ctx.userModel.findOne.mockReturnValue( - makeQueryChain({ - _id: SELLER_ID, - name: "Seller Corp", - wallet: { address: SELLER_WALLET_ADDRESS }, - }), - ); + ctx.userFacade.findByIdLean.mockResolvedValue({ + type: "buyer", + name: "Buyer Corp", + wallet: { address: BUYER_WALLET_ADDRESS }, + }); + ctx.userFacade.findByWalletAddressAndType.mockResolvedValue({ + _id: SELLER_ID, + name: "Seller Corp", + wallet: { address: SELLER_WALLET_ADDRESS }, + }); }); it("totalAmountXrp를 escrow 항목 합산으로 계산", async () => { await ctx.service.create(makeDto(), BUYER_ID.toString()); - const constructorArg = ctx.escrowPaymentModel.mock.calls[0][0]; + const constructorArg = ctx.escrowPaymentRepo.create.mock.calls[0][0]; expect(constructorArg.totalAmountXrp).toBe(1000); expect(constructorArg.buyerWalletAddress).toBe(BUYER_WALLET_ADDRESS); expect(constructorArg.sellerWalletAddress).toBe(SELLER_WALLET_ADDRESS); @@ -70,7 +61,7 @@ describe("EscrowPaymentsCrudService › create", () => { it("각 escrow 항목의 approvals를 requiredEventTypes로 초기화", async () => { await ctx.service.create(makeDto(), BUYER_ID.toString()); - const constructorArg = ctx.escrowPaymentModel.mock.calls[0][0]; + const constructorArg = ctx.escrowPaymentRepo.create.mock.calls[0][0]; expect(constructorArg.escrows[0].approvals).toEqual([ expect.objectContaining({ eventType: "SHIPMENT_CONFIRMED", @@ -84,7 +75,7 @@ describe("EscrowPaymentsCrudService › create", () => { it("생성자(buyer)의 buyerApproved를 true로 자동 설정", async () => { await ctx.service.create(makeDto(), BUYER_ID.toString()); - const constructorArg = ctx.escrowPaymentModel.mock.calls[0][0]; + const constructorArg = ctx.escrowPaymentRepo.create.mock.calls[0][0]; expect(constructorArg.buyerApproved).toBe(true); expect(constructorArg.buyerApprovedAt).toBeInstanceOf(Date); expect(constructorArg.sellerApproved).toBe(false); @@ -93,38 +84,30 @@ describe("EscrowPaymentsCrudService › create", () => { it("상태를 PENDING_APPROVAL로 초기화", async () => { await ctx.service.create(makeDto(), BUYER_ID.toString()); - const constructorArg = ctx.escrowPaymentModel.mock.calls[0][0]; + const constructorArg = ctx.escrowPaymentRepo.create.mock.calls[0][0]; expect(constructorArg.status).toBe("PENDING_APPROVAL"); }); - it("save() 호출", async () => { - const payment = { buyerId: BUYER_ID, sellerId: SELLER_ID }; - const instance = { - save: jest.fn().mockReturnThis(), - toObject: jest.fn().mockReturnValue(payment), - }; - instance.save.mockResolvedValue(instance); - ctx.escrowPaymentModel.mockReturnValue(instance); - + it("create() 호출", async () => { await ctx.service.create(makeDto(), BUYER_ID.toString()); - expect(instance.save).toHaveBeenCalled(); + expect(ctx.escrowPaymentRepo.create).toHaveBeenCalled(); }); it("counterpartyWalletAddress로 seller 조회 후 sellerId를 document에 주입", async () => { await ctx.service.create(makeDto(), BUYER_ID.toString()); - expect(ctx.userModel.findOne).toHaveBeenCalledWith( - { "wallet.address": SELLER_WALLET_ADDRESS, type: "seller" }, - { _id: 1, name: 1, wallet: 1 }, + expect(ctx.userFacade.findByWalletAddressAndType).toHaveBeenCalledWith( + SELLER_WALLET_ADDRESS, + "seller", ); - const constructorArg = ctx.escrowPaymentModel.mock.calls[0][0]; + const constructorArg = ctx.escrowPaymentRepo.create.mock.calls[0][0]; expect(constructorArg.sellerId.toString()).toBe(SELLER_ID.toString()); }); it("존재하지 않는 seller 지갑 주소 → SellerWalletNotFoundException", async () => { - ctx.userModel.findOne.mockReturnValue(makeQueryChain(null)); + ctx.userFacade.findByWalletAddressAndType.mockResolvedValue(null); await expect( ctx.service.create(makeDto(), BUYER_ID.toString()), @@ -143,26 +126,22 @@ describe("EscrowPaymentsCrudService › create", () => { beforeEach(async () => { ctx = await makeCrudServiceTestingModule(); - ctx.userModel.findById.mockReturnValue( - makeQueryChain({ - type: "seller", - name: "Seller Corp", - wallet: { address: SELLER_WALLET_ADDRESS }, - }), - ); - ctx.userModel.findOne.mockReturnValue( - makeQueryChain({ - _id: BUYER_ID, - name: "Buyer Corp", - wallet: { address: BUYER_WALLET_ADDRESS }, - }), - ); + ctx.userFacade.findByIdLean.mockResolvedValue({ + type: "seller", + name: "Seller Corp", + wallet: { address: SELLER_WALLET_ADDRESS }, + }); + ctx.userFacade.findByWalletAddressAndType.mockResolvedValue({ + _id: BUYER_ID, + name: "Buyer Corp", + wallet: { address: BUYER_WALLET_ADDRESS }, + }); }); it("생성자(seller)의 sellerApproved를 true로 자동 설정", async () => { await ctx.service.create(makeDto(), SELLER_ID.toString()); - const constructorArg = ctx.escrowPaymentModel.mock.calls[0][0]; + const constructorArg = ctx.escrowPaymentRepo.create.mock.calls[0][0]; expect(constructorArg.sellerApproved).toBe(true); expect(constructorArg.sellerApprovedAt).toBeInstanceOf(Date); expect(constructorArg.buyerApproved).toBe(false); @@ -173,17 +152,17 @@ describe("EscrowPaymentsCrudService › create", () => { it("counterpartyWalletAddress로 buyer 조회 후 buyerId를 document에 주입", async () => { await ctx.service.create(makeDto(), SELLER_ID.toString()); - expect(ctx.userModel.findOne).toHaveBeenCalledWith( - { "wallet.address": BUYER_WALLET_ADDRESS, type: "buyer" }, - { _id: 1, name: 1, wallet: 1 }, + expect(ctx.userFacade.findByWalletAddressAndType).toHaveBeenCalledWith( + BUYER_WALLET_ADDRESS, + "buyer", ); - const constructorArg = ctx.escrowPaymentModel.mock.calls[0][0]; + const constructorArg = ctx.escrowPaymentRepo.create.mock.calls[0][0]; expect(constructorArg.buyerId.toString()).toBe(BUYER_ID.toString()); }); it("존재하지 않는 buyer 지갑 주소 → BuyerWalletNotFoundException", async () => { - ctx.userModel.findOne.mockReturnValue(makeQueryChain(null)); + ctx.userFacade.findByWalletAddressAndType.mockResolvedValue(null); await expect( ctx.service.create(makeDto(), SELLER_ID.toString()), @@ -195,7 +174,7 @@ describe("EscrowPaymentsCrudService › create", () => { it("DB에 없는 userId → UnauthorizedPaymentActionException", async () => { ctx = await makeCrudServiceTestingModule(); - // findById 기본값: null (makeUserModelMock 참고) + // findByIdLean 기본값: null (makeUserFacadeMock 참고) await expect( ctx.service.create( diff --git a/src/modules/escrow-payments/__tests__/helpers.ts b/src/modules/escrow-payments/__tests__/helpers.ts index 3502d07..1a5a206 100644 --- a/src/modules/escrow-payments/__tests__/helpers.ts +++ b/src/modules/escrow-payments/__tests__/helpers.ts @@ -1,11 +1,10 @@ import { Test } from "@nestjs/testing"; -import { getModelToken } from "@nestjs/mongoose"; import { Types } from "mongoose"; import { EscrowPaymentsCrudService } from "../escrow-payments-crud.service"; import { EscrowPaymentsService } from "../escrow-payments.service"; import { EscrowCreateProcessor } from "../escrow-create.processor"; -import { EscrowPayment } from "../schemas/escrow-payment.schema"; -import { User } from "../../users/schemas/user.schema"; +import { EscrowPaymentRepository } from "../repositories/escrow-payment.repository"; +import { UserFacade } from "../repositories/user.facade"; import { XrplService } from "../../xrpl/xrpl.service"; import { OutboxService } from "../../outbox/outbox.service"; @@ -27,21 +26,6 @@ export const XRPL_SEQUENCE = 42; // ── 픽스처 헬퍼 ─────────────────────────────────────────────────────────────── -export function makeQueryChain(value: any) { - const promise = Promise.resolve(value); - const chain: any = { - select: jest.fn().mockReturnThis(), - lean: jest.fn().mockResolvedValue(value), - sort: jest.fn().mockReturnThis(), - skip: jest.fn().mockReturnThis(), - limit: jest.fn().mockReturnThis(), - then: promise.then.bind(promise), - catch: promise.catch.bind(promise), - finally: promise.finally.bind(promise), - }; - return chain; -} - export function makeApproval(eventType: string, overrides: object = {}) { return { eventType, @@ -119,7 +103,7 @@ export function makeSellerUser() { // ── 모킹 팩토리 ─────────────────────────────────────────────────────────────── -export function makeEscrowPaymentModelMock() { +export function makeEscrowPaymentRepoMock() { const session = { withTransaction: jest .fn() @@ -127,26 +111,34 @@ export function makeEscrowPaymentModelMock() { endSession: jest.fn().mockResolvedValue(undefined), }; - const ModelMock: any = jest.fn().mockImplementation((data: any) => { - const instance = { ...data }; - instance.save = jest.fn().mockResolvedValue(instance); - instance.toObject = jest.fn().mockReturnValue(instance); - return instance; - }); - ModelMock.findById = jest.fn().mockReturnValue(makeQueryChain(null)); - ModelMock.find = jest.fn().mockReturnValue(makeQueryChain([])); - ModelMock.countDocuments = jest.fn().mockResolvedValue(0); - ModelMock.findOneAndUpdate = jest.fn().mockResolvedValue(null); - ModelMock.findByIdAndUpdate = jest.fn().mockResolvedValue(null); - ModelMock.db = { startSession: jest.fn().mockResolvedValue(session) }; - return ModelMock; + return { + findById: jest.fn().mockResolvedValue(null), + findByIdWithFulfillment: jest.fn().mockResolvedValue(null), + findByIdLean: jest.fn().mockResolvedValue(null), + save: jest.fn().mockImplementation((doc: any) => Promise.resolve(doc)), + create: jest.fn().mockResolvedValue(makePayment()), + findMany: jest.fn().mockResolvedValue([]), + countDocuments: jest.fn().mockResolvedValue(0), + findCancelling: jest.fn().mockResolvedValue([]), + findStuckSubmitting: jest.fn().mockResolvedValue([]), + startSession: jest.fn().mockResolvedValue(session), + markProcessing: jest.fn().mockResolvedValue(null), + markActive: jest.fn().mockResolvedValue(undefined), + preflight: jest.fn().mockResolvedValue(null), + revertSubmitting: jest.fn().mockResolvedValue(undefined), + markEscrowed: jest.fn().mockResolvedValue(null), + cancelSubmittingEscrow: jest.fn().mockResolvedValue(undefined), + }; } -export function makeUserModelMock() { - const ModelMock: any = jest.fn(); - ModelMock.findById = jest.fn().mockReturnValue(makeQueryChain(null)); - ModelMock.findOne = jest.fn().mockReturnValue(makeQueryChain(null)); - return ModelMock; +export function makeUserFacadeMock() { + return { + findById: jest.fn().mockResolvedValue(null), + findByIdLean: jest.fn().mockResolvedValue(null), + findByIdWithSeed: jest.fn().mockResolvedValue(null), + findByWalletAddressAndType: jest.fn().mockResolvedValue(null), + findByWalletAddress: jest.fn().mockResolvedValue(null), + }; } export function makeXrplServiceMock() { @@ -179,30 +171,27 @@ export function makeOutboxServiceMock() { // ── NestJS 테스팅 모듈 ──────────────────────────────────────────────────────── export async function makeCrudServiceTestingModule() { - const escrowPaymentModel = makeEscrowPaymentModelMock(); - const userModel = makeUserModelMock(); + const escrowPaymentRepo = makeEscrowPaymentRepoMock(); + const userFacade = makeUserFacadeMock(); const module = await Test.createTestingModule({ providers: [ EscrowPaymentsCrudService, - { - provide: getModelToken(EscrowPayment.name), - useValue: escrowPaymentModel, - }, - { provide: getModelToken(User.name), useValue: userModel }, + { provide: EscrowPaymentRepository, useValue: escrowPaymentRepo }, + { provide: UserFacade, useValue: userFacade }, ], }).compile(); return { service: module.get(EscrowPaymentsCrudService), - escrowPaymentModel, - userModel, + escrowPaymentRepo, + userFacade, }; } export async function makeProcessorTestingModule() { - const escrowPaymentModel = makeEscrowPaymentModelMock(); - const userModel = makeUserModelMock(); + const escrowPaymentRepo = makeEscrowPaymentRepoMock(); + const userFacade = makeUserFacadeMock(); const xrplService = makeXrplServiceMock(); const escrowPaymentsService = { getEscrowStatus: jest.fn(), @@ -212,11 +201,8 @@ export async function makeProcessorTestingModule() { const module = await Test.createTestingModule({ providers: [ EscrowCreateProcessor, - { - provide: getModelToken(EscrowPayment.name), - useValue: escrowPaymentModel, - }, - { provide: getModelToken(User.name), useValue: userModel }, + { provide: EscrowPaymentRepository, useValue: escrowPaymentRepo }, + { provide: UserFacade, useValue: userFacade }, { provide: XrplService, useValue: xrplService }, { provide: EscrowPaymentsService, useValue: escrowPaymentsService }, ], @@ -224,27 +210,24 @@ export async function makeProcessorTestingModule() { return { processor: module.get(EscrowCreateProcessor), - escrowPaymentModel, - userModel, + escrowPaymentRepo, + userFacade, xrplService, escrowPaymentsService, }; } export async function makeServiceTestingModule() { - const escrowPaymentModel = makeEscrowPaymentModelMock(); - const userModel = makeUserModelMock(); + const escrowPaymentRepo = makeEscrowPaymentRepoMock(); + const userFacade = makeUserFacadeMock(); const xrplService = makeXrplServiceMock(); const outboxService = makeOutboxServiceMock(); const module = await Test.createTestingModule({ providers: [ EscrowPaymentsService, - { - provide: getModelToken(EscrowPayment.name), - useValue: escrowPaymentModel, - }, - { provide: getModelToken(User.name), useValue: userModel }, + { provide: EscrowPaymentRepository, useValue: escrowPaymentRepo }, + { provide: UserFacade, useValue: userFacade }, { provide: XrplService, useValue: xrplService }, { provide: OutboxService, useValue: outboxService }, ], @@ -252,8 +235,8 @@ export async function makeServiceTestingModule() { return { service: module.get(EscrowPaymentsService), - escrowPaymentModel, - userModel, + escrowPaymentRepo, + userFacade, xrplService, outboxService, }; diff --git a/src/modules/escrow-payments/__tests__/initiate.spec.ts b/src/modules/escrow-payments/__tests__/initiate.spec.ts index 345f666..ee9d5a6 100644 --- a/src/modules/escrow-payments/__tests__/initiate.spec.ts +++ b/src/modules/escrow-payments/__tests__/initiate.spec.ts @@ -7,7 +7,6 @@ import { makePayment, makeEscrowItem, makeBuyerUser, - makeQueryChain, makeServiceTestingModule, } from "./helpers"; import { @@ -27,26 +26,23 @@ describe("EscrowPaymentsService › initiatePayment", () => { beforeEach(async () => { ctx = await makeServiceTestingModule(); - ctx.userModel.findById.mockReturnValue(makeQueryChain(makeBuyerUser())); + ctx.userFacade.findById.mockResolvedValue(makeBuyerUser()); }); - it("APPROVED → findOneAndUpdate로 PROCESSING 원자 전환 + outbox 이벤트 생성", async () => { + it("APPROVED → markProcessing 원자 전환 + outbox 이벤트 생성", async () => { const payment = makePayment({ status: "APPROVED", buyerId: BUYER_ID }); const processingPayment = makePayment({ status: "PROCESSING" }); - ctx.escrowPaymentModel.findById.mockReturnValue(makeQueryChain(payment)); - ctx.escrowPaymentModel.findOneAndUpdate.mockResolvedValue( - processingPayment, - ); + ctx.escrowPaymentRepo.findById.mockResolvedValue(payment); + ctx.escrowPaymentRepo.markProcessing.mockResolvedValue(processingPayment); await ctx.service.initiatePayment( PAYMENT_ID.toString(), BUYER_ID.toString(), ); - expect(ctx.escrowPaymentModel.findOneAndUpdate).toHaveBeenCalledWith( - expect.objectContaining({ status: "APPROVED" }), - { $set: { status: "PROCESSING" } }, - expect.objectContaining({ new: true }), + expect(ctx.escrowPaymentRepo.markProcessing).toHaveBeenCalledWith( + PAYMENT_ID.toString(), + expect.any(Object), ); expect(ctx.outboxService.createPendingEvent).toHaveBeenCalledWith( expect.anything(), @@ -60,17 +56,17 @@ describe("EscrowPaymentsService › initiatePayment", () => { status: "PENDING_APPROVAL", buyerId: BUYER_ID, }); - ctx.escrowPaymentModel.findById.mockReturnValue(makeQueryChain(payment)); + ctx.escrowPaymentRepo.findById.mockResolvedValue(payment); await expect( ctx.service.initiatePayment(PAYMENT_ID.toString(), BUYER_ID.toString()), ).rejects.toThrow(PaymentNotApprovedForPayException); }); - it("동시 요청으로 race 경쟁 실패(findOneAndUpdate null) → PaymentNotApprovedForPayException", async () => { + it("동시 요청으로 race 경쟁 실패(markProcessing null) → PaymentNotApprovedForPayException", async () => { const payment = makePayment({ status: "APPROVED", buyerId: BUYER_ID }); - ctx.escrowPaymentModel.findById.mockReturnValue(makeQueryChain(payment)); - ctx.escrowPaymentModel.findOneAndUpdate.mockResolvedValue(null); + ctx.escrowPaymentRepo.findById.mockResolvedValue(payment); + ctx.escrowPaymentRepo.markProcessing.mockResolvedValue(null); await expect( ctx.service.initiatePayment(PAYMENT_ID.toString(), BUYER_ID.toString()), @@ -83,7 +79,7 @@ describe("EscrowPaymentsService › initiatePayment", () => { buyerId: BUYER_ID, sellerId: SELLER_ID, }); - ctx.escrowPaymentModel.findById.mockReturnValue(makeQueryChain(payment)); + ctx.escrowPaymentRepo.findById.mockResolvedValue(payment); await expect( ctx.service.initiatePayment(PAYMENT_ID.toString(), SELLER_ID.toString()), @@ -96,7 +92,7 @@ describe("EscrowPaymentsService › initiatePayment", () => { buyerId: BUYER_ID, sellerId: SELLER_ID, }); - ctx.escrowPaymentModel.findById.mockReturnValue(makeQueryChain(payment)); + ctx.escrowPaymentRepo.findById.mockResolvedValue(payment); await expect( ctx.service.initiatePayment( @@ -107,7 +103,7 @@ describe("EscrowPaymentsService › initiatePayment", () => { }); it("결제 없음 → EscrowPaymentNotFoundException", async () => { - ctx.escrowPaymentModel.findById.mockReturnValue(makeQueryChain(null)); + ctx.escrowPaymentRepo.findById.mockResolvedValue(null); await expect( ctx.service.initiatePayment(PAYMENT_ID.toString(), BUYER_ID.toString()), @@ -116,8 +112,8 @@ describe("EscrowPaymentsService › initiatePayment", () => { it("트랜잭션 내부 DB 오류 → PaymentInitiationFailedException", async () => { const payment = makePayment({ status: "APPROVED", buyerId: BUYER_ID }); - ctx.escrowPaymentModel.findById.mockReturnValue(makeQueryChain(payment)); - ctx.escrowPaymentModel.findOneAndUpdate.mockRejectedValue( + ctx.escrowPaymentRepo.findById.mockResolvedValue(payment); + ctx.escrowPaymentRepo.markProcessing.mockRejectedValue( new Error("DB write error"), ); @@ -129,10 +125,8 @@ describe("EscrowPaymentsService › initiatePayment", () => { it("validateEscrowFunds에 buyer 주소와 PENDING_ESCROW 항목 금액을 전달", async () => { const payment = makePayment({ status: "APPROVED", buyerId: BUYER_ID }); const processingPayment = makePayment({ status: "PROCESSING" }); - ctx.escrowPaymentModel.findById.mockReturnValue(makeQueryChain(payment)); - ctx.escrowPaymentModel.findOneAndUpdate.mockResolvedValue( - processingPayment, - ); + ctx.escrowPaymentRepo.findById.mockResolvedValue(payment); + ctx.escrowPaymentRepo.markProcessing.mockResolvedValue(processingPayment); await ctx.service.initiatePayment( PAYMENT_ID.toString(), @@ -147,7 +141,7 @@ describe("EscrowPaymentsService › initiatePayment", () => { it("XRP 잔고 부족 → InsufficientXrpBalanceException 전파", async () => { const payment = makePayment({ status: "APPROVED", buyerId: BUYER_ID }); - ctx.escrowPaymentModel.findById.mockReturnValue(makeQueryChain(payment)); + ctx.escrowPaymentRepo.findById.mockResolvedValue(payment); ctx.xrplService.validateEscrowFunds.mockRejectedValue( new InsufficientXrpBalanceException(5, 312.001), ); @@ -175,14 +169,14 @@ describe("EscrowPaymentsService › rollbackAllEscrows", () => { makeEscrowItem({ _id: new Types.ObjectId(), status: "PENDING_ESCROW" }), ], }); - ctx.escrowPaymentModel.findById.mockReturnValue(makeQueryChain(payment)); + ctx.escrowPaymentRepo.findById.mockResolvedValue(payment); await ctx.service.rollbackAllEscrows(PAYMENT_ID.toString()); expect(payment.escrows[0].status).toBe("CANCELLING"); expect(payment.escrows[1].status).toBe("CANCELLED"); expect(payment.status).toBe("CANCELLED"); - expect(payment.save).toHaveBeenCalled(); + expect(ctx.escrowPaymentRepo.save).toHaveBeenCalledWith(payment); }); it("SUBMITTING → CANCELLING (XRPL 제출 여부 불명으로 보수적 처리)", async () => { @@ -190,7 +184,7 @@ describe("EscrowPaymentsService › rollbackAllEscrows", () => { status: "PROCESSING", escrows: [makeEscrowItem({ status: "SUBMITTING" })], }); - ctx.escrowPaymentModel.findById.mockReturnValue(makeQueryChain(payment)); + ctx.escrowPaymentRepo.findById.mockResolvedValue(payment); await ctx.service.rollbackAllEscrows(PAYMENT_ID.toString()); @@ -200,15 +194,15 @@ describe("EscrowPaymentsService › rollbackAllEscrows", () => { it("이미 CANCELLED → no-op (멱등)", async () => { const payment = makePayment({ status: "CANCELLED" }); - ctx.escrowPaymentModel.findById.mockReturnValue(makeQueryChain(payment)); + ctx.escrowPaymentRepo.findById.mockResolvedValue(payment); await ctx.service.rollbackAllEscrows(PAYMENT_ID.toString()); - expect(payment.save).not.toHaveBeenCalled(); + expect(ctx.escrowPaymentRepo.save).not.toHaveBeenCalled(); }); it("결제 없음 → 오류 없이 종료", async () => { - ctx.escrowPaymentModel.findById.mockReturnValue(makeQueryChain(null)); + ctx.escrowPaymentRepo.findById.mockResolvedValue(null); await expect( ctx.service.rollbackAllEscrows(PAYMENT_ID.toString()), @@ -227,7 +221,7 @@ describe("EscrowPaymentsService › cancelEscrowItem", () => { it("PENDING_ESCROW 상태 → CANCELLED로 전환", async () => { const payment = makePayment(); - ctx.escrowPaymentModel.findById.mockReturnValue(makeQueryChain(payment)); + ctx.escrowPaymentRepo.findById.mockResolvedValue(payment); await ctx.service.cancelEscrowItem( PAYMENT_ID.toString(), @@ -235,14 +229,14 @@ describe("EscrowPaymentsService › cancelEscrowItem", () => { ); expect(payment.escrows[0].status).toBe("CANCELLED"); - expect(payment.save).toHaveBeenCalled(); + expect(ctx.escrowPaymentRepo.save).toHaveBeenCalledWith(payment); }); it("ESCROWED 상태는 취소 불가 → InvalidEscrowCancelStatusException", async () => { const payment = makePayment({ escrows: [makeEscrowItem({ status: "ESCROWED" })], }); - ctx.escrowPaymentModel.findById.mockReturnValue(makeQueryChain(payment)); + ctx.escrowPaymentRepo.findById.mockResolvedValue(payment); await expect( ctx.service.cancelEscrowItem(PAYMENT_ID.toString(), ESCROW_ID.toString()), @@ -250,7 +244,7 @@ describe("EscrowPaymentsService › cancelEscrowItem", () => { }); it("존재하지 않는 결제 → EscrowPaymentNotFoundException", async () => { - ctx.escrowPaymentModel.findById.mockReturnValue(makeQueryChain(null)); + ctx.escrowPaymentRepo.findById.mockResolvedValue(null); await expect( ctx.service.cancelEscrowItem(PAYMENT_ID.toString(), ESCROW_ID.toString()), @@ -259,7 +253,7 @@ describe("EscrowPaymentsService › cancelEscrowItem", () => { it("존재하지 않는 escrowId → EscrowItemNotFoundException", async () => { const payment = makePayment(); - ctx.escrowPaymentModel.findById.mockReturnValue(makeQueryChain(payment)); + ctx.escrowPaymentRepo.findById.mockResolvedValue(payment); await expect( ctx.service.cancelEscrowItem( diff --git a/src/modules/escrow-payments/__tests__/query.spec.ts b/src/modules/escrow-payments/__tests__/query.spec.ts index 2417260..138f4ff 100644 --- a/src/modules/escrow-payments/__tests__/query.spec.ts +++ b/src/modules/escrow-payments/__tests__/query.spec.ts @@ -5,7 +5,6 @@ import { ESCROW_ID, PAYMENT_ID, makePayment, - makeQueryChain, makeCrudServiceTestingModule, makeServiceTestingModule, } from "./helpers"; @@ -25,14 +24,13 @@ describe("EscrowPaymentsCrudService › findAll", () => { it("buyerId OR sellerId 조건으로 조회", async () => { await ctx.service.findAll(BUYER_ID.toString(), { page: 1, limit: 5 }); - expect(ctx.escrowPaymentModel.find).toHaveBeenCalledWith( - expect.objectContaining({ - $or: [ - { buyerId: expect.any(Types.ObjectId) }, - { sellerId: expect.any(Types.ObjectId) }, - ], - }), - ); + const [filter] = ctx.escrowPaymentRepo.findMany.mock.calls[0]; + expect(filter).toMatchObject({ + $or: [ + { buyerId: expect.any(Types.ObjectId) }, + { sellerId: expect.any(Types.ObjectId) }, + ], + }); }); it("group=ongoing → PENDING_APPROVAL/APPROVED/PROCESSING/ACTIVE 필터", async () => { @@ -42,7 +40,7 @@ describe("EscrowPaymentsCrudService › findAll", () => { limit: 5, }); - const filter = ctx.escrowPaymentModel.find.mock.calls[0][0]; + const [filter] = ctx.escrowPaymentRepo.findMany.mock.calls[0]; expect(filter.status.$in).toEqual( expect.arrayContaining([ "PENDING_APPROVAL", @@ -60,7 +58,7 @@ describe("EscrowPaymentsCrudService › findAll", () => { limit: 5, }); - const filter = ctx.escrowPaymentModel.find.mock.calls[0][0]; + const [filter] = ctx.escrowPaymentRepo.findMany.mock.calls[0]; expect(filter.status.$in).toEqual( expect.arrayContaining(["COMPLETED", "CANCELLED"]), ); @@ -73,21 +71,21 @@ describe("EscrowPaymentsCrudService › findAll", () => { limit: 5, }); - const filter = ctx.escrowPaymentModel.find.mock.calls[0][0]; + const [filter] = ctx.escrowPaymentRepo.findMany.mock.calls[0]; expect(filter.status).toBe("ACTIVE"); }); it("필터 없으면 status 조건 없이 조회", async () => { await ctx.service.findAll(BUYER_ID.toString(), { page: 1, limit: 5 }); - const filter = ctx.escrowPaymentModel.find.mock.calls[0][0]; + const [filter] = ctx.escrowPaymentRepo.findMany.mock.calls[0]; expect(filter.status).toBeUndefined(); }); it("total, page, limit 포함한 응답 반환", async () => { const mockDocs = [makePayment(), makePayment()]; - ctx.escrowPaymentModel.find.mockReturnValue(makeQueryChain(mockDocs)); - ctx.escrowPaymentModel.countDocuments.mockResolvedValue(2); + ctx.escrowPaymentRepo.findMany.mockResolvedValue(mockDocs); + ctx.escrowPaymentRepo.countDocuments.mockResolvedValue(2); const result = await ctx.service.findAll(BUYER_ID.toString(), { page: 1, @@ -117,7 +115,7 @@ describe("EscrowPaymentsCrudService › findById", () => { buyerWalletAddress: "rBuyer123", sellerWalletAddress: "rSeller456", }); - ctx.escrowPaymentModel.findById.mockReturnValue(makeQueryChain(payment)); + ctx.escrowPaymentRepo.findByIdLean.mockResolvedValue(payment); const result = await ctx.service.findById( PAYMENT_ID.toString(), @@ -138,7 +136,7 @@ describe("EscrowPaymentsCrudService › findById", () => { }); it("존재하지 않는 ID → EscrowPaymentNotFoundException", async () => { - ctx.escrowPaymentModel.findById.mockReturnValue(makeQueryChain(null)); + ctx.escrowPaymentRepo.findByIdLean.mockResolvedValue(null); await expect( ctx.service.findById(PAYMENT_ID.toString(), BUYER_ID.toString()), @@ -147,7 +145,7 @@ describe("EscrowPaymentsCrudService › findById", () => { it("buyer도 seller도 아닌 제3자 → UnauthorizedPaymentActionException", async () => { const payment = makePayment(); - ctx.escrowPaymentModel.findById.mockReturnValue(makeQueryChain(payment)); + ctx.escrowPaymentRepo.findByIdLean.mockResolvedValue(payment); await expect( ctx.service.findById( @@ -167,7 +165,7 @@ describe("EscrowPaymentsService › getEscrowStatus", () => { it("정상 조회 → escrow 항목 반환", async () => { const payment = makePayment(); - ctx.escrowPaymentModel.findById.mockReturnValue(makeQueryChain(payment)); + ctx.escrowPaymentRepo.findByIdLean.mockResolvedValue(payment); const result = await ctx.service.getEscrowStatus( PAYMENT_ID.toString(), @@ -179,7 +177,7 @@ describe("EscrowPaymentsService › getEscrowStatus", () => { }); it("존재하지 않는 결제 → EscrowPaymentNotFoundException", async () => { - ctx.escrowPaymentModel.findById.mockReturnValue(makeQueryChain(null)); + ctx.escrowPaymentRepo.findByIdLean.mockResolvedValue(null); await expect( ctx.service.getEscrowStatus(PAYMENT_ID.toString(), ESCROW_ID.toString()), @@ -188,7 +186,7 @@ describe("EscrowPaymentsService › getEscrowStatus", () => { it("존재하지 않는 escrowId → EscrowItemNotFoundException", async () => { const payment = makePayment(); - ctx.escrowPaymentModel.findById.mockReturnValue(makeQueryChain(payment)); + ctx.escrowPaymentRepo.findByIdLean.mockResolvedValue(payment); await expect( ctx.service.getEscrowStatus( diff --git a/src/modules/escrow-payments/__tests__/recovery-scheduler.spec.ts b/src/modules/escrow-payments/__tests__/recovery-scheduler.spec.ts index 9d90dc8..c3e431c 100644 --- a/src/modules/escrow-payments/__tests__/recovery-scheduler.spec.ts +++ b/src/modules/escrow-payments/__tests__/recovery-scheduler.spec.ts @@ -1,9 +1,8 @@ import { Test, TestingModule } from "@nestjs/testing"; -import { getModelToken } from "@nestjs/mongoose"; import { Types } from "mongoose"; import { EscrowSubmitRecoveryScheduler } from "../escrow-submit-recovery.scheduler"; -import { EscrowPayment } from "../schemas/escrow-payment.schema"; -import { User } from "../../users/schemas/user.schema"; +import { EscrowPaymentRepository } from "../repositories/escrow-payment.repository"; +import { UserFacade } from "../repositories/user.facade"; import { EscrowPaymentsService } from "../escrow-payments.service"; import { XrplService } from "../../xrpl/xrpl.service"; import { @@ -17,15 +16,14 @@ import { makePayment, makeEscrowItem, makeBuyerUser, - makeQueryChain, - makeEscrowPaymentModelMock, - makeUserModelMock, + makeEscrowPaymentRepoMock, + makeUserFacadeMock, } from "./helpers"; describe("EscrowSubmitRecoveryScheduler › recoverStuckSubmittingEscrows (스케줄링 로직)", () => { let scheduler: EscrowSubmitRecoveryScheduler; - let escrowPaymentModel: ReturnType; - let userModel: ReturnType; + let escrowPaymentRepo: ReturnType; + let userFacade: ReturnType; let recoverSpy: jest.SpyInstance; const OLD_DATE = new Date(Date.now() - 10 * 60 * 1000); // 10분 전 @@ -46,17 +44,14 @@ describe("EscrowSubmitRecoveryScheduler › recoverStuckSubmittingEscrows (스 } beforeEach(async () => { - escrowPaymentModel = makeEscrowPaymentModelMock(); - userModel = makeUserModelMock(); + escrowPaymentRepo = makeEscrowPaymentRepoMock(); + userFacade = makeUserFacadeMock(); const module: TestingModule = await Test.createTestingModule({ providers: [ EscrowSubmitRecoveryScheduler, - { - provide: getModelToken(EscrowPayment.name), - useValue: escrowPaymentModel, - }, - { provide: getModelToken(User.name), useValue: userModel }, + { provide: EscrowPaymentRepository, useValue: escrowPaymentRepo }, + { provide: UserFacade, useValue: userFacade }, { provide: XrplService, useValue: { @@ -86,7 +81,7 @@ describe("EscrowSubmitRecoveryScheduler › recoverStuckSubmittingEscrows (스 }); it("SUBMITTING 없으면 → recoverSubmittingEscrow 미호출", async () => { - escrowPaymentModel.find.mockReturnValue(makeQueryChain([])); + escrowPaymentRepo.findStuckSubmitting.mockResolvedValue([]); await scheduler.recoverStuckSubmittingEscrows(); @@ -95,8 +90,8 @@ describe("EscrowSubmitRecoveryScheduler › recoverStuckSubmittingEscrows (스 it("5분 이상된 SUBMITTING 에스크로 → recoverSubmittingEscrow 호출", async () => { const payment = makeStuckPayment(); - escrowPaymentModel.find.mockReturnValue(makeQueryChain([payment])); - userModel.findById.mockReturnValue(makeQueryChain(makeBuyerUser())); + escrowPaymentRepo.findStuckSubmitting.mockResolvedValue([payment]); + userFacade.findById.mockResolvedValue(makeBuyerUser()); await scheduler.recoverStuckSubmittingEscrows(); @@ -110,8 +105,8 @@ describe("EscrowSubmitRecoveryScheduler › recoverStuckSubmittingEscrows (스 it("5분 미만 SUBMITTING → 아직 진행 중으로 판단, 건너뜀", async () => { const recentDate = new Date(Date.now() - 2 * 60 * 1000); // 2분 전 const payment = makeStuckPayment({ submittingAt: recentDate }); - escrowPaymentModel.find.mockReturnValue(makeQueryChain([payment])); - userModel.findById.mockReturnValue(makeQueryChain(makeBuyerUser())); + escrowPaymentRepo.findStuckSubmitting.mockResolvedValue([payment]); + userFacade.findById.mockResolvedValue(makeBuyerUser()); await scheduler.recoverStuckSubmittingEscrows(); @@ -123,12 +118,13 @@ describe("EscrowSubmitRecoveryScheduler › recoverStuckSubmittingEscrows (스 const payment2 = makeStuckPayment(); payment2._id = new Types.ObjectId(); - escrowPaymentModel.find.mockReturnValue( - makeQueryChain([payment1, payment2]), - ); - userModel.findById - .mockReturnValueOnce(makeQueryChain(null)) // payment1: 지갑 없음 - .mockReturnValueOnce(makeQueryChain(makeBuyerUser())); // payment2: 정상 + escrowPaymentRepo.findStuckSubmitting.mockResolvedValue([ + payment1, + payment2, + ]); + userFacade.findById + .mockResolvedValueOnce(null) // payment1: 지갑 없음 + .mockResolvedValueOnce(makeBuyerUser()); // payment2: 정상 await scheduler.recoverStuckSubmittingEscrows(); @@ -137,8 +133,8 @@ describe("EscrowSubmitRecoveryScheduler › recoverStuckSubmittingEscrows (스 it("recovered → 에러 없이 정상 완료", async () => { const payment = makeStuckPayment(); - escrowPaymentModel.find.mockReturnValue(makeQueryChain([payment])); - userModel.findById.mockReturnValue(makeQueryChain(makeBuyerUser())); + escrowPaymentRepo.findStuckSubmitting.mockResolvedValue([payment]); + userFacade.findById.mockResolvedValue(makeBuyerUser()); recoverSpy.mockResolvedValue("recovered"); await expect( @@ -148,8 +144,8 @@ describe("EscrowSubmitRecoveryScheduler › recoverStuckSubmittingEscrows (스 it("cancelled → 에러 없이 정상 완료", async () => { const payment = makeStuckPayment(); - escrowPaymentModel.find.mockReturnValue(makeQueryChain([payment])); - userModel.findById.mockReturnValue(makeQueryChain(makeBuyerUser())); + escrowPaymentRepo.findStuckSubmitting.mockResolvedValue([payment]); + userFacade.findById.mockResolvedValue(makeBuyerUser()); recoverSpy.mockResolvedValue("cancelled"); await expect( @@ -169,8 +165,8 @@ describe("EscrowSubmitRecoveryScheduler › recoverStuckSubmittingEscrows (스 submittingAt: OLD_DATE, }); const payment = makePayment({ escrows: [escrow1, escrow2] }); - escrowPaymentModel.find.mockReturnValue(makeQueryChain([payment])); - userModel.findById.mockReturnValue(makeQueryChain(makeBuyerUser())); + escrowPaymentRepo.findStuckSubmitting.mockResolvedValue([payment]); + userFacade.findById.mockResolvedValue(makeBuyerUser()); recoverSpy .mockRejectedValueOnce(new Error("XRPL connection error")) @@ -183,19 +179,14 @@ describe("EscrowSubmitRecoveryScheduler › recoverStuckSubmittingEscrows (스 expect(recoverSpy).toHaveBeenCalledTimes(2); }); - it("$elemMatch로 status=SUBMITTING AND submittingAt { - escrowPaymentModel.find.mockReturnValue(makeQueryChain([])); + it("findStuckSubmitting에 cutoff 날짜 전달", async () => { + escrowPaymentRepo.findStuckSubmitting.mockResolvedValue([]); await scheduler.recoverStuckSubmittingEscrows(); - expect(escrowPaymentModel.find).toHaveBeenCalledWith({ - escrows: { - $elemMatch: { - status: "SUBMITTING", - submittingAt: { $lt: expect.any(Date) }, - }, - }, - }); + expect(escrowPaymentRepo.findStuckSubmitting).toHaveBeenCalledWith( + expect.any(Date), + ); }); }); @@ -203,8 +194,8 @@ describe("EscrowSubmitRecoveryScheduler › recoverStuckSubmittingEscrows (스 describe("EscrowSubmitRecoveryScheduler › recoverSubmittingEscrow", () => { let scheduler: EscrowSubmitRecoveryScheduler; - let escrowPaymentModel: ReturnType; - let userModel: ReturnType; + let escrowPaymentRepo: ReturnType; + let userFacade: ReturnType; let xrplService: { findEscrowByCondition: jest.Mock }; let escrowPaymentsService: { getEscrowStatus: jest.Mock; @@ -214,8 +205,8 @@ describe("EscrowSubmitRecoveryScheduler › recoverSubmittingEscrow", () => { const BUYER_ADDRESS = "rBuyerAddress123"; beforeEach(async () => { - escrowPaymentModel = makeEscrowPaymentModelMock(); - userModel = makeUserModelMock(); + escrowPaymentRepo = makeEscrowPaymentRepoMock(); + userFacade = makeUserFacadeMock(); xrplService = { findEscrowByCondition: jest.fn().mockResolvedValue(null) }; escrowPaymentsService = { getEscrowStatus: jest.fn(), @@ -225,11 +216,8 @@ describe("EscrowSubmitRecoveryScheduler › recoverSubmittingEscrow", () => { const module: TestingModule = await Test.createTestingModule({ providers: [ EscrowSubmitRecoveryScheduler, - { - provide: getModelToken(EscrowPayment.name), - useValue: escrowPaymentModel, - }, - { provide: getModelToken(User.name), useValue: userModel }, + { provide: EscrowPaymentRepository, useValue: escrowPaymentRepo }, + { provide: UserFacade, useValue: userFacade }, { provide: XrplService, useValue: xrplService }, { provide: EscrowPaymentsService, useValue: escrowPaymentsService }, ], @@ -240,7 +228,7 @@ describe("EscrowSubmitRecoveryScheduler › recoverSubmittingEscrow", () => { ); }); - it("XRPL에 에스크로 있음 → DB 업데이트 후 recovered 반환", async () => { + it("XRPL에 에스크로 있음 → markEscrowed 호출 후 recovered 반환", async () => { const submittingEscrow = makeEscrowItem({ status: "SUBMITTING", condition: CONDITION, @@ -253,7 +241,7 @@ describe("EscrowSubmitRecoveryScheduler › recoverSubmittingEscrow", () => { const escrowedResult = makePayment({ escrows: [makeEscrowItem({ status: "ESCROWED" })], }); - escrowPaymentModel.findOneAndUpdate.mockResolvedValue(escrowedResult); + escrowPaymentRepo.markEscrowed.mockResolvedValue(escrowedResult); const result = await scheduler.recoverSubmittingEscrow( PAYMENT_ID.toString(), @@ -266,31 +254,21 @@ describe("EscrowSubmitRecoveryScheduler › recoverSubmittingEscrow", () => { BUYER_ADDRESS, CONDITION, ); - expect(escrowPaymentModel.findOneAndUpdate).toHaveBeenCalledWith( - expect.objectContaining({ - escrows: { - $elemMatch: expect.objectContaining({ status: "SUBMITTING" }), - }, - }), - expect.objectContaining({ - $set: expect.objectContaining({ - "escrows.$.status": "ESCROWED", - "escrows.$.xrplSequence": XRPL_SEQUENCE, - "escrows.$.txHashCreate": TX_HASH_CREATE, - }), - }), - expect.objectContaining({ new: true }), + expect(escrowPaymentRepo.markEscrowed).toHaveBeenCalledWith( + PAYMENT_ID.toString(), + ESCROW_ID.toString(), + XRPL_SEQUENCE, + TX_HASH_CREATE, ); }); - it("XRPL에 에스크로 없음 → 결제 취소 후 cancelled 반환", async () => { + it("XRPL에 에스크로 없음 → cancelSubmittingEscrow + rollback 후 cancelled 반환", async () => { const submittingEscrow = makeEscrowItem({ status: "SUBMITTING", condition: CONDITION, }); escrowPaymentsService.getEscrowStatus.mockResolvedValue(submittingEscrow); xrplService.findEscrowByCondition.mockResolvedValue(null); - escrowPaymentModel.findOneAndUpdate.mockResolvedValue(makePayment()); const result = await scheduler.recoverSubmittingEscrow( PAYMENT_ID.toString(), @@ -299,9 +277,9 @@ describe("EscrowSubmitRecoveryScheduler › recoverSubmittingEscrow", () => { ); expect(result).toBe("cancelled"); - expect(escrowPaymentModel.findOneAndUpdate).toHaveBeenCalledWith( - expect.objectContaining({ "escrows.status": "SUBMITTING" }), - { $set: { "escrows.$.status": "CANCELLED" } }, + expect(escrowPaymentRepo.cancelSubmittingEscrow).toHaveBeenCalledWith( + PAYMENT_ID.toString(), + ESCROW_ID.toString(), ); expect(escrowPaymentsService.rollbackAllEscrows).toHaveBeenCalledWith( PAYMENT_ID.toString(), @@ -314,7 +292,6 @@ describe("EscrowSubmitRecoveryScheduler › recoverSubmittingEscrow", () => { condition: undefined, }); escrowPaymentsService.getEscrowStatus.mockResolvedValue(submittingEscrow); - escrowPaymentModel.findOneAndUpdate.mockResolvedValue(makePayment()); const result = await scheduler.recoverSubmittingEscrow( PAYMENT_ID.toString(), @@ -338,10 +315,10 @@ describe("EscrowSubmitRecoveryScheduler › recoverSubmittingEscrow", () => { expect(result).toBe("recovered"); expect(xrplService.findEscrowByCondition).not.toHaveBeenCalled(); - expect(escrowPaymentModel.findOneAndUpdate).not.toHaveBeenCalled(); + expect(escrowPaymentRepo.markEscrowed).not.toHaveBeenCalled(); }); - it("복구 후 모든 escrow ESCROWED → payment ACTIVE 전환", async () => { + it("복구 후 모든 escrow ESCROWED → markActive 호출", async () => { const submittingEscrow = makeEscrowItem({ status: "SUBMITTING", condition: CONDITION, @@ -355,7 +332,7 @@ describe("EscrowSubmitRecoveryScheduler › recoverSubmittingEscrow", () => { status: "PROCESSING", escrows: [makeEscrowItem({ status: "ESCROWED" })], }); - escrowPaymentModel.findOneAndUpdate.mockResolvedValue(allEscrowedResult); + escrowPaymentRepo.markEscrowed.mockResolvedValue(allEscrowedResult); await scheduler.recoverSubmittingEscrow( PAYMENT_ID.toString(), @@ -363,9 +340,8 @@ describe("EscrowSubmitRecoveryScheduler › recoverSubmittingEscrow", () => { BUYER_ADDRESS, ); - expect(escrowPaymentModel.findByIdAndUpdate).toHaveBeenCalledWith( + expect(escrowPaymentRepo.markActive).toHaveBeenCalledWith( PAYMENT_ID.toString(), - { $set: { status: "ACTIVE" } }, ); }); }); diff --git a/src/modules/escrow-payments/__tests__/xrpl.spec.ts b/src/modules/escrow-payments/__tests__/xrpl.spec.ts index a3d06b7..b6fbd49 100644 --- a/src/modules/escrow-payments/__tests__/xrpl.spec.ts +++ b/src/modules/escrow-payments/__tests__/xrpl.spec.ts @@ -11,7 +11,6 @@ import { makeEscrowItem, makeBuyerUser, makeSellerUser, - makeQueryChain, makeProcessorTestingModule, } from "./helpers"; import { @@ -24,7 +23,7 @@ import { describe("EscrowCreateProcessor › createXrplEscrow", () => { let ctx: Awaited>; - // post-flight findOneAndUpdate 결과로 사용할 ESCROWED 상태 payment 픽스처 + // post-flight markEscrowed 결과로 사용할 ESCROWED 상태 payment 픽스처 function makeEscrowedResult(escrowOverrides: object = {}) { return makePayment({ status: "PROCESSING", @@ -49,18 +48,16 @@ describe("EscrowCreateProcessor › createXrplEscrow", () => { }); const escrowedResult = makeEscrowedResult(); - ctx.escrowPaymentModel.findById.mockReturnValue(makeQueryChain(payment)); - ctx.escrowPaymentModel.findOneAndUpdate - .mockResolvedValueOnce(payment) // pre-flight: PENDING_ESCROW → SUBMITTING - .mockResolvedValueOnce(escrowedResult); // post-flight: SUBMITTING → ESCROWED + ctx.escrowPaymentRepo.findById.mockResolvedValue(payment); + ctx.escrowPaymentRepo.preflight.mockResolvedValueOnce(payment); + ctx.escrowPaymentRepo.markEscrowed.mockResolvedValueOnce(escrowedResult); return { payment, escrowedResult }; } function setupWallets() { - ctx.userModel.findById - .mockReturnValueOnce(makeQueryChain(makeBuyerUser())) - .mockReturnValueOnce(makeQueryChain(makeSellerUser())); + ctx.userFacade.findByIdWithSeed.mockResolvedValueOnce(makeBuyerUser()); + ctx.userFacade.findById.mockResolvedValueOnce(makeSellerUser()); } beforeEach(async () => { @@ -71,7 +68,7 @@ describe("EscrowCreateProcessor › createXrplEscrow", () => { it("결제가 PROCESSING이 아니면 → PaymentNotActiveException", async () => { const payment = makePayment({ status: "APPROVED" }); - ctx.escrowPaymentModel.findById.mockReturnValue(makeQueryChain(payment)); + ctx.escrowPaymentRepo.findById.mockResolvedValue(payment); await expect( ctx.processor.createXrplEscrow( @@ -86,7 +83,7 @@ describe("EscrowCreateProcessor › createXrplEscrow", () => { status: "PROCESSING", escrows: [makeEscrowItem({ status: "ESCROWED" })], }); - ctx.escrowPaymentModel.findById.mockReturnValue(makeQueryChain(payment)); + ctx.escrowPaymentRepo.findById.mockResolvedValue(payment); await expect( ctx.processor.createXrplEscrow( @@ -97,7 +94,7 @@ describe("EscrowCreateProcessor › createXrplEscrow", () => { }); it("존재하지 않는 결제 → EscrowPaymentNotFoundException", async () => { - ctx.escrowPaymentModel.findById.mockReturnValue(makeQueryChain(null)); + ctx.escrowPaymentRepo.findById.mockResolvedValue(null); await expect( ctx.processor.createXrplEscrow( @@ -109,7 +106,7 @@ describe("EscrowCreateProcessor › createXrplEscrow", () => { it("buyer 지갑 없으면 → WalletNotAvailableException", async () => { setupProcessingPayment(); - ctx.userModel.findById.mockReturnValue(makeQueryChain({ wallet: null })); + ctx.userFacade.findByIdWithSeed.mockResolvedValue({ wallet: null }); await expect( ctx.processor.createXrplEscrow( @@ -121,9 +118,8 @@ describe("EscrowCreateProcessor › createXrplEscrow", () => { it("seller 지갑 없으면 → WalletNotAvailableException", async () => { setupProcessingPayment(); - ctx.userModel.findById - .mockReturnValueOnce(makeQueryChain(makeBuyerUser())) - .mockReturnValueOnce(makeQueryChain({ wallet: null })); + ctx.userFacade.findByIdWithSeed.mockResolvedValueOnce(makeBuyerUser()); + ctx.userFacade.findById.mockResolvedValueOnce({ wallet: null }); await expect( ctx.processor.createXrplEscrow( @@ -135,7 +131,7 @@ describe("EscrowCreateProcessor › createXrplEscrow", () => { // ── 정상 동작 ───────────────────────────────────────────────────────────── - it("pre-flight: PENDING_ESCROW → SUBMITTING + condition/fulfillment 원자적 저장", async () => { + it("pre-flight: preflight 호출 — PENDING_ESCROW → SUBMITTING + condition/fulfillment 원자적 저장", async () => { setupProcessingPayment(); setupWallets(); @@ -144,21 +140,11 @@ describe("EscrowCreateProcessor › createXrplEscrow", () => { ESCROW_ID.toString(), ); - expect(ctx.escrowPaymentModel.findOneAndUpdate).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - escrows: { - $elemMatch: expect.objectContaining({ status: "PENDING_ESCROW" }), - }, - }), - expect.objectContaining({ - $set: expect.objectContaining({ - "escrows.$.status": "SUBMITTING", - "escrows.$.condition": CONDITION, - "escrows.$.fulfillment": ENCRYPTED_FULFILLMENT, - "escrows.$.submittingAt": expect.any(Date), - }), - }), + expect(ctx.escrowPaymentRepo.preflight).toHaveBeenCalledWith( + PAYMENT_ID.toString(), + ESCROW_ID.toString(), + CONDITION, + ENCRYPTED_FULFILLMENT, ); }); @@ -202,7 +188,7 @@ describe("EscrowCreateProcessor › createXrplEscrow", () => { ); }); - it("post-flight: ESCROWED + xrplSequence + txHashCreate + escrowedAt 저장", async () => { + it("post-flight: markEscrowed 호출 — ESCROWED + xrplSequence + txHashCreate 저장", async () => { setupProcessingPayment(); setupWallets(); @@ -211,22 +197,15 @@ describe("EscrowCreateProcessor › createXrplEscrow", () => { ESCROW_ID.toString(), ); - expect(ctx.escrowPaymentModel.findOneAndUpdate).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ "escrows._id": ESCROW_ID }), - expect.objectContaining({ - $set: expect.objectContaining({ - "escrows.$.status": "ESCROWED", - "escrows.$.xrplSequence": XRPL_SEQUENCE, - "escrows.$.txHashCreate": TX_HASH_CREATE, - "escrows.$.escrowedAt": expect.any(Date), - }), - }), - expect.objectContaining({ new: true }), + expect(ctx.escrowPaymentRepo.markEscrowed).toHaveBeenCalledWith( + PAYMENT_ID.toString(), + ESCROW_ID.toString(), + XRPL_SEQUENCE, + TX_HASH_CREATE, ); }); - it("모든 escrow ESCROWED → findByIdAndUpdate로 payment ACTIVE 전환", async () => { + it("모든 escrow ESCROWED → markActive로 payment ACTIVE 전환", async () => { setupProcessingPayment(); setupWallets(); @@ -235,14 +214,13 @@ describe("EscrowCreateProcessor › createXrplEscrow", () => { ESCROW_ID.toString(), ); - expect(ctx.escrowPaymentModel.findByIdAndUpdate).toHaveBeenCalledWith( + expect(ctx.escrowPaymentRepo.markActive).toHaveBeenCalledWith( PAYMENT_ID.toString(), - { $set: { status: "ACTIVE" } }, ); expect(result.status).toBe("ACTIVE"); }); - it("PENDING_ESCROW 항목이 남아있으면 → payment ACTIVE 미전환", async () => { + it("PENDING_ESCROW 항목이 남아있으면 → markActive 미호출", async () => { const extraEscrow = makeEscrowItem({ _id: new Types.ObjectId(), status: "PENDING_ESCROW", @@ -256,10 +234,9 @@ describe("EscrowCreateProcessor › createXrplEscrow", () => { escrows: [makeEscrowItem({ status: "ESCROWED" }), extraEscrow], }); - ctx.escrowPaymentModel.findById.mockReturnValue(makeQueryChain(payment)); - ctx.escrowPaymentModel.findOneAndUpdate - .mockResolvedValueOnce(payment) - .mockResolvedValueOnce(partialResult); + ctx.escrowPaymentRepo.findById.mockResolvedValue(payment); + ctx.escrowPaymentRepo.preflight.mockResolvedValueOnce(payment); + ctx.escrowPaymentRepo.markEscrowed.mockResolvedValueOnce(partialResult); setupWallets(); await ctx.processor.createXrplEscrow( @@ -267,12 +244,12 @@ describe("EscrowCreateProcessor › createXrplEscrow", () => { ESCROW_ID.toString(), ); - expect(ctx.escrowPaymentModel.findByIdAndUpdate).not.toHaveBeenCalled(); + expect(ctx.escrowPaymentRepo.markActive).not.toHaveBeenCalled(); }); // ── Case A: XRPL 제출 실패 시 즉시 PENDING_ESCROW 복구 ────────────────────── - it("XRPL 제출 실패 → SUBMITTING → PENDING_ESCROW 즉시 복구 후 에러 재throw", async () => { + it("XRPL 제출 실패 → revertSubmitting 호출 후 에러 재throw", async () => { setupProcessingPayment(); setupWallets(); ctx.xrplService.createEscrow.mockRejectedValue( @@ -286,19 +263,13 @@ describe("EscrowCreateProcessor › createXrplEscrow", () => { ), ).rejects.toThrow("XRPL network error"); - // findOneAndUpdate 호출: 1) pre-flight, 2) revert(SUBMITTING→PENDING_ESCROW) - expect(ctx.escrowPaymentModel.findOneAndUpdate).toHaveBeenCalledTimes(2); - expect(ctx.escrowPaymentModel.findOneAndUpdate).toHaveBeenLastCalledWith( - expect.objectContaining({ - escrows: { - $elemMatch: expect.objectContaining({ status: "SUBMITTING" }), - }, - }), - { $set: { "escrows.$.status": "PENDING_ESCROW" } }, + expect(ctx.escrowPaymentRepo.revertSubmitting).toHaveBeenCalledWith( + PAYMENT_ID.toString(), + ESCROW_ID.toString(), ); }); - it("XRPL 실패 후 post-flight는 호출되지 않음", async () => { + it("XRPL 실패 후 markEscrowed는 호출되지 않음", async () => { setupProcessingPayment(); setupWallets(); ctx.xrplService.createEscrow.mockRejectedValue(new Error("XRPL error")); @@ -310,8 +281,7 @@ describe("EscrowCreateProcessor › createXrplEscrow", () => { ), ).rejects.toThrow(); - // pre-flight(1) + revert(1) = 2회, post-flight는 없음 - expect(ctx.escrowPaymentModel.findOneAndUpdate).toHaveBeenCalledTimes(2); + expect(ctx.escrowPaymentRepo.markEscrowed).not.toHaveBeenCalled(); }); // ── Case B: post-flight DB 저장 실패 시 재시도 ─────────────────────────────── @@ -323,11 +293,11 @@ describe("EscrowCreateProcessor › createXrplEscrow", () => { }); const escrowedResult = makeEscrowedResult(); - ctx.escrowPaymentModel.findById.mockReturnValue(makeQueryChain(payment)); - ctx.escrowPaymentModel.findOneAndUpdate - .mockResolvedValueOnce(payment) // pre-flight - .mockRejectedValueOnce(new Error("DB write error")) // post-flight 1회 실패 - .mockResolvedValueOnce(escrowedResult); // post-flight 2회 성공 + ctx.escrowPaymentRepo.findById.mockResolvedValue(payment); + ctx.escrowPaymentRepo.preflight.mockResolvedValueOnce(payment); + ctx.escrowPaymentRepo.markEscrowed + .mockRejectedValueOnce(new Error("DB write error")) + .mockResolvedValueOnce(escrowedResult); setupWallets(); const result = await ctx.processor.createXrplEscrow( @@ -336,8 +306,7 @@ describe("EscrowCreateProcessor › createXrplEscrow", () => { ); expect(result.escrows[0].status).toBe("ESCROWED"); - // pre-flight(1) + post-flight 실패(1) + post-flight 성공(1) = 3회 - expect(ctx.escrowPaymentModel.findOneAndUpdate).toHaveBeenCalledTimes(3); + expect(ctx.escrowPaymentRepo.markEscrowed).toHaveBeenCalledTimes(2); }, 10_000); it("post-flight DB 저장 3회 모두 실패 → 에러 throw (SUBMITTING 유지)", async () => { @@ -347,10 +316,9 @@ describe("EscrowCreateProcessor › createXrplEscrow", () => { }); const dbError = new Error("DB write error"); - ctx.escrowPaymentModel.findById.mockReturnValue(makeQueryChain(payment)); - ctx.escrowPaymentModel.findOneAndUpdate - .mockResolvedValueOnce(payment) // pre-flight - .mockRejectedValue(dbError); // 이후 모든 호출 실패 + ctx.escrowPaymentRepo.findById.mockResolvedValue(payment); + ctx.escrowPaymentRepo.preflight.mockResolvedValueOnce(payment); + ctx.escrowPaymentRepo.markEscrowed.mockRejectedValue(dbError); setupWallets(); await expect( @@ -360,7 +328,6 @@ describe("EscrowCreateProcessor › createXrplEscrow", () => { ), ).rejects.toThrow("DB write error"); - // pre-flight(1) + post-flight 3회 = 총 4회 - expect(ctx.escrowPaymentModel.findOneAndUpdate).toHaveBeenCalledTimes(4); + expect(ctx.escrowPaymentRepo.markEscrowed).toHaveBeenCalledTimes(3); }, 10_000); }); From b3592d627b05f371bbd2567ddc9d88f69c6636f5 Mon Sep 17 00:00:00 2001 From: takch02 Date: Thu, 14 May 2026 17:50:45 +0900 Subject: [PATCH 4/6] =?UTF-8?q?fix:=20=EB=B0=98=ED=99=98=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=20Document=20=EC=A0=9C=ED=9A=8C=ED=95=9C=20User?= =?UTF-8?q?=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/modules/escrow-payments/escrow-payments-crud.service.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/modules/escrow-payments/escrow-payments-crud.service.ts b/src/modules/escrow-payments/escrow-payments-crud.service.ts index 6eb9fa0..025b041 100644 --- a/src/modules/escrow-payments/escrow-payments-crud.service.ts +++ b/src/modules/escrow-payments/escrow-payments-crud.service.ts @@ -7,7 +7,7 @@ import { UnauthorizedPaymentActionException, WalletUserNotFoundException, } from "../../common/exceptions"; -import { UserDocument } from "../users/schemas/user.schema"; +import { User } from "../users/schemas/user.schema"; import { CreateEscrowPaymentDto } from "./dto/create-escrow-payment.dto"; import { QueryEscrowPaymentDto } from "./dto/query-escrow-payment.dto"; import { @@ -174,10 +174,10 @@ export class EscrowPaymentsCrudService { /** * XRPL 지갑 주소로 사용자 조회 */ - async findUserByWalletAddress(walletAddress: string): Promise { + async findUserByWalletAddress(walletAddress: string): Promise { const user = await this.userFacade.findByWalletAddress(walletAddress); if (!user) throw new WalletUserNotFoundException(walletAddress); - return user as UserDocument; + return user; } private mapToResponse(item: any, userId: string): EscrowPaymentResponse { From a63690f3b15466423a2a65b0ee52dee83161f851 Mon Sep 17 00:00:00 2001 From: takch02 Date: Thu, 14 May 2026 17:51:17 +0900 Subject: [PATCH 5/6] =?UTF-8?q?feat:=20preFlight=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=8B=9C=20=EC=9D=B8=EB=8D=B1=EC=8A=A4=20?= =?UTF-8?q?=EC=97=86=EC=9C=BC=EB=A9=B4=20=ED=92=80=EC=8A=A4=EC=BA=94=20?= =?UTF-8?q?=EB=B0=9C=EC=83=9D=ED=95=98=EB=AF=80=EB=A1=9C=20=EC=9D=B8?= =?UTF-8?q?=EB=8D=B1=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/modules/escrow-payments/schemas/escrow-payment.schema.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/modules/escrow-payments/schemas/escrow-payment.schema.ts b/src/modules/escrow-payments/schemas/escrow-payment.schema.ts index 6466b3c..0df6492 100644 --- a/src/modules/escrow-payments/schemas/escrow-payment.schema.ts +++ b/src/modules/escrow-payments/schemas/escrow-payment.schema.ts @@ -123,3 +123,4 @@ export class EscrowPayment { export const EscrowPaymentSchema = SchemaFactory.createForClass(EscrowPayment); EscrowPaymentSchema.index({ createdAt: -1 }); +EscrowPaymentSchema.index({ "escrows.status": 1, "escrows.submittingAt": 1 }); From b9a6d598655d98e7664a2f3b5062e7eaf0f22642 Mon Sep 17 00:00:00 2001 From: takch02 Date: Thu, 14 May 2026 17:51:36 +0900 Subject: [PATCH 6/6] =?UTF-8?q?feat:=20startSession=EC=9D=98=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=20=EB=AA=85=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../escrow-payments/repositories/escrow-payment.repository.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/modules/escrow-payments/repositories/escrow-payment.repository.ts b/src/modules/escrow-payments/repositories/escrow-payment.repository.ts index 6fe379b..f0c5df1 100644 --- a/src/modules/escrow-payments/repositories/escrow-payment.repository.ts +++ b/src/modules/escrow-payments/repositories/escrow-payment.repository.ts @@ -65,7 +65,7 @@ export class EscrowPaymentRepository { .exec(); } - startSession() { + startSession(): Promise { return this.model.db.startSession(); } @@ -116,6 +116,7 @@ export class EscrowPaymentRepository { "escrows.$.submittingAt": new Date(), }, }, + { new: true }, ) .exec(); }