user = $this->createUser(); $this->manager = $this->createAdminUser(['sirsoft-ecommerce.inquiries.update']); $product = Product::factory()->create(); $this->inquiry = ProductInquiry::factory()->create([ 'user_id' => $this->user->id, 'product_id' => $product->id, 'is_answered' => false, ]); app(EcommerceSettingsService::class)->setSetting('inquiry.board_slug', 'test-inquiry-board'); // 다른 모듈이 등록한 inquiry.* 필터가 스냅샷으로 잔존해 서비스 mock 과 충돌하는 // cross-module contamination 차단 (UserProductInquiryControllerTest 와 동형). foreach ([ 'sirsoft-ecommerce.inquiry.update', 'sirsoft-ecommerce.inquiry.delete', 'sirsoft-ecommerce.inquiry.create', 'sirsoft-ecommerce.inquiry.update_reply', 'sirsoft-ecommerce.inquiry.delete_reply', 'sirsoft-ecommerce.inquiry.update_validation_rules', 'sirsoft-board.post.get_by_ids', ] as $hook) { HookManager::clearFilter($hook); } } /** * 도메인 실패를 던지는 서비스 mock (본인 문의 조회는 성공). */ private function mockServiceThrowing(string $method, \Throwable $e): void { $inquiry = $this->inquiry; $this->mock(ProductInquiryService::class, function ($mock) use ($method, $e, $inquiry) { $mock->shouldReceive('findById')->andReturn($inquiry); $mock->shouldReceive($method)->andThrow($e); $mock->shouldReceive()->andReturnNull(); }); } /** * 도메인 예외 — 게시판 미설정. */ private function domainException(): ProductInquiryOperationException { return new ProductInquiryOperationException('sirsoft-ecommerce::messages.inquiries.board_not_configured'); } /** * 인프라 예외 — DB 장애. `\RuntimeException` 이라 종전 광역 catch 에 걸렸다. */ private function infraException(): \RuntimeException { return new \RuntimeException('SQLSTATE[HY000]: General error: 2006 MySQL server has gone away'); } /** * 응답 메시지가 "해석된 도메인 사유" 인지 단언한다. * * 응답 로케일은 요청 처리 중에 결정되어 테스트 프로세스의 로케일과 다를 수 있으므로 * 특정 언어로 못박지 않고 지원 로케일의 해석 결과 집합과 대조한다. 고정하려는 것은 * ① 메시지 키 원문이 새지 않을 것 ② 일반 실패 문구로 뭉개지지 않을 것 두 가지다. * * @param string|null $message 응답 message 필드 * @param string $context 실패 시 표시할 맥락 설명 */ private function assertResolvedReason(?string $message, string $context = '도메인 실패 사유가 해석된 다국어 문구로 나가야 합니다.'): void { $this->assertNotNull($message, $context); $this->assertStringNotContainsString( 'sirsoft-ecommerce::', $message, '메시지 키 원문이 응답에 노출되면 안 됩니다.' ); $key = 'sirsoft-ecommerce::messages.inquiries.board_not_configured'; $resolved = array_map( fn (string $locale) => trans($key, [], $locale), config('app.supported_locales') ); $this->assertContains($message, $resolved, $context); } // ------------------------------------------------------------------ // 문의 수정 // ------------------------------------------------------------------ /** * @scenario endpoint=update_inquiry, error_class=domain * * @effects inquiry_update_domain_exception_returns_422 */ public function test_update_domain_exception_returns_422_with_resolved_reason(): void { $this->mockServiceThrowing('updateInquiry', $this->domainException()); $response = $this->actingAs($this->user)->putJson( "/api/modules/sirsoft-ecommerce/user/inquiries/{$this->inquiry->id}", ['content' => '수정된 문의 내용입니다.'] ); $response->assertStatus(422); $this->assertResolvedReason($response->json('message')); } /** * @scenario endpoint=update_inquiry, error_class=infrastructure * * @effects inquiry_update_infrastructure_exception_returns_500_not_422 */ public function test_update_infrastructure_exception_returns_500_not_422(): void { $this->mockServiceThrowing('updateInquiry', $this->infraException()); $response = $this->actingAs($this->user)->putJson( "/api/modules/sirsoft-ecommerce/user/inquiries/{$this->inquiry->id}", ['content' => '수정된 문의 내용입니다.'] ); $response->assertStatus(500); $this->assertStringNotContainsString('SQLSTATE', $response->getContent()); } // ------------------------------------------------------------------ // 문의 삭제 (도메인 예외가 500 으로 나가던 역방향 짝) // ------------------------------------------------------------------ /** * @scenario endpoint=destroy_inquiry, error_class=domain * * @effects inquiry_destroy_domain_exception_returns_422 */ public function test_destroy_domain_exception_returns_422_not_500(): void { $this->mockServiceThrowing('deleteInquiry', $this->domainException()); $response = $this->actingAs($this->user)->deleteJson( "/api/modules/sirsoft-ecommerce/user/inquiries/{$this->inquiry->id}" ); $response->assertStatus(422); $this->assertResolvedReason($response->json('message'), '삭제 실패 사유가 운영자에게 도달해야 합니다 (종전에는 500 일반 문구).'); } /** * @scenario endpoint=destroy_inquiry, error_class=infrastructure * * @effects inquiry_destroy_infrastructure_exception_returns_500_not_422 */ public function test_destroy_infrastructure_exception_returns_500(): void { $this->mockServiceThrowing('deleteInquiry', $this->infraException()); $response = $this->actingAs($this->user)->deleteJson( "/api/modules/sirsoft-ecommerce/user/inquiries/{$this->inquiry->id}" ); $response->assertStatus(500); $this->assertStringNotContainsString('SQLSTATE', $response->getContent()); } // ------------------------------------------------------------------ // 답변 등록 // ------------------------------------------------------------------ /** * @scenario endpoint=create_reply, error_class=domain * * @effects inquiry_reply_domain_exception_returns_422 */ public function test_reply_domain_exception_returns_422(): void { $this->mockServiceThrowing('createReply', $this->domainException()); $response = $this->actingAs($this->manager)->postJson( "/api/modules/sirsoft-ecommerce/user/inquiries/{$this->inquiry->id}/reply", ['content' => '답변 내용입니다.'] ); $response->assertStatus(422); $this->assertResolvedReason($response->json('message')); } /** * @scenario endpoint=create_reply, error_class=infrastructure * * @effects inquiry_reply_infrastructure_exception_returns_500_not_422 */ public function test_reply_infrastructure_exception_returns_500_not_422(): void { $this->mockServiceThrowing('createReply', $this->infraException()); $response = $this->actingAs($this->manager)->postJson( "/api/modules/sirsoft-ecommerce/user/inquiries/{$this->inquiry->id}/reply", ['content' => '답변 내용입니다.'] ); $response->assertStatus(500); $this->assertStringNotContainsString('SQLSTATE', $response->getContent()); } // ------------------------------------------------------------------ // 답변 수정 // ------------------------------------------------------------------ /** * @scenario endpoint=update_reply, error_class=domain * * @effects inquiry_update_reply_domain_exception_returns_422 */ public function test_update_reply_domain_exception_returns_422(): void { $this->mockServiceThrowing('updateReply', $this->domainException()); $response = $this->actingAs($this->manager)->putJson( "/api/modules/sirsoft-ecommerce/user/inquiries/{$this->inquiry->id}/reply", ['content' => '수정된 답변 내용입니다.'] ); $response->assertStatus(422); $this->assertResolvedReason($response->json('message')); } /** * @scenario endpoint=update_reply, error_class=infrastructure * * @effects inquiry_update_reply_infrastructure_exception_returns_500_not_422 */ public function test_update_reply_infrastructure_exception_returns_500_not_422(): void { $this->mockServiceThrowing('updateReply', $this->infraException()); $response = $this->actingAs($this->manager)->putJson( "/api/modules/sirsoft-ecommerce/user/inquiries/{$this->inquiry->id}/reply", ['content' => '수정된 답변 내용입니다.'] ); $response->assertStatus(500); $this->assertStringNotContainsString('SQLSTATE', $response->getContent()); } // ------------------------------------------------------------------ // 답변 삭제 // ------------------------------------------------------------------ /** * @scenario endpoint=destroy_reply, error_class=domain * * @effects inquiry_destroy_reply_domain_exception_returns_422 */ public function test_destroy_reply_domain_exception_returns_422(): void { $this->mockServiceThrowing('deleteReply', $this->domainException()); $response = $this->actingAs($this->manager)->deleteJson( "/api/modules/sirsoft-ecommerce/user/inquiries/{$this->inquiry->id}/reply" ); $response->assertStatus(422); $this->assertResolvedReason($response->json('message')); } /** * @scenario endpoint=destroy_reply, error_class=infrastructure * * @effects inquiry_destroy_reply_infrastructure_exception_returns_500_not_422 */ public function test_destroy_reply_infrastructure_exception_returns_500_not_422(): void { $this->mockServiceThrowing('deleteReply', $this->infraException()); $response = $this->actingAs($this->manager)->deleteJson( "/api/modules/sirsoft-ecommerce/user/inquiries/{$this->inquiry->id}/reply" ); $response->assertStatus(500); $this->assertStringNotContainsString('SQLSTATE', $response->getContent()); } }