$this->board->id, 'title' => '본문 썸네일 테스트', 'content' => '

본문

', 'content_mode' => 'html', 'author_name' => '테스트', 'ip_address' => '127.0.0.1', 'is_notice' => false, 'is_secret' => false, 'status' => 'published', 'trigger_type' => 'admin', ], $attributes)); } /** * @scenario image_source=content_internal_only, secrecy=normal * * @effects content_internal_image_fills_list_thumbnail */ #[Test] public function saving_extracts_first_internal_image_on_create(): void { $post = $this->createPostViaModel([ 'content' => '

글

', ]); $this->assertSame('/storage/uploads/first.jpg', $post->fresh()->content_thumbnail_url); } /** * @scenario image_source=content_external_only, secrecy=normal * * @effects external_only_content_yields_null */ #[Test] public function external_only_content_yields_null(): void { $post = $this->createPostViaModel([ 'content' => '', ]); $this->assertNull($post->fresh()->content_thumbnail_url); } /** * @scenario image_source=none, secrecy=normal * * @effects external_only_content_yields_null */ #[Test] public function content_without_image_yields_null(): void { $post = $this->createPostViaModel(['content' => '

이미지가 없는 본문

']); $this->assertNull($post->fresh()->content_thumbnail_url); } /** * @effects recompute_on_content_change */ #[Test] public function content_change_recomputes_cache(): void { $post = $this->createPostViaModel([ 'content' => '', ]); $post->update(['content' => '']); $this->assertSame('/storage/uploads/new.jpg', $post->fresh()->content_thumbnail_url); } /** * @effects recompute_on_content_change */ #[Test] public function removing_all_images_resets_cache_to_null(): void { $post = $this->createPostViaModel([ 'content' => '', ]); $post->update(['content' => '

이미지 전부 제거됨

']); $this->assertNull($post->fresh()->content_thumbnail_url); } /** * @effects no_recompute_on_unrelated_update */ #[Test] public function unrelated_update_does_not_recompute(): void { $post = $this->createPostViaModel([ 'content' => '', ]); // 이벤트 우회로 캐시를 다른 값으로 바꿔 두면, content 무변경 저장이 // 재계산을 하지 않는다는 사실이 값 보존으로 관측된다 DB::table('board_posts')->where('id', $post->id) ->update(['content_thumbnail_url' => '/storage/uploads/manual.jpg']); $post->fresh()->update(['title' => '제목만 변경']); $this->assertSame( '/storage/uploads/manual.jpg', $post->fresh()->content_thumbnail_url, 'content 가 dirty 가 아니면 캐시를 재계산하지 않아야 합니다.' ); } /** * @effects filter_hook_can_override_or_block */ #[Test] public function filter_hook_can_override_value(): void { HookManager::addFilter(self::FILTER_HOOK, function ($value, $post, $candidates) { $this->assertIsArray($candidates, '세 번째 인자로 전체 후보 목록이 전달되어야 합니다.'); return 'https://cdn.example.net/promoted.jpg'; }); $post = $this->createPostViaModel([ 'content' => '', ]); $this->assertSame('https://cdn.example.net/promoted.jpg', $post->fresh()->content_thumbnail_url); } /** * @effects filter_hook_can_override_or_block */ #[Test] public function filter_hook_can_block_fallback(): void { HookManager::addFilter(self::FILTER_HOOK, fn ($value) => null); $post = $this->createPostViaModel([ 'content' => '', ]); $this->assertNull($post->fresh()->content_thumbnail_url); } /** * text 모드 글은 리터럴 img 마크업이 있어도 캐시하지 않아야 합니다. * * text 모드 본문은 이스케이프되어 렌더되므로(상세에 이미지가 표시되지 않음) * 목록 썸네일만 뜨면 상세와 목록이 어긋난다 (Chrome MCP T10② 실측 회귀). * * @effects text_mode_content_never_caches */ #[Test] public function text_mode_content_is_never_cached(): void { $post = $this->createPostViaModel([ 'content_mode' => 'text', 'content' => '텍스트 본문에 쓴 마크업', ]); $this->assertNull($post->fresh()->content_thumbnail_url); } /** * content_mode 를 html → text 로 바꾸면 캐시가 비워져야 합니다 (content 무변경이어도). * * @effects mode_switch_recomputes_cache */ #[Test] public function switching_to_text_mode_clears_cache(): void { $post = $this->createPostViaModel([ 'content' => '', ]); $this->assertSame('/storage/uploads/a.jpg', $post->fresh()->content_thumbnail_url); $post->fresh()->update(['content_mode' => 'text']); $this->assertNull($post->fresh()->content_thumbnail_url, 'text 모드 전환 시 캐시가 비워져야 합니다.'); } /** * content_mode 를 text → html 로 바꾸면 캐시가 다시 계산되어야 합니다. * * @effects mode_switch_recomputes_cache */ #[Test] public function switching_to_html_mode_recomputes_cache(): void { $post = $this->createPostViaModel([ 'content_mode' => 'text', 'content' => '', ]); $this->assertNull($post->fresh()->content_thumbnail_url); $post->fresh()->update(['content_mode' => 'html']); $this->assertSame('/storage/uploads/promoted.jpg', $post->fresh()->content_thumbnail_url); } /** * 필터가 비정상 값(비문자열/상한 초과)을 돌려줘도 저장이 실패하지 않아야 합니다. */ #[Test] public function filter_hook_invalid_return_degrades_to_null(): void { HookManager::addFilter(self::FILTER_HOOK, fn ($value) => str_repeat('a', 1500)); $post = $this->createPostViaModel([ 'content' => '', ]); $this->assertNull($post->fresh()->content_thumbnail_url); } }