Files
hyungi_document_server/tests/test_crawl_cycle2_shapes.py
T
hyungi f4e5db9723 fix(news): 304 를 redirect 로 오인하던 버그 — is_redirect → has_redirect_location
httpx 의 Response.is_redirect 는 3xx 전체(304 Not Modified 포함)에 True 라,
조건부 GET 으로 304 를 받으면 location 없는 같은 URL 을 3회 재요청 후
'redirect 3회 초과'로 오류 처리 → ETag/Last-Modified 받는 안정 피드(SEP/HSE/OSHA
/철학 RSS 등)가 2번째 사이클부터 전멸하던 systematic 버그.

- 304 처리를 redirect 루프보다 앞으로 이동.
- redirect 판별을 has_redirect_location(=location 헤더 있는 진짜 redirect)으로 교체.
  news_collector._fetch_rss + crawl_politeness.fetch_page 동일 함정 양쪽 수정.
- 사이클 1 파일럿(경향)은 304 를 받은 적 없어 잠복했고, 안정 피드 첫 304 에서 발현.
- 회귀 테스트 3건(304 비-redirect / 진짜 redirect / 코드 패턴 audit).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 06:32:15 +09:00

140 lines
5.6 KiB
Python

"""crawl-24x7 사이클 2 — 순수 함수/형태 회귀 테스트 (DB 불요).
Guardian 호출 형태 + fixture 응답 파싱 + 채널 정체성 + B-5 quirk.
fixture = tests/fixtures/guardian_open_platform_search_response.json
(2026-06-10 실키 live 박제, api-key 응답 본문 미포함 확인 — [[feedback_external_api_fixture_first]]).
"""
import json
import re
from pathlib import Path
from workers.news_collector import (
_article_hash,
_doc_identity,
_guardian_request,
_normalize_category,
)
FIXTURE = Path(__file__).parent / "fixtures" / "guardian_open_platform_search_response.json"
def _make_source(**kw):
"""ORM 인스턴스 없이 속성만 흉내 (식별성 함수는 속성 접근만 사용)."""
class S:
pass
s = S()
s.source_channel = kw.get("source_channel", "news")
s.parser_quirk = kw.get("parser_quirk")
return s
class TestGuardianCallShape:
def test_request_shape_matches_fixture_recipe(self):
"""fixture 박제 시 사용한 호출과 단일 source-of-truth 정합
([[feedback_fixture_first_call_shape]])."""
endpoint, params = _guardian_request(
"https://content.guardianapis.com/search?section=world", "KEY"
)
assert endpoint == "https://content.guardianapis.com/search"
assert params["section"] == "world"
assert params["show-fields"] == "bodyText,trailText"
assert params["order-by"] == "newest"
assert params["api-key"] == "KEY"
def test_feed_url_query_overridden_by_fixed_fields(self):
# feed_url 에 show-fields 가 잘못 박혀 있어도 고정 필드가 이긴다 (dict merge 순서)
_, params = _guardian_request(
"https://content.guardianapis.com/search?section=world&show-fields=headline", "K"
)
assert params["show-fields"] == "bodyText,trailText"
class TestGuardianFixtureParsing:
def test_fixture_response_shape(self):
payload = json.loads(FIXTURE.read_text())["response"]
assert payload["status"] == "ok"
assert payload["results"], "fixture 에 결과 0건"
for item in payload["results"]:
assert item["webTitle"].strip()
assert item["webUrl"].startswith("https://")
assert "webPublicationDate" in item
assert "sectionName" in item
fields = item.get("fields") or {}
assert "bodyText" in fields and "trailText" in fields
def test_fixture_bodytext_is_fulltext_grade(self):
payload = json.loads(FIXTURE.read_text())["response"]
# 전문 게이트(200자)를 fixture 가 통과해야 어댑터 is_full 경로가 산다
assert any(len(i["fields"]["bodyText"]) >= 200 for i in payload["results"])
def test_fixture_contains_no_api_key(self):
assert "api-key" not in FIXTURE.read_text()
class TestChannelIdentity:
def test_news_channel_unchanged(self):
ident = _doc_identity(_make_source(source_channel="news"), "경향신문", "Society")
assert ident == {
"path_prefix": "news",
"ai_domain": "News",
"ai_tags": ["News/경향신문/Society"],
}
def test_crawl_channel_domain_identity(self):
ident = _doc_identity(_make_source(source_channel="crawl"), "TWI", "Engineering")
assert ident["path_prefix"] == "crawl"
assert ident["ai_domain"] == "Engineering"
assert ident["ai_tags"] == ["Engineering/TWI"]
def test_crawl_channel_unknown_category_falls_back(self):
ident = _doc_identity(_make_source(source_channel="crawl"), "X", "Other")
assert ident["ai_domain"] == "Domain"
def test_category_map_has_domain_axes(self):
assert _normalize_category("안전") == "Safety"
assert _normalize_category("Engineering") == "Engineering"
assert _normalize_category("철학") == "Philosophy"
class TestSkipVideoQuirk:
PATTERN = re.compile(r"/videos?/")
def test_video_urls_match(self):
assert self.PATTERN.search("https://psyche.co/videos/some-film")
assert self.PATTERN.search("https://aeon.co/video/another")
def test_article_urls_pass(self):
assert not self.PATTERN.search("https://psyche.co/ideas/how-to-think")
class TestRedirect304Distinction:
"""httpx is_redirect 가 304(3xx 전체)에 True 라 redirect 로 오인 → 조건부 GET
안정 피드가 'redirect 3회 초과'로 전멸하던 버그. has_redirect_location 으로 구분."""
def test_304_is_not_a_redirect_location(self):
import httpx
r = httpx.Response(304, request=httpx.Request("GET", "https://x/"))
assert r.is_redirect is True # httpx 함정: 304 도 is_redirect
assert r.has_redirect_location is False # 우리가 써야 하는 정확한 판별
def test_real_redirect_has_location(self):
import httpx
r = httpx.Response(301, headers={"location": "https://y/"},
request=httpx.Request("GET", "https://x/"))
assert r.has_redirect_location is True
def test_collector_uses_has_redirect_location(self):
import inspect
from workers import news_collector
src = inspect.getsource(news_collector._fetch_rss)
assert "has_redirect_location" in src
assert "while resp.is_redirect" not in src # 옛 버그 패턴 부재
class TestArticleHashStability:
def test_static_corpus_hash_deterministic(self):
a = _article_hash("Creep and Creep Failures", "static", "National Board 기술 아티클")
b = _article_hash("Creep and Creep Failures", "static", "National Board 기술 아티클")
assert a == b and len(a) == 32