'https://shop.example.com']); } // ── candidates: src 수집 ────────────────────────── public function test_candidates_collects_srcs_in_document_order(): void { $html = '

a

'; $this->assertSame( ['/one.jpg', '/two.png', 'https://ext.com/three.gif'], HtmlImageExtractor::candidates($html) ); } public function test_candidates_returns_empty_for_no_images(): void { $this->assertSame([], HtmlImageExtractor::candidates('

텍스트만 있는 본문

')); $this->assertSame([], HtmlImageExtractor::candidates('')); $this->assertSame([], HtmlImageExtractor::candidates(' ')); } public function test_candidates_skips_empty_src(): void { $this->assertSame( ['/real.jpg'], HtmlImageExtractor::candidates('') ); } public function test_candidates_survives_broken_html(): void { // 미닫힘 태그 + 중첩 오류 — DOM 파서 관용 파싱으로 예외 없이 처리돼야 한다 $this->assertIsArray(HtmlImageExtractor::candidates('

텍스트

')); // 미닫힘이어도 정상 형태의 img 는 수집된다 $this->assertSame( ['/ok.jpg'], HtmlImageExtractor::candidates('

미닫힘 문단

') ); } public function test_candidates_safe_on_plain_text(): void { $this->assertSame([], HtmlImageExtractor::candidates('그냥 평문입니다. assertSame( ['/single.jpg', '/double.jpg'], HtmlImageExtractor::candidates("") ); } // ── firstInternal: 내부 판정 ────────────────────────── /** * @effects content_internal_image_fills_list_thumbnail */ public function test_relative_path_passes(): void { $this->assertSame( '/storage/uploads/a.jpg', HtmlImageExtractor::firstInternal('') ); } public function test_absolute_same_host_converted_to_relative(): void { $this->assertSame( '/storage/uploads/a.jpg?v=1', HtmlImageExtractor::firstInternal('') ); } public function test_absolute_same_host_case_insensitive(): void { $this->assertSame( '/img/a.png', HtmlImageExtractor::firstInternal('') ); } /** * @effects external_only_content_yields_null */ public function test_external_host_excluded(): void { $this->assertNull( HtmlImageExtractor::firstInternal('') ); } public function test_protocol_relative_excluded(): void { $this->assertNull( HtmlImageExtractor::firstInternal('') ); } /** * 정규화 공격 문자열 — 문자열상 path 지만 브라우저는 외부 origin 으로 읽는 형태. * 정규화 SSoT 를 거치지 않으면 내부로 오판된다. */ public function test_normalization_attack_strings_judged_external(): void { $attacks = [ '/\\/evil.com/x.jpg', '\\\\evil.com\\x.jpg', "/\t/evil.com/x.jpg", '///evil.com/x.jpg', ]; foreach ($attacks as $attack) { $this->assertNull( HtmlImageExtractor::firstInternal(''), "외부 판정 실패: {$attack}" ); } } public function test_non_http_schemes_excluded(): void { $this->assertNull(HtmlImageExtractor::firstInternal('')); $this->assertNull(HtmlImageExtractor::firstInternal('')); $this->assertNull(HtmlImageExtractor::firstInternal('')); $this->assertNull(HtmlImageExtractor::firstInternal('')); } public function test_first_external_then_internal_picks_internal(): void { $html = ''; $this->assertSame('/second.jpg', HtmlImageExtractor::firstInternal($html)); } public function test_no_image_returns_null(): void { $this->assertNull(HtmlImageExtractor::firstInternal('

이미지 없음

')); $this->assertNull(HtmlImageExtractor::firstInternal('')); } public function test_extra_allowed_prefix_passes_with_original_form(): void { $html = ''; $this->assertNull(HtmlImageExtractor::firstInternal($html)); $this->assertSame( 'https://cdn.example.net/bucket/a.jpg', HtmlImageExtractor::firstInternal($html, ['https://cdn.example.net/']) ); } public function test_extra_prefix_does_not_rescue_unlisted_host(): void { $this->assertNull( HtmlImageExtractor::firstInternal( '', ['https://cdn.example.net/'] ) ); } public function test_url_over_1000_chars_excluded(): void { $longUrl = '/storage/'.str_repeat('a', 1000).'.jpg'; $html = ''; $this->assertSame('/short.jpg', HtmlImageExtractor::firstInternal($html)); } public function test_absolute_same_host_without_path_returns_root(): void { $this->assertSame( '/', HtmlImageExtractor::firstInternal('') ); } }