chore: 저장소 구조 정리 및 문서화, 첫 커밋
- src/dist 산출물 분리 원칙 정리(.gitignore, .gitattributes) - 루트 및 주요 폴더(config/scripts/prompts/tests/src, 런타임 폴더 5종)에 안내용 README.md 추가 - CHANGELOG.md, LICENSE, docs/ops/05-release-and-versioning.md 추가 - docs/README.md 문서 지도 갱신
This commit is contained in:
commit
56a6e2da93
159 changed files with 145825 additions and 0 deletions
291
tests/conftest.py
Normal file
291
tests/conftest.py
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
from __future__ import annotations
|
||||
|
||||
"""pytest 공통 픽스처.
|
||||
|
||||
이 파일이 제공하는 것 두 가지.
|
||||
|
||||
1. **네트워크 차단** — 실수로 실제 네트워크 요청이 나가는 사고를 막는다. 소스 API·agy·
|
||||
웹훅 전부 이 프로젝트에서는 네트워크를 만지므로, 픽스처 전용 테스트가 아닌 한
|
||||
실제 소켓 연결은 전부 예외로 막는다(``@pytest.mark.network`` 로 표시한 테스트만 예외).
|
||||
2. **임시 프로젝트 트리** — DB·로그·리포트·백업 경로가 전부 ``tmp_path`` 밑을 가리키는
|
||||
:class:`~dmf_crawler.config.Config` 를 만들어 준다. 실제 프로젝트 루트(``data/``,
|
||||
``reports/`` 등)를 절대 건드리지 않기 위해서다.
|
||||
|
||||
다른 모듈이 아직 구현되지 않았을 수 있으므로, 이 파일 자체는 ``dmf_crawler.models`` ·
|
||||
``dmf_crawler.config`` · ``dmf_crawler.paths`` 세 개(이미 존재가 확정된 모듈)에만 의존한다.
|
||||
그 밖의 모듈에 의존하는 픽스처는 픽스처 본문 안에서 ``pytest.importorskip`` 을 쓴다.
|
||||
"""
|
||||
|
||||
import dataclasses
|
||||
import hashlib
|
||||
import json
|
||||
import socket
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Iterator
|
||||
|
||||
import pytest
|
||||
|
||||
from dmf_crawler.config import (
|
||||
BackupCfg,
|
||||
Config,
|
||||
LoggingCfg,
|
||||
ReportCfg,
|
||||
StorageCfg,
|
||||
)
|
||||
from dmf_crawler.models import (
|
||||
COMPARE_FIELDS,
|
||||
COMPARE_KEY_FIELDS,
|
||||
DmfRecord,
|
||||
PermitParts,
|
||||
)
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 경로 상수
|
||||
# --------------------------------------------------------------------------
|
||||
FIXTURES_DIR: Path = Path(__file__).parent / "fixtures"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 1. 네트워크 차단
|
||||
# --------------------------------------------------------------------------
|
||||
class BlockedNetworkError(RuntimeError):
|
||||
"""테스트 중 실제 네트워크 연결 시도를 감지했을 때 던진다.
|
||||
|
||||
이 예외가 뜨면 테스트가 ``httpx.MockTransport`` 등으로 목킹하지 않고
|
||||
진짜 소켓을 열려 했다는 뜻이다. 진짜 네트워크가 정말 필요하면
|
||||
``@pytest.mark.network`` 로 표시하라(기본 실행에서 제외된다).
|
||||
"""
|
||||
|
||||
|
||||
def _blocked(*args: Any, **kwargs: Any) -> Any:
|
||||
raise BlockedNetworkError(
|
||||
"테스트에서 실제 네트워크 연결이 시도됐다. "
|
||||
"httpx.MockTransport 나 monkeypatch 로 응답을 목킹하라. "
|
||||
"진짜 네트워크가 필요한 테스트는 @pytest.mark.network 로 표시하라."
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def block_network(request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
|
||||
"""``network`` 마커가 없는 모든 테스트에서 실제 소켓 연결을 차단한다.
|
||||
|
||||
``socket.socket.connect`` / ``connect_ex`` 를 클래스 레벨에서 막으므로
|
||||
``httpx``·``urllib``·표준 ``socket`` 모듈을 통한 어떤 실제 연결 시도도 걸린다.
|
||||
로컬 파일 I/O·SQLite 는 소켓을 쓰지 않으므로 영향받지 않는다.
|
||||
"""
|
||||
if request.node.get_closest_marker("network") is not None:
|
||||
yield
|
||||
return
|
||||
monkeypatch.setattr(socket.socket, "connect", _blocked, raising=True)
|
||||
monkeypatch.setattr(socket.socket, "connect_ex", _blocked, raising=True)
|
||||
monkeypatch.setattr(socket, "create_connection", _blocked, raising=True)
|
||||
yield
|
||||
|
||||
|
||||
def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None:
|
||||
"""``network`` 마커 테스트는 ``-m network`` 를 명시하지 않는 한 자동 제외한다.
|
||||
|
||||
``pyproject.toml`` 의 마커 설명("기본 실행에서는 제외한다")을 실제로 강제한다.
|
||||
"""
|
||||
markexpr = config.getoption("markexpr", default="")
|
||||
if "network" in markexpr:
|
||||
return
|
||||
skip_network = pytest.mark.skip(reason="network 마커 테스트는 기본 실행에서 제외한다(-m network 로 실행)")
|
||||
for item in items:
|
||||
if item.get_closest_marker("network") is not None:
|
||||
item.add_marker(skip_network)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 2. 임시 프로젝트 트리 · 설정
|
||||
# --------------------------------------------------------------------------
|
||||
@pytest.fixture
|
||||
def project_dirs(tmp_path: Path) -> dict[str, Path]:
|
||||
"""DB·로그·리포트·백업·원문 아카이브용 임시 디렉터리 묶음을 만든다.
|
||||
|
||||
Returns:
|
||||
dict[str, Path]: ``data``·``raw``·``state``·``logs``·``reports``·``backup`` 경로.
|
||||
"""
|
||||
dirs = {
|
||||
"data": tmp_path / "data",
|
||||
"raw": tmp_path / "data" / "raw",
|
||||
"state": tmp_path / "state",
|
||||
"logs": tmp_path / "logs",
|
||||
"reports": tmp_path / "reports",
|
||||
"backup": tmp_path / "backup",
|
||||
}
|
||||
for d in dirs.values():
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return dirs
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_config(project_dirs: dict[str, Path]) -> Config:
|
||||
"""모든 경로가 ``tmp_path`` 밑을 가리키는 :class:`Config` 를 만든다.
|
||||
|
||||
실제 프로젝트 루트의 ``data/``·``reports/``·``backup/``·``logs/`` 를
|
||||
절대 건드리지 않는다. ``storage.sqlite_path`` 등은 절대경로이므로
|
||||
``paths.resolve_under_root`` 를 거쳐도 그대로 쓰인다(절대경로는 통과).
|
||||
"""
|
||||
return Config().with_overrides(
|
||||
storage=StorageCfg(sqlite_path=str(project_dirs["data"] / "dmf.sqlite3")),
|
||||
report=ReportCfg(output_dir=str(project_dirs["reports"])),
|
||||
backup=BackupCfg(dir=str(project_dirs["backup"])),
|
||||
logging=LoggingCfg(dir=str(project_dirs["logs"])),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_conn(test_config: Config):
|
||||
"""마이그레이션까지 적용된 SQLite 연결. ``storage.db`` 가 아직 없으면 스킵한다."""
|
||||
db = pytest.importorskip("dmf_crawler.storage.db")
|
||||
from dmf_crawler import paths
|
||||
|
||||
backup_dir = test_config.backup_dir()
|
||||
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||
conn = db.connect(test_config.db_path())
|
||||
db.apply_migrations(conn, paths.MIGRATIONS_DIR, backup_dir)
|
||||
try:
|
||||
yield conn
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 3. 픽스처 파일 로딩 도우미
|
||||
# --------------------------------------------------------------------------
|
||||
def load_fixture_text(name: str) -> str:
|
||||
"""``tests/fixtures/<name>`` 을 UTF-8 텍스트로 읽는다.
|
||||
|
||||
Args:
|
||||
name: 픽스처 파일명(예: ``"api_page_ok.json"``).
|
||||
|
||||
Returns:
|
||||
str: 파일 내용.
|
||||
"""
|
||||
return (FIXTURES_DIR / name).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def load_fixture_json(name: str) -> Any:
|
||||
"""``tests/fixtures/<name>`` 을 JSON 으로 파싱해 돌려준다.
|
||||
|
||||
Args:
|
||||
name: 픽스처 파일명(예: ``"agy_envelope_clean.json"``).
|
||||
|
||||
Returns:
|
||||
Any: 파싱된 JSON 값.
|
||||
"""
|
||||
return json.loads(load_fixture_text(name))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fixtures_dir() -> Path:
|
||||
"""``tests/fixtures/`` 절대경로."""
|
||||
return FIXTURES_DIR
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def load_fixture() -> Callable[[str], str]:
|
||||
"""픽스처 파일을 텍스트로 읽는 함수를 돌려주는 픽스처."""
|
||||
return load_fixture_text
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def load_fixture_dict() -> Callable[[str], Any]:
|
||||
"""픽스처 파일을 JSON 으로 읽는 함수를 돌려주는 픽스처."""
|
||||
return load_fixture_json
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 4. DmfRecord 최소 생성 팩토리 — diff · integrity · repo 테스트가 공유한다
|
||||
# --------------------------------------------------------------------------
|
||||
_UNIT_SEP = "\x1f"
|
||||
|
||||
|
||||
def _hash16(parts: "Iterator[str] | list[str]") -> str:
|
||||
"""모델 계약의 해시 규칙(``docs/design/02-data-model.md`` §4.3)과 같은 방식."""
|
||||
joined = _UNIT_SEP.join(parts)
|
||||
return hashlib.sha256(joined.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
def _simple_key(display: str) -> str:
|
||||
"""테스트 전용 간이 매칭키. 영숫자·한글만 남기고 소문자화한다.
|
||||
|
||||
실제 ``normalize.matching_key`` 와 완전히 같지는 않지만(법인격 어휘 제거 등은
|
||||
하지 않는다), 공백·구두점·대소문자 차이를 흡수한다는 핵심 성질은 같다 —
|
||||
diff·integrity 테스트가 필요로 하는 것은 그 성질뿐이다.
|
||||
"""
|
||||
return "".join(ch for ch in display if ch.isalnum()).lower()
|
||||
|
||||
|
||||
def _build_dmf_record(
|
||||
permit_no: str = "20200101-1-A-1-1",
|
||||
*,
|
||||
dup_index: int = 0,
|
||||
ingredient_name: str = "테스트성분",
|
||||
applicant: str = "테스트제약",
|
||||
manufacturer: str = "Test Manufacturer Co.",
|
||||
manufacture_place: str = "1 Test Rd, Testville",
|
||||
countries: tuple[str, ...] = ("미국",),
|
||||
permit_date: str = "2020-01-01",
|
||||
permit_no_raw: str | None = None,
|
||||
) -> DmfRecord:
|
||||
"""테스트용 :class:`DmfRecord` 를 만든다.
|
||||
|
||||
``content_hash``·``identity_hash`` 는 실제로 ``compare_view()``/``key_view()`` 에서
|
||||
계산하므로, 두 레코드를 만들어 필드 하나를 바꾸면 해시도 정확히 그만큼만 바뀐다
|
||||
(diff 테스트가 기대하는 성질).
|
||||
|
||||
Args:
|
||||
permit_no: 정규화된 등록번호.
|
||||
dup_index: 중복 그룹 내 순번(0 이면 접미사 없음).
|
||||
ingredient_name: 성분명 표시값.
|
||||
applicant: 신청인 표시값.
|
||||
manufacturer: 제조소명 표시값.
|
||||
manufacture_place: 제조소 소재지 표시값.
|
||||
countries: 제조국가 튜플(이미 정렬된 값을 넣는다).
|
||||
permit_date: 발급일자 ``YYYY-MM-DD``.
|
||||
permit_no_raw: 원문 등록번호. 생략하면 ``permit_no`` 와 같다.
|
||||
|
||||
Returns:
|
||||
DmfRecord: content_hash·identity_hash 까지 채워진 완결 레코드.
|
||||
"""
|
||||
dmf_key = permit_no if dup_index == 0 else f"{permit_no}#{dup_index + 1}"
|
||||
sorted_countries = tuple(sorted(countries))
|
||||
permit = PermitParts(raw=permit_no_raw or permit_no, normalized=permit_no, fmt="standard")
|
||||
|
||||
rec = DmfRecord(
|
||||
dmf_key=dmf_key,
|
||||
permit_no=permit_no,
|
||||
permit_no_raw=permit_no_raw or permit_no,
|
||||
ingredient_name=ingredient_name,
|
||||
ingredient_key=_simple_key(ingredient_name),
|
||||
ingredient_base=_simple_key(ingredient_name),
|
||||
is_micronized=False,
|
||||
applicant=applicant,
|
||||
applicant_key=_simple_key(applicant),
|
||||
manufacturer=manufacturer,
|
||||
manufacturer_key=_simple_key(manufacturer),
|
||||
manufacture_place=manufacture_place,
|
||||
manufacture_place_key=_simple_key(manufacture_place),
|
||||
countries=sorted_countries,
|
||||
sites=(),
|
||||
permit_date=permit_date,
|
||||
permit=permit,
|
||||
raw={},
|
||||
dup_index=dup_index,
|
||||
)
|
||||
content_hash = _hash16(rec.compare_view()[f] for f in COMPARE_FIELDS)
|
||||
identity_hash = _hash16(rec.key_view()[f] for f in COMPARE_KEY_FIELDS)
|
||||
return dataclasses.replace(rec, content_hash=content_hash, identity_hash=identity_hash)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def record_factory() -> Callable[..., DmfRecord]:
|
||||
""":class:`DmfRecord` 를 만드는 팩토리 함수를 돌려주는 픽스처.
|
||||
|
||||
``test_diff.py``·``test_integrity.py``·``test_repo_idempotency.py`` 가 공유한다.
|
||||
같은 인자로 두 번 호출하면 ``content_hash``·``identity_hash`` 까지 완전히 같은
|
||||
값이 나온다(결정론적) — diff 의 "동일" 판정 테스트가 이 성질에 의존한다.
|
||||
"""
|
||||
return _build_dmf_record
|
||||
Loading…
Add table
Add a link
Reference in a new issue