"""Shared test helpers: fixture paths, a network-free FakeFetcher, and a DB availability check for integration tests.""" from __future__ import annotations import asyncio from dataclasses import dataclass, field from datetime import UTC, datetime from pathlib import Path from typing import Any, Self import pytest from companyatlas.fetch import FetchError, FetchResult, NotModified from companyatlas.taxonomy import FailureClass ROOT = Path(__file__).resolve().parents[1] FIXTURES = ROOT / "fixtures" / "connectors" def fixture_path(*parts: str) -> str: return str(FIXTURES.joinpath(*parts)) def fixture_bytes(*parts: str) -> bytes: return FIXTURES.joinpath(*parts).read_bytes() def fixture_text(*parts: str) -> str: return FIXTURES.joinpath(*parts).read_text(encoding="utf-8") def make_result(url: str, content: bytes | str, *, content_type: str = "text/html; charset=utf-8", status: int = 200, final_url: str | None = None, headers: dict[str, str] | None = None) -> FetchResult: body = content.encode("utf-8") if isinstance(content, str) else content return FetchResult(url=url, final_url=final_url or url, status=status, headers={k.lower(): v for k, v in (headers or {}).items()}, content=body, content_type=content_type, fetched_at=datetime.now(UTC), duration_ms=1, transport="fake") @dataclass class FakeFetcher: """Network-free stand-in for `fetch.Fetcher`: maps URL → (status, content_type, body) or an exception. Records every request.""" routes: dict[str, Any] = field(default_factory=dict) calls: list[str] = field(default_factory=list) default_status: int = 404 def add(self, url: str, body: bytes | str, *, content_type: str = "text/html; charset=utf-8", status: int = 200, final_url: str | None = None) -> None: self.routes[url] = (status, content_type, body, final_url) def add_error(self, url: str, exc: Exception) -> None: self.routes[url] = exc async def open(self) -> None: return None async def close(self) -> None: return None async def __aenter__(self) -> Self: return self async def __aexit__(self, *exc: object) -> None: return None async def get(self, url: str, **kw: Any) -> FetchResult: self.calls.append(url) hit = self.routes.get(url) or self.routes.get(url.rstrip("/")) or self.routes.get(url + "/") if hit is None: raise FetchError(f"http 404 for {url}", status=404, url=url, failure=FailureClass.PAGE_REMOVED) if isinstance(hit, Exception): if isinstance(hit, NotModified): raise hit raise hit status, ctype, body, final = hit if status >= 400: raise FetchError(f"http {status} for {url}", status=status, url=url, failure=FailureClass.HTTP_4XX if status < 500 else FailureClass.HTTP_5XX) return make_result(url, body, content_type=ctype, status=status, final_url=final) async def request(self, method: str, url: str, **kw: Any) -> FetchResult: return await self.get(url, **kw) async def post_json(self, url: str, payload: Any, **kw: Any) -> FetchResult: self.calls.append(f"POST {url}") return await self.get(url, **kw) @pytest.fixture def fake_fetcher() -> FakeFetcher: return FakeFetcher() def _db_available() -> bool: async def probe() -> bool: from companyatlas.db import dispose, fetch_val, transaction try: async with transaction() as conn: await fetch_val(conn, "select 1") return True except Exception: # noqa: BLE001 return False finally: await dispose() try: return asyncio.run(probe()) except Exception: # noqa: BLE001 return False _DB_OK: bool | None = None @pytest.fixture def db(): # type: ignore[no-untyped-def] """Skip integration tests when the local Postgres is unavailable.""" global _DB_OK if _DB_OK is None: _DB_OK = _db_available() if not _DB_OK: pytest.skip("local Postgres not available") return True