mockRepository = Mockery::mock(OrderRepositoryInterface::class); $mockUserAddressRepository = Mockery::mock(UserAddressRepositoryInterface::class); $this->service = new OrderService( $this->mockRepository, $mockUserAddressRepository, app(StockService::class) ); } protected function tearDown(): void { Mockery::close(); parent::tearDown(); } // ======================================== // getList() 테스트 // ======================================== public function test_get_list_returns_paginated_orders(): void { // Given: Repository가 페이지네이션 결과 반환 $filters = ['order_status' => ['pending_payment'], 'per_page' => 10]; $mockPaginator = Mockery::mock(LengthAwarePaginator::class); $this->mockRepository ->shouldReceive('getListWithFilters') ->with($filters, 10) ->once() ->andReturn($mockPaginator); // When: getList 호출 $result = $this->service->getList($filters); // Then: 결과 반환 $this->assertSame($mockPaginator, $result); } public function test_get_list_uses_default_per_page(): void { // Given: per_page 미지정 $filters = ['order_status' => ['pending_payment']]; $mockPaginator = Mockery::mock(LengthAwarePaginator::class); $this->mockRepository ->shouldReceive('getListWithFilters') ->with($filters, 20) // 기본값 20 ->once() ->andReturn($mockPaginator); // When: getList 호출 $result = $this->service->getList($filters); // Then: 기본 per_page 적용 $this->assertSame($mockPaginator, $result); } // ======================================== // getDetail() 테스트 // ======================================== public function test_get_detail_returns_order_with_relations(): void { // Given: Repository가 주문 반환 $order = new Order(['id' => 1, 'order_number' => 'ORD-001']); $this->mockRepository ->shouldReceive('findWithRelations') ->with(1) ->once() ->andReturn($order); // When: getDetail 호출 $result = $this->service->getDetail(1); // Then: 주문 반환 $this->assertEquals($order, $result); } public function test_get_detail_returns_null_for_nonexistent_order(): void { // Given: Repository가 null 반환 $this->mockRepository ->shouldReceive('findWithRelations') ->with(99999) ->once() ->andReturn(null); // When: getDetail 호출 $result = $this->service->getDetail(99999); // Then: null 반환 $this->assertNull($result); } // ======================================== // getStatistics() 테스트 // ======================================== public function test_get_statistics_returns_statistics_array(): void { // Given: Repository가 통계 반환 $statistics = [ 'total_orders' => 100, 'pending_payment' => 10, 'payment_complete' => 30, ]; $this->mockRepository ->shouldReceive('getStatistics') ->once() ->andReturn($statistics); // When: getStatistics 호출 $result = $this->service->getStatistics(); // Then: 통계 반환 $this->assertEquals($statistics, $result); } // ======================================== // update() 테스트 // ======================================== public function test_update_modifies_order(): void { // Given: 주문 존재 $order = new Order(['id' => 1, 'order_status' => OrderStatusEnum::PENDING_PAYMENT->value]); $data = ['order_status' => OrderStatusEnum::PAYMENT_COMPLETE->value]; $updatedOrder = new Order(['order_status' => OrderStatusEnum::PAYMENT_COMPLETE->value]); $updatedOrder->id = 1; // id 는 guarded 라 mass assignment 로 들어가지 않음 $this->mockRepository ->shouldReceive('update') ->once() ->andReturn($updatedOrder); // 주문 상태가 변경되면 옵션 상태도 동기화되어야 한다. $this->mockRepository ->shouldReceive('bulkUpdateOptionStatus') ->once() ->with([1], OrderStatusEnum::PAYMENT_COMPLETE->value) ->andReturn(1); // When: update 호출 $result = $this->service->update($order, $data); // Then: 수정된 주문 반환 $this->assertEquals(OrderStatusEnum::PAYMENT_COMPLETE, $result->order_status); } /** * D7 — 해외(US) 주소 수정 시 intl 컬럼이 채워지고 국내 컬럼은 초기화되어야 한다. */ public function test_update_maps_intl_address_and_clears_kr_columns(): void { $order = new Order(['id' => 11, 'order_status' => OrderStatusEnum::PAYMENT_COMPLETE->value]); // shippingAddress 모킹 — update($addressData) 호출 내용 캡처 $captured = null; $shippingAddress = Mockery::mock(); $shippingAddress->recipient_country_code = 'KR'; $shippingAddress->shouldReceive('update')->once()->andReturnUsing(function ($d) use (&$captured) { $captured = $d; return true; }); $shippingAddress->shouldReceive('fresh')->andReturnNull(); $orderMock = Mockery::mock($order)->makePartial(); $orderMock->shouldReceive('getAttribute')->with('shippingAddress')->andReturn($shippingAddress); $this->mockRepository->shouldReceive('update')->once()->andReturn($orderMock); $this->service->update($orderMock, [ 'recipient_name' => 'John Smith', 'recipient_country_code' => 'US', 'address_line_1' => '1600 Amphitheatre Parkway', 'address_line_2' => 'Bldg 40', 'intl_city' => 'Mountain View', 'intl_state' => 'CA', 'intl_postal_code' => '94043', ]); $this->assertNotNull($captured); $this->assertSame('1600 Amphitheatre Parkway', $captured['address_line_1']); $this->assertSame('Mountain View', $captured['intl_city']); $this->assertSame('94043', $captured['intl_postal_code']); // 국내 컬럼 초기화 — zipcode/address 는 NOT NULL 이므로 빈 문자열 (DB 무결성 위반 방지) $this->assertSame('', $captured['zipcode']); $this->assertSame('', $captured['address']); $this->assertNull($captured['address_detail']); } /** * D7 — 국내(KR) 주소 수정 시 KR 컬럼이 채워지고 해외 컬럼은 초기화되어야 한다. */ public function test_update_maps_kr_address_and_clears_intl_columns(): void { $order = new Order(['id' => 12, 'order_status' => OrderStatusEnum::PAYMENT_COMPLETE->value]); $captured = null; $shippingAddress = Mockery::mock(); $shippingAddress->recipient_country_code = 'US'; $shippingAddress->shouldReceive('update')->once()->andReturnUsing(function ($d) use (&$captured) { $captured = $d; return true; }); $shippingAddress->shouldReceive('fresh')->andReturnNull(); $orderMock = Mockery::mock($order)->makePartial(); $orderMock->shouldReceive('getAttribute')->with('shippingAddress')->andReturn($shippingAddress); $this->mockRepository->shouldReceive('update')->once()->andReturn($orderMock); $this->service->update($orderMock, [ 'recipient_name' => '홍길동', 'recipient_country_code' => 'KR', 'recipient_zipcode' => '06236', 'recipient_address' => '서울특별시 강남구 테헤란로 152', 'recipient_detail_address' => '강남빌딩 10층', ]); $this->assertNotNull($captured); $this->assertSame('06236', $captured['zipcode']); $this->assertSame('서울특별시 강남구 테헤란로 152', $captured['address']); // 해외 컬럼은 초기화(null) $this->assertNull($captured['address_line_1']); $this->assertNull($captured['intl_city']); $this->assertNull($captured['intl_postal_code']); } public function test_update_syncs_option_status_when_order_status_changes(): void { // Given: 결제대기 주문을 결제완료로 변경 $order = new Order(['id' => 7, 'order_status' => OrderStatusEnum::PENDING_PAYMENT->value]); $data = ['order_status' => OrderStatusEnum::PAYMENT_COMPLETE->value]; $updatedOrder = new Order(['order_status' => OrderStatusEnum::PAYMENT_COMPLETE->value]); $updatedOrder->id = 7; $this->mockRepository->shouldReceive('update')->once()->andReturn($updatedOrder); // Then: 주문 옵션 상태도 동일 상태로 일괄 동기화되어야 한다. $this->mockRepository ->shouldReceive('bulkUpdateOptionStatus') ->once() ->with([7], OrderStatusEnum::PAYMENT_COMPLETE->value) ->andReturn(2); $result = $this->service->update($order, $data); $this->assertEquals(OrderStatusEnum::PAYMENT_COMPLETE, $result->order_status); } public function test_update_does_not_sync_options_when_status_unchanged(): void { // Given: 상태 변경 없이 배송 메모만 수정 $order = new Order(['id' => 9, 'order_status' => OrderStatusEnum::PAYMENT_COMPLETE->value]); $data = ['admin_memo' => '메모 수정']; $updatedOrder = new Order(['order_status' => OrderStatusEnum::PAYMENT_COMPLETE->value]); $updatedOrder->id = 9; $this->mockRepository->shouldReceive('update')->once()->andReturn($updatedOrder); // Then: order_status 가 data 에 없으므로 옵션 동기화는 호출되지 않아야 한다. $this->mockRepository->shouldNotReceive('bulkUpdateOptionStatus'); $result = $this->service->update($order, $data); $this->assertEquals(OrderStatusEnum::PAYMENT_COMPLETE, $result->order_status); } public function test_update_does_not_sync_options_for_cancelled_status(): void { // Given: 주문을 취소 상태로 변경 (옵션 라이프사이클은 취소 서비스가 담당) $order = new Order(['id' => 11, 'order_status' => OrderStatusEnum::PAYMENT_COMPLETE->value]); $data = ['order_status' => OrderStatusEnum::CANCELLED->value]; $updatedOrder = new Order(['order_status' => OrderStatusEnum::CANCELLED->value]); $updatedOrder->id = 11; $this->mockRepository->shouldReceive('update')->once()->andReturn($updatedOrder); // Then: CANCELLED 는 동기화 제외 상태이므로 옵션 일괄 변경은 호출되지 않아야 한다. $this->mockRepository->shouldNotReceive('bulkUpdateOptionStatus'); $result = $this->service->update($order, $data); $this->assertEquals(OrderStatusEnum::CANCELLED, $result->order_status); } // ======================================== // delete() 테스트 // ======================================== public function test_delete_removes_order(): void { // Given: 주문 존재 (관계 메서드 mock) $order = Mockery::mock(Order::class)->makePartial(); $order->shouldReceive('getAttribute')->with('id')->andReturn(1); $order->shouldReceive('getAttribute')->with('order_number')->andReturn('ORD-001'); // 관계 삭제 mock (반환 타입 일치 필수) $mockTaxInvoices = Mockery::mock(HasMany::class); $mockTaxInvoices->shouldReceive('delete')->once(); $order->shouldReceive('taxInvoices')->once()->andReturn($mockTaxInvoices); $mockShippings = Mockery::mock(HasMany::class); $mockShippings->shouldReceive('delete')->once(); $order->shouldReceive('shippings')->once()->andReturn($mockShippings); $mockAddresses = Mockery::mock(HasMany::class); $mockAddresses->shouldReceive('delete')->once(); $order->shouldReceive('addresses')->once()->andReturn($mockAddresses); $mockPayment = Mockery::mock(HasOne::class); $mockPayment->shouldReceive('delete')->once(); $order->shouldReceive('payment')->once()->andReturn($mockPayment); $mockOptions = Mockery::mock(HasMany::class); $mockOptions->shouldReceive('delete')->once(); $order->shouldReceive('options')->once()->andReturn($mockOptions); $this->mockRepository ->shouldReceive('delete') ->with($order) ->once() ->andReturn(true); // When: delete 호출 $result = $this->service->delete($order); // Then: true 반환 + 모든 관계 삭제 호출됨 $this->assertTrue($result); } // ======================================== // bulkUpdate() 테스트 // ======================================== public function test_bulk_update_processes_multiple_orders(): void { // Given: 여러 주문 ID와 상태 $ids = [1, 2, 3]; $data = [ 'ids' => $ids, 'order_status' => 'payment_complete', ]; // ChangeDetector용 스냅샷 조회 (Repository 위임) $this->mockRepository ->shouldReceive('getSnapshotsByIds') ->with($ids) ->once() ->andReturn([]); $this->mockRepository ->shouldReceive('bulkUpdateStatus') ->with($ids, 'payment_complete') ->once() ->andReturn(3); // 주문상품옵션 상태도 동일하게 일괄 변경 $this->mockRepository ->shouldReceive('bulkUpdateOptionStatus') ->with($ids, 'payment_complete') ->once() ->andReturn(5); // 전이 알림 발화용 — 스냅샷이 비어(이전 상태 null) 전 주문이 전이 대상 → fresh 조회 $this->mockRepository ->shouldReceive('findByIdsKeyed') ->with($ids) ->once() ->andReturn(new Collection); // When: bulkUpdate 호출 $result = $this->service->bulkUpdate($data); // Then: 처리된 개수 반환 $this->assertEquals(3, $result['updated_count']); $this->assertEquals(3, $result['requested_count']); } public function test_bulk_update_with_shipping_info(): void { // Given: 배송 정보 변경 $ids = [1, 2]; $data = [ 'ids' => $ids, 'carrier_id' => 1, 'tracking_number' => '123456789012', ]; // ChangeDetector용 스냅샷 조회 (Repository 위임) $this->mockRepository ->shouldReceive('getSnapshotsByIds') ->with($ids) ->once() ->andReturn([]); $this->mockRepository ->shouldReceive('bulkUpdateShipping') ->with($ids, 1, '123456789012') ->once() ->andReturn(2); // When: bulkUpdate 호출 $result = $this->service->bulkUpdate($data); // Then: 처리된 개수 반환 $this->assertEquals(2, $result['updated_count']); } // ======================================== // getByOrderNumber() 테스트 // ======================================== public function test_get_by_order_number_returns_order(): void { // Given: 주문번호로 조회 $order = new Order(['order_number' => 'ORD-20250117-00001']); $this->mockRepository ->shouldReceive('findByOrderNumber') ->with('ORD-20250117-00001') ->once() ->andReturn($order); // When: getByOrderNumber 호출 $result = $this->service->getByOrderNumber('ORD-20250117-00001'); // Then: 주문 반환 $this->assertEquals('ORD-20250117-00001', $result->order_number); } // ======================================== // 상태 전이 알림 훅 (A35/A36/D9) + IDV 가드 (A8) // ======================================== /** * update 가 상태 전이 시 order.after_status_change 훅을 1회 발화한다. * * @scenario transition_path=update, target_status=shipping, previous_status=different, order_count=single * * @effects update_fires_after_status_change_on_transition */ public function test_update_fires_after_status_change_on_transition(): void { $order = new Order(['id' => 21, 'order_status' => OrderStatusEnum::PAYMENT_COMPLETE->value]); $updated = new Order(['order_status' => OrderStatusEnum::SHIPPING->value]); $updated->id = 21; $this->mockRepository->shouldReceive('update')->once()->andReturn($updated); $this->mockRepository->shouldReceive('bulkUpdateOptionStatus')->once()->andReturn(1); $fired = []; $cb = function ($o, $prev = null) use (&$fired) { $fired[] = $prev; }; HookManager::addAction('sirsoft-ecommerce.order.after_status_change', $cb, 1); try { $this->service->update($order, ['order_status' => OrderStatusEnum::SHIPPING->value]); } finally { HookManager::removeAction('sirsoft-ecommerce.order.after_status_change', $cb); } $this->assertCount(1, $fired); $this->assertSame(OrderStatusEnum::PAYMENT_COMPLETE->value, $fired[0]); } /** * update 가 상태 미전이(동일 상태) 시 after_status_change 를 발화하지 않는다. * * @scenario transition_path=update, target_status=shipping, previous_status=same_as_target, order_count=single * * @effects no_fire_when_status_unchanged_in_bulk */ public function test_update_does_not_fire_when_status_unchanged(): void { $order = new Order(['id' => 22, 'order_status' => OrderStatusEnum::SHIPPING->value]); $updated = new Order(['order_status' => OrderStatusEnum::SHIPPING->value]); $updated->id = 22; // order_status 가 data 에 없음 → 동기화/전이 없음 $this->mockRepository->shouldReceive('update')->once()->andReturn($updated); $fired = 0; $cb = function () use (&$fired) { $fired++; }; HookManager::addAction('sirsoft-ecommerce.order.after_status_change', $cb, 1); try { $this->service->update($order, ['admin_memo' => '메모만 수정']); } finally { HookManager::removeAction('sirsoft-ecommerce.order.after_status_change', $cb); } $this->assertSame(0, $fired); } /** * update 로 payment_complete 전이 시 비-DBANK 주문은 IDV approve 훅을 발화한다 (결정 7). * * @scenario policy=approve, enabled=on, actor=admin, verified_state=unverified * * @effects admin_update_pg_fires_approve */ public function test_update_to_payment_complete_fires_idv_approve_for_non_dbank(): void { $order = new Order(['id' => 23, 'order_status' => OrderStatusEnum::PENDING_PAYMENT->value]); $order->setRelation('payment', new OrderPayment(['payment_method' => 'card'])); $updated = new Order(['order_status' => OrderStatusEnum::PAYMENT_COMPLETE->value]); $updated->id = 23; $this->mockRepository->shouldReceive('update')->once()->andReturn($updated); $this->mockRepository->shouldReceive('bulkUpdateOptionStatus')->once()->andReturn(1); $approveFired = false; $depositFired = false; $cbA = function () use (&$approveFired) { $approveFired = true; }; $cbD = function () use (&$depositFired) { $depositFired = true; }; HookManager::addAction('sirsoft-ecommerce.payment.before_approve', $cbA, 1); HookManager::addAction('sirsoft-ecommerce.payment.before_confirm_deposit', $cbD, 1); try { $this->service->update($order, ['order_status' => OrderStatusEnum::PAYMENT_COMPLETE->value]); } finally { HookManager::removeAction('sirsoft-ecommerce.payment.before_approve', $cbA); HookManager::removeAction('sirsoft-ecommerce.payment.before_confirm_deposit', $cbD); } $this->assertTrue($approveFired, 'non-DBANK payment_complete 전이는 approve 훅을 발화해야 함'); $this->assertFalse($depositFired, 'non-DBANK 는 confirm_deposit 훅을 발화하지 않아야 함'); } /** * update 로 payment_complete 전이 시 DBANK 주문은 confirm_deposit 훅을 발화한다 (결정 7). * * @scenario policy=confirm_deposit, enabled=on, actor=admin, verified_state=unverified * * @effects admin_update_dbank_fires_confirm_deposit, dbank_update_does_not_fire_approve */ public function test_update_to_payment_complete_fires_confirm_deposit_for_dbank(): void { $order = new Order(['id' => 24, 'order_status' => OrderStatusEnum::PENDING_PAYMENT->value]); $order->setRelation('payment', new OrderPayment(['payment_method' => 'dbank'])); $updated = new Order(['order_status' => OrderStatusEnum::PAYMENT_COMPLETE->value]); $updated->id = 24; $this->mockRepository->shouldReceive('update')->once()->andReturn($updated); $this->mockRepository->shouldReceive('bulkUpdateOptionStatus')->once()->andReturn(1); $approveFired = false; $depositFired = false; $cbA = function () use (&$approveFired) { $approveFired = true; }; $cbD = function () use (&$depositFired) { $depositFired = true; }; HookManager::addAction('sirsoft-ecommerce.payment.before_approve', $cbA, 1); HookManager::addAction('sirsoft-ecommerce.payment.before_confirm_deposit', $cbD, 1); try { $this->service->update($order, ['order_status' => OrderStatusEnum::PAYMENT_COMPLETE->value]); } finally { HookManager::removeAction('sirsoft-ecommerce.payment.before_approve', $cbA); HookManager::removeAction('sirsoft-ecommerce.payment.before_confirm_deposit', $cbD); } $this->assertTrue($depositFired, 'DBANK payment_complete 전이는 confirm_deposit 훅을 발화해야 함'); $this->assertFalse($approveFired, 'DBANK 는 approve 훅을 발화하지 않아야 함 (분기 역검증)'); } /** * bulkUpdate 가 payment_complete 일괄 전이 시 IDV approve 를 배치당 1회만 발화한다 (결정 4). * * @scenario policy=approve, enabled=on, actor=admin, verified_state=unverified * * @effects bulk_update_fires_approve_once */ public function test_bulk_update_fires_idv_approve_once(): void { $ids = [31, 32, 33]; $this->mockRepository->shouldReceive('getSnapshotsByIds')->with($ids)->once()->andReturn([]); $this->mockRepository->shouldReceive('bulkUpdateStatus')->once()->andReturn(3); $this->mockRepository->shouldReceive('bulkUpdateOptionStatus')->once()->andReturn(3); $this->mockRepository->shouldReceive('findByIdsKeyed')->once()->andReturn(new Collection); $approveCount = 0; $cb = function () use (&$approveCount) { $approveCount++; }; HookManager::addAction('sirsoft-ecommerce.payment.before_approve', $cb, 1); try { $this->service->bulkUpdate(['ids' => $ids, 'order_status' => 'payment_complete']); } finally { HookManager::removeAction('sirsoft-ecommerce.payment.before_approve', $cb); } $this->assertSame(1, $approveCount, 'approve enforce 는 배치당 1회만 발화해야 함'); } }