Files
Gnuboard7/tests/Feature/Api/Public/LayoutSourceMetaServingTest.php
HeuJung c819820573 perf(core): JSON 서빙 네트워크 오버헤드 최적화
실측(g7.dev) 결과 줄바꿈 제거·gzip 은 이미 적용된 상태였고,
남은 실질 개선 3종을 구현:

- 모든 API 응답에 JSON_UNESCAPED_UNICODE 적용 — 한글이 \uXXXX(6B)로
 팽창하던 것을 raw UTF-8(3B)로 직렬화 (lang pack gzip -9.1%)
- 공개 레이아웃 서빙에서 개발자용 comment/_comment 재귀 제거,
 편집 모드(with_source_meta)는 보존 (layout gzip -34.9%)
- 번들 JS/CSS(BinaryFileResponse) gzip 압축 사각지대 해소 —
 기존 미들웨어가 getContent==false 로 압축을 건너뛰던 문제 (-73~77%)
2026-07-05 22:31:04 +09:00

502 lines
19 KiB
PHP

<?php
namespace Tests\Feature\Api\Public;
use App\Enums\ExtensionStatus;
use App\Enums\LayoutSourceType;
use App\Models\Permission;
use App\Models\Role;
use App\Models\Template;
use App\Models\TemplateLayout;
use App\Models\User;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
/**
* 레이아웃 출처 메타 옵션 서빙 검증
*
* `GET /api/layouts/{id}/{name}.json?with_source_meta=1` 가 응답의 각 노드에
* `__source` 메타를 부여하고, 옵션 미사용 시 응답 형식이 종전과 100% 동일함을
* 보장한다 (계획서).
*
* 동시에 권한 가드(편집 권한 필수) 와 비파괴 보장(일반 사이트 렌더 영향 0) 회귀
* 가드도 함께 검증한다.
*/
class LayoutSourceMetaServingTest extends TestCase
{
use RefreshDatabase;
/**
* @var array<string>
*/
protected array $requiredExtensions = [
'plugins/sirsoft-gdpr',
];
protected function setUp(): void
{
parent::setUp();
// 권한 시더 명시 실행 — `core.templates.layouts.edit` 등 코어 권한 등록
$this->seed(RolePermissionSeeder::class);
}
private function createActiveTemplate(): Template
{
return Template::factory()->create([
'identifier' => 'sirsoft-admin_basic',
'type' => 'admin',
'status' => ExtensionStatus::Active->value,
]);
}
/**
* 편집 권한을 가진 사용자 생성 (`core.templates.layouts.edit`)
*/
private function makeEditor(): User
{
$user = User::factory()->create();
$role = Role::query()->create([
'identifier' => 'meta_test_role_'.uniqid(),
'name' => ['ko' => '테스트 편집자', 'en' => 'Test Editor'],
'description' => ['ko' => '', 'en' => ''],
'is_active' => true,
]);
$permission = Permission::query()
->where('identifier', 'core.templates.layouts.edit')
->first();
if ($permission !== null) {
$role->permissions()->attach($permission->id);
}
$user->roles()->attach($role->id);
return $user;
}
/**
* extends + slots 구조의 부모/자식 레이아웃 쌍을 생성한다.
*
* 부모(`_admin_base`) 가 헤더 + 콘텐츠 슬롯을 정의하고, 자식(`dashboard`) 이
* 콘텐츠 슬롯을 채운다. 메타 옵션 검증을 위해 의도적으로 단순한 구조 사용.
*/
private function createBaseAndChildLayouts(Template $template): array
{
$base = TemplateLayout::create([
'template_id' => $template->id,
'name' => '_admin_base',
'content' => [
'meta' => ['title' => 'Base'],
'components' => [
[
'type' => 'basic',
'name' => 'Div',
'props' => ['className' => 'header'],
'children' => [
['type' => 'basic', 'name' => 'Span', 'text' => 'Header'],
],
],
[
'type' => 'basic',
'name' => 'Div',
'slot' => 'content',
'props' => ['className' => 'content'],
],
],
],
]);
$child = TemplateLayout::create([
'template_id' => $template->id,
'name' => 'dashboard',
'content' => [
'extends' => '_admin_base',
'meta' => ['title' => 'Dashboard'],
'slots' => [
'content' => [
[
'type' => 'basic',
'name' => 'H1',
'text' => 'Dashboard',
],
],
],
],
]);
return ['base' => $base, 'child' => $child];
}
#[Test]
public function serve_without_option_returns_response_without_source_meta(): void
{
$template = $this->createActiveTemplate();
$this->createBaseAndChildLayouts($template);
$response = $this->getJson("/api/layouts/{$template->identifier}/dashboard.json");
$response->assertStatus(200);
$components = $response->json('data.components');
$this->assertIsArray($components);
// 옵션 미전달 — 어느 노드에도 __source 가 없어야 한다 (비파괴 보장)
$hasSourceMeta = $this->treeHasKey($components, '__source');
$this->assertFalse(
$hasSourceMeta,
'옵션 미전달 시 응답에 __source 메타가 포함되면 안 됨 (일반 사이트 렌더 영향 0)'
);
}
#[Test]
public function serve_with_option_returns_response_with_source_meta_on_every_node(): void
{
$template = $this->createActiveTemplate();
$this->createBaseAndChildLayouts($template);
$admin = $this->makeEditor();
Sanctum::actingAs($admin, ['*'], 'sanctum');
$response = $this->getJson("/api/layouts/{$template->identifier}/dashboard.json?with_source_meta=1");
$response->assertStatus(200);
$components = $response->json('data.components');
$this->assertIsArray($components);
// 첫 번째 컴포넌트(헤더) 는 base 출처
$this->assertEquals('base', $components[0]['__source']['kind'] ?? null);
$this->assertEquals('_admin_base', $components[0]['__source']['layout'] ?? null);
// 헤더 children 도 base 출처 (부모로부터 상속)
$this->assertEquals('base', $components[0]['children'][0]['__source']['kind'] ?? null);
// 두 번째 컴포넌트(콘텐츠 슬롯 래퍼) 는 base 출처 — slot 위치는 부모가 정의
$this->assertEquals('base', $components[1]['__source']['kind'] ?? null);
// 콘텐츠 슬롯의 children(H1 Dashboard) 은 route 출처 — 자식 레이아웃의 slot 콘텐츠
$contentChildren = $components[1]['children'] ?? [];
$this->assertNotEmpty($contentChildren);
$this->assertEquals('route', $contentChildren[0]['__source']['kind'] ?? null);
}
#[Test]
public function serve_with_option_requires_authenticated_user(): void
{
$template = $this->createActiveTemplate();
$this->createBaseAndChildLayouts($template);
// 비로그인 사용자의 메타 옵션 요청은 401
$response = $this->getJson("/api/layouts/{$template->identifier}/dashboard.json?with_source_meta=1");
$response->assertStatus(401);
}
#[Test]
public function serve_with_option_requires_edit_permission(): void
{
$template = $this->createActiveTemplate();
$this->createBaseAndChildLayouts($template);
// 일반 사용자 (편집 권한 없음) 의 메타 옵션 요청은 403
$user = User::factory()->create(['is_super' => false]);
Sanctum::actingAs($user, ['*'], 'sanctum');
$response = $this->getJson("/api/layouts/{$template->identifier}/dashboard.json?with_source_meta=1");
$response->assertStatus(403);
}
#[Test]
public function meta_cache_is_separate_from_normal_response_cache(): void
{
$template = $this->createActiveTemplate();
$this->createBaseAndChildLayouts($template);
$admin = $this->makeEditor();
// 일반 응답 먼저 캐시 적재 (비인증 사이트 렌더)
$normal = $this->getJson("/api/layouts/{$template->identifier}/dashboard.json");
$normal->assertStatus(200);
$this->assertFalse($this->treeHasKey($normal->json('data.components'), '__source'));
// 메타 옵션 응답이 일반 캐시를 오염시키지 않아야 한다
Sanctum::actingAs($admin, ['*'], 'sanctum');
$meta = $this->getJson("/api/layouts/{$template->identifier}/dashboard.json?with_source_meta=1");
$meta->assertStatus(200);
$this->assertTrue($this->treeHasKey($meta->json('data.components'), '__source'));
// 일반 요청을 다시 호출 (비인증) → 여전히 메타 없음 (캐시 분리 확인)
app('auth')->guard('sanctum')->forgetUser();
$normalAgain = $this->getJson("/api/layouts/{$template->identifier}/dashboard.json");
$normalAgain->assertStatus(200);
$this->assertFalse($this->treeHasKey($normalAgain->json('data.components'), '__source'));
}
/**
* data_sources 항목을 가진 레이아웃을 생성한다 (방안 B 출처 메타 검증용).
*/
private function createLayoutWithDataSources(Template $template): TemplateLayout
{
return TemplateLayout::create([
'template_id' => $template->id,
'name' => 'with_sources',
'content' => [
'meta' => ['title' => 'With Sources'],
'data_sources' => [
['id' => 'settings', 'type' => 'api', 'endpoint' => '/api/admin/settings', 'method' => 'GET'],
['id' => 'roles', 'type' => 'api', 'endpoint' => '/api/admin/roles', 'method' => 'GET'],
],
'components' => [
['type' => 'basic', 'name' => 'Div', 'props' => ['className' => 'root']],
],
],
]);
}
#[Test]
public function data_source_source_meta_present_with_flag(): void
{
$template = $this->createActiveTemplate();
$this->createLayoutWithDataSources($template);
$admin = $this->makeEditor();
Sanctum::actingAs($admin, ['*'], 'sanctum');
$response = $this->getJson("/api/layouts/{$template->identifier}/with_sources.json?with_source_meta=1");
$response->assertStatus(200);
$dataSources = $response->json('data.data_sources');
$this->assertIsArray($dataSources);
$this->assertNotEmpty($dataSources);
// 템플릿 자체 레이아웃(prefix 없음) → kind=route / identifier=null (방안 B §deriveDataSourceSourceMeta)
foreach ($dataSources as $ds) {
$this->assertArrayHasKey('__source', $ds, "data_source '{$ds['id']}' 에 __source 메타가 부여되어야 함");
$this->assertEquals('route', $ds['__source']['kind'] ?? null);
$this->assertArrayHasKey('identifier', $ds['__source']);
$this->assertNull($ds['__source']['identifier']);
}
}
/**
* 플러그인 소유 레이아웃(`source_type = Plugin`)을 prefix 이름으로 생성한다.
*
* 이름 패턴(`{ext}.{layout}`)만으로는 module/plugin 을 구분하지 못하므로,
* 행의 `source_type` 을 명시해 `LayoutResolverService::resolve` 가 plugin 으로
* 해석하게 한다.
*/
private function createPluginLayoutWithDataSources(Template $template): TemplateLayout
{
return TemplateLayout::create([
'template_id' => $template->id,
'name' => 'sirsoft-gdpr.plugin_settings',
'source_type' => LayoutSourceType::Plugin->value,
'source_identifier' => 'sirsoft-gdpr',
'content' => [
'meta' => ['title' => 'GDPR Settings'],
'data_sources' => [
['id' => 'settings', 'type' => 'api', 'endpoint' => '/api/plugins/sirsoft-gdpr/settings', 'method' => 'GET'],
],
'components' => [
['type' => 'basic', 'name' => 'Div', 'props' => ['className' => 'root']],
],
],
]);
}
#[Test]
public function data_source_source_meta_classifies_plugin_layout_as_plugin_kind(): void
{
$template = $this->createActiveTemplate();
$this->createPluginLayoutWithDataSources($template);
$admin = $this->makeEditor();
Sanctum::actingAs($admin, ['*'], 'sanctum');
$response = $this->getJson("/api/layouts/{$template->identifier}/sirsoft-gdpr.plugin_settings.json?with_source_meta=1");
$response->assertStatus(200);
$dataSources = $response->json('data.data_sources');
$this->assertIsArray($dataSources);
$this->assertNotEmpty($dataSources);
// 플러그인 레이아웃 data_source → kind=plugin / identifier=sirsoft-gdpr.
// 엔진 editorSpecLoader 가 plugin 스펙을 `plugin:{id}` 키로 보존하므로 kind 가
// plugin 이어야 그 확장 샘플이 매칭된다. (module 오분류 시 `plugin:` 스펙을 빗나감)
foreach ($dataSources as $ds) {
$this->assertArrayHasKey('__source', $ds, "data_source '{$ds['id']}' 에 __source 메타가 부여되어야 함");
$this->assertEquals('plugin', $ds['__source']['kind'] ?? null, "plugin 레이아웃 data_source '{$ds['id']}' 의 kind 는 plugin 이어야 함");
$this->assertEquals('sirsoft-gdpr', $ds['__source']['identifier'] ?? null);
}
}
#[Test]
public function data_source_source_meta_classifies_module_layout_as_module_kind(): void
{
$template = $this->createActiveTemplate();
TemplateLayout::create([
'template_id' => $template->id,
'name' => 'sirsoft-ecommerce.admin_ecommerce_settings',
'source_type' => LayoutSourceType::Module->value,
'source_identifier' => 'sirsoft-ecommerce',
'content' => [
'meta' => ['title' => 'Shop Settings'],
'data_sources' => [
['id' => 'settings', 'type' => 'api', 'endpoint' => '/api/admin/ecommerce/settings', 'method' => 'GET'],
],
'components' => [
['type' => 'basic', 'name' => 'Div', 'props' => ['className' => 'root']],
],
],
]);
$admin = $this->makeEditor();
Sanctum::actingAs($admin, ['*'], 'sanctum');
$response = $this->getJson("/api/layouts/{$template->identifier}/sirsoft-ecommerce.admin_ecommerce_settings.json?with_source_meta=1");
$response->assertStatus(200);
$dataSources = $response->json('data.data_sources');
$this->assertIsArray($dataSources);
$this->assertNotEmpty($dataSources);
foreach ($dataSources as $ds) {
$this->assertArrayHasKey('__source', $ds);
$this->assertEquals('module', $ds['__source']['kind'] ?? null, "module 레이아웃 data_source '{$ds['id']}' 의 kind 는 module 이어야 함");
$this->assertEquals('sirsoft-ecommerce', $ds['__source']['identifier'] ?? null);
}
}
#[Test]
public function data_source_source_meta_absent_without_flag(): void
{
$template = $this->createActiveTemplate();
$this->createLayoutWithDataSources($template);
// 옵션 미전달 — 일반 사이트 렌더 경로에는 __source 가 없어야 한다 (비파괴 보장)
$response = $this->getJson("/api/layouts/{$template->identifier}/with_sources.json");
$response->assertStatus(200);
$dataSources = $response->json('data.data_sources');
$this->assertIsArray($dataSources);
foreach ($dataSources as $ds) {
$this->assertArrayNotHasKey('__source', $ds, '옵션 미전달 시 data_source 에 __source 가 포함되면 안 됨');
}
}
/**
* comment / _comment 필드를 가진 레이아웃을 생성합니다.
*
* @param Template $template 대상 템플릿
* @return TemplateLayout 생성된 레이아웃
*/
private function createCommentedLayout(Template $template): TemplateLayout
{
return TemplateLayout::create([
'template_id' => $template->id,
'name' => 'commented',
'content' => [
'comment' => '레이아웃 최상단 개발자 주석',
'meta' => ['title' => 'Commented'],
'components' => [
[
'comment' => '컴포넌트 설명 주석',
'type' => 'basic',
'name' => 'Div',
'props' => ['className' => 'container'],
'children' => [
[
'_comment' => '중첩 자식 주석',
'type' => 'basic',
'name' => 'Span',
'text' => 'Hello',
],
],
],
],
],
]);
}
/**
* 편집 모드(with_source_meta) 서빙에서는 comment 필드가 보존되어야 합니다.
*
* 편집기는 comment 를 편집 가능한 속성으로 노출하므로 편집 경로에서는 제거하지 않는다.
* 공개 서빙과 별도 캐시 키로 분리되어 조건부 제거가 안전함을 함께 검증한다.
*/
#[Test]
public function serve_with_source_meta_preserves_developer_comments(): void
{
$template = $this->createActiveTemplate();
$this->createCommentedLayout($template);
$admin = $this->makeEditor();
Sanctum::actingAs($admin, ['*'], 'sanctum');
$response = $this->getJson("/api/layouts/{$template->identifier}/commented.json?with_source_meta=1");
$response->assertStatus(200);
// 편집 모드에서는 comment / _comment 가 보존됨
$data = $response->json('data');
$this->assertSame('레이아웃 최상단 개발자 주석', $data['comment'] ?? null);
$this->assertSame('컴포넌트 설명 주석', $data['components'][0]['comment'] ?? null);
$this->assertSame('중첩 자식 주석', $data['components'][0]['children'][0]['_comment'] ?? null);
}
/**
* 공개 서빙(옵션 미전달)에서는 comment 필드가 제거되어야 합니다.
*
* 편집 경로와 동일 레이아웃 소스를 사용해 공개/편집 캐시 키 분리를 함께 검증한다.
*/
#[Test]
public function serve_without_source_meta_strips_developer_comments(): void
{
$template = $this->createActiveTemplate();
$this->createCommentedLayout($template);
$response = $this->getJson("/api/layouts/{$template->identifier}/commented.json");
$response->assertStatus(200);
$body = $response->getContent();
$this->assertStringNotContainsString('"comment"', $body);
$this->assertStringNotContainsString('"_comment"', $body);
// 실제 콘텐츠는 보존
$data = $response->json('data');
$this->assertSame('Div', $data['components'][0]['name']);
}
/**
* 컴포넌트 트리에서 특정 키가 존재하는지 재귀 탐색
*/
private function treeHasKey(array $components, string $key): bool
{
foreach ($components as $component) {
if (! is_array($component)) {
continue;
}
if (array_key_exists($key, $component)) {
return true;
}
if (isset($component['children']) && is_array($component['children'])) {
if ($this->treeHasKey($component['children'], $key)) {
return true;
}
}
}
return false;
}
}