Files
Gnuboard7/tests/Feature/Api/Public/LayoutServingTest.php
T
HeuJung 5ba7a83597 feat(core,engine): 부트스트랩 리소스 정적 게시(bake) 및 폴백 체계 도입
공개 제보 https://github.com/gnuboard/g7/issues/122 대응 — 초기 부트스트랩
리소스(다국어 병합·컴포넌트 정의·라우트·확장 번들·템플릿 dist)를 캐시 버전
디렉토리(public/build/ext/{v}/)에 실파일로 게시해 웹서버가 rewrite 전에 직접
서빙한다. 부트 임계 경로의 PHP 왕복을 제거하고(실측 TTFB 131~144ms → 1~7ms),
미게시·부분게시·GC 직후에는 fetch·태그·번들 3계층이 종전 API 로 즉시 폴백한다.

- 게시: 원자적 tmp→rename→manifest(존재=완료), 캐시 락 단일 실행, 인라인 GC
 (현재+직전 1개), incrementExtensionCacheVersion 단일 지점 terminating 트리거
 + blade 자가 치유 + 설치기 태스크(best_effort) + 일일 cleanup 스케줄,
 sudo 업데이트 대비 소유권 정상화(normalizeOwnership)·prune/백업 제외
- 프론트(engine-v1.61.0): blade 주입 cache_version 1급 시드(이중 부트 로드 제거),
 fetchStaticFirst 즉시 폴백, ComponentRegistry 버전 키드 매니페스트,
 ModuleAssetLoader 번들 정적→레거시 폴백, asset-url-recovery staticToLegacy 역변환
- 폴백 API 품질: lang/components/routes ETag+304 + 환경 분기 Cache-Control,
 열화 라우트 스냅샷 공개 캐시 금지(서버측 캐시 회피와 대칭), 게시 .htaccess
 mod_deflate + nginx gzip 스니펫(압축 전송량 회귀 방지)
- SEO 정합: 봇 HTML 은 GC 대상 정적 URL 미사용(allowStatic:false), props $switch
 봇측 해석 구현(engine-v1.56.0 패리티), 패리티 룰 expression-dialect 그룹 신설,
 상주 allow 헤더 제거로 잠금 복원, _comment* 접두 주석 키 분류
- 검증: 전 수정 red→green 4단계, Playwright 라이브 21건, Chrome MCP 24축+M1~M3,
 봇 curl 3축, 캐시 저장소(file/redis/database) 축 판정, 히스토리·공개이슈·커밋
 이력 전수 재조사 반영
- 코어 7.0.10, engine-v1.61.0. kill-switch: G7_STATIC_CACHE=false
2026-08-25 17:02:53 +09:00

982 lines
37 KiB
PHP

<?php
namespace Tests\Feature\Api\Public;
use App\Contracts\Extension\CacheInterface;
use App\Enums\ExtensionStatus;
use App\Models\Template;
use App\Models\TemplateLayout;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;
class LayoutServingTest extends TestCase
{
use RefreshDatabase;
/**
* 같은 스위트의 DB 테스트(LayoutSourceMetaServingTest, PublicLayoutControllerTest)
* 와 마이그레이션 정합성을 맞추기 위해 — 레이아웃 서빙 경로가 GDPR 미들웨어를
* 거치므로 해당 플러그인 마이그레이션을 테스트 DB 에 포함시킨다.
*
* @var array<string>
*/
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' => '<script>alert("XSS")</script>Dashboard',
],
'data_sources' => [],
'components' => [
[
'type' => 'div',
'props' => [
'onclick' => 'alert("XSS")',
'class' => 'container',
],
'children' => [
'<script>alert("XSS")</script>',
],
],
],
],
]);
// 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);
}
}