Files
Gnuboard7/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderOptionServiceTest.php
T
HeuJung abb40ad210 fix(core,board,ecommerce,page): 중첩 리소스 스코프·계층 무결성 결함 12건 일괄 수정
중첩 라우트의 상위 리소스 ID 를 조회 스코프에 반영하지 않아 A 의 경로로 B 의
하위 리소스를 읽거나 변경할 수 있었다. 방어 위치를 Repository where 절(SSoT)로
통일하고 컨트롤러 사후 비교에 의존하지 않게 했다. 같은 리소스의 형제 엔드포인트가
서로 다른 강도로 검증하던 부분도 맞췄다.

계층 리소스는 자기 자신·자손을 부모로 지정하는 순환을 검증 계층에서 차단하고,
검증을 우회하는 경로(시더/훅/오염 데이터)를 위해 path 재귀에 방문 ID 가드를 뒀다.
설정값이 정하는 깊이 상한을 Service 가 리터럴로 재클램프하던 부분을 제거해
게시판 설정이 실제로 적용되게 했다.

쓰기 경로는 validated 기준으로 좁혀 FormRequest 미정의 필드가 fillable 로
새지 않게 했다. 기존 오염 데이터는 업그레이드 스텝이 정리한다(삭제 없이 최상위로 이동).

재발 방지로 audit 룰 3종을 추가하고, 규정 문서와 트러블슈팅 사례를 함께 갱신했다.

공개 이슈: gnuboard/g7
2026-07-24 16:17:44 +09:00

314 lines
12 KiB
PHP

