feat(tosspayments,pay_kginicis,ecommerce,core): 현금영수증 프로바이더 훅 축 + 토스 환불 정합성
현금영수증 발급사를 PG 결제사와 독립적으로 선택할 수 있도록 공용 훅 축 (registered_providers/issue/cancel)에 토스·KG 프로바이더를 등록한다. KG 결제로 받으면서 영수증만 토스로 발급하는 구성이 가능해진다. KG 전용 발행 컨트롤러· 라우트·관리자 패널은 이 축으로 이전하며 제거했다 — 이중 발행 경로를 남기지 않기 위해서다. 토스 환불은 네 가지를 바로잡았다. 전액취소를 부분취소로 보내던 문제(R1), 면세금액 미전달로 복합과세 상점의 부가세가 틀어지던 문제(R2), 가상계좌 환불계좌를 싣지 않아 취소가 실패하던 문제(R3), 에스크로 부분취소를 PG 호출 전에 차단하지 않던 문제(E4). 취소·발급 요청에는 멱등키를 부착해 네트워크 재시도가 국세청에 이중 신고되거나 이중 환불되는 것을 막는다. 코어 활동로그 가드가 \Exception 만 잡아 채널 해석 실패(\Error)를 놓치고 있었다. 결제 입금통보 같은 외부 콜백이 로그 문제만으로 500을 반환하던 문제를 \Throwable 로 수정한다. 활동로그는 부수 기록이므로 그것을 유발한 요청까지 죽여서는 안 된다. 토스 API 서비스는 베이스 \Exception 을 직접 던지고 있어 도메인 예외 (TossPaymentsApiException)를 신설했다. KG의 KgInicisApiException 선례와 동형이며 RuntimeException 상속이라 기존 소비자의 catch 는 그대로 동작한다.
This commit is contained in:
1 parent
a9f85ff92b
commit
faa79b0ae1
95 files changed
+6637
-1372
No files matched your search
@@ -62,6 +62,7 @@
|
||||
- 과세·면세 금액 합계에 배송비가 포함됩니다. 그동안 배송비는 과세/면세 어느 쪽에도 집계되지 않아 상품 금액만 합산되었습니다.
|
||||
- 현금영수증 발급 용도 값을 소득공제(income) / 지출증빙(expense)으로 통일했습니다. 기존에 다른 값으로 저장된 주문은 모듈 업데이트 시 자동으로 정리됩니다.
|
||||
- 주문 상세 응답에 현금영수증 정보와 결제 영수증 링크가 포함됩니다.
|
||||
- 결제대행사 환불 연동에 환불 건 정보를 함께 전달합니다. 결제 플러그인이 이 값으로 중복 취소를 방지할 수 있습니다 — 네트워크 재시도로 같은 취소 요청이 두 번 전달되어도 이중 환불되지 않습니다.
|
||||
- 관리자 상품 목록이 상품마다 옵션 전체를 함께 내려주지 않습니다. 목록에는 옵션 개수와 재고 합계만 실리고, 옵션 상세는 행을 펼칠 때 필요한 상품의 것만 한 번에 가져옵니다. 옵션을 많이 쓰는 쇼핑몰일수록 목록을 여는 속도와 전송량이 크게 개선됩니다(상품 100건에 상품당 옵션 20건 기준, 목록 응답 크기 약 94% 감소). 화면에서 보이는 내용과 일괄 변경 적용 범위는 이전과 동일하며, 옵션을 목록 응답에 함께 받으려면 주소 뒤에 `?with_options=1` 을 붙이면 됩니다. (#76 @jordy-bitree 님께서 제보해주셨습니다.)
|
||||
- 관리자 주문 목록이 주문마다 주문상품 전체를 함께 내려주지 않습니다. 목록은 대표 상품 1건과 "외 N건" 표기만 사용하므로, 이제 그 1건과 개수만 읽습니다. 한 주문에 상품을 많이 담는 쇼핑몰일수록 목록을 여는 속도와 전송량이 개선됩니다. 화면에 보이는 내용(대표 상품·추가옵션 요약·"외 N건"·부분취소 표시)은 이전과 동일하며, 마이페이지 주문내역은 종전대로 주문상품 전체를 보여줍니다. (#76 @jordy-bitree 님께서 제보해주셨습니다.)
|
||||
- 상품 옵션의 표시 순서를 상품 등록/수정 화면과 동일하게 정렬 순서 기준으로 통일했습니다. 이전에는 관리자 상품 목록의 펼친 옵션만 정해진 순서 없이 표시돼 화면마다 순서가 달라 보일 수 있었습니다. (#76 @jordy-bitree 님께서 제보해주셨습니다.)
|
||||
|
||||
@@ -986,13 +986,17 @@ class OrderCancellationService
|
||||
$orderCurrency = $order->payment->currency ?: ($order->currency_snapshot['order_currency'] ?? null);
|
||||
$refundLocal = $this->resolveOrderCurrencyRefundAmount($result, $orderCurrency);
|
||||
|
||||
// $refund 는 이 환불 시도의 고유 레코드다. PG 리스너가 멱등키(Idempotency-Key)를 조립할 때
|
||||
// 사용한다 — 네트워크 재시도로 같은 취소가 두 번 도달해도 PG 가 중복 청구를 차단한다.
|
||||
// 인자 추가는 하위호환이다: 기존 리스너(5-파라미터)는 초과 인자를 그대로 무시한다.
|
||||
$pgResult = HookManager::applyFilters(
|
||||
'sirsoft-ecommerce.payment.refund',
|
||||
['success' => false, 'error_code' => null, 'error_message' => null, 'transaction_id' => null],
|
||||
$order,
|
||||
$order->payment,
|
||||
$refundLocal,
|
||||
$reason
|
||||
$reason,
|
||||
$refund
|
||||
);
|
||||
|
||||
if (! empty($pgResult['success'])) {
|
||||
|
||||
@@ -21,6 +21,16 @@ type EcommerceAuthFixtures = {
|
||||
dashboardToken: string;
|
||||
/** 권한 없는 일반 사용자 토큰 (메뉴 미노출 검증용) */
|
||||
noPermissionToken: string;
|
||||
/** 일반 구매자 토큰 (상점 화면 — 장바구니·주문서 등. 관리자 권한 없음) */
|
||||
userToken: string;
|
||||
/** 구매자 토큰 (마이페이지 — 주문조회·취소요청 등. 관리자 권한 없음) */
|
||||
customerToken: string;
|
||||
/** 주문 조회 전용 권한 토큰 (읽기만 — 수정 게이팅 검증용) */
|
||||
ordersReadToken: string;
|
||||
/** 회원 토큰 (마이페이지 주문상세·리뷰 등. 관리자 권한 없음) */
|
||||
memberToken: string;
|
||||
/** 주문 수정 권한 토큰 (입금확인·상태 전이 검증용) */
|
||||
ordersUpdateToken: string;
|
||||
/** 마일리지 조회 + 수동 관리 권한 보유 토큰 */
|
||||
mileageManageToken: string;
|
||||
/** 마일리지 조회 전용 토큰 (수동 지급/차감 게이팅 검증용) */
|
||||
@@ -29,6 +39,8 @@ type EcommerceAuthFixtures = {
|
||||
productManageToken: string;
|
||||
/** 주문 조회(`orders.read`) 권한 보유 토큰 (주문 상세 표시 검증용) */
|
||||
ordersReadToken: string;
|
||||
/** 주문 조회 + 수정 권한 보유 토큰 (입금확인·취소·현금영수증 발급 검증용) */
|
||||
orderManageToken: string;
|
||||
/** 배송정책 조회+수정 권한 보유 토큰 (배송정책 목록 표시 검증용) */
|
||||
shippingPolicyToken: string;
|
||||
/** 일반 쇼핑 사용자 토큰 (관리자 권한 없음 — 유저 화면 검증용) */
|
||||
@@ -48,6 +60,24 @@ export const test = base.extend<EcommerceAuthFixtures>({
|
||||
// 빈 권한 — 인증만 통과, 어떤 모듈 권한도 없음
|
||||
await use(issueToken());
|
||||
},
|
||||
userToken: async ({}, use) => {
|
||||
// 상점 이용자 — 관리자 권한 없이 인증만 통과한다 (장바구니/주문서는 회원이면 이용 가능).
|
||||
await use(issueToken());
|
||||
},
|
||||
customerToken: async ({}, use) => {
|
||||
// 마이페이지 이용자 — 자기 주문만 다루므로 관리자 권한이 필요 없다.
|
||||
await use(issueToken());
|
||||
},
|
||||
ordersReadToken: async ({}, use) => {
|
||||
await use(issueToken('sirsoft-ecommerce.orders.read'));
|
||||
},
|
||||
memberToken: async ({}, use) => {
|
||||
// 회원 — 자기 주문/리뷰만 다루므로 관리자 권한이 필요 없다.
|
||||
await use(issueToken());
|
||||
},
|
||||
ordersUpdateToken: async ({}, use) => {
|
||||
await use(issueToken('sirsoft-ecommerce.orders.read', 'sirsoft-ecommerce.orders.update'));
|
||||
},
|
||||
mileageManageToken: async ({}, use) => {
|
||||
await use(issueToken('sirsoft-ecommerce.mileage.read', 'sirsoft-ecommerce.mileage.manage'));
|
||||
},
|
||||
@@ -60,6 +90,9 @@ export const test = base.extend<EcommerceAuthFixtures>({
|
||||
ordersReadToken: async ({}, use) => {
|
||||
await use(issueToken('sirsoft-ecommerce.orders.read'));
|
||||
},
|
||||
orderManageToken: async ({}, use) => {
|
||||
await use(issueToken('sirsoft-ecommerce.orders.read', 'sirsoft-ecommerce.orders.update'));
|
||||
},
|
||||
shippingPolicyToken: async ({}, use) => {
|
||||
await use(
|
||||
issueToken(
|
||||
|
||||
+2
-2
@@ -13,8 +13,8 @@
|
||||
* - 무통장 1회 제한 쿠폰은 주문 생성 즉시 USED 차감되어 입금 전까지 재사용 불가(공개#57).
|
||||
*
|
||||
* Select 렌더/disabled 바인딩은 레이아웃 렌더 테스트(adminEcommerceSettingsOrder.test.tsx)가,
|
||||
* 차감 시점 분기·쿠폰 검증·선차감/반환은 PHPUnit(OrderCalculation·CouponUse·CouponRestore·
|
||||
* EcommerceSettingsMileageTiming·MigrateMileageDeductionTiming)가 커버한다. 본 spec 은 브라우저에서
|
||||
* 차감 시점 분기·쿠폰 검증·선차감/반환은 PHPUnit(OrderCalculation, CouponUse, CouponRestore,
|
||||
* EcommerceSettingsMileageTiming, MigrateMileageDeductionTiming)가 커버한다. 본 spec 은 브라우저에서
|
||||
* 설정 disabled 연동 + 주문서 위반 안내 노출만 왕복 검증한다.
|
||||
*
|
||||
* 활성화 전 사전 작업(data-testid 보강):
|
||||
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* 관리자 상태변경 시 사용자 알림 발송 (skeleton, placeholder).
|
||||
*
|
||||
* @scenario order-status-notification
|
||||
* @effects admin_status_change_triggers_user_notification
|
||||
*
|
||||
* e2e:allow 본 변경의 관리자측 표면은 무통장 입금확인 버튼+모달 추가(B2/D7)와 상태전이 알림
|
||||
* 중앙화(A35/A36/D9 — 발화는 백엔드 훅)다. 무통장 입금확인 UI 는 레이아웃 렌더링
|
||||
* 테스트(adminOrderConfirmDeposit.test.tsx 8건 green)가 버튼 노출조건(dbank+미결제)·
|
||||
* 모달 필드·confirm-deposit PATCH·order refetch 를 구조적으로 회귀 차단한다.
|
||||
* 알림 발화/매핑은 백엔드 회귀(OrderStatusNotificationListenerTest 5건,
|
||||
* OrderStatusNotificationTest Feature 5건, 전부 green)가 라이브 검증한다.
|
||||
* 본 placeholder spec 은 아래 사전작업 후 활성화한다.
|
||||
*
|
||||
* 활성화 전 사전 작업 (data-testid 보강):
|
||||
* 1. _partial_payment_info.json 입금확인 버튼에 data-testid="confirm-deposit-btn"
|
||||
* 2. _modal_confirm_deposit.json 에 data-testid="confirm-deposit-modal"
|
||||
* + 입금액/입금자명 입력 data-testid
|
||||
* 3. dbank+미결제 주문을 시드하는 fixture (adminDbankOrder)
|
||||
* 4. notification_logs 조회 또는 사용자 알림함 노출 검증 헬퍼
|
||||
* 5. orders.update 권한 토큰 fixture (ordersUpdateToken)
|
||||
* 6. test.describe.skip → test.describe
|
||||
*
|
||||
* 매트릭스(시나리오 매니페스트 order-status-notification.yaml 와 1:1):
|
||||
* - 관리자 update/일괄로 payment_complete 전이 → order_confirmed(결제완료) 발송 (D9)
|
||||
* - 무통장 입금확인 액션 → 결제완료 전이 + 알림
|
||||
* - DELIVERED→CONFIRMED 일괄 → 각 주문 order_completed + 재고 무변동 (A36)
|
||||
*/
|
||||
import { test, expect, authenticatePage } from '../../fixtures/ecommerce-auth';
|
||||
|
||||
const ORDER_DETAIL_URL = (orderNumber: string) => `/admin/ecommerce/orders/${orderNumber}`;
|
||||
|
||||
test.describe.skip('관리자 상태변경 → 사용자 알림 (placeholder — data-testid 보강 후 활성화)', () => {
|
||||
test('무통장 입금확인 버튼은 dbank+미결제 주문에서 노출되고 모달로 결제완료 처리한다', async ({
|
||||
page,
|
||||
ordersUpdateToken,
|
||||
}) => {
|
||||
await authenticatePage(page, ordersUpdateToken);
|
||||
await page.goto(ORDER_DETAIL_URL('ORD-DBANK-PENDING'));
|
||||
|
||||
const btn = page.getByTestId('confirm-deposit-btn');
|
||||
await expect(btn).toBeVisible();
|
||||
await btn.click();
|
||||
|
||||
await expect(page.getByTestId('confirm-deposit-modal')).toBeVisible();
|
||||
});
|
||||
|
||||
test('관리자가 payment_complete 로 전이하면 사용자에게 결제완료 알림이 적재된다 (D9)', async ({
|
||||
page,
|
||||
ordersUpdateToken,
|
||||
}) => {
|
||||
await authenticatePage(page, ordersUpdateToken);
|
||||
await page.goto(ORDER_DETAIL_URL('ORD-DBANK-PENDING'));
|
||||
// 입금확인 → 결제완료 → notification_logs(order_confirmed) 적재 검증 (헬퍼 보강 후)
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* 주문 항목상태 전이 + 리뷰 인라인 모달 (skeleton, placeholder).
|
||||
*
|
||||
* @scenario order-option-status-review
|
||||
* @effects member_sees_option_status_transition_on_order_detail,
|
||||
* member_can_open_review_modal_after_confirm,
|
||||
* review_button_opens_inline_modal_not_dead_route,
|
||||
* review_button_visible_only_when_can_write_review
|
||||
*
|
||||
* e2e:allow 본 변경의 사용자측 표면은 리뷰작성 진입을 데드라우트 navigate 제거 후 항목별 인라인
|
||||
* 모달(openModal modal_write_review)로 통일한 것(F1/D4)으로, 신규 화면이 아닌 기존
|
||||
* 항목 모달 단일화다. 레이아웃 렌더링 테스트(orderReviewButton.test.tsx 7건 green)가
|
||||
* show.json 데드라우트 부재 + _items 항목별 can_write_review 게이트 + openModal +
|
||||
* reviewTarget setState 를 구조적으로 회귀 차단한다. option_status 전이 자체는
|
||||
* 백엔드 회귀(OrderProcessingServiceTest/OrderServiceTest/OrderOptionServiceTest,
|
||||
* 전부 green)가 라이브 검증한다. 본 placeholder spec 은 아래 사전작업 후 활성화한다.
|
||||
*
|
||||
* 활성화 전 사전 작업 (data-testid 보강):
|
||||
* 1. _items.json 리뷰 버튼에 data-testid="order-item-review-btn-{optionId}"
|
||||
* 2. _modal_write_review.json 에 data-testid="review-modal"
|
||||
* 3. CONFIRMED 옵션을 가진 주문을 시드하는 fixture (memberConfirmedOrder)
|
||||
* 4. 회원 토큰 fixture (memberToken)
|
||||
* 5. test.describe.skip → test.describe
|
||||
*
|
||||
* 매트릭스(시나리오 매니페스트 order-option-status-transition.yaml 와 1:1):
|
||||
* - 무통장 입금확인/관리자 상태변경 → 주문상세 항목 배지 결제완료 전이
|
||||
* - CONFIRMED 옵션 → 리뷰 버튼 노출 + 클릭 시 인라인 모달 오픈 (데드라우트 없음)
|
||||
*/
|
||||
import { test, expect, authenticatePage } from '../fixtures/ecommerce-auth';
|
||||
|
||||
const ORDER_DETAIL_URL = (orderNumber: string) => `/mypage/orders/${orderNumber}`;
|
||||
|
||||
test.describe.skip('주문 항목상태 전이 + 리뷰 모달 (placeholder — data-testid 보강 후 활성화)', () => {
|
||||
test('CONFIRMED 옵션에 리뷰 버튼이 노출되고 클릭 시 인라인 모달이 열린다 (데드라우트 아님)', async ({
|
||||
page,
|
||||
memberToken,
|
||||
}) => {
|
||||
await authenticatePage(page, memberToken);
|
||||
await page.goto(ORDER_DETAIL_URL('ORD-CONFIRMED-WITH-OPTION'));
|
||||
|
||||
const reviewBtn = page.getByTestId(/^order-item-review-btn-/).first();
|
||||
await expect(reviewBtn).toBeVisible();
|
||||
await reviewBtn.click();
|
||||
|
||||
// 데드라우트 navigate 가 아니라 인라인 모달이 떠야 한다 (URL 불변)
|
||||
await expect(page.getByTestId('review-modal')).toBeVisible();
|
||||
expect(page.url()).not.toContain('/mypage/reviews/write');
|
||||
});
|
||||
|
||||
test('무통장 입금확인 후 주문상세 항목 배지가 결제완료로 전이된다', async ({
|
||||
page,
|
||||
memberToken,
|
||||
}) => {
|
||||
await authenticatePage(page, memberToken);
|
||||
await page.goto(ORDER_DETAIL_URL('ORD-DBANK-CONFIRMED'));
|
||||
|
||||
const statusBadge = page.getByTestId(/^order-option-status-badge-/).first();
|
||||
await expect(statusBadge).toHaveText(/결제완료|Payment/i);
|
||||
});
|
||||
});
|
||||
+682
@@ -0,0 +1,682 @@
|
||||
/**
|
||||
* 현금영수증 전주기 E2E (#454 S3 이월 → S5).
|
||||
*
|
||||
* checkout-cash-receipt.spec.ts 가 "신청 폼이 렌더·동작하는가" 를 본다면, 이 spec 은
|
||||
* 신청 이후의 **발급 생애주기**를 본다: 입금확인 → 자동발급 → 금액변동 → 전액취소·재발급.
|
||||
*
|
||||
* 단위 테스트가 green 인 상태에서도 실환경에서만 드러난 결함이 실제로 있었다(§13-3 실측):
|
||||
* - 취소 API 에 cancelReason 누락 → 토스가 400 거부 (Http::fake 는 본문을 검증하지 않음)
|
||||
* - 취소 응답의 'c_' 접두 키를 저장 → 영수증이 영원히 "활성" 으로 남아 재발급 미발동
|
||||
* 두 결함 모두 브라우저 + 실 PG 경로에서만 재현된다. 그래서 이 spec 은 PG 응답을 모킹하지 않고
|
||||
* 관리자 화면의 실제 버튼을 눌러 서버가 PG 를 호출하게 한다.
|
||||
*
|
||||
* 발급 결과는 화면 문구가 아니라 서버 상태(주문 상세 API 의 cash_receipt / cash_receipts)로
|
||||
* 단언한다 — 화면 문구는 i18n 으로 바뀌지만 발급 회차·금액·상태는 도메인 사실이다.
|
||||
*
|
||||
* 전제: 관리자 환경설정에 현금영수증 발급사(cash_receipt_provider)가 설정되어 있고 그 PG 의
|
||||
* 테스트 키가 유효해야 한다. 미설정/무효면 발급 자체가 불가하므로 해당 테스트는 skip 한다
|
||||
* (실패로 위장하지 않는다 — 환경 미비와 코드 결함을 구분한다).
|
||||
*
|
||||
* @scenario cash-receipt-provider-and-refund-integrity
|
||||
* @effects amount_change_triggers_cancel_then_reissue,
|
||||
* cancel_delegated_as_full_cancel_without_amount,
|
||||
* order_id_suffixed_with_issue_sequence,
|
||||
* tax_free_amount_sent_with_issue,
|
||||
* issue_response_mapped_to_receipt_key_and_issue_number,
|
||||
* receipt_type_converted_to_provider_specific_value
|
||||
*/
|
||||
import { test, expect, authenticatePage } from '../../fixtures/ecommerce-auth';
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
const CART_LOCALE = 'ko';
|
||||
|
||||
const TOGGLE = '#ext_checkout_cash_receipt_toggle';
|
||||
const IDENTIFIER = '#ext_checkout_cash_receipt_identifier';
|
||||
|
||||
type ApiResult = { status: number; body: string };
|
||||
|
||||
/** 앱이 쓰는 것과 동일한 Bearer 헤더로 API 를 호출한다. */
|
||||
async function api(
|
||||
page: Page,
|
||||
path: string,
|
||||
init: { method?: string; body?: unknown } = {}
|
||||
): Promise<ApiResult> {
|
||||
return page.evaluate(
|
||||
async ({ path, init, locale }) => {
|
||||
const res = await fetch(path, {
|
||||
method: init.method ?? 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'Accept-Language': locale,
|
||||
Authorization: `Bearer ${localStorage.getItem('auth_token')}`,
|
||||
},
|
||||
body: init.body === undefined ? undefined : JSON.stringify(init.body),
|
||||
});
|
||||
return { status: res.status, body: await res.text() };
|
||||
},
|
||||
{ path, init, locale: CART_LOCALE }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 주문번호로 관리자용 주문 id 를 얻는다.
|
||||
*
|
||||
* 관리자 상세 라우트는 id 바인딩이고, 주문 생성 결과로 손에 쥐는 건 주문번호다.
|
||||
* 유저 주문 상세는 주문번호로 조회되며 id 를 함께 내려준다.
|
||||
*/
|
||||
async function resolveOrderId(page: Page, orderNumber: string): Promise<number> {
|
||||
const res = await api(page, `/api/modules/sirsoft-ecommerce/user/orders/${orderNumber}`);
|
||||
expect(res.status, `주문(${orderNumber}) 조회 실패: ${res.body}`).toBe(200);
|
||||
const id = JSON.parse(res.body)?.data?.id as number;
|
||||
expect(id, '주문 id 를 얻지 못했습니다.').toBeTruthy();
|
||||
return id;
|
||||
}
|
||||
|
||||
/** 주문 상세(관리자)를 읽어 현금영수증 상태를 돌려준다. */
|
||||
async function readReceiptState(page: Page, orderId: number) {
|
||||
const res = await api(page, `/api/modules/sirsoft-ecommerce/admin/orders/${orderId}`);
|
||||
expect(res.status, `주문 상세 조회 실패: ${res.body}`).toBe(200);
|
||||
const data = JSON.parse(res.body)?.data ?? {};
|
||||
return {
|
||||
total: Number(data.total_amount ?? 0),
|
||||
active: data.cash_receipt ?? null,
|
||||
history: (data.cash_receipts ?? []) as Array<Record<string, unknown>>,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 주문 상세에서 입금확인을 처리한다 (자동발급 리스너의 발화 지점).
|
||||
*
|
||||
* 이미 주문 상세 화면에 있어야 한다.
|
||||
*/
|
||||
async function confirmDeposit(page: Page, amount: number): Promise<void> {
|
||||
await page.getByRole('button', { name: '입금확인' }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await dialog.waitFor({ state: 'visible' });
|
||||
await dialog.locator('input[type=number]').fill(String(amount));
|
||||
await dialog.locator('input[type=checkbox]').check();
|
||||
await dialog.getByRole('button', { name: '입금확인' }).click();
|
||||
|
||||
// 입금확인 요청은 그 안에서 자동발급 리스너가 **동기적으로** PG 왕복을 수행한다.
|
||||
// 실측상 응답까지 30초를 넘기는 경우가 있어(주문 생성 → 발급 완료 시각차 ~40초),
|
||||
// 30초로 잡으면 "모달이 안 닫힘" 으로 위장된 타임아웃이 난다 — 서버는 정상 처리 중이다.
|
||||
await expect(dialog).toBeHidden({ timeout: 120_000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* 자진발급(미신청 주문을 국세청 지정번호로 자동발급) 설정을 읽는다.
|
||||
*
|
||||
* 이 설정이 켜져 있으면 "신청하지 않은 주문" 도 입금완료 시 자동발급된다 —
|
||||
* 사후 발급(유저가 나중에 직접 발급)의 전제인 "미발급 주문" 이 만들어지지 않는다.
|
||||
*/
|
||||
async function selfIssueEnabled(page: Page): Promise<boolean> {
|
||||
const res = await api(page, '/api/modules/sirsoft-ecommerce/settings/payment');
|
||||
if (res.status !== 200) return false;
|
||||
return Boolean(JSON.parse(res.body)?.data?.order_settings?.cash_receipt_self_issue);
|
||||
}
|
||||
|
||||
/** 발급사가 설정되어 있는지 — 미설정이면 발급 경로 자체가 존재하지 않는다. */
|
||||
async function cashReceiptProvider(page: Page): Promise<string | null> {
|
||||
const res = await api(page, '/api/modules/sirsoft-ecommerce/settings/payment');
|
||||
if (res.status !== 200) return null;
|
||||
const provider = JSON.parse(res.body)?.data?.order_settings?.cash_receipt_provider;
|
||||
return typeof provider === 'string' && provider !== '' ? provider : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 무통장 + 현금영수증 신청 주문을 체크아웃 화면에서 실제로 만든다.
|
||||
*
|
||||
* 주문 생성은 검증 대상(발급 생애주기)의 전제이지 검증 대상이 아니므로 카트/임시주문은
|
||||
* 사용자가 밟는 API 경로로 만들되, 현금영수증 신청 자체는 화면 폼으로 입력한다.
|
||||
*/
|
||||
async function placeDbankOrderWithCashReceipt(
|
||||
page: Page,
|
||||
identifier: string | null
|
||||
): Promise<string> {
|
||||
const seeded = await page.evaluate(async ({ locale }) => {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'Accept-Language': locale,
|
||||
Authorization: `Bearer ${localStorage.getItem('auth_token')}`,
|
||||
};
|
||||
|
||||
const list = await fetch('/api/modules/sirsoft-ecommerce/products?per_page=30', {
|
||||
headers,
|
||||
}).then((r) => r.json());
|
||||
const candidates = (list?.data?.data ?? []) as Array<{ id: number }>;
|
||||
|
||||
await fetch('/api/modules/sirsoft-ecommerce/cart/all', { method: 'DELETE', headers });
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const detail = await fetch(
|
||||
`/api/modules/sirsoft-ecommerce/products/${candidate.id}`,
|
||||
{ headers }
|
||||
).then((r) => r.json());
|
||||
const options = (detail?.data?.options ?? []) as Array<{
|
||||
option_values_localized?: Record<string, string>;
|
||||
stock_quantity?: number;
|
||||
}>;
|
||||
|
||||
// 부분취소를 만들려면 수량 2 가 필요하다. 재고가 모자란 옵션을 고르면
|
||||
// 주문 생성이 422(재고 부족)로 실패하고, 그 실패가 "현금영수증 결함" 으로 오해된다.
|
||||
const usable = options.find((o) => (o.stock_quantity ?? 0) >= 2);
|
||||
if (!usable) continue;
|
||||
|
||||
const added = await fetch('/api/modules/sirsoft-ecommerce/cart', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
product_id: candidate.id,
|
||||
items: [{ option_values: usable.option_values_localized ?? {}, quantity: 2 }],
|
||||
}),
|
||||
});
|
||||
if (!added.ok) continue;
|
||||
|
||||
const cart = await fetch('/api/modules/sirsoft-ecommerce/cart', { headers }).then((r) =>
|
||||
r.json()
|
||||
);
|
||||
const itemIds = (cart?.data?.items ?? []).map((i: { id: number }) => i.id);
|
||||
const checkout = await fetch('/api/modules/sirsoft-ecommerce/checkout', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ item_ids: itemIds }),
|
||||
});
|
||||
if (checkout.ok) return { ok: true, body: '' };
|
||||
}
|
||||
return { ok: false, body: '담을 수 있는 상품이 없습니다.' };
|
||||
}, { locale: CART_LOCALE });
|
||||
|
||||
expect(seeded.ok, seeded.body).toBe(true);
|
||||
|
||||
await page.goto('/shop/checkout');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
|
||||
// 무통장 결제수단 선택
|
||||
await page.getByTestId('checkout-payment-method-dbank').click();
|
||||
|
||||
// 현금영수증 신청 (소득공제 + 휴대폰번호 — 기본 선택 그대로 사용).
|
||||
// identifier 가 null 이면 신청하지 않는다 — "미신청 주문" 은 사후 발급의 전제다.
|
||||
if (identifier !== null) {
|
||||
await page.locator(TOGGLE).click();
|
||||
await page.locator(IDENTIFIER).fill(identifier);
|
||||
}
|
||||
|
||||
// 나머지 필수값은 앱 상태로 채운다 — 이 spec 의 검증 대상이 아닌 입력이다.
|
||||
// - 입금할 계좌(_local.selectedDbank): 아이콘 기반 커스텀 컨트롤이라 클릭 대상이 불안정하다.
|
||||
// 관리자 환경설정에 등록된 계좌를 그대로 골라 앱 핸들러와 같은 상태를 만든다.
|
||||
// - 주문자 연락처: 회원 프로필에 없으면 빈 값이라 서버가 422 로 거절한다.
|
||||
// - 배송지 주소: 주소검색이 외부 스크립트(다음 우편번호) 팝업이다.
|
||||
const filled = await page.evaluate(async () => {
|
||||
const core = (
|
||||
window as unknown as {
|
||||
G7Core: {
|
||||
state: {
|
||||
getLocal(): Record<string, unknown>;
|
||||
setLocal(v: Record<string, unknown>): void;
|
||||
};
|
||||
};
|
||||
}
|
||||
).G7Core;
|
||||
|
||||
const settings = await fetch('/api/modules/sirsoft-ecommerce/settings/payment', {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${localStorage.getItem('auth_token')}`,
|
||||
},
|
||||
}).then((r) => r.json());
|
||||
|
||||
const accounts = (settings?.data?.order_settings?.bank_accounts ?? []) as Array<
|
||||
Record<string, unknown>
|
||||
>;
|
||||
if (accounts.length === 0) return { ok: false, reason: '등록된 입금 계좌가 없습니다.' };
|
||||
|
||||
const local = core.state.getLocal();
|
||||
const shipping = (local.shipping ?? {}) as Record<string, unknown>;
|
||||
const orderer = (local.orderer ?? {}) as Record<string, unknown>;
|
||||
|
||||
core.state.setLocal({
|
||||
selectedDbank: accounts[0],
|
||||
orderer: { ...orderer, phone: '010-1234-5678' },
|
||||
shipping: {
|
||||
...shipping,
|
||||
recipient_name: '테스트수령인',
|
||||
recipient_phone: '010-1234-5678',
|
||||
zipcode: '06236',
|
||||
address: '서울특별시 강남구 테헤란로 152',
|
||||
address_detail: '10층',
|
||||
},
|
||||
});
|
||||
return { ok: true, reason: '' };
|
||||
});
|
||||
|
||||
expect(filled.ok, filled.reason).toBe(true);
|
||||
|
||||
const submit = page.getByRole('button', { name: /결제하기/ });
|
||||
await expect(submit).toBeEnabled({ timeout: 10_000 });
|
||||
|
||||
// 주문 생성 실패를 "화면이 안 넘어감" 으로 뭉뚱그리면 원인을 알 수 없다.
|
||||
// 서버 응답을 붙잡아 실패 시 본문을 그대로 드러낸다.
|
||||
const orderPost = page.waitForResponse(
|
||||
(r) => r.url().includes('/user/orders') && r.request().method() === 'POST',
|
||||
{ timeout: 30_000 }
|
||||
);
|
||||
await submit.click();
|
||||
|
||||
const posted = await orderPost;
|
||||
expect(
|
||||
posted.status(),
|
||||
`주문 생성 실패 (${posted.status()}): ${await posted.text()}`
|
||||
).toBeLessThan(300);
|
||||
|
||||
await page.waitForURL(/\/shop\/orders\/.+\/complete/, { timeout: 30_000 });
|
||||
const orderNumber = page.url().match(/orders\/([^/]+)\/complete/)?.[1];
|
||||
expect(orderNumber, '주문번호를 URL 에서 얻지 못했습니다.').toBeTruthy();
|
||||
return orderNumber as string;
|
||||
}
|
||||
|
||||
test.describe('현금영수증 전주기 (신청 → 입금확인 → 자동발급 → 금액변동 재발급)', () => {
|
||||
// 각 테스트가 실 PG(토스) 왕복을 여러 번 수행한다. 병렬로 돌리면 동시 발급 요청이 겹쳐
|
||||
// 응답 지연이 누적되고, 그 지연이 "모달이 안 닫힘" 같은 UI 결함으로 오인된다.
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
// 한 테스트가 주문생성 → 입금확인 → PG 발급 → 부분취소 → PG 취소 → PG 재발급을 모두 밟는다.
|
||||
// PG 왕복이 여러 번이라 기본 30초로는 부족하다 (초과 시 모달 대기 실패로 위장된다).
|
||||
test.setTimeout(180_000);
|
||||
|
||||
test('무통장 신청 → 입금확인 → 자동발급 → 영수증 링크 노출', async ({
|
||||
page,
|
||||
orderManageToken,
|
||||
}) => {
|
||||
await authenticatePage(page, orderManageToken);
|
||||
await page.addInitScript((l) => localStorage.setItem('g7_locale', l), CART_LOCALE);
|
||||
await page.goto('/shop');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
|
||||
const provider = await cashReceiptProvider(page);
|
||||
test.skip(provider === null, '현금영수증 발급사가 설정되지 않은 환경 — 발급 경로 자체가 없음');
|
||||
|
||||
const orderNumber = await placeDbankOrderWithCashReceipt(page, '01012341234');
|
||||
|
||||
// 주문 조회 → 입금 전에는 발급 이력이 없어야 한다.
|
||||
const orderId = await resolveOrderId(page, orderNumber);
|
||||
|
||||
const before = await readReceiptState(page, orderId);
|
||||
expect(before.active, '입금 전에는 발급된 영수증이 없어야 한다').toBeNull();
|
||||
|
||||
// 관리자 화면에서 입금확인 — 자동발급 리스너가 여기서 발화한다.
|
||||
await page.goto(`/admin/ecommerce/orders/${orderId}`);
|
||||
await confirmDeposit(page, before.total);
|
||||
|
||||
// 자동발급 결과를 서버 상태로 단언한다.
|
||||
await expect
|
||||
.poll(async () => (await readReceiptState(page, orderId)).active?.issue_status, {
|
||||
timeout: 30_000,
|
||||
})
|
||||
.toBe('COMPLETED');
|
||||
|
||||
const after = await readReceiptState(page, orderId);
|
||||
expect(after.active?.provider).toBe(provider);
|
||||
expect(Number(after.active?.amount)).toBe(before.total);
|
||||
expect(after.active?.issue_number, '발급번호가 없다').toBeTruthy();
|
||||
expect(String(after.active?.receipt_url ?? ''), '영수증 URL 이 없다').toMatch(/^https?:\/\//);
|
||||
|
||||
// 식별번호는 마스킹되어야 한다 (원문 노출 금지).
|
||||
expect(String(after.active?.identifier_masked ?? '')).not.toContain('01012341234');
|
||||
|
||||
// 화면에도 링크가 노출된다.
|
||||
await page.reload();
|
||||
await expect(page.locator('a[href^="http"]', { hasText: /영수증/ }).first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
});
|
||||
|
||||
test('자동발급 → 부분환불 → 전액취소 후 재발급 (발급 회차 증가)', async ({
|
||||
page,
|
||||
orderManageToken,
|
||||
}) => {
|
||||
await authenticatePage(page, orderManageToken);
|
||||
await page.addInitScript((l) => localStorage.setItem('g7_locale', l), CART_LOCALE);
|
||||
await page.goto('/shop');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
|
||||
const provider = await cashReceiptProvider(page);
|
||||
test.skip(provider === null, '현금영수증 발급사가 설정되지 않은 환경');
|
||||
|
||||
const orderNumber = await placeDbankOrderWithCashReceipt(page, '01055556666');
|
||||
const orderId = await resolveOrderId(page, orderNumber);
|
||||
|
||||
const before = await readReceiptState(page, orderId);
|
||||
|
||||
// 입금확인 → 자동발급
|
||||
await page.goto(`/admin/ecommerce/orders/${orderId}`);
|
||||
await confirmDeposit(page, before.total);
|
||||
|
||||
await expect
|
||||
.poll(async () => (await readReceiptState(page, orderId)).active?.issue_status, {
|
||||
timeout: 30_000,
|
||||
})
|
||||
.toBe('COMPLETED');
|
||||
|
||||
const issued = await readReceiptState(page, orderId);
|
||||
const firstReceiptId = issued.active?.id;
|
||||
const firstNumber = issued.active?.issue_number;
|
||||
expect(firstReceiptId, '최초 발급 영수증을 식별하지 못했다').toBeTruthy();
|
||||
|
||||
// 부분취소 — 주문상품 1건(수량 2) 중 1 개만 취소해 주문 금액을 낮춘다.
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// 주문상품 행이 렌더될 때까지 기다린다. 주문 데이터는 비동기로 로드되므로
|
||||
// 바로 체크박스를 누르면 헤더(전체선택)만 있는 상태를 건드리게 된다.
|
||||
const rowCheckbox = page.locator('input[type=checkbox]').nth(1);
|
||||
await rowCheckbox.waitFor({ state: 'visible', timeout: 20_000 });
|
||||
await rowCheckbox.click();
|
||||
|
||||
// 화면 문구("N개 선택됨")가 아니라 핸들러가 실제로 읽는 앱 상태를 단언한다.
|
||||
// buildOrderDetailBulkConfirmData 는 _local.selectedProducts 를 읽으며,
|
||||
// 이 값이 비어 있으면 취소할 항목이 없다고 판단해 모달을 열지 않는다.
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
page.evaluate(() => {
|
||||
const g = (
|
||||
window as unknown as {
|
||||
G7Core?: { state?: { getLocal?(): Record<string, unknown> } };
|
||||
}
|
||||
).G7Core;
|
||||
return ((g?.state?.getLocal?.()?.selectedProducts ?? []) as unknown[]).length;
|
||||
}),
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
// 주문상태 Select 는 네이티브 <select> 가 아니라 커스텀 listbox 다 (Select.tsx).
|
||||
// 옵션 클릭이 합성 change 이벤트를 만들어 _local.batchOrderStatus 를 커밋한다.
|
||||
await page.getByRole('button', { name: '주문상태 변경' }).click();
|
||||
const statusListbox = page.getByRole('listbox').last();
|
||||
await statusListbox.waitFor({ state: 'visible', timeout: 10_000 });
|
||||
await statusListbox.getByRole('option', { name: '주문취소', exact: true }).click();
|
||||
|
||||
await expect(statusListbox).toBeHidden({ timeout: 10_000 });
|
||||
|
||||
// 핸들러(buildOrderDetailBulkConfirmData)는 _local.batchOrderStatus 를 읽는다.
|
||||
// 화면상 드롭다운 라벨이 바뀌어도 이 상태가 커밋되지 않으면 핸들러는 경고 토스트만
|
||||
// 띄우고 조용히 반환한다 — 증상은 "모달이 안 열림" 이라 원인을 오해하기 쉽다.
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
page.evaluate(() => {
|
||||
const g = (
|
||||
window as unknown as {
|
||||
G7Core?: { state?: { getLocal?(): Record<string, unknown> } };
|
||||
}
|
||||
).G7Core;
|
||||
return (g?.state?.getLocal?.()?.batchOrderStatus ?? null) as string | null;
|
||||
}),
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toBe('cancelled');
|
||||
|
||||
const applyButton = page.getByRole('button', { name: '일괄변경' });
|
||||
await expect(applyButton).toBeEnabled({ timeout: 10_000 });
|
||||
await applyButton.click();
|
||||
|
||||
const cancelModal = page.getByRole('dialog');
|
||||
await cancelModal.waitFor({ state: 'visible', timeout: 30_000 });
|
||||
|
||||
// 취소 수량을 1 로 낮춰 부분취소로 만든다 (기본값은 주문 수량 전량).
|
||||
// 전량이면 발급 대상 금액이 0 이 되어 "취소 후 재발급" 이 아니라 "발급 대상 소멸" 이 된다.
|
||||
const qty = cancelModal.locator('input[type=number]').first();
|
||||
await qty.fill('1');
|
||||
|
||||
await cancelModal.locator('select').first().selectOption({ label: '관리자 취소' });
|
||||
await cancelModal.getByRole('button', { name: '주문 취소' }).click();
|
||||
await expect(cancelModal).toBeHidden({ timeout: 60_000 });
|
||||
|
||||
// 금액이 줄었으면 기존 영수증은 전액취소되고 새 금액으로 재발급되어야 한다 (D7).
|
||||
// receipt_key 는 API 가 노출하지 않으므로(자격증명성 식별자) 영수증 행 id 로 판별한다.
|
||||
await expect
|
||||
.poll(async () => (await readReceiptState(page, orderId)).active?.id, {
|
||||
timeout: 60_000,
|
||||
})
|
||||
.not.toBe(firstReceiptId);
|
||||
|
||||
const reissued = await readReceiptState(page, orderId);
|
||||
|
||||
expect(reissued.active?.issue_status).toBe('COMPLETED');
|
||||
expect(Number(reissued.active?.amount), '재발급 금액이 취소 후 주문 금액과 달라졌다').toBe(
|
||||
reissued.total
|
||||
);
|
||||
expect(Number(reissued.active?.amount)).toBeLessThan(before.total);
|
||||
expect(reissued.active?.issue_number).not.toBe(firstNumber);
|
||||
|
||||
// 이력에 취소 행이 남아야 한다 — 부분환불이 영수증에 반영되지 않으면
|
||||
// 고객이 실제 결제액과 다른 금액의 영수증을 계속 보유하게 된다.
|
||||
const cancelRow = reissued.history.find((r) => r.transaction_type === 'cancel');
|
||||
expect(cancelRow, '취소 이력이 없다 — 부분취소가 영수증에 반영되지 않았다').toBeTruthy();
|
||||
expect(cancelRow?.issue_status).toBe('COMPLETED');
|
||||
|
||||
// 부분취소인데 영수증을 부분취소하면 안 된다 — 취소 행 금액은 원 발급액 전액이다 (D7).
|
||||
expect(Number(cancelRow?.amount)).toBe(before.total);
|
||||
|
||||
// 이력은 발급 → 취소 → 재발급 3건이어야 한다.
|
||||
expect(reissued.history.length).toBe(3);
|
||||
});
|
||||
|
||||
test('입금 전에는 발급 버튼이 비활성 — 발급 시도가 불가능하다', async ({
|
||||
page,
|
||||
orderManageToken,
|
||||
}) => {
|
||||
await authenticatePage(page, orderManageToken);
|
||||
await page.addInitScript((l) => localStorage.setItem('g7_locale', l), CART_LOCALE);
|
||||
await page.goto('/shop');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
|
||||
const provider = await cashReceiptProvider(page);
|
||||
test.skip(provider === null, '현금영수증 발급사가 설정되지 않은 환경');
|
||||
|
||||
const orderNumber = await placeDbankOrderWithCashReceipt(page, '01077778888');
|
||||
const orderId = await resolveOrderId(page, orderNumber);
|
||||
|
||||
await page.goto(`/admin/ecommerce/orders/${orderId}`);
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
|
||||
// 입금 전 상태에서는 발급 버튼이 없어야 한다 (입금확인 후에만 발급 가능).
|
||||
await expect(page.getByRole('button', { name: '현금영수증 발급' })).toHaveCount(0);
|
||||
|
||||
// 서버도 같은 판정을 해야 한다 — UI 만 숨기고 API 가 열려 있으면 우회 발급이 가능하다.
|
||||
// 필드명은 FormRequest 규칙(receipt_type)과 일치해야 한다. 틀린 키를 보내면 "미입금 거부"가
|
||||
// 아니라 "필수값 누락"으로 422 가 나서, 가드가 죽어 있어도 테스트는 통과해 버린다.
|
||||
const forced = await api(
|
||||
page,
|
||||
`/api/modules/sirsoft-ecommerce/admin/orders/${orderId}/cash-receipt`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: { receipt_type: 'income', identifier_type: 'phone', identifier: '01012341234' },
|
||||
}
|
||||
);
|
||||
expect(forced.status, `입금 전 발급이 서버에서 허용됐다: ${forced.body}`).toBeGreaterThanOrEqual(400);
|
||||
|
||||
// 거부 사유가 "미입금"이어야 한다 — 검증 실패로 인한 422 와 구분한다.
|
||||
expect(
|
||||
JSON.parse(forced.body)?.errors?.error_code,
|
||||
`미입금 가드가 아닌 다른 사유로 거부됐다: ${forced.body}`
|
||||
).toBe('PAYMENT_NOT_PAID');
|
||||
|
||||
const state = await readReceiptState(page, orderId);
|
||||
expect(state.active, '입금 전인데 영수증이 발급되었다').toBeNull();
|
||||
});
|
||||
|
||||
test('미신청 주문 → 입금완료 후 사후 발급 (유저가 직접 발급)', async ({
|
||||
page,
|
||||
orderManageToken,
|
||||
}) => {
|
||||
await authenticatePage(page, orderManageToken);
|
||||
await page.addInitScript((l) => localStorage.setItem('g7_locale', l), CART_LOCALE);
|
||||
await page.goto('/shop');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
|
||||
const provider = await cashReceiptProvider(page);
|
||||
test.skip(provider === null, '현금영수증 발급사가 설정되지 않은 환경');
|
||||
|
||||
// 자진발급이 켜져 있으면 미신청 주문도 입금완료 시 국세청 지정번호로 자동발급된다.
|
||||
// 그 경우 "미발급 상태의 입금완료 주문" 자체가 만들어지지 않으므로 사후 발급을 검증할 수 없다.
|
||||
// (자진발급 자체의 동작은 아래 '자진발급' 테스트가 검증한다.)
|
||||
test.skip(
|
||||
await selfIssueEnabled(page),
|
||||
'자진발급이 켜진 환경 — 미신청 주문도 자동발급되어 사후 발급 대상이 생기지 않음'
|
||||
);
|
||||
|
||||
// 신청 없이 무통장 주문 → 자진발급 꺼짐 + 미신청이므로 발급되지 않아야 한다.
|
||||
const orderNumber = await placeDbankOrderWithCashReceipt(page, null);
|
||||
const orderId = await resolveOrderId(page, orderNumber);
|
||||
const before = await readReceiptState(page, orderId);
|
||||
|
||||
await page.goto(`/admin/ecommerce/orders/${orderId}`);
|
||||
await confirmDeposit(page, before.total);
|
||||
|
||||
// 입금완료 후에도 영수증은 없어야 한다 — 신청하지 않았고 자진발급도 꺼져 있으므로.
|
||||
expect(
|
||||
(await readReceiptState(page, orderId)).active,
|
||||
'신청하지 않았고 자진발급도 꺼졌는데 발급되었다'
|
||||
).toBeNull();
|
||||
|
||||
// 이제 사후 발급 — 유저 경로로 발급한다 (입금완료 + 미발급이므로 허용되어야 한다).
|
||||
const issued = await api(
|
||||
page,
|
||||
`/api/modules/sirsoft-ecommerce/user/orders/${orderId}/cash-receipt`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: { receipt_type: 'income', identifier_type: 'phone', identifier: '01099998888' },
|
||||
}
|
||||
);
|
||||
expect(issued.status, `사후 발급이 거부됐다: ${issued.body}`).toBe(200);
|
||||
|
||||
await expect
|
||||
.poll(async () => (await readReceiptState(page, orderId)).active?.issue_status, {
|
||||
timeout: 30_000,
|
||||
})
|
||||
.toBe('COMPLETED');
|
||||
|
||||
const after = await readReceiptState(page, orderId);
|
||||
expect(Number(after.active?.amount)).toBe(before.total);
|
||||
expect(after.active?.issue_number, '발급번호가 없다').toBeTruthy();
|
||||
expect(String(after.active?.identifier_masked ?? '')).not.toContain('01099998888');
|
||||
|
||||
// 이미 발급된 주문의 재발급 시도는 409 여야 한다 (중복 발급 차단).
|
||||
const again = await api(
|
||||
page,
|
||||
`/api/modules/sirsoft-ecommerce/user/orders/${orderId}/cash-receipt`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: { receipt_type: 'income', identifier_type: 'phone', identifier: '01099998888' },
|
||||
}
|
||||
);
|
||||
expect(again.status, `이미 발급된 주문에 중복 발급이 허용됐다: ${again.body}`).toBe(409);
|
||||
expect(JSON.parse(again.body)?.errors?.error_code).toBe('ALREADY_ISSUED');
|
||||
});
|
||||
|
||||
test('자진발급 ON + 미신청 주문 → 국세청 지정번호로 소득공제 자동발급', async ({
|
||||
page,
|
||||
orderManageToken,
|
||||
}) => {
|
||||
await authenticatePage(page, orderManageToken);
|
||||
await page.addInitScript((l) => localStorage.setItem('g7_locale', l), CART_LOCALE);
|
||||
await page.goto('/shop');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
|
||||
const provider = await cashReceiptProvider(page);
|
||||
test.skip(provider === null, '현금영수증 발급사가 설정되지 않은 환경');
|
||||
test.skip(!(await selfIssueEnabled(page)), '자진발급이 꺼진 환경 — 이 경로가 존재하지 않음');
|
||||
|
||||
// 신청하지 않은 주문이라도 자진발급이 켜져 있으면 입금완료 시 발급된다.
|
||||
// (현금 결제분을 미발급으로 남기지 않기 위한 제도적 처리 — 국세청 지정번호로 소득공제 발급)
|
||||
const orderNumber = await placeDbankOrderWithCashReceipt(page, null);
|
||||
const orderId = await resolveOrderId(page, orderNumber);
|
||||
const before = await readReceiptState(page, orderId);
|
||||
|
||||
await page.goto(`/admin/ecommerce/orders/${orderId}`);
|
||||
await confirmDeposit(page, before.total);
|
||||
|
||||
await expect
|
||||
.poll(async () => (await readReceiptState(page, orderId)).active?.issue_status, {
|
||||
timeout: 60_000,
|
||||
})
|
||||
.toBe('COMPLETED');
|
||||
|
||||
const issued = await readReceiptState(page, orderId);
|
||||
|
||||
// 자진발급은 제도상 소득공제용만 가능하다 (지출증빙 자진발급 불가).
|
||||
expect(issued.active?.receipt_type, '자진발급인데 소득공제가 아니다').toBe('income');
|
||||
expect(Number(issued.active?.amount)).toBe(before.total);
|
||||
expect(issued.active?.issue_number, '발급번호가 없다').toBeTruthy();
|
||||
|
||||
// 국세청 지정번호(0100001234)로 발급되어야 한다 — 마스킹 뒤 4자리로 확인한다.
|
||||
expect(
|
||||
String(issued.active?.identifier_masked ?? ''),
|
||||
'자진발급 지정번호가 아닌 식별번호로 발급되었다'
|
||||
).toContain('1234');
|
||||
});
|
||||
|
||||
test('지출증빙 + 자진발급 지정번호(0100001234) 조합은 거부된다 (422)', async ({
|
||||
page,
|
||||
orderManageToken,
|
||||
}) => {
|
||||
await authenticatePage(page, orderManageToken);
|
||||
await page.addInitScript((l) => localStorage.setItem('g7_locale', l), CART_LOCALE);
|
||||
await page.goto('/shop');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
|
||||
const provider = await cashReceiptProvider(page);
|
||||
test.skip(provider === null, '현금영수증 발급사가 설정되지 않은 환경');
|
||||
|
||||
// 입금확인을 하지 않는다 — 용도×식별번호 조합 검증은 FormRequest 단계라
|
||||
// 발급 가드(미입금/기발급)보다 **먼저** 걸린다. 입금까지 진행하면 자진발급 설정에 따라
|
||||
// 이미 발급된 상태가 되어 409(ALREADY_ISSUED)가 먼저 나고, 정작 검증 규칙을 못 건드린다.
|
||||
const orderNumber = await placeDbankOrderWithCashReceipt(page, null);
|
||||
const orderId = await resolveOrderId(page, orderNumber);
|
||||
|
||||
const endpoint = `/api/modules/sirsoft-ecommerce/admin/orders/${orderId}/cash-receipt`;
|
||||
|
||||
// 자진발급 지정번호는 소득공제 전용이다 (지출증빙 자진발급은 제도상 불가).
|
||||
const selfIssueAsExpense = await api(page, endpoint, {
|
||||
method: 'POST',
|
||||
body: { receipt_type: 'expense', identifier_type: 'phone', identifier: '0100001234' },
|
||||
});
|
||||
expect(
|
||||
selfIssueAsExpense.status,
|
||||
`지출증빙 + 자진발급번호가 허용됐다: ${selfIssueAsExpense.body}`
|
||||
).toBe(422);
|
||||
|
||||
// 소득공제 + 사업자등록번호도 거부된다 (INCOME 은 phone/card 만 허용).
|
||||
const incomeWithBusiness = await api(page, endpoint, {
|
||||
method: 'POST',
|
||||
body: { receipt_type: 'income', identifier_type: 'business', identifier: '1234567890' },
|
||||
});
|
||||
expect(
|
||||
incomeWithBusiness.status,
|
||||
`소득공제 + 사업자등록번호가 허용됐다: ${incomeWithBusiness.body}`
|
||||
).toBe(422);
|
||||
|
||||
// 반증: 허용 조합(소득공제 + 자진발급번호)은 검증을 통과해야 한다.
|
||||
// 거부가 "자진발급번호 자체 금지"로 과잉 적용되면 여기서 드러난다.
|
||||
// 미입금 주문이므로 검증 통과 후 발급 가드에 걸려 PAYMENT_NOT_PAID 가 나온다 —
|
||||
// 즉 422 라도 error_code 가 검증 오류가 아닌 발급 가드여야 한다.
|
||||
const selfIssueAsIncome = await api(page, endpoint, {
|
||||
method: 'POST',
|
||||
body: { receipt_type: 'income', identifier_type: 'phone', identifier: '0100001234' },
|
||||
});
|
||||
expect(
|
||||
JSON.parse(selfIssueAsIncome.body)?.errors?.error_code,
|
||||
`소득공제 + 자진발급번호가 검증에서 거부됐다 — 과잉 차단: ${selfIssueAsIncome.body}`
|
||||
).toBe('PAYMENT_NOT_PAID');
|
||||
|
||||
// 거부된 조합이 발급을 남기지 않았는지 확인한다 — 422 를 주고도 발급하면 최악이다.
|
||||
expect(
|
||||
(await readReceiptState(page, orderId)).active,
|
||||
'거부된 조합인데 영수증이 발급되었다'
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
+57
-13
@@ -27,16 +27,12 @@ import { test, expect, authenticatePage } from '../../fixtures/ecommerce-auth';
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* 카트 담기에 쓰는 상품 — 전 상품이 옵션 보유(has_options=true)라 option_values 가 필수다.
|
||||
* 카트 담기 로케일.
|
||||
*
|
||||
* CartService::bulkAddToCart 는 저장된 다국어 JSON 이 아니라 현재 로케일로 평탄화된 맵
|
||||
* (`{"색상":"화이트"}`) 과 비교한다(getLocalizedOptionValues). 그래서 요청 로케일을 ko 로 고정한다.
|
||||
*/
|
||||
const CART_LOCALE = 'ko';
|
||||
const CART_ITEM = {
|
||||
product_id: 1,
|
||||
items: [{ option_values: { 색상: '화이트' }, quantity: 1 }],
|
||||
};
|
||||
|
||||
/** 발급수단 드롭다운의 항목 라벨 (ko 고정 — beforeEach 가 g7_locale 을 ko 로 pin 한다) */
|
||||
const IDENTIFIER_LABEL = {
|
||||
@@ -61,7 +57,7 @@ const IDENTIFIER = '#ext_checkout_cash_receipt_identifier';
|
||||
*/
|
||||
async function seedCheckout(page: Page): Promise<void> {
|
||||
const result = await page.evaluate(
|
||||
async ({ payload, locale }) => {
|
||||
async ({ locale }) => {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -70,14 +66,62 @@ async function seedCheckout(page: Page): Promise<void> {
|
||||
Authorization: `Bearer ${token}`,
|
||||
};
|
||||
|
||||
// 담을 상품을 런타임에 찾는다. 상품 ID 를 고정하면 시드가 다른 환경에서
|
||||
// 전부 실패하고, 그 실패가 "현금영수증 폼 결함" 으로 오해된다.
|
||||
const list = await fetch(
|
||||
'/api/modules/sirsoft-ecommerce/products?per_page=30',
|
||||
{ headers }
|
||||
).then((r) => r.json());
|
||||
|
||||
const candidates = (list?.data?.data ?? list?.data ?? []) as Array<{ id: number }>;
|
||||
if (candidates.length === 0) {
|
||||
return { step: 'product-list', status: 0, body: '판매 중인 상품이 없습니다.' };
|
||||
}
|
||||
|
||||
await fetch('/api/modules/sirsoft-ecommerce/cart/all', { method: 'DELETE', headers });
|
||||
|
||||
const added = await fetch('/api/modules/sirsoft-ecommerce/cart', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!added.ok) return { step: 'cart', status: added.status, body: await added.text() };
|
||||
// 상품마다 옵션 구성이 다르므로 상세를 읽어 판매 가능한 첫 옵션을 그대로 쓴다.
|
||||
// option_values_localized 는 서버가 현재 로케일로 평탄화해 내려준 맵이며,
|
||||
// CartService::bulkAddToCart 가 비교하는 형태와 동일하다.
|
||||
let added: Response | null = null;
|
||||
let lastBody = '';
|
||||
for (const candidate of candidates) {
|
||||
const detail = await fetch(
|
||||
`/api/modules/sirsoft-ecommerce/products/${candidate.id}`,
|
||||
{ headers }
|
||||
).then((r) => r.json());
|
||||
|
||||
const options = (detail?.data?.options ?? []) as Array<{
|
||||
option_values_localized?: Record<string, string>;
|
||||
stock_quantity?: number;
|
||||
}>;
|
||||
// 재고가 없는 옵션을 고르면 주문 생성이 422 로 막혀 검증 대상에 닿지도 못한다.
|
||||
const usable = options.find((o) => (o.stock_quantity ?? 0) > 0);
|
||||
if (!usable) continue;
|
||||
|
||||
const res = await fetch('/api/modules/sirsoft-ecommerce/cart', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
product_id: candidate.id,
|
||||
items: [
|
||||
{
|
||||
option_values: usable.option_values_localized ?? {},
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
added = res;
|
||||
break;
|
||||
}
|
||||
lastBody = await res.text();
|
||||
}
|
||||
|
||||
if (!added) {
|
||||
return { step: 'cart', status: 422, body: `담을 수 있는 상품 없음: ${lastBody}` };
|
||||
}
|
||||
|
||||
const cart = await fetch('/api/modules/sirsoft-ecommerce/cart', { headers }).then((r) => r.json());
|
||||
const itemIds = (cart?.data?.items ?? cart?.data ?? []).map((i: { id: number }) => i.id);
|
||||
@@ -93,7 +137,7 @@ async function seedCheckout(page: Page): Promise<void> {
|
||||
});
|
||||
return { step: 'checkout', status: checkout.status, body: await checkout.text() };
|
||||
},
|
||||
{ payload: CART_ITEM, locale: CART_LOCALE }
|
||||
{ locale: CART_LOCALE }
|
||||
);
|
||||
|
||||
expect(result.status, `${result.step} 단계 실패: ${result.body}`).toBeLessThan(300);
|
||||
|
||||
@@ -6,7 +6,7 @@ use Modules\Sirsoft\Ecommerce\DTO\AppliedPromotions;
|
||||
use Modules\Sirsoft\Ecommerce\DTO\CouponApplication;
|
||||
use Modules\Sirsoft\Ecommerce\DTO\DiscountCodeApplication;
|
||||
use Modules\Sirsoft\Ecommerce\DTO\PromotionsSummary;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
|
||||
/**
|
||||
* PromotionsSummary 평탄 키 생산 표준 검증 (주문 스냅샷 표준화/MP06)
|
||||
@@ -14,7 +14,7 @@ use PHPUnit\Framework\TestCase;
|
||||
* 주문 생성 시 promotions_applied_snapshot 이 coupon_issue_ids / item_coupons / discount_code
|
||||
* 평탄 키를 항상 포함하도록 표준화한 것을 검증한다. 취소 복원/재계산이 이 평탄 키를 SSoT 로 읽는다.
|
||||
*/
|
||||
class PromotionsSummaryFlatKeysTest extends TestCase
|
||||
class PromotionsSummaryFlatKeysTest extends ModuleTestCase
|
||||
{
|
||||
/**
|
||||
* 상품 쿠폰 + 주문 쿠폰을 가진 요약 DTO 를 구성합니다.
|
||||
|
||||
+2
-2
@@ -3,14 +3,14 @@
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Database\Seeders;
|
||||
|
||||
use Modules\Sirsoft\Ecommerce\Database\Seeders\Sample\ProductSeeder;
|
||||
use Tests\TestCase;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
|
||||
/**
|
||||
* ProductSeeder 이미지 색상 필터 테스트
|
||||
*
|
||||
* applyColorFilter 메서드의 GD 기반 색상 필터 로직을 검증합니다.
|
||||
*/
|
||||
class ProductSeederImageTest extends TestCase
|
||||
class ProductSeederImageTest extends ModuleTestCase
|
||||
{
|
||||
private ProductSeeder $seeder;
|
||||
|
||||
|
||||
+24
-25
@@ -10,7 +10,8 @@ use Modules\Sirsoft\Ecommerce\Listeners\EcommerceUserActivityLogListener;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Cart;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Modules\Sirsoft\Ecommerce\Models\OrderOption;
|
||||
use Tests\TestCase;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* EcommerceUserActivityLogListener 테스트
|
||||
@@ -23,7 +24,7 @@ use Tests\TestCase;
|
||||
*
|
||||
* 모든 메서드가 ActivityLogType::User를 사용하는지 검증합니다.
|
||||
*/
|
||||
class EcommerceUserActivityLogListenerTest extends TestCase
|
||||
class EcommerceUserActivityLogListenerTest extends ModuleTestCase
|
||||
{
|
||||
private EcommerceUserActivityLogListener $listener;
|
||||
|
||||
@@ -34,7 +35,7 @@ class EcommerceUserActivityLogListenerTest extends TestCase
|
||||
parent::setUp();
|
||||
$this->app->instance('request', Request::create('/api/shop/test'));
|
||||
$this->listener = app(EcommerceUserActivityLogListener::class);
|
||||
$this->logChannel = Mockery::mock(\Psr\Log\LoggerInterface::class);
|
||||
$this->logChannel = Mockery::mock(LoggerInterface::class);
|
||||
Log::shouldReceive('channel')
|
||||
->with('activity')
|
||||
->andReturn($this->logChannel);
|
||||
@@ -45,7 +46,7 @@ class EcommerceUserActivityLogListenerTest extends TestCase
|
||||
// getSubscribedHooks
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
public function test_getSubscribedHooks_returns_all_hooks(): void
|
||||
public function test_get_subscribed_hooks_returns_all_hooks(): void
|
||||
{
|
||||
$hooks = EcommerceUserActivityLogListener::getSubscribedHooks();
|
||||
|
||||
@@ -65,7 +66,7 @@ class EcommerceUserActivityLogListenerTest extends TestCase
|
||||
// Cart 핸들러 테스트
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
public function test_handleCartAfterAdd_logs_activity_with_product_relation(): void
|
||||
public function test_handle_cart_after_add_logs_activity_with_product_relation(): void
|
||||
{
|
||||
$cart = $this->createCartMockWithProduct(1, 'Test Product');
|
||||
$data = ['quantity' => 2];
|
||||
@@ -85,7 +86,7 @@ class EcommerceUserActivityLogListenerTest extends TestCase
|
||||
$this->listener->handleCartAfterAdd($cart, $data);
|
||||
}
|
||||
|
||||
public function test_handleCartAfterAdd_falls_back_to_product_name_attribute(): void
|
||||
public function test_handle_cart_after_add_falls_back_to_product_name_attribute(): void
|
||||
{
|
||||
$cart = $this->createCartMockWithProductName(2, 'Fallback Name');
|
||||
$data = ['quantity' => 1];
|
||||
@@ -102,7 +103,7 @@ class EcommerceUserActivityLogListenerTest extends TestCase
|
||||
$this->listener->handleCartAfterAdd($cart, $data);
|
||||
}
|
||||
|
||||
public function test_handleCartAfterAdd_with_empty_product_info(): void
|
||||
public function test_handle_cart_after_add_with_empty_product_info(): void
|
||||
{
|
||||
$cart = $this->createCartMockWithProductName(3, null);
|
||||
|
||||
@@ -117,7 +118,7 @@ class EcommerceUserActivityLogListenerTest extends TestCase
|
||||
$this->listener->handleCartAfterAdd($cart, []);
|
||||
}
|
||||
|
||||
public function test_handleCartAfterUpdateQuantity_logs_activity(): void
|
||||
public function test_handle_cart_after_update_quantity_logs_activity(): void
|
||||
{
|
||||
$cart = $this->createCartMockWithProduct(4, 'Qty Product');
|
||||
$quantity = 5;
|
||||
@@ -137,7 +138,7 @@ class EcommerceUserActivityLogListenerTest extends TestCase
|
||||
$this->listener->handleCartAfterUpdateQuantity($cart, $quantity);
|
||||
}
|
||||
|
||||
public function test_handleCartAfterChangeOption_logs_activity(): void
|
||||
public function test_handle_cart_after_change_option_logs_activity(): void
|
||||
{
|
||||
$cart = $this->createCartMockWithProduct(5, 'Option Product');
|
||||
$newProductOptionId = 42;
|
||||
@@ -159,7 +160,7 @@ class EcommerceUserActivityLogListenerTest extends TestCase
|
||||
$this->listener->handleCartAfterChangeOption($cart, $newProductOptionId, $quantity);
|
||||
}
|
||||
|
||||
public function test_handleCartAfterDelete_logs_activity(): void
|
||||
public function test_handle_cart_after_delete_logs_activity(): void
|
||||
{
|
||||
$cartId = 10;
|
||||
|
||||
@@ -176,7 +177,7 @@ class EcommerceUserActivityLogListenerTest extends TestCase
|
||||
$this->listener->handleCartAfterDelete($cartId);
|
||||
}
|
||||
|
||||
public function test_handleCartAfterDeleteAll_logs_activity(): void
|
||||
public function test_handle_cart_after_delete_all_logs_activity(): void
|
||||
{
|
||||
$userId = 100;
|
||||
$cartKey = 'user_100_cart';
|
||||
@@ -210,7 +211,7 @@ class EcommerceUserActivityLogListenerTest extends TestCase
|
||||
* description_params 패턴이 검증되었으므로, wishlist 핸들러의 로직(add/remove 분기,
|
||||
* product_name 추출)은 소스코드 리뷰로 보장합니다.
|
||||
*/
|
||||
public function test_handleWishlistAfterToggle_method_exists_and_hooks_registered(): void
|
||||
public function test_handle_wishlist_after_toggle_method_exists_and_hooks_registered(): void
|
||||
{
|
||||
$hooks = EcommerceUserActivityLogListener::getSubscribedHooks();
|
||||
|
||||
@@ -228,7 +229,7 @@ class EcommerceUserActivityLogListenerTest extends TestCase
|
||||
* alias mock의 PHP 프로세스 내 클래스 재정의 제한으로 인해
|
||||
* 훅 등록 및 메서드 존재를 검증합니다.
|
||||
*/
|
||||
public function test_handleUserCouponAfterDownload_method_exists_and_hooks_registered(): void
|
||||
public function test_handle_user_coupon_after_download_method_exists_and_hooks_registered(): void
|
||||
{
|
||||
$hooks = EcommerceUserActivityLogListener::getSubscribedHooks();
|
||||
|
||||
@@ -241,7 +242,7 @@ class EcommerceUserActivityLogListenerTest extends TestCase
|
||||
// Order 핸들러 테스트
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
public function test_handleOrderAfterCreate_logs_activity(): void
|
||||
public function test_handle_order_after_create_logs_activity(): void
|
||||
{
|
||||
$order = Mockery::mock(Order::class)->makePartial();
|
||||
$order->forceFill(['id' => 10, 'order_code' => 'ORD-20260326-001']);
|
||||
@@ -263,7 +264,7 @@ class EcommerceUserActivityLogListenerTest extends TestCase
|
||||
$this->listener->handleOrderAfterCreate($order);
|
||||
}
|
||||
|
||||
public function test_handleOrderAfterCreate_handles_null_order_code(): void
|
||||
public function test_handle_order_after_create_handles_null_order_code(): void
|
||||
{
|
||||
$order = Mockery::mock(Order::class)->makePartial();
|
||||
$order->forceFill(['id' => 11]);
|
||||
@@ -285,7 +286,7 @@ class EcommerceUserActivityLogListenerTest extends TestCase
|
||||
// Order Option 핸들러 테스트
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
public function test_handleOrderOptionAfterConfirm_logs_activity(): void
|
||||
public function test_handle_order_option_after_confirm_logs_activity(): void
|
||||
{
|
||||
$order = Mockery::mock(Order::class)->makePartial();
|
||||
$order->forceFill(['id' => 20]);
|
||||
@@ -311,7 +312,7 @@ class EcommerceUserActivityLogListenerTest extends TestCase
|
||||
$this->listener->handleOrderOptionAfterConfirm($order, $option);
|
||||
}
|
||||
|
||||
public function test_handleOrderOptionAfterConfirm_hooks_registered(): void
|
||||
public function test_handle_order_option_after_confirm_hooks_registered(): void
|
||||
{
|
||||
$hooks = EcommerceUserActivityLogListener::getSubscribedHooks();
|
||||
|
||||
@@ -324,7 +325,7 @@ class EcommerceUserActivityLogListenerTest extends TestCase
|
||||
// 에러 핸들링 테스트
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
public function test_logActivity_catches_exception_and_logs_error(): void
|
||||
public function test_log_activity_catches_exception_and_logs_error(): void
|
||||
{
|
||||
$this->logChannel->shouldReceive('info')
|
||||
->once()
|
||||
@@ -375,13 +376,12 @@ class EcommerceUserActivityLogListenerTest extends TestCase
|
||||
/**
|
||||
* product 관계가 있는 Cart mock 생성
|
||||
*
|
||||
* @param int $id 장바구니 ID
|
||||
* @param string $productName 상품명
|
||||
* @return Cart
|
||||
* @param int $id 장바구니 ID
|
||||
* @param string $productName 상품명
|
||||
*/
|
||||
private function createCartMockWithProduct(int $id, string $productName): Cart
|
||||
{
|
||||
$product = new \stdClass();
|
||||
$product = new \stdClass;
|
||||
$product->name = $productName;
|
||||
|
||||
$cart = Mockery::mock(Cart::class)->makePartial();
|
||||
@@ -397,9 +397,8 @@ class EcommerceUserActivityLogListenerTest extends TestCase
|
||||
/**
|
||||
* product 관계 없이 product_name 속성만 있는 Cart mock 생성
|
||||
*
|
||||
* @param int $id 장바구니 ID
|
||||
* @param string|null $productName 상품명
|
||||
* @return Cart
|
||||
* @param int $id 장바구니 ID
|
||||
* @param string|null $productName 상품명
|
||||
*/
|
||||
private function createCartMockWithProductName(int $id, ?string $productName): Cart
|
||||
{
|
||||
|
||||
+2
-2
@@ -11,12 +11,12 @@ use App\Support\Query\BoundedCount;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Modules\Sirsoft\Ecommerce\Listeners\SearchProductsListener;
|
||||
use Modules\Sirsoft\Ecommerce\Services\ProductService;
|
||||
use Tests\TestCase;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
|
||||
/**
|
||||
* SearchProductsListener 단위 테스트
|
||||
*/
|
||||
class SearchProductsListenerTest extends TestCase
|
||||
class SearchProductsListenerTest extends ModuleTestCase
|
||||
{
|
||||
private SearchProductsListener $listener;
|
||||
|
||||
|
||||
+2
-2
@@ -9,14 +9,14 @@ use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Mockery;
|
||||
use Modules\Sirsoft\Ecommerce\Listeners\SeoCategoryCacheListener;
|
||||
use Tests\TestCase;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
|
||||
/**
|
||||
* SeoCategoryCacheListener 테스트
|
||||
*
|
||||
* 카테고리 변경 시 SEO 캐시 무효화 리스너의 동작을 검증합니다.
|
||||
*/
|
||||
class SeoCategoryCacheListenerTest extends TestCase
|
||||
class SeoCategoryCacheListenerTest extends ModuleTestCase
|
||||
{
|
||||
protected SeoCategoryCacheListener $listener;
|
||||
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@ use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Mockery;
|
||||
use Modules\Sirsoft\Ecommerce\Listeners\SeoProductCacheListener;
|
||||
use Tests\TestCase;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
|
||||
/**
|
||||
* SeoProductCacheListener 테스트
|
||||
@@ -21,7 +21,7 @@ use Tests\TestCase;
|
||||
* - 생성/수정 시 단건 캐시 재생성
|
||||
* - 삭제 시 재생성 없이 무효화만
|
||||
*/
|
||||
class SeoProductCacheListenerTest extends TestCase
|
||||
class SeoProductCacheListenerTest extends ModuleTestCase
|
||||
{
|
||||
protected SeoProductCacheListener $listener;
|
||||
|
||||
|
||||
+2
-2
@@ -7,14 +7,14 @@ use App\Seo\Contracts\SeoCacheManagerInterface;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Mockery;
|
||||
use Modules\Sirsoft\Ecommerce\Listeners\SeoSettingsCacheListener;
|
||||
use Tests\TestCase;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
|
||||
/**
|
||||
* 이커머스 SEO 설정 리스너 테스트
|
||||
*
|
||||
* 이커머스 모듈 설정 변경 시 SEO 관련 캐시 선별 무효화를 검증합니다.
|
||||
*/
|
||||
class SeoSettingsCacheListenerTest extends TestCase
|
||||
class SeoSettingsCacheListenerTest extends ModuleTestCase
|
||||
{
|
||||
private SeoSettingsCacheListener $listener;
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Requests;
|
||||
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\StoreBanksRequest;
|
||||
use Tests\TestCase;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
|
||||
/**
|
||||
* 은행 목록 저장 요청 검증 테스트
|
||||
@@ -15,7 +15,7 @@ use Tests\TestCase;
|
||||
* - 현재 로케일 은행명만 필수
|
||||
* - 빈 배열 허용
|
||||
*/
|
||||
class StoreBanksRequestTest extends TestCase
|
||||
class StoreBanksRequestTest extends ModuleTestCase
|
||||
{
|
||||
/**
|
||||
* 은행명 검증은 "현재 앱 로케일" 필드만 필수로 본다. 테스트 데이터가 ko/en 을 제공하므로
|
||||
@@ -30,12 +30,11 @@ class StoreBanksRequestTest extends TestCase
|
||||
/**
|
||||
* 검증 수행
|
||||
*
|
||||
* @param array $data 검증 대상 데이터
|
||||
* @return \Illuminate\Validation\Validator
|
||||
* @param array $data 검증 대상 데이터
|
||||
*/
|
||||
protected function validate(array $data): \Illuminate\Validation\Validator
|
||||
{
|
||||
$request = new StoreBanksRequest();
|
||||
$request = new StoreBanksRequest;
|
||||
|
||||
return Validator::make($data, $request->rules());
|
||||
}
|
||||
|
||||
+2
-2
@@ -3,14 +3,14 @@
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Requests;
|
||||
|
||||
use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\StoreEcommerceSettingsRequest;
|
||||
use Tests\TestCase;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
|
||||
/**
|
||||
* StoreEcommerceSettingsRequest SEO 필드 검증 테스트
|
||||
*
|
||||
* 코어로 이관된 SEO 필드가 제거되었는지, 이커머스 고유 SEO 필드가 유지되는지 검증합니다.
|
||||
*/
|
||||
class StoreEcommerceSettingsRequestSeoTest extends TestCase
|
||||
class StoreEcommerceSettingsRequestSeoTest extends ModuleTestCase
|
||||
{
|
||||
private StoreEcommerceSettingsRequest $request;
|
||||
|
||||
|
||||
+14
-14
@@ -2,8 +2,9 @@
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Resources;
|
||||
|
||||
use Modules\Sirsoft\Ecommerce\Http\Resources\Traits\HasMultiCurrencyPrices;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* HasMultiCurrencyPrices Trait 단위 테스트
|
||||
@@ -12,13 +13,12 @@ use PHPUnit\Framework\TestCase;
|
||||
* - applyRounding 결과의 IEEE 754 오차가 decimal_places로 제거되는지 확인
|
||||
* - 예: 4505 * 0.01 = 45.050000000000004 → round(_, 2) → 45.05
|
||||
*/
|
||||
class HasMultiCurrencyPricesTest extends TestCase
|
||||
class HasMultiCurrencyPricesTest extends ModuleTestCase
|
||||
{
|
||||
/**
|
||||
* Trait 메서드를 테스트할 수 있는 익명 클래스 인스턴스 생성
|
||||
*
|
||||
* @param array $currencies 통화 설정
|
||||
* @return object
|
||||
*/
|
||||
protected function createTraitInstance(array $currencies = []): object
|
||||
{
|
||||
@@ -74,7 +74,7 @@ class HasMultiCurrencyPricesTest extends TestCase
|
||||
|
||||
return new class($currencySettings)
|
||||
{
|
||||
use \Modules\Sirsoft\Ecommerce\Http\Resources\Traits\HasMultiCurrencyPrices;
|
||||
use HasMultiCurrencyPrices;
|
||||
|
||||
private array $testCurrencies;
|
||||
|
||||
@@ -104,12 +104,12 @@ class HasMultiCurrencyPricesTest extends TestCase
|
||||
/**
|
||||
* 테스트용 공개 래퍼
|
||||
*/
|
||||
public function testBuildMultiCurrencyPrices(float|int $basePrice): array
|
||||
public function exposeBuildMultiCurrencyPrices(float|int $basePrice): array
|
||||
{
|
||||
return $this->buildMultiCurrencyPrices($basePrice);
|
||||
}
|
||||
|
||||
public function testApplyRounding(float $price, string $unit, string $method): float
|
||||
public function exposeApplyRounding(float $price, string $unit, string $method): float
|
||||
{
|
||||
return $this->applyRounding($price, $unit, $method);
|
||||
}
|
||||
@@ -123,7 +123,7 @@ class HasMultiCurrencyPricesTest extends TestCase
|
||||
public function price_값에_부동소수점_오차가_없어야_한다(): void
|
||||
{
|
||||
$instance = $this->createTraitInstance();
|
||||
$result = $instance->testBuildMultiCurrencyPrices(53000);
|
||||
$result = $instance->exposeBuildMultiCurrencyPrices(53000);
|
||||
|
||||
// USD: (53000/1000) * 0.85 = 45.05 (45.050000000000004가 아님)
|
||||
$this->assertSame(45.05, $result['USD']['price']);
|
||||
@@ -145,7 +145,7 @@ class HasMultiCurrencyPricesTest extends TestCase
|
||||
public function 기본통화는_원래_가격을_반환한다(): void
|
||||
{
|
||||
$instance = $this->createTraitInstance();
|
||||
$result = $instance->testBuildMultiCurrencyPrices(53000);
|
||||
$result = $instance->exposeBuildMultiCurrencyPrices(53000);
|
||||
|
||||
$this->assertTrue($result['KRW']['is_default']);
|
||||
$this->assertSame(53000, $result['KRW']['price']);
|
||||
@@ -158,7 +158,7 @@ class HasMultiCurrencyPricesTest extends TestCase
|
||||
public function decimal_places_0인_통화는_정수를_반환한다(): void
|
||||
{
|
||||
$instance = $this->createTraitInstance();
|
||||
$result = $instance->testBuildMultiCurrencyPrices(53500);
|
||||
$result = $instance->exposeBuildMultiCurrencyPrices(53500);
|
||||
|
||||
// JPY: (53500/1000) * 115 = 6152.5 → floor → 6152
|
||||
$jpyPrice = $result['JPY']['price'];
|
||||
@@ -175,7 +175,7 @@ class HasMultiCurrencyPricesTest extends TestCase
|
||||
$instance = $this->createTraitInstance();
|
||||
|
||||
// 51500원 → EUR: (51500/1000) * 0.78 = 40.17
|
||||
$result = $instance->testBuildMultiCurrencyPrices(51500);
|
||||
$result = $instance->exposeBuildMultiCurrencyPrices(51500);
|
||||
$this->assertSame(40.17, $result['EUR']['price']);
|
||||
}
|
||||
|
||||
@@ -188,7 +188,7 @@ class HasMultiCurrencyPricesTest extends TestCase
|
||||
$instance = $this->createTraitInstance();
|
||||
|
||||
// 51000원 → CNY: (51000/1000) * 5.8 = 295.8
|
||||
$result = $instance->testBuildMultiCurrencyPrices(51000);
|
||||
$result = $instance->exposeBuildMultiCurrencyPrices(51000);
|
||||
$this->assertSame(295.8, $result['CNY']['price']);
|
||||
}
|
||||
|
||||
@@ -203,7 +203,7 @@ class HasMultiCurrencyPricesTest extends TestCase
|
||||
$testPrices = [10000, 25000, 53000, 57000, 99000, 150000];
|
||||
|
||||
foreach ($testPrices as $price) {
|
||||
$result = $instance->testBuildMultiCurrencyPrices($price);
|
||||
$result = $instance->exposeBuildMultiCurrencyPrices($price);
|
||||
|
||||
foreach (['USD', 'JPY', 'CNY', 'EUR'] as $code) {
|
||||
$priceValue = $result[$code]['price'];
|
||||
@@ -226,12 +226,12 @@ class HasMultiCurrencyPricesTest extends TestCase
|
||||
* applyRounding 내부 함수의 부동소수점 문제 직접 검증
|
||||
*/
|
||||
#[Test]
|
||||
public function applyRounding_결과에_부동소수점_오차_확인(): void
|
||||
public function apply_rounding_결과에_부동소수점_오차_확인(): void
|
||||
{
|
||||
$instance = $this->createTraitInstance();
|
||||
|
||||
// 4505 * 0.01 → 이전에는 45.050000000000004 발생
|
||||
$rounded = $instance->testApplyRounding(45.05, '0.01', 'round');
|
||||
$rounded = $instance->exposeApplyRounding(45.05, '0.01', 'round');
|
||||
// applyRounding 자체는 여전히 부동소수점 오차가 있을 수 있지만
|
||||
// buildMultiCurrencyPrices에서 round()로 제거됨
|
||||
$this->assertEqualsWithDelta(45.05, $rounded, 0.0001);
|
||||
|
||||
@@ -220,6 +220,11 @@ class CashReceiptServiceTest extends ModuleTestCase
|
||||
$this->assertSame('receipt-1', $receipt->raw_response['receiptKey']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 금액 변동(감소) 시 전체취소 후 잔액으로 재발급하는지 확인
|
||||
*
|
||||
* @effects amount_change_triggers_cancel_then_reissue
|
||||
*/
|
||||
#[Test]
|
||||
public function sync_금액감소시_전체취소_후_전액_재발급한다(): void
|
||||
{
|
||||
|
||||
+7
-20
@@ -6,32 +6,30 @@ use App\Contracts\Extension\StorageInterface;
|
||||
use App\Extension\HookManager;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Mockery;
|
||||
use Mockery\MockInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Models\CategoryImage;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\CategoryImageRepositoryInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Services\CategoryImageService;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
use Tests\TestCase;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
/**
|
||||
* CategoryImageService 단위 테스트
|
||||
*
|
||||
* StorageInterface 기반 이미지 업로드, 삭제 등을 테스트합니다.
|
||||
*/
|
||||
class CategoryImageServiceTest extends TestCase
|
||||
class CategoryImageServiceTest extends ModuleTestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private CategoryImageService $service;
|
||||
|
||||
/** @var \Mockery\MockInterface&CategoryImageRepositoryInterface */
|
||||
/** @var MockInterface&CategoryImageRepositoryInterface */
|
||||
private $repository;
|
||||
|
||||
/** @var \Mockery\MockInterface&StorageInterface */
|
||||
/** @var MockInterface&StorageInterface */
|
||||
private $storage;
|
||||
|
||||
private User $user;
|
||||
@@ -63,7 +61,6 @@ class CategoryImageServiceTest extends TestCase
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/** */
|
||||
#[Test]
|
||||
public function test_upload_stores_image_and_creates_record(): void
|
||||
{
|
||||
@@ -127,7 +124,6 @@ class CategoryImageServiceTest extends TestCase
|
||||
$this->assertEquals(600, $result->height);
|
||||
}
|
||||
|
||||
/** */
|
||||
#[Test]
|
||||
public function test_upload_with_temp_key_creates_temp_image(): void
|
||||
{
|
||||
@@ -179,7 +175,6 @@ class CategoryImageServiceTest extends TestCase
|
||||
$this->assertEquals($tempKey, $result->temp_key);
|
||||
}
|
||||
|
||||
/** */
|
||||
#[Test]
|
||||
public function test_upload_with_alt_text(): void
|
||||
{
|
||||
@@ -213,7 +208,6 @@ class CategoryImageServiceTest extends TestCase
|
||||
$this->assertEquals($altText, $result->alt_text);
|
||||
}
|
||||
|
||||
/** */
|
||||
#[Test]
|
||||
public function test_upload_fires_hooks(): void
|
||||
{
|
||||
@@ -259,7 +253,6 @@ class CategoryImageServiceTest extends TestCase
|
||||
HookManager::clearAction('sirsoft-ecommerce.category-image.after_upload');
|
||||
}
|
||||
|
||||
/** */
|
||||
#[Test]
|
||||
public function test_delete_removes_file_from_storage(): void
|
||||
{
|
||||
@@ -310,7 +303,6 @@ class CategoryImageServiceTest extends TestCase
|
||||
$this->assertTrue($result);
|
||||
}
|
||||
|
||||
/** */
|
||||
#[Test]
|
||||
public function test_delete_fires_hooks(): void
|
||||
{
|
||||
@@ -347,9 +339,8 @@ class CategoryImageServiceTest extends TestCase
|
||||
HookManager::clearAction('sirsoft-ecommerce.category-image.after_delete');
|
||||
}
|
||||
|
||||
/** */
|
||||
#[Test]
|
||||
public function test_linkTempImages_links_temp_files_to_category(): void
|
||||
public function test_link_temp_images_links_temp_files_to_category(): void
|
||||
{
|
||||
// Arrange
|
||||
$tempKey = 'temp-uuid-456';
|
||||
@@ -368,7 +359,6 @@ class CategoryImageServiceTest extends TestCase
|
||||
$this->assertEquals(3, $result);
|
||||
}
|
||||
|
||||
/** */
|
||||
#[Test]
|
||||
public function test_reorder_updates_image_order(): void
|
||||
{
|
||||
@@ -392,7 +382,6 @@ class CategoryImageServiceTest extends TestCase
|
||||
$this->assertTrue($result);
|
||||
}
|
||||
|
||||
/** */
|
||||
#[Test]
|
||||
public function test_reorder_fires_hooks(): void
|
||||
{
|
||||
@@ -521,7 +510,6 @@ class CategoryImageServiceTest extends TestCase
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
/** */
|
||||
#[Test]
|
||||
public function test_update_modifies_image_metadata(): void
|
||||
{
|
||||
@@ -549,7 +537,6 @@ class CategoryImageServiceTest extends TestCase
|
||||
$this->assertEquals($altText, $result->alt_text);
|
||||
}
|
||||
|
||||
/** */
|
||||
#[Test]
|
||||
public function test_update_fires_hooks(): void
|
||||
{
|
||||
|
||||
+2
-2
@@ -5,8 +5,8 @@ namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Services;
|
||||
use App\Extension\HookManager;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
use ReflectionClass;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 결제수단별 마일리지 차감 시점 설정 테스트 (마일리지/MP06)
|
||||
@@ -14,7 +14,7 @@ use Tests\TestCase;
|
||||
* EcommerceSettingsService::getMileageDeductionTiming(string) 의 결제수단별 반환과
|
||||
* 미설정 기본값(무통장=order_placed / 그 외=payment_complete)을 검증한다.
|
||||
*/
|
||||
class EcommerceSettingsMileageTimingTest extends TestCase
|
||||
class EcommerceSettingsMileageTimingTest extends ModuleTestCase
|
||||
{
|
||||
private EcommerceSettingsService $service;
|
||||
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Services;
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
|
||||
use Tests\TestCase;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
|
||||
/**
|
||||
* 통화 환율 영속성 테스트 (U11-A)
|
||||
@@ -13,7 +13,7 @@ use Tests\TestCase;
|
||||
* 관리자가 일부 통화를 빼고 저장하면 defaults 통화가 영구 소실되던 영속성 공백을 검증한다.
|
||||
* getAllSettings() 의 code 기준 병합(mergeCurrenciesByCode)으로 누락 통화 보충(환율 저장본 우선).
|
||||
*/
|
||||
class EcommerceSettingsServiceCurrencyMergeTest extends TestCase
|
||||
class EcommerceSettingsServiceCurrencyMergeTest extends ModuleTestCase
|
||||
{
|
||||
private EcommerceSettingsService $service;
|
||||
|
||||
|
||||
+67
@@ -772,6 +772,73 @@ class OrderCancellationServiceTest extends ModuleTestCase
|
||||
$this->assertEquals(RefundStatusEnum::COMPLETED, $result->orderRefund->refund_status);
|
||||
}
|
||||
|
||||
/**
|
||||
* B-3-1-a: PG 환불 훅이 OrderRefund 레코드를 6번째 인자로 전달하는지 검증
|
||||
*
|
||||
* PG 리스너는 이 레코드의 id 로 멱등키(Idempotency-Key)를 조립한다. 네트워크 재시도로
|
||||
* 같은 취소가 두 번 도달해도 PG 가 중복 청구를 차단하려면 환불 시도마다 고유한 값이 필요하다.
|
||||
*
|
||||
* @effects refund_hook_passes_order_refund_record
|
||||
*/
|
||||
public function test_payment_refund_hook_passes_order_refund_record(): void
|
||||
{
|
||||
$received = null;
|
||||
HookManager::addFilter(
|
||||
'sirsoft-ecommerce.payment.refund',
|
||||
function ($default, $order, $payment, $refundAmount, $reason, $refund = null) use (&$received) {
|
||||
$received = $refund;
|
||||
|
||||
return ['success' => true, 'transaction_id' => 'TXN', 'error_code' => null, 'error_message' => null];
|
||||
}
|
||||
);
|
||||
|
||||
$this->createShippingPolicy();
|
||||
[$pA, $oA] = $this->createProductWithOption(price: 20000);
|
||||
|
||||
$input = new CalculationInput(
|
||||
items: [new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1)],
|
||||
);
|
||||
$order = $this->createOrderFromCalculation($input);
|
||||
|
||||
$result = $this->cancellationService->cancelOrder(order: $order, cancelPg: true);
|
||||
|
||||
$this->assertInstanceOf(OrderRefund::class, $received, 'PG 환불 훅은 OrderRefund 레코드를 전달해야 합니다');
|
||||
$this->assertSame($result->orderRefund->id, $received->id, '전달된 환불 레코드는 이번 환불 시도의 것이어야 합니다');
|
||||
}
|
||||
|
||||
/**
|
||||
* B-3-1-b: 인자를 5개만 선언한 기존(레거시) 리스너가 계속 동작하는지 검증
|
||||
*
|
||||
* OrderRefund 인자 추가는 하위호환이어야 한다 — 초과 인자는 무시되고 예외가 나지 않는다.
|
||||
*
|
||||
* @effects refund_hook_backward_compatible_with_five_arg_listener
|
||||
*/
|
||||
public function test_payment_refund_hook_is_backward_compatible_with_five_arg_listener(): void
|
||||
{
|
||||
$hookCalled = false;
|
||||
HookManager::addFilter(
|
||||
'sirsoft-ecommerce.payment.refund',
|
||||
function ($default, $order, $payment, $refundAmount, $reason) use (&$hookCalled) {
|
||||
$hookCalled = true;
|
||||
|
||||
return ['success' => true, 'transaction_id' => 'TXN', 'error_code' => null, 'error_message' => null];
|
||||
}
|
||||
);
|
||||
|
||||
$this->createShippingPolicy();
|
||||
[$pA, $oA] = $this->createProductWithOption(price: 20000);
|
||||
|
||||
$input = new CalculationInput(
|
||||
items: [new CalculationItem(productId: $pA->id, productOptionId: $oA->id, quantity: 1)],
|
||||
);
|
||||
$order = $this->createOrderFromCalculation($input);
|
||||
|
||||
$result = $this->cancellationService->cancelOrder(order: $order, cancelPg: true);
|
||||
|
||||
$this->assertTrue($hookCalled, '5-파라미터 리스너도 호출되어야 합니다');
|
||||
$this->assertEquals(RefundStatusEnum::COMPLETED, $result->orderRefund->refund_status);
|
||||
}
|
||||
|
||||
/**
|
||||
* B-3-2: PG 환불 실패 시 전체 트랜잭션이 롤백되는지 검증
|
||||
*/
|
||||
|
||||
@@ -6,7 +6,6 @@ use App\Contracts\Extension\StorageInterface;
|
||||
use App\Extension\HookManager;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
@@ -18,18 +17,16 @@ use Modules\Sirsoft\Ecommerce\Models\ProductImage;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductImageRepositoryInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductRepositoryInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Services\ProductImageService;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* ProductImageService 단위 테스트
|
||||
*
|
||||
* StorageInterface 기반 상품 이미지 업로드, 삭제, 순서변경, 임시연결 등을 테스트합니다.
|
||||
*/
|
||||
class ProductImageServiceTest extends TestCase
|
||||
class ProductImageServiceTest extends ModuleTestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private ProductImageService $service;
|
||||
|
||||
/** @var MockInterface&ProductImageRepositoryInterface */
|
||||
|
||||
@@ -5,25 +5,23 @@ namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Services;
|
||||
use App\Extension\HookManager;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Mockery;
|
||||
use Mockery\MockInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Exceptions\UnauthorizedPresetAccessException;
|
||||
use Modules\Sirsoft\Ecommerce\Models\SearchPreset;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\SearchPresetRepositoryInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Services\SearchPresetService;
|
||||
use Tests\TestCase;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
|
||||
/**
|
||||
* SearchPresetService 단위 테스트
|
||||
*/
|
||||
class SearchPresetServiceTest extends TestCase
|
||||
class SearchPresetServiceTest extends ModuleTestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private SearchPresetService $service;
|
||||
|
||||
/** @var \Mockery\MockInterface&SearchPresetRepositoryInterface */
|
||||
/** @var MockInterface&SearchPresetRepositoryInterface */
|
||||
private $repository;
|
||||
|
||||
private User $user;
|
||||
@@ -54,9 +52,8 @@ class SearchPresetServiceTest extends TestCase
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/** */
|
||||
#[Test]
|
||||
public function test_getPresets_returns_user_presets(): void
|
||||
public function test_get_presets_returns_user_presets(): void
|
||||
{
|
||||
// Arrange
|
||||
$this->actingAs($this->user);
|
||||
@@ -80,7 +77,6 @@ class SearchPresetServiceTest extends TestCase
|
||||
$this->assertCount(2, $result);
|
||||
}
|
||||
|
||||
/** */
|
||||
#[Test]
|
||||
public function test_create_creates_preset_with_correct_data(): void
|
||||
{
|
||||
@@ -116,7 +112,6 @@ class SearchPresetServiceTest extends TestCase
|
||||
$this->assertEquals($name, $result->preset_name);
|
||||
}
|
||||
|
||||
/** */
|
||||
#[Test]
|
||||
public function test_create_fires_hooks(): void
|
||||
{
|
||||
@@ -167,7 +162,6 @@ class SearchPresetServiceTest extends TestCase
|
||||
HookManager::clearAction('sirsoft-ecommerce.preset.after_create');
|
||||
}
|
||||
|
||||
/** */
|
||||
#[Test]
|
||||
public function test_update_updates_preset(): void
|
||||
{
|
||||
@@ -203,7 +197,6 @@ class SearchPresetServiceTest extends TestCase
|
||||
$this->assertEquals('New Name', $result->preset_name);
|
||||
}
|
||||
|
||||
/** */
|
||||
#[Test]
|
||||
public function test_update_fires_hooks(): void
|
||||
{
|
||||
@@ -254,7 +247,6 @@ class SearchPresetServiceTest extends TestCase
|
||||
HookManager::clearAction('sirsoft-ecommerce.preset.after_update');
|
||||
}
|
||||
|
||||
/** */
|
||||
#[Test]
|
||||
public function test_update_throws_exception_for_other_user_preset(): void
|
||||
{
|
||||
@@ -277,7 +269,6 @@ class SearchPresetServiceTest extends TestCase
|
||||
$this->service->update($preset, ['name' => 'Hacked']);
|
||||
}
|
||||
|
||||
/** */
|
||||
#[Test]
|
||||
public function test_delete_deletes_preset(): void
|
||||
{
|
||||
@@ -305,7 +296,6 @@ class SearchPresetServiceTest extends TestCase
|
||||
$this->assertTrue($result);
|
||||
}
|
||||
|
||||
/** */
|
||||
#[Test]
|
||||
public function test_delete_fires_hooks(): void
|
||||
{
|
||||
@@ -347,7 +337,6 @@ class SearchPresetServiceTest extends TestCase
|
||||
HookManager::clearAction('sirsoft-ecommerce.preset.after_delete');
|
||||
}
|
||||
|
||||
/** */
|
||||
#[Test]
|
||||
public function test_delete_throws_exception_for_other_user_preset(): void
|
||||
{
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Services;
|
||||
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Mockery;
|
||||
use Mockery\MockInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\SequenceAlgorithm;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\SequenceType;
|
||||
use Modules\Sirsoft\Ecommerce\Exceptions\SequenceCodeDuplicateException;
|
||||
@@ -13,20 +13,18 @@ use Modules\Sirsoft\Ecommerce\Exceptions\SequenceOverflowException;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Sequence;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\SequenceRepositoryInterface;
|
||||
use Modules\Sirsoft\Ecommerce\Services\SequenceService;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
use PDOException;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* SequenceService 단위 테스트
|
||||
*/
|
||||
class SequenceServiceTest extends TestCase
|
||||
class SequenceServiceTest extends ModuleTestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private SequenceService $service;
|
||||
|
||||
/** @var \Mockery\MockInterface&SequenceRepositoryInterface */
|
||||
/** @var MockInterface&SequenceRepositoryInterface */
|
||||
private $repository;
|
||||
|
||||
protected function setUp(): void
|
||||
|
||||
@@ -7,16 +7,16 @@ use App\Extension\Helpers\SettingsMigrator;
|
||||
use App\Extension\UpgradeContext;
|
||||
use App\Services\ModuleSettingsService;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
use Modules\Sirsoft\Ecommerce\Upgrades\Upgrade_0_7_0;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* v0.7.0 업그레이드 스텝 테스트
|
||||
*
|
||||
* SEO 설정 코어 이관에 따른 User Agent 마이그레이션 및 비이커머스 설정 제거를 검증합니다.
|
||||
*/
|
||||
class Upgrade_0_7_0_Test extends TestCase
|
||||
class Upgrade_0_7_0_Test extends ModuleTestCase
|
||||
{
|
||||
private Upgrade_0_7_0 $upgradeStep;
|
||||
|
||||
|
||||
Reference in new issue
Block a user