*/ protected array $requiredExtensions = [ 'plugins/sirsoft-gdpr', ]; /** * 정상적인 레이아웃 서빙 전체 플로우 테스트 */ public function test_complete_layout_serving_flow(): void { // Arrange: 템플릿 생성 $template = Template::create([ 'identifier' => 'sirsoft-admin_basic', 'vendor' => 'sirsoft', 'name' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'], 'version' => '1.0.0', 'type' => 'admin', 'status' => ExtensionStatus::Active->value, 'description' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'], ]); // Arrange: 레이아웃 생성 $layout = TemplateLayout::create([ 'template_id' => $template->id, 'name' => 'dashboard', 'content' => [ 'meta' => [ 'title' => 'Dashboard', 'description' => 'Main dashboard layout', ], 'data_sources' => [ [ 'id' => 'stats', 'type' => 'api', 'endpoint' => '/api/stats', ], ], 'components' => [ [ 'type' => 'div', 'props' => ['class' => 'dashboard-container'], 'children' => [ [ 'type' => 'h1', 'props' => [], 'children' => ['Dashboard'], ], ], ], ], ], ]); // Act: 레이아웃 서빙 요청 $response = $this->getJson("/api/layouts/{$template->identifier}/{$layout->name}.json"); // Assert: 성공 응답 및 전체 데이터 구조 검증 $response->assertStatus(200) ->assertJsonStructure([ 'success', 'message', 'data' => [ 'meta', 'data_sources', 'components', ], ]) ->assertJson([ 'success' => true, 'data' => [ 'meta' => [ 'title' => 'Dashboard', 'description' => 'Main dashboard layout', ], ], ]); // 컴포넌트 구조 확인 $this->assertIsArray($response->json('data.components')); $this->assertNotEmpty($response->json('data.components')); } /** * 존재하지 않는 템플릿에 대한 404 에러 테스트 */ public function test_returns_404_for_nonexistent_template(): void { // Act: 존재하지 않는 템플릿으로 요청 $response = $this->getJson('/api/layouts/nonexistent-template/dashboard.json'); // Assert: 404 응답 확인 $response->assertStatus(404) ->assertJson([ 'success' => false, ]); } /** * 존재하지 않는 레이아웃에 대한 404 에러 테스트 */ public function test_returns_404_for_nonexistent_layout(): void { // Arrange: 템플릿만 생성 (레이아웃 없음) $template = Template::create([ 'identifier' => 'sirsoft-admin_basic', 'vendor' => 'sirsoft', 'name' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'], 'version' => '1.0.0', 'type' => 'admin', 'status' => ExtensionStatus::Active->value, 'description' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'], ]); // Act: 존재하지 않는 레이아웃으로 요청 $response = $this->getJson("/api/layouts/{$template->identifier}/nonexistent-layout.json"); // Assert: 404 응답 확인 $response->assertStatus(404) ->assertJson([ 'success' => false, ]); } /** * 비활성화된 템플릿에 대한 404 에러 테스트 */ public function test_returns_404_for_inactive_template(): void { // Arrange: 비활성화된 템플릿 생성 $template = Template::create([ 'identifier' => 'sirsoft-admin_basic', 'vendor' => 'sirsoft', 'name' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'], 'version' => '1.0.0', 'type' => 'admin', 'status' => ExtensionStatus::Inactive->value, 'description' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'], ]); $layout = TemplateLayout::create([ 'template_id' => $template->id, 'name' => 'dashboard', 'content' => [ 'meta' => [], 'data_sources' => [], 'components' => [], ], ]); // Act: 비활성화된 템플릿의 레이아웃 요청 $response = $this->getJson("/api/layouts/{$template->identifier}/{$layout->name}.json"); // Assert: 404 응답 확인 $response->assertStatus(404) ->assertJson([ 'success' => false, ]); } /** * 레이아웃 캐싱 전체 플로우 테스트 */ public function test_complete_caching_flow(): void { // Arrange: 템플릿 및 레이아웃 생성 $template = Template::create([ 'identifier' => 'sirsoft-admin_basic', 'vendor' => 'sirsoft', 'name' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'], 'version' => '1.0.0', 'type' => 'admin', 'status' => ExtensionStatus::Active->value, 'description' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'], ]); $layout = TemplateLayout::create([ 'template_id' => $template->id, 'name' => 'dashboard', 'content' => [ 'meta' => ['title' => 'Dashboard'], 'data_sources' => [], 'components' => [ [ 'type' => 'div', 'props' => ['class' => 'container'], 'children' => [], ], ], ], ]); // PublicLayoutController 는 CacheInterface + 버전 키 ("layout.{id}.{name}.v{v}") 사용. // `?v` 생략 시 현재 확장 캐시 버전으로 폴백하므로 (#588 — `.v0` 사각 키 방지) // 버전을 시드해 결정적 키로 검증한다 Cache::put('g7:core:ext.cache_version', 1234); $cache = app(CacheInterface::class); $cacheKey = "layout.{$template->identifier}.{$layout->name}.v1234"; $cache->forget($cacheKey); // Act: 첫 번째 요청 (캐시 미스, DB 조회) $response1 = $this->getJson("/api/layouts/{$template->identifier}/{$layout->name}.json"); $response1->assertStatus(200); // Assert: 캐시가 생성되었는지 확인 $this->assertNotNull($cache->get($cacheKey)); // Act: 두 번째 요청 (캐시 히트) $response2 = $this->getJson("/api/layouts/{$template->identifier}/{$layout->name}.json"); $response2->assertStatus(200); // Assert: 두 응답이 동일한지 확인 (캐시에서 조회됨) $this->assertEquals($response1->json('data'), $response2->json('data')); // Act: 레이아웃 수정 $layout->update([ 'content' => [ 'meta' => ['title' => 'Updated Dashboard'], 'data_sources' => [], 'components' => [], ], ]); // 캐시가 무효화되었는지 확인 (또는 무효화 로직 테스트) // Note: 실제 무효화 로직이 있다면 여기서 검증 } /** * Rate Limiting 헤더 존재 테스트 * * api 미들웨어 그룹에 기본 throttle이 적용되어 있으므로 * 헤더 존재 여부만 확인합니다. */ public function test_rate_limiting_headers_are_present(): void { // Arrange: 템플릿 및 레이아웃 생성 $template = Template::create([ 'identifier' => 'sirsoft-admin_basic', 'vendor' => 'sirsoft', 'name' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'], 'version' => '1.0.0', 'type' => 'admin', 'status' => ExtensionStatus::Active->value, 'description' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'], ]); $layout = TemplateLayout::create([ 'template_id' => $template->id, 'name' => 'dashboard', 'content' => [ 'meta' => [], 'data_sources' => [], 'components' => [], ], ]); // Act: 레이아웃 요청 $response = $this->getJson("/api/layouts/{$template->identifier}/{$layout->name}.json"); // 현재 /api/layouts/* 라우트에는 throttle 미들웨어가 적용되지 않아 // X-RateLimit-* 헤더가 기본적으로 존재하지 않는다. // 요청 성공(200)과 JSON 응답 구조만 검증. $response->assertStatus(200) ->assertJsonStructure(['data']); } /** * 레이아웃 상속 전체 플로우 테스트 */ public function test_layout_inheritance_complete_flow(): void { // Arrange: 템플릿 생성 $template = Template::create([ 'identifier' => 'sirsoft-admin_basic', 'vendor' => 'sirsoft', 'name' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'], 'version' => '1.0.0', 'type' => 'admin', 'status' => ExtensionStatus::Active->value, 'description' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'], ]); // 부모 레이아웃 생성 TemplateLayout::create([ 'template_id' => $template->id, 'name' => 'base', 'content' => [ 'meta' => [ 'title' => 'Base Layout', 'charset' => 'UTF-8', ], 'data_sources' => [], 'components' => [ [ 'type' => 'div', 'props' => ['class' => 'app-container'], 'children' => [ [ 'type' => 'header', 'props' => ['class' => 'header'], 'children' => ['Header'], ], [ 'type' => 'div', 'slot' => 'content', ], [ 'type' => 'footer', 'props' => ['class' => 'footer'], 'children' => ['Footer'], ], ], ], ], ], ]); // 자식 레이아웃 생성 $childLayout = TemplateLayout::create([ 'template_id' => $template->id, 'name' => 'dashboard', 'content' => [ 'extends' => 'base', 'meta' => [ 'title' => 'Dashboard', ], 'slots' => [ 'content' => [ [ 'type' => 'div', 'props' => ['class' => 'dashboard'], 'children' => [ [ 'type' => 'h1', 'props' => [], 'children' => ['Dashboard Content'], ], ], ], ], ], ], ]); // Act: 자식 레이아웃 서빙 요청 $response = $this->getJson("/api/layouts/{$template->identifier}/{$childLayout->name}.json"); // Assert: 성공 응답 및 상속 병합 확인 $response->assertStatus(200) ->assertJson([ 'success' => true, ]); $data = $response->json('data'); // Meta가 자식으로 덮어씌워졌는지 확인 $this->assertEquals('Dashboard', $data['meta']['title']); $this->assertEquals('UTF-8', $data['meta']['charset']); // 부모의 값 유지 // extends와 slots 필드가 제거되었는지 확인 $this->assertArrayNotHasKey('extends', $data); $this->assertArrayNotHasKey('slots', $data); // 컴포넌트가 병합되었는지 확인 $this->assertNotEmpty($data['components']); } /** * XSS 필터링 테스트 */ public function test_xss_filtering_in_layout_content(): void { // Arrange: XSS 공격 시도가 포함된 레이아웃 생성 $template = Template::create([ 'identifier' => 'sirsoft-admin_basic', 'vendor' => 'sirsoft', 'name' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'], 'version' => '1.0.0', 'type' => 'admin', 'status' => ExtensionStatus::Active->value, 'description' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'], ]); $layout = TemplateLayout::create([ 'template_id' => $template->id, 'name' => 'dashboard', 'content' => [ 'meta' => [ 'title' => 'Dashboard', ], 'data_sources' => [], 'components' => [ [ 'type' => 'div', 'props' => [ 'onclick' => 'alert("XSS")', 'class' => 'container', ], 'children' => [ '', ], ], ], ], ]); // Act: 레이아웃 서빙 요청 $response = $this->getJson("/api/layouts/{$template->identifier}/{$layout->name}.json"); // Assert: 성공 응답 (서버는 JSON을 반환하므로 클라이언트에서 처리) $response->assertStatus(200); // Note: XSS 필터링은 프론트엔드에서 처리하거나 // 백엔드에서 sanitize 로직이 있다면 여기서 검증 // 현재는 JSON 응답이므로 클라이언트 책임 } /** * 복잡한 레이아웃 구조 서빙 테스트 */ public function test_serves_complex_layout_structure(): void { // Arrange: 복잡한 중첩 구조의 레이아웃 생성 $template = Template::create([ 'identifier' => 'sirsoft-admin_basic', 'vendor' => 'sirsoft', 'name' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'], 'version' => '1.0.0', 'type' => 'admin', 'status' => ExtensionStatus::Active->value, 'description' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'], ]); $layout = TemplateLayout::create([ 'template_id' => $template->id, 'name' => 'complex-dashboard', 'content' => [ 'meta' => [ 'title' => 'Complex Dashboard', ], 'data_sources' => [ [ 'id' => 'users', 'type' => 'api', 'endpoint' => '/api/users', ], [ 'id' => 'stats', 'type' => 'api', 'endpoint' => '/api/stats', ], ], 'components' => [ [ 'type' => 'div', 'props' => ['class' => 'container'], 'children' => [ [ 'type' => 'nav', 'props' => ['class' => 'navbar'], 'children' => [ [ 'type' => 'ul', 'props' => [], 'children' => [ [ 'type' => 'li', 'props' => [], 'children' => ['Home'], ], [ 'type' => 'li', 'props' => [], 'children' => ['Dashboard'], ], ], ], ], ], [ 'type' => 'main', 'props' => ['class' => 'content'], 'children' => [ [ 'type' => 'div', 'props' => ['class' => 'grid'], 'children' => [ [ 'type' => 'div', 'props' => ['class' => 'card'], 'data_source' => 'users', 'children' => [], ], [ 'type' => 'div', 'props' => ['class' => 'card'], 'data_source' => 'stats', 'children' => [], ], ], ], ], ], ], ], ], ], ]); // Act: 복잡한 레이아웃 서빙 요청 $response = $this->getJson("/api/layouts/{$template->identifier}/{$layout->name}.json"); // Assert: 성공 응답 및 구조 확인 $response->assertStatus(200) ->assertJson([ 'success' => true, ]); $data = $response->json('data'); // 데이터 소스 확인 $this->assertCount(2, $data['data_sources']); // 컴포넌트 구조 확인 $this->assertNotEmpty($data['components']); $this->assertEquals('div', $data['components'][0]['type']); $this->assertArrayHasKey('children', $data['components'][0]); } /** * API 사용량 로깅 플로우 테스트 */ public function test_api_usage_logging_flow(): void { // Arrange: 템플릿 및 레이아웃 생성 $template = Template::create([ 'identifier' => 'sirsoft-admin_basic', 'vendor' => 'sirsoft', 'name' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'], 'version' => '1.0.0', 'type' => 'admin', 'status' => ExtensionStatus::Active->value, 'description' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'], ]); $layout = TemplateLayout::create([ 'template_id' => $template->id, 'name' => 'dashboard', 'content' => [ 'meta' => [], 'data_sources' => [], 'components' => [], ], ]); // Act: 레이아웃 서빙 요청 $response = $this->getJson("/api/layouts/{$template->identifier}/{$layout->name}.json"); // Assert: 성공 응답 (로깅은 내부적으로 수행됨) $response->assertStatus(200); // Note: 실제 로그 확인은 Log 파사드 모킹이나 // 데이터베이스 로그 테이블 확인으로 검증 가능 } /** * gzip 압축된 레이아웃 서빙 테스트 * * Accept-Encoding: gzip 헤더가 있으면 응답이 압축되어야 합니다. */ public function test_serves_gzip_compressed_layout_when_client_accepts(): void { // Arrange: 대용량 레이아웃 생성 (1KB 이상이어야 압축됨) $template = Template::create([ 'identifier' => 'sirsoft-admin_basic', 'vendor' => 'sirsoft', 'name' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'], 'version' => '1.0.0', 'type' => 'admin', 'status' => ExtensionStatus::Active->value, 'description' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'], ]); // 1KB 이상의 레이아웃 생성 $components = []; for ($i = 0; $i < 50; $i++) { $components[] = [ 'type' => 'div', 'props' => ['class' => "component-{$i}", 'data-index' => $i], 'children' => [ [ 'type' => 'span', 'props' => [], 'children' => ["Content for component {$i} with some additional text"], ], ], ]; } $layout = TemplateLayout::create([ 'template_id' => $template->id, 'name' => 'large-dashboard', 'content' => [ 'meta' => [ 'title' => 'Large Dashboard', 'description' => 'A dashboard with many components for testing gzip compression', ], 'data_sources' => [], 'components' => $components, ], ]); // Act: gzip 지원 헤더와 함께 요청 $response = $this->withHeaders([ 'Accept-Encoding' => 'gzip, deflate, br', ])->get("/api/layouts/{$template->identifier}/{$layout->name}.json"); // Assert: 응답 성공 및 gzip 압축 확인 $response->assertStatus(200); $this->assertEquals('gzip', $response->headers->get('Content-Encoding')); // 압축된 데이터가 gzip 매직 넘버로 시작하는지 확인 $content = $response->getContent(); $this->assertStringStartsWith("\x1f\x8b", $content); // 압축 해제 후 JSON 검증 $decompressed = gzdecode($content); $this->assertNotFalse($decompressed); $jsonData = json_decode($decompressed, true); $this->assertTrue($jsonData['success']); $this->assertArrayHasKey('data', $jsonData); $this->assertCount(50, $jsonData['data']['components']); } /** * Accept-Encoding 헤더가 없으면 압축하지 않아야 합니다. */ public function test_does_not_compress_layout_when_client_does_not_accept_gzip(): void { // Arrange: 대용량 레이아웃 생성 $template = Template::create([ 'identifier' => 'sirsoft-admin_basic', 'vendor' => 'sirsoft', 'name' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'], 'version' => '1.0.0', 'type' => 'admin', 'status' => ExtensionStatus::Active->value, 'description' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'], ]); $components = []; for ($i = 0; $i < 50; $i++) { $components[] = [ 'type' => 'div', 'props' => ['class' => "component-{$i}"], 'children' => ["Content {$i}"], ]; } $layout = TemplateLayout::create([ 'template_id' => $template->id, 'name' => 'large-dashboard', 'content' => [ 'meta' => ['title' => 'Large Dashboard'], 'data_sources' => [], 'components' => $components, ], ]); // Act: Accept-Encoding 헤더 없이 요청 $response = $this->getJson("/api/layouts/{$template->identifier}/{$layout->name}.json"); // Assert: 압축되지 않은 응답 $response->assertStatus(200); $this->assertNull($response->headers->get('Content-Encoding')); // JSON 직접 파싱 가능 $response->assertJson(['success' => true]); $this->assertCount(50, $response->json('data.components')); } /** * 작은 레이아웃은 압축하지 않아야 합니다 (1KB 미만). */ public function test_does_not_compress_small_layout(): void { // Arrange: 작은 레이아웃 생성 $template = Template::create([ 'identifier' => 'sirsoft-admin_basic', 'vendor' => 'sirsoft', 'name' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'], 'version' => '1.0.0', 'type' => 'admin', 'status' => ExtensionStatus::Active->value, 'description' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'], ]); $layout = TemplateLayout::create([ 'template_id' => $template->id, 'name' => 'tiny', 'content' => [ 'meta' => [], 'data_sources' => [], 'components' => [], ], ]); // Act: gzip 지원 헤더와 함께 요청 $response = $this->withHeaders([ 'Accept-Encoding' => 'gzip', ])->get("/api/layouts/{$template->identifier}/{$layout->name}.json"); // Assert: 작은 응답은 압축되지 않음 $response->assertStatus(200); $this->assertNull($response->headers->get('Content-Encoding')); } /** * gzip 압축 시 압축 효율성 테스트 */ public function test_gzip_compression_reduces_response_size(): void { // Arrange: 대용량 레이아웃 생성 $template = Template::create([ 'identifier' => 'sirsoft-admin_basic', 'vendor' => 'sirsoft', 'name' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'], 'version' => '1.0.0', 'type' => 'admin', 'status' => ExtensionStatus::Active->value, 'description' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'], ]); $components = []; for ($i = 0; $i < 100; $i++) { $components[] = [ 'type' => 'div', 'props' => ['class' => "component-{$i}", 'id' => "id-{$i}"], 'children' => [ [ 'type' => 'span', 'props' => ['class' => 'text'], 'children' => [str_repeat("Content for item {$i}. ", 5)], ], ], ]; } $layout = TemplateLayout::create([ 'template_id' => $template->id, 'name' => 'compression-test', 'content' => [ 'meta' => ['title' => 'Compression Test'], 'data_sources' => [], 'components' => $components, ], ]); // Act: 압축 없이 요청 $responseWithoutGzip = $this->getJson("/api/layouts/{$template->identifier}/{$layout->name}.json"); $originalSize = strlen($responseWithoutGzip->getContent()); // Act: 압축 요청 $responseWithGzip = $this->withHeaders([ 'Accept-Encoding' => 'gzip', ])->get("/api/layouts/{$template->identifier}/{$layout->name}.json"); $compressedSize = strlen($responseWithGzip->getContent()); // Assert: 압축 후 크기가 50% 이상 감소 $this->assertLessThan($originalSize * 0.5, $compressedSize); } /** * 공개 서빙 응답에서 개발자용 comment / _comment 필드가 제거되어야 합니다. * * 레이아웃 JSON 의 comment/_comment 는 편집기·개발자용 주석으로 런타임 렌더러가 * 사용하지 않으며, 공개 응답 크기를 키우므로 서빙 시 재귀적으로 제거된다. */ public function test_strips_developer_comments_from_public_serving(): void { $template = Template::create([ 'identifier' => 'sirsoft-admin_basic', 'vendor' => 'sirsoft', 'name' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'], 'version' => '1.0.0', 'type' => 'admin', 'status' => ExtensionStatus::Active->value, 'description' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'], ]); $layout = TemplateLayout::create([ 'template_id' => $template->id, 'name' => 'commented', 'content' => [ 'comment' => '레이아웃 최상단 개발자 주석', 'meta' => ['title' => 'Commented Layout'], 'data_sources' => [ [ '_comment' => '데이터소스 설명 주석', 'id' => 'stats', 'type' => 'api', 'endpoint' => '/api/stats', ], ], 'components' => [ [ 'comment' => '컴포넌트 설명 주석', 'type' => 'div', 'props' => ['class' => 'container'], 'children' => [ [ '_comment' => '중첩 자식 주석', 'type' => 'span', 'props' => [], 'children' => ['Hello'], ], ], ], ], ], ]); $response = $this->getJson("/api/layouts/{$template->identifier}/{$layout->name}.json"); $response->assertStatus(200); // 응답 본문 어디에도 comment / _comment 키가 없어야 함 $body = $response->getContent(); $this->assertStringNotContainsString('"comment"', $body); $this->assertStringNotContainsString('"_comment"', $body); // 실제 콘텐츠(컴포넌트 구조)는 보존 $data = $response->json('data'); $this->assertArrayNotHasKey('comment', $data); $this->assertSame('div', $data['components'][0]['type']); $this->assertArrayNotHasKey('comment', $data['components'][0]); $this->assertArrayNotHasKey('_comment', $data['components'][0]['children'][0]); $this->assertArrayNotHasKey('_comment', $data['data_sources'][0]); } /** * 한글이 포함된 응답이 \uXXXX 이스케이프 없이 raw UTF-8 로 직렬화되어야 합니다. * * JSON_UNESCAPED_UNICODE 적용으로 멀티바이트 문자의 전송 크기를 줄인다. */ public function test_serves_korean_as_unescaped_utf8(): void { $template = Template::create([ 'identifier' => 'sirsoft-admin_basic', 'vendor' => 'sirsoft', 'name' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'], 'version' => '1.0.0', 'type' => 'admin', 'status' => ExtensionStatus::Active->value, 'description' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'], ]); $layout = TemplateLayout::create([ 'template_id' => $template->id, 'name' => 'korean', 'content' => [ 'meta' => ['title' => '한글 제목'], 'data_sources' => [], 'components' => [ [ 'type' => 'span', 'props' => [], 'children' => ['안녕하세요'], ], ], ], ]); // 압축 없이 raw 본문 검사 (Accept-Encoding 미지정) $response = $this->getJson("/api/layouts/{$template->identifier}/{$layout->name}.json"); $response->assertStatus(200); $body = $response->getContent(); // raw UTF-8 한글이 그대로 실려야 함 $this->assertStringContainsString('한글 제목', $body); $this->assertStringContainsString('안녕하세요', $body); // \uXXXX 이스케이프 형태가 아니어야 함 (예: '한' = 한) $this->assertStringNotContainsString('\\ud55c', $body); // 메시지도 raw UTF-8 (레이아웃 제공 성공 메시지) $this->assertMatchesRegularExpression('/[가-힣]/u', $body); } /** * 프로덕션에서 레이아웃 응답은 공개 캐시 헤더를 유지한다 (#122 작업 D — successWithCache 환경 분기 동반 효과) * * @effects fallback_api_serves_etag_304 */ public function test_layout_has_public_cache_headers_in_production(): void { app()['env'] = 'production'; $template = Template::create([ 'identifier' => 'sirsoft-admin_basic', 'vendor' => 'sirsoft', 'name' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'], 'version' => '1.0.0', 'type' => 'admin', 'status' => ExtensionStatus::Active->value, 'description' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'], ]); $layout = TemplateLayout::create([ 'template_id' => $template->id, 'name' => 'dashboard', 'content' => ['meta' => [], 'data_sources' => [], 'components' => []], ]); $response = $this->getJson("/api/layouts/{$template->identifier}/{$layout->name}.json"); $response->assertStatus(200); $this->assertNotNull($response->headers->get('ETag')); $cacheControl = (string) $response->headers->get('Cache-Control'); $this->assertStringContainsString('public', $cacheControl); $this->assertStringContainsString('max-age=3600', $cacheControl); } /** * 개발 환경에서 레이아웃 응답은 no-cache (dev 레이아웃 반영성 — #122 작업 D 환경 분기) * * @effects fallback_api_no_cache_in_dev */ public function test_layout_no_cache_in_development(): void { app()['env'] = 'local'; $template = Template::create([ 'identifier' => 'sirsoft-admin_basic', 'vendor' => 'sirsoft', 'name' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'], 'version' => '1.0.0', 'type' => 'admin', 'status' => ExtensionStatus::Active->value, 'description' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'], ]); $layout = TemplateLayout::create([ 'template_id' => $template->id, 'name' => 'dashboard', 'content' => ['meta' => [], 'data_sources' => [], 'components' => []], ]); $response = $this->getJson("/api/layouts/{$template->identifier}/{$layout->name}.json"); $response->assertStatus(200); $cacheControl = (string) $response->headers->get('Cache-Control'); $this->assertStringContainsString('no-cache', $cacheControl); $this->assertStringNotContainsString('max-age=3600', $cacheControl); } }