<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Services;
use App\Extension\HookManager;
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderFactory;
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderOptionFactory;
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderPaymentFactory;
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderShippingFactory;
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
use Modules\Sirsoft\Ecommerce\Models\Order;
use Modules\Sirsoft\Ecommerce\Services\OrderOptionService;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
/**
* OrderOptionService 전이 알림 테스트 (A35 / A36 / C3)
*
* 옵션별 일괄 상태변경(운송장 등)으로 부모 주문 상태가 전이되면
* order.after_status_change 훅이 발화되어 OrderStatusNotificationListener 가
* 알림으로 매핑하는지 검증한다.
*/
class OrderOptionServiceTest extends ModuleTestCase
{
private OrderOptionService $service;
/** @var array<string|null> */
private array $fired = [];
private \Closure $cb;
protected function setUp(): void
{
parent::setUp();
$this->service = app(OrderOptionService::class);
$this->fired = [];
$this->cb = function ($order, $prev = null) {
$this->fired[] = $prev;
};
HookManager::addAction('sirsoft-ecommerce.order.after_status_change', $this->cb, 1);
}
protected function tearDown(): void
{
HookManager::removeAction('sirsoft-ecommerce.order.after_status_change', $this->cb);
parent::tearDown();
}
private function makePaidOrderWithOptions(int $optionCount, int $quantity = 1): Order
{
$order = OrderFactory::new()->create([
'order_status' => OrderStatusEnum::PAYMENT_COMPLETE,
]);
for ($i = 0; $i < $optionCount; $i++) {
OrderOptionFactory::new()->forOrder($order)->create([
'option_status' => OrderStatusEnum::PAYMENT_COMPLETE,
'quantity' => $quantity,
]);
}
return $order->fresh();
}
/**
* 전 옵션이 SHIPPING 으로 전이되면 부모도 SHIPPING 으로 전이 → after_status_change 발화.
*
* @scenario transition_path=bulk_change_with_quantity, target_status=shipping, previous_status=different, order_count=single
*
* @effects bulk_change_with_quantity_fires_when_parent_transitions
*/
public function test_bulk_change_fires_when_parent_transitions(): void
{
$order = $this->makePaidOrderWithOptions(2);
$items = $order->options->map(fn ($o) => ['option_id' => $o->id, 'quantity' => $o->quantity])->all();
$this->service->bulkChangeStatusWithQuantity($items, OrderStatusEnum::SHIPPING, $order->id);
$this->assertContains(OrderStatusEnum::PAYMENT_COMPLETE->value, $this->fired);
$this->assertEquals(OrderStatusEnum::SHIPPING, $order->fresh()->order_status);
}
/**
* 일부 옵션만 전이되면 부모는 혼합상태 → 가장 낮은 단계(payment_complete) 유지 → 미전이 → 미발화.
*
* @scenario transition_path=bulk_change_with_quantity, target_status=shipping, previous_status=same_as_target, order_count=single
*
* @effects no_fire_when_status_unchanged_in_bulk
*/
public function test_bulk_change_does_not_fire_when_parent_stays_mixed(): void
{
$order = $this->makePaidOrderWithOptions(2);
// 2개 옵션 중 1개만 SHIPPING 으로 전이 → 부모는 혼합(payment_complete + shipping) → 가장 낮은 payment_complete 유지
$firstOption = $order->options->first();
$this->service->bulkChangeStatusWithQuantity(
[['option_id' => $firstOption->id, 'quantity' => $firstOption->quantity]],
OrderStatusEnum::SHIPPING,
$order->id,
);
$this->assertSame([], $this->fired, '부모가 전이되지 않으면 after_status_change 미발화여야 함');
$this->assertEquals(OrderStatusEnum::PAYMENT_COMPLETE, $order->fresh()->order_status);
}
/**
* 배송 상태(shipping)로 전이할 때 metadata(carrier_id/tracking_number)가
* 옵션의 배송 레코드에 기록된다 (송장 input 버그 — 백엔드).
*
* @scenario transition_path=bulk_change_with_quantity, target_status=shipping, has_shipping_row=true, metadata=carrier_and_tracking
*
* @effects bulk_change_persists_tracking_to_shipping_row
*/
public function test_bulk_change_persists_tracking_metadata_to_shipping_row(): void
{
$order = $this->makePaidOrderWithOptions(1);
$option = $order->options->first();
// 정상 주문 흐름과 동일하게 옵션별 배송 레코드 존재
$shipping = OrderShippingFactory::new()->forOrderOption($option)->create([
'carrier_id' => null,
'tracking_number' => null,
]);
$this->service->bulkChangeStatusWithQuantity(
[['option_id' => $option->id, 'quantity' => $option->quantity]],
OrderStatusEnum::SHIPPING,
$order->id,
['carrier_id' => 1, 'tracking_number' => 'SAVED-TRACK-108']
);
$fresh = $shipping->fresh();
$this->assertEquals(1, $fresh->carrier_id);
$this->assertEquals('SAVED-TRACK-108', $fresh->tracking_number);
}
/**
* 비배송 상태(payment_complete 등)로 전이할 때는 metadata 가 있어도
* 배송 레코드에 기록하지 않는다 (배송 상태 전이에만 반영).
*
* @scenario transition_path=bulk_change_with_quantity, target_status=payment_complete, has_shipping_row=true, metadata=carrier_and_tracking
*
* @effects bulk_change_skips_tracking_for_non_shipping_status
*/
public function test_bulk_change_does_not_persist_tracking_for_non_shipping_status(): void
{
// 미결제 → 결제완료 전이 (비배송 상태)
$order = OrderFactory::new()->create(['order_status' => OrderStatusEnum::PENDING_PAYMENT]);
$option = OrderOptionFactory::new()->forOrder($order)->create([
'option_status' => OrderStatusEnum::PENDING_PAYMENT,
'quantity' => 1,
]);
$shipping = OrderShippingFactory::new()->forOrderOption($option)->create([
'carrier_id' => null,
'tracking_number' => null,
]);
$this->service->bulkChangeStatusWithQuantity(
[['option_id' => $option->id, 'quantity' => $option->quantity]],
OrderStatusEnum::PAYMENT_COMPLETE,
$order->id,
['carrier_id' => 1, 'tracking_number' => 'SHOULD-NOT-SAVE']
);
$fresh = $shipping->fresh();
$this->assertNull($fresh->carrier_id, '비배송 전이 시 carrier_id 미기록');
$this->assertNull($fresh->tracking_number, '비배송 전이 시 tracking_number 미기록');
}
/**
* 발화된 IDV 훅 이름을 수집하는 임시 리스너를 등록하고, 정리 콜백을 반환한다.
*
* @param array<string> &$collected 발화된 훅 이름 수집 배열
* @return callable 정리(removeAction) 콜백
*/
private function captureIdvHooks(array &$collected): callable
{
$hooks = [
'sirsoft-ecommerce.payment.before_confirm_deposit',
'sirsoft-ecommerce.payment.before_approve',
];
$handles = [];
foreach ($hooks as $hook) {
$cb = function () use ($hook, &$collected) {
$collected[] = $hook;
};
HookManager::addAction($hook, $cb, 1);
$handles[$hook] = $cb;
}
return function () use ($handles) {
foreach ($handles as $hook => $cb) {
HookManager::removeAction($hook, $cb);
}
};
}
/**
* N4: 무통장(dbank) 주문이 옵션 일괄변경으로 결제완료 전이될 때, 진입부에서
* payment.before_confirm_deposit IDV 훅이 발화된다.
*
* @scenario transition_path=bulk_change_with_quantity, target_status=payment_complete, payment_method=dbank
*
* @effects bulk_payment_complete_fires_confirm_deposit_idv_for_dbank
*/
public function test_bulk_payment_complete_fires_confirm_deposit_idv_for_dbank(): void
{
$order = OrderFactory::new()->create(['order_status' => OrderStatusEnum::PENDING_PAYMENT]);
OrderPaymentFactory::new()->forOrder($order)->directBank()->create();
$option = OrderOptionFactory::new()->forOrder($order)->create([
'option_status' => OrderStatusEnum::PENDING_PAYMENT,
'quantity' => 1,
]);
$collected = [];
$cleanup = $this->captureIdvHooks($collected);
$this->service->bulkChangeStatusWithQuantity(
[['option_id' => $option->id, 'quantity' => $option->quantity]],
OrderStatusEnum::PAYMENT_COMPLETE,
$order->id,
);
$cleanup();
$this->assertContains('sirsoft-ecommerce.payment.before_confirm_deposit', $collected);
$this->assertNotContains('sirsoft-ecommerce.payment.before_approve', $collected);
}
/**
* N4: 카드 등 그 외 결제수단 주문은 결제완료 전이 시 payment.before_approve 발화.
*
* @scenario transition_path=bulk_change_with_quantity, target_status=payment_complete, payment_method=card
*
* @effects bulk_payment_complete_fires_approve_idv_for_non_dbank
*/
public function test_bulk_payment_complete_fires_approve_idv_for_card(): void
{
$order = OrderFactory::new()->create(['order_status' => OrderStatusEnum::PENDING_PAYMENT]);
OrderPaymentFactory::new()->forOrder($order)->card()->create();
$option = OrderOptionFactory::new()->forOrder($order)->create([
'option_status' => OrderStatusEnum::PENDING_PAYMENT,
'quantity' => 1,
]);
$collected = [];
$cleanup = $this->captureIdvHooks($collected);
$this->service->bulkChangeStatusWithQuantity(
[['option_id' => $option->id, 'quantity' => $option->quantity]],
OrderStatusEnum::PAYMENT_COMPLETE,
$order->id,
);
$cleanup();
$this->assertContains('sirsoft-ecommerce.payment.before_approve', $collected);
$this->assertNotContains('sirsoft-ecommerce.payment.before_confirm_deposit', $collected);
}
/**
* N4: 이미 결제완료인 주문은 전이가 일어나지 않으므로 IDV 가드 대상에서 제외된다.
*
* @scenario transition_path=bulk_change_with_quantity, target_status=payment_complete, current_status=payment_complete
*
* @effects bulk_payment_complete_skips_idv_when_already_paid
*/
public function test_bulk_payment_complete_skips_idv_when_already_paid(): void
{
$order = OrderFactory::new()->create(['order_status' => OrderStatusEnum::PAYMENT_COMPLETE]);
OrderPaymentFactory::new()->forOrder($order)->directBank()->create();
$option = OrderOptionFactory::new()->forOrder($order)->create([
'option_status' => OrderStatusEnum::PAYMENT_COMPLETE,
'quantity' => 1,
]);
$collected = [];
$cleanup = $this->captureIdvHooks($collected);
$this->service->bulkChangeStatusWithQuantity(
[['option_id' => $option->id, 'quantity' => $option->quantity]],
OrderStatusEnum::PAYMENT_COMPLETE,
$order->id,
);
$cleanup();
$this->assertSame([], $collected, '이미 결제완료인 주문은 IDV 훅 미발화');
}
/**
* N4: 결제완료가 아닌 다른 상태(배송중) 전이에는 IDV 가드를 발화하지 않는다.
*
* @scenario transition_path=bulk_change_with_quantity, target_status=shipping, payment_method=dbank
*
* @effects bulk_non_payment_complete_skips_idv
*/
public function test_bulk_non_payment_complete_does_not_fire_idv(): void
{
$order = $this->makePaidOrderWithOptions(1);
$option = $order->options->first();
$collected = [];
$cleanup = $this->captureIdvHooks($collected);
$this->service->bulkChangeStatusWithQuantity(
[['option_id' => $option->id, 'quantity' => $option->quantity]],
OrderStatusEnum::SHIPPING,
$order->id,
);
$cleanup();
$this->assertSame([], $collected, '결제완료 외 전이는 IDV 훅 미발화');
}
}