'core.templates.layouts.edit', ], [ 'name' => '레이아웃 편집', 'display_name' => '레이아웃 편집', 'type' => 'admin', ]); $role = Role::firstOrCreate(['identifier' => 'super-admin'], [ 'name' => 'Super Admin', 'display_name' => 'Super Admin', 'is_default' => false, ]); $role->permissions()->syncWithoutDetaching([$editPermission->id]); $admin = User::factory()->create(); $admin->roles()->syncWithoutDetaching([$role->id]); $this->adminToken = $admin->createToken('admin')->plainTextToken; } private function authHeaders(): array { return [ 'Authorization' => "Bearer {$this->adminToken}", 'Accept' => 'application/json', ]; } private function noPermToken(): string { return User::factory()->create()->createToken('noperm')->plainTextToken; } // ── SEO 후보 ── public function test_seo_candidates_returns_200_with_candidate_shape(): void { $response = $this->withHeaders($this->authHeaders()) ->getJson('/api/admin/templates/sirsoft-basic/editor/seo-candidates.json?page_type=post'); $response->assertStatus(200); $response->assertJsonStructure([ 'success', 'data' => ['identifier', 'page_types', 'toggle_settings', 'vars', 'extensions'], ]); $this->assertIsArray($response->json('data.page_types')); $this->assertIsArray($response->json('data.toggle_settings')); $this->assertIsArray($response->json('data.vars')); $this->assertIsArray($response->json('data.extensions')); } public function test_seo_candidates_extensions_carry_type_id_and_label(): void { // 확장 SEO 연동 칩(`g7le-seo-extensions`)의 활성 모듈/플러그인 후보 shape. // 각 항목은 {type, id, label} — label 은 확장 getName() 로케일 해석값(폴백=id). // (RefreshDatabase 환경엔 활성 확장이 0건일 수 있으므로 빈 배열 허용 — shape 만 보증. // requiredExtensions 는 마이그레이션 경로만 등록하지 활성화하지 않는다.) $response = $this->withHeaders($this->authHeaders()) ->getJson('/api/admin/templates/sirsoft-basic/editor/seo-candidates.json'); $response->assertStatus(200); $extensions = $response->json('data.extensions'); $this->assertIsArray($extensions); foreach ($extensions as $ext) { $this->assertArrayHasKey('type', $ext); $this->assertContains($ext['type'], ['module', 'plugin']); $this->assertArrayHasKey('id', $ext); $this->assertIsString($ext['id']); $this->assertArrayHasKey('label', $ext); $this->assertIsString($ext['label']); $this->assertNotSame('', $ext['label'], 'label 은 폴백(id)이라도 비어선 안 된다'); } } public function test_seo_candidates_requires_edit_permission(): void { $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$this->noPermToken(), 'Accept' => 'application/json', ])->getJson('/api/admin/templates/sirsoft-basic/editor/seo-candidates.json'); $response->assertStatus(403); } // ── OG 미리보기 ── public function test_seo_og_preview_computes_cascade_and_missing(): void { // extensions/page_type 둘 다 비면 defaultsAvailable=false + missing 표기. $response = $this->withHeaders($this->authHeaders()) ->postJson('/api/admin/templates/sirsoft-basic/editor/seo-og-preview', [ 'seo' => ['enabled' => true, 'og' => ['title' => '테스트 제목']], 'seed_context' => [], 'route_params' => [], ]); $response->assertStatus(200); $response->assertJsonStructure([ 'data' => ['defaultsAvailable', 'missing', 'og', 'twitter', 'structured'], ]); $this->assertFalse($response->json('data.defaultsAvailable')); $this->assertContains('extensions', $response->json('data.missing')); $this->assertContains('page_type', $response->json('data.missing')); // og 키별 cascade 항목 shape. $ogKeys = array_column($response->json('data.og'), 'key'); $this->assertContains('title', $ogKeys); foreach ($response->json('data.og') as $row) { $this->assertArrayHasKey('source', $row); $this->assertArrayHasKey('overriddenByLayout', $row); $this->assertArrayHasKey('lockedByFilter', $row); } } public function test_seo_og_preview_layout_override_source(): void { // 레이아웃이 og.title 을 직접 선언 → 그 키 source=layout, overriddenByLayout=true. $response = $this->withHeaders($this->authHeaders()) ->postJson('/api/admin/templates/sirsoft-basic/editor/seo-og-preview', [ 'seo' => ['enabled' => true, 'og' => ['title' => '레이아웃 제목']], ]); $response->assertStatus(200); $titleRow = collect($response->json('data.og'))->firstWhere('key', 'title'); $this->assertNotNull($titleRow); $this->assertTrue($titleRow['overriddenByLayout']); $this->assertSame('layout', $titleRow['source']); } public function test_seo_og_preview_requires_edit_permission(): void { $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$this->noPermToken(), 'Accept' => 'application/json', ])->postJson('/api/admin/templates/sirsoft-basic/editor/seo-og-preview', ['seo' => []]); $response->assertStatus(403); } public function test_seo_og_preview_validates_required_seo(): void { $response = $this->withHeaders($this->authHeaders()) ->postJson('/api/admin/templates/sirsoft-basic/editor/seo-og-preview', []); $response->assertStatus(422); } // ── 봇 HTML 미리보기 ── public function test_seo_bot_preview_disabled_returns_null_html(): void { // meta.seo.enabled=false → renderFromResolved null → enabled=false. $response = $this->withHeaders($this->authHeaders()) ->postJson('/api/admin/templates/sirsoft-basic/editor/seo-bot-preview', [ 'layout' => ['meta' => ['seo' => ['enabled' => false]], 'components' => []], 'url' => '/test', 'locale' => 'ko', ]); $response->assertStatus(200); $response->assertJsonStructure(['data' => ['identifier', 'enabled', 'html']]); $this->assertFalse($response->json('data.enabled')); $this->assertNull($response->json('data.html')); } public function test_seo_bot_preview_enabled_returns_html(): void { $response = $this->withHeaders($this->authHeaders()) ->postJson('/api/admin/templates/sirsoft-basic/editor/seo-bot-preview', [ 'layout' => [ 'layout_name' => 'preview/test', 'meta' => ['seo' => ['enabled' => true, 'title' => '봇 미리보기']], 'components' => [], ], 'url' => '/preview/test', 'locale' => 'ko', 'seed_context' => [], ]); $response->assertStatus(200); $this->assertTrue($response->json('data.enabled')); $html = $response->json('data.html'); $this->assertIsString($html); // 봇 미리보기는 SEO 설정 산출물만 — head 안 SEO 메타(og/twitter)·title 태그는 포함. $this->assertStringContainsString('
', $html); $this->assertStringContainsString('