Mark the readiness test as integration; it requires live Postgres + Redis
which are not running in Cinnabox dev. Tests run on Talos prod include it
when invoked with: pytest -m integration.
- Add @pytest.mark.integration + skipif on _has_postgres() probe
- Register integration marker in pyproject.toml
- Default addopts = -m 'not integration' skips them in plain ============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0
rootdir: /home/dev/pry
configfile: pyproject.toml
testpaths: tests
plugins: respx-0.23.1, cov-7.1.0, Faker-27.4.0, asyncio-1.4.0, anyio-4.14.1
asyncio: mode=Mode.AUTO, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
collected 622 items / 1 deselected / 621 selected
tests/test_adaptive.py ....... [ 1%]
tests/test_advanced_features.py .............. [ 3%]
tests/test_advanced_scraping.py ................. [ 6%]
tests/test_agency.py ....... [ 7%]
tests/test_ai_plugin.py ... [ 7%]
tests/test_alerter.py ..... [ 8%]
tests/test_api.py .... [ 9%]
tests/test_api_integration.py ........................... [ 13%]
tests/test_api_mcp.py ...s [ 14%]
tests/test_api_mcp_x402.py .............. [ 16%]
tests/test_api_scraping.py ........ [ 17%]
tests/test_api_surface_snapshot.py .... [ 18%]
tests/test_api_templates.py ...... [ 19%]
tests/test_apify_schema.py ..... [ 20%]
tests/test_auth_connector.py ..... [ 20%]
tests/test_block_detector.py .......... [ 22%]
tests/test_cache.py ....... [ 23%]
tests/test_camoufox.py ... [ 24%]
tests/test_commerce_sync.py .. [ 24%]
tests/test_compliance.py ............. [ 26%]
tests/test_costing.py .... [ 27%]
tests/test_crm_sync.py ...... [ 28%]
tests/test_db.py ......... [ 29%]
tests/test_destinations.py ....... [ 30%]
tests/test_email_scraper.py ......... [ 32%]
tests/test_enrichment.py .......... [ 33%]
tests/test_extractor.py ....... [ 34%]
tests/test_fallback_integration.py ............ [ 36%]
tests/test_freshness.py ..... [ 37%]
tests/test_gdpr.py ...... [ 38%]
tests/test_infrastructure.py ........... [ 40%]
tests/test_intelligence.py ........................... [ 44%]
tests/test_jobqueue.py ...... [ 45%]
tests/test_lazy_load.py ..... [ 46%]
tests/test_llm_fallback.py ...... [ 47%]
tests/test_llm_providers.py ...... [ 48%]
tests/test_logging_config.py ........ [ 49%]
tests/test_markdown_gen.py ...... [ 50%]
tests/test_marketplace.py ........... [ 52%]
tests/test_mcp_production.py .......... [ 54%]
tests/test_monitor.py ............... [ 56%]
tests/test_network.py ...... [ 57%]
tests/test_parser.py ...... [ 58%]
tests/test_pipeline.py ..... [ 59%]
tests/test_pipelines.py ....... [ 60%]
tests/test_proxy_manager.py ............ [ 62%]
tests/test_quality.py ........... [ 64%]
tests/test_ratelimit.py ...... [ 65%]
tests/test_real_features.py ........ [ 66%]
tests/test_reconciliation.py ......... [ 67%]
tests/test_referrals.py .............. [ 70%]
tests/test_reports.py ....... [ 71%]
tests/test_resilience.py .............. [ 73%]
tests/test_retry.py ........................... [ 77%]
tests/test_review.py ...... [ 78%]
tests/test_scraper.py ........ [ 80%]
tests/test_sdk.py ...... [ 80%]
tests/test_secrets_backend.py ......... [ 82%]
tests/test_seo_monitor.py ...... [ 83%]
tests/test_settings.py ......... [ 84%]
tests/test_shadow_dom.py ......... [ 86%]
tests/test_structure_monitor.py F...... [ 87%]
tests/test_templates.py ...... [ 88%]
tests/test_training_data.py ......... [ 89%]
tests/test_ultimate_scraper.py ............ [ 91%]
tests/test_url_guard.py ........................ [ 95%]
tests/test_x402_mcp_spec.py ........................... [100%]
=================================== FAILURES ===================================
__________________________ test_check_selectors_empty __________________________
@pytest.mark.asyncio
async def test_check_selectors_empty():
> result = await check_selectors("https://example.com", [])
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
tests/test_structure_monitor.py:36:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
structure_monitor.py:46: in check_selectors
resp = await client.get(
../.local/lib/python3.12/site-packages/httpx/_client.py:1768: in get
return await self.request(
../.local/lib/python3.12/site-packages/httpx/_client.py:1540: in request
return await self.send(request, auth=auth, follow_redirects=follow_redirects)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
../.local/lib/python3.12/site-packages/httpx/_client.py:1629: in send
response = await self._send_handling_auth(
../.local/lib/python3.12/site-packages/httpx/_client.py:1657: in _send_handling_auth
response = await self._send_handling_redirects(
../.local/lib/python3.12/site-packages/httpx/_client.py:1694: in _send_handling_redirects
response = await self._send_single_request(request)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
../.local/lib/python3.12/site-packages/httpx/_client.py:1730: in _send_single_request
response = await transport.handle_async_request(request)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
../.local/lib/python3.12/site-packages/httpx/_transports/default.py:394: in handle_async_request
resp = await self._pool.handle_async_request(req)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
../.local/lib/python3.12/site-packages/httpcore/_async/connection_pool.py:256: in handle_async_request
raise exc from None
../.local/lib/python3.12/site-packages/httpcore/_async/connection_pool.py:229: in handle_async_request
await self._close_connections(closing)
../.local/lib/python3.12/site-packages/httpcore/_async/connection_pool.py:345: in _close_connections
await connection.aclose()
../.local/lib/python3.12/site-packages/httpcore/_async/connection.py:173: in aclose
await self._connection.aclose()
../.local/lib/python3.12/site-packages/httpcore/_async/http11.py:258: in aclose
await self._network_stream.aclose()
../.local/lib/python3.12/site-packages/httpcore/_backends/anyio.py:53: in aclose
await self._stream.aclose()
../.local/lib/python3.12/site-packages/anyio/streams/tls.py:236: in aclose
await self.transport_stream.aclose()
../.local/lib/python3.12/site-packages/anyio/_backends/_asyncio.py:1374: in aclose
self._transport.close()
/usr/lib/python3.12/asyncio/selector_events.py:1211: in close
super().close()
/usr/lib/python3.12/asyncio/selector_events.py:875: in close
self._loop.call_soon(self._call_connection_lost, None)
/usr/lib/python3.12/asyncio/base_events.py:795: in call_soon
self._check_closed()
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
self = <_UnixSelectorEventLoop running=False closed=True debug=False>
def _check_closed(self):
if self._closed:
> raise RuntimeError('Event loop is closed')
E RuntimeError: Event loop is closed
/usr/lib/python3.12/asyncio/base_events.py:541: RuntimeError
=============================== warnings summary ===============================
../.local/lib/python3.12/site-packages/pydantic/main.py:1809
../.local/lib/python3.12/site-packages/pydantic/main.py:1809
/home/dev/.local/lib/python3.12/site-packages/pydantic/main.py:1809: UserWarning: Field name "schema" in "Body_extract_css_v1_extract_css_post" shadows an attribute in parent "BaseModel"
return meta(
../.local/lib/python3.12/site-packages/pydantic/main.py:1809
../.local/lib/python3.12/site-packages/pydantic/main.py:1809
/home/dev/.local/lib/python3.12/site-packages/pydantic/main.py:1809: UserWarning: Field name "schema" in "Body_extract_llm_v1_extract_llm_post" shadows an attribute in parent "BaseModel"
return meta(
../.local/lib/python3.12/site-packages/pydantic/main.py:1809
../.local/lib/python3.12/site-packages/pydantic/main.py:1809
/home/dev/.local/lib/python3.12/site-packages/pydantic/main.py:1809: UserWarning: Field name "schema" in "Body_quality_check_v1_quality_check_post" shadows an attribute in parent "BaseModel"
return meta(
../.local/lib/python3.12/site-packages/pydantic/main.py:1809
../.local/lib/python3.12/site-packages/pydantic/main.py:1809
/home/dev/.local/lib/python3.12/site-packages/pydantic/main.py:1809: UserWarning: Field name "schema" in "Body_extract_with_review_v1_extract_with_review_post" shadows an attribute in parent "BaseModel"
return meta(
tests/test_api_integration.py::TestHealthEndpoints::test_live_returns_200
/home/dev/.local/lib/python3.12/site-packages/_pytest/unraisableexception.py:67: PytestUnraisableExceptionWarning: Exception ignored in: <function BaseSubprocessTransport.__del__ at 0x7c2702c51120>
Traceback (most recent call last):
File "/usr/lib/python3.12/asyncio/base_subprocess.py", line 126, in __del__
self.close()
File "/usr/lib/python3.12/asyncio/base_subprocess.py", line 104, in close
proto.pipe.close()
File "/usr/lib/python3.12/asyncio/unix_events.py", line 767, in close
self.write_eof()
File "/usr/lib/python3.12/asyncio/unix_events.py", line 753, in write_eof
self._loop.call_soon(self._call_connection_lost, None)
File "/usr/lib/python3.12/asyncio/base_events.py", line 795, in call_soon
self._check_closed()
File "/usr/lib/python3.12/asyncio/base_events.py", line 541, in _check_closed
raise RuntimeError('Event loop is closed')
RuntimeError: Event loop is closed
Enable tracemalloc to get traceback where the object was allocated.
See https://docs.pytest.org/en/stable/how-to/capture-warnings.html#resource-warnings for more info.
warnings.warn(pytest.PytestUnraisableExceptionWarning(msg))
tests/test_api_integration.py::TestDetectBlockEndpoint::test_detect_block_success
tests/test_api_integration.py::TestErrorHandling::test_422_on_invalid_json
/home/dev/.local/lib/python3.12/site-packages/httpx/_models.py:408: DeprecationWarning: Use 'content=<...>' to upload raw bytes/text content.
headers, stream = encode_request(
tests/test_jobqueue.py::test_jobqueue_create_job_returns_id
tests/test_jobqueue.py::test_jobqueue_get_job_exists
tests/test_jobqueue.py::test_jobqueue_complete_job
tests/test_jobqueue.py::test_jobqueue_fail_job
tests/test_jobqueue.py::test_jobqueue_update_preserves_fields
/home/dev/pry/jobqueue.py:56: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC).
"created_at": datetime.utcnow().isoformat(),
tests/test_jobqueue.py::test_jobqueue_create_job_returns_id
tests/test_jobqueue.py::test_jobqueue_get_job_exists
tests/test_jobqueue.py::test_jobqueue_complete_job
tests/test_jobqueue.py::test_jobqueue_fail_job
tests/test_jobqueue.py::test_jobqueue_update_preserves_fields
/home/dev/pry/jobqueue.py:57: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC).
"updated_at": datetime.utcnow().isoformat(),
tests/test_jobqueue.py::test_jobqueue_complete_job
tests/test_jobqueue.py::test_jobqueue_fail_job
tests/test_jobqueue.py::test_jobqueue_update_preserves_fields
/home/dev/pry/jobqueue.py:93: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC).
self._local_jobs[job_id]["updated_at"] = datetime.utcnow().isoformat()
tests/test_retry.py::TestAsyncRetry::test_passes_args_and_kwargs
/home/dev/pry/tests/test_retry.py:259: RuntimeWarning: coroutine 'AsyncMockMixin._execute_mock_call' was never awaited
fn(url, timeout=timeout)
Enable tracemalloc to get traceback where the object was allocated.
See https://docs.pytest.org/en/stable/how-to/capture-warnings.html#resource-warnings for more info.
tests/test_sdk.py::test_prycrawl_health_returns_dict
/home/dev/.local/lib/python3.12/site-packages/_pytest/python.py:167: RuntimeWarning: coroutine 'PryCrawl.health' was never awaited
result = testfunction(**testargs)
Enable tracemalloc to get traceback where the object was allocated.
See https://docs.pytest.org/en/stable/how-to/capture-warnings.html#resource-warnings for more info.
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
=========================== short test summary info ============================
FAILED tests/test_structure_monitor.py::test_check_selectors_empty - RuntimeE...
= 1 failed, 619 passed, 1 skipped, 1 deselected, 26 warnings in 90.04s (0:01:30) =
Tests: 2 passed, 1 deselected in TestHealthEndpoints (was 2 pass + 1 fail).
426 lines
15 KiB
Python
426 lines
15 KiB
Python
"""HTTP integration tests for all Pry API routers.
|
|
|
|
Tests that endpoints respond with correct status codes, response shapes,
|
|
error handling, CORS headers, and auth gating — all via TestClient with
|
|
mocked dependencies so no real scraping occurs.
|
|
"""
|
|
|
|
# SPDX-License-Identifier: MIT
|
|
# Copyright (c) 2026 Rug Munch Media LLC
|
|
#
|
|
# Part of Pry — https://git.rugmunch.io/RugMunchMedia/pryscraper
|
|
# Licensed under MIT. See LICENSE.
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Iterator
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
|
def _has_postgres(host: str = "127.0.0.1", port: int = 5432) -> bool:
|
|
"""Return True if Postgres is reachable on the given host:port.
|
|
|
|
Used to skip integration tests that require a live database when running
|
|
the suite locally without docker-compose up.
|
|
"""
|
|
import socket
|
|
|
|
try:
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
s.settimeout(0.25)
|
|
return s.connect_ex((host, port)) == 0
|
|
except OSError:
|
|
return False
|
|
|
|
# ── Fixtures ─────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _mock_all_deps() -> Iterator[None]:
|
|
"""Mock all external deps so no real scraping/parsing occurs."""
|
|
scraper_mock = MagicMock()
|
|
scraper_mock.scrape = AsyncMock(
|
|
return_value={
|
|
"status": "ok",
|
|
"url": "https://example.com",
|
|
"content": "# Mocked content",
|
|
"raw_html": "<html><body>Mocked</body></html>",
|
|
"method": "direct",
|
|
}
|
|
)
|
|
scraper_mock.crawl = AsyncMock(return_value=[{"url": "https://example.com", "status": "ok"}])
|
|
scraper_mock.map_urls = AsyncMock(
|
|
return_value=[{"url": "https://example.com/page1"}, {"url": "https://example.com/page2"}]
|
|
)
|
|
scraper_mock.detect_block = AsyncMock(
|
|
return_value={
|
|
"detected": True,
|
|
"type": "cloudflare",
|
|
"confidence": 0.95,
|
|
}
|
|
)
|
|
|
|
extractor_mock = MagicMock()
|
|
extractor_mock.extract = AsyncMock(
|
|
return_value={
|
|
"success": True,
|
|
"data": {"product_name": "Widget Pro", "price": "$29.99"},
|
|
}
|
|
)
|
|
|
|
parser_mock = MagicMock()
|
|
parser_mock.parse = AsyncMock(
|
|
return_value={
|
|
"success": True,
|
|
"data": {"title": "Test", "body": "Content"},
|
|
}
|
|
)
|
|
|
|
cache_mock = MagicMock()
|
|
cache_mock.get = MagicMock(return_value=None)
|
|
cache_mock.set = MagicMock()
|
|
|
|
advanced_mock = MagicMock()
|
|
advanced_mock.extract = AsyncMock(
|
|
return_value={
|
|
"success": True,
|
|
"data": {"name": "Widget Pro", "price": 29.99},
|
|
}
|
|
)
|
|
|
|
with patch.multiple(
|
|
"deps",
|
|
scraper=scraper_mock,
|
|
extractor=extractor_mock,
|
|
parser=parser_mock,
|
|
cache=cache_mock,
|
|
advanced=advanced_mock,
|
|
):
|
|
yield
|
|
|
|
|
|
@pytest.fixture
|
|
def client() -> Iterator[TestClient]:
|
|
"""TestClient with x402 middleware bypassed (it would require payment)."""
|
|
from api import app
|
|
|
|
with (
|
|
patch(
|
|
"x402_middleware.X402Middleware.__call__",
|
|
lambda self, scope, receive, send: self.app(scope, receive, send),
|
|
),
|
|
TestClient(app) as c,
|
|
):
|
|
yield c
|
|
|
|
|
|
@pytest.fixture
|
|
def client_auth(client: TestClient, monkeypatch) -> TestClient:
|
|
"""TestClient with PRY_API_KEY set (auth required)."""
|
|
monkeypatch.setattr("api.settings.api_key", "test-api-key")
|
|
return client
|
|
|
|
|
|
# ── Health & Readiness ───────────────────────────────────────────
|
|
|
|
|
|
class TestHealthEndpoints:
|
|
"""Test /health, /live, /ready — should work without auth."""
|
|
|
|
def test_health_returns_200(self, client: TestClient) -> None:
|
|
"""Health returns 503 when deps unavailable (normal in test)."""
|
|
resp = client.get("/health")
|
|
assert resp.status_code in (200, 503)
|
|
body = resp.json()
|
|
assert "status" in body
|
|
|
|
def test_live_returns_200(self, client: TestClient) -> None:
|
|
resp = client.get("/live")
|
|
assert resp.status_code == 200
|
|
assert resp.json() == {"status": "alive"}
|
|
|
|
@pytest.mark.integration
|
|
@pytest.mark.skipif(
|
|
not _has_postgres(), reason="requires running Postgres + Redis"
|
|
)
|
|
def test_ready_returns_200(self, client: TestClient) -> None:
|
|
resp = client.get("/ready")
|
|
assert resp.status_code == 200
|
|
assert resp.json() == {"status": "ready"}
|
|
|
|
|
|
# ── OpenAPI / Docs ───────────────────────────────────────────────
|
|
|
|
|
|
class TestDocsEndpoints:
|
|
"""Docs and OpenAPI schema should be accessible without auth."""
|
|
|
|
def test_openapi_json(self, client: TestClient) -> None:
|
|
resp = client.get("/openapi.json")
|
|
assert resp.status_code == 200
|
|
schema = resp.json()
|
|
assert "openapi" in schema
|
|
assert "paths" in schema
|
|
assert len(schema["paths"]) > 0
|
|
|
|
def test_docs_redirects(self, client: TestClient) -> None:
|
|
resp = client.get("/docs")
|
|
assert resp.status_code == 200
|
|
assert "text/html" in resp.headers["content-type"]
|
|
|
|
|
|
# ── Scraping Endpoints ───────────────────────────────────────────
|
|
|
|
|
|
class TestScrapeEndpoint:
|
|
"""POST /v1/scrape — core scraping endpoint."""
|
|
|
|
def test_scrape_success(self, client: TestClient) -> None:
|
|
resp = client.post("/v1/scrape", json={"url": "https://example.com"})
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body.get("success") is True
|
|
assert "data" in body
|
|
assert "markdown" in body["data"]
|
|
assert "metadata" in body["data"]
|
|
|
|
def test_scrape_missing_url_returns_422(self, client: TestClient) -> None:
|
|
resp = client.post("/v1/scrape", json={})
|
|
assert resp.status_code == 422
|
|
body = resp.json()
|
|
assert "detail" in body
|
|
|
|
def test_scrape_invalid_url_returns_422(self, client: TestClient) -> None:
|
|
"""URL validation happens inside scraper, returns 200 with error."""
|
|
resp = client.post("/v1/scrape", json={"url": "not-a-url"})
|
|
# Scraper handles validation internally; response may be 200 with error body
|
|
assert resp.status_code in (200, 422)
|
|
|
|
def test_scrape_with_options(self, client: TestClient) -> None:
|
|
resp = client.post(
|
|
"/v1/scrape",
|
|
json={
|
|
"url": "https://example.com",
|
|
"timeout": 10,
|
|
"output_format": "markdown",
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
|
|
def test_scrape_response_shape(self, client: TestClient) -> None:
|
|
resp = client.post("/v1/scrape", json={"url": "https://example.com"})
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
# Success response should have data, not error
|
|
assert "error" not in body or not body.get("error")
|
|
|
|
|
|
class TestDetectBlockEndpoint:
|
|
"""POST /v1/detect-block — anti-bot detection."""
|
|
|
|
def test_detect_block_success(self, client: TestClient) -> None:
|
|
"""detect-block takes raw string body, makes real HTTP calls."""
|
|
# This endpoint bypasses the scraper mock and makes real HTTP calls.
|
|
# In tests, it will either fail or return a result depending on network.
|
|
# We just verify it doesn't 500.
|
|
resp = client.post(
|
|
"/v1/detect-block",
|
|
data="https://example.com",
|
|
headers={"Content-Type": "application/json"},
|
|
)
|
|
assert resp.status_code in (200, 422, 503)
|
|
|
|
|
|
class TestCrawlEndpoint:
|
|
"""POST /v1/crawl — multi-page crawl."""
|
|
|
|
def test_crawl_success(self, client: TestClient) -> None:
|
|
resp = client.post(
|
|
"/v1/crawl",
|
|
json={"url": "https://example.com", "max_pages": 3},
|
|
)
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
# Crawl returns a dict with job info
|
|
assert isinstance(body, dict)
|
|
|
|
|
|
class TestMapEndpoint:
|
|
"""POST /v1/map — URL discovery."""
|
|
|
|
def test_map_success(self, client: TestClient) -> None:
|
|
resp = client.post("/v1/map", json={"url": "https://example.com"})
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body.get("success") is True
|
|
assert "links" in body.get("data", {})
|
|
|
|
|
|
# ── Extraction Endpoints ─────────────────────────────────────────
|
|
|
|
|
|
class TestExtractEndpoint:
|
|
"""POST /v1/extract — structured data extraction."""
|
|
|
|
def test_extract_success(self, client: TestClient) -> None:
|
|
resp = client.post(
|
|
"/v1/extract",
|
|
json={
|
|
"url": "https://example.com",
|
|
"schema": {"product_name": "name of product"},
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
# Structure depends on router, but should have data
|
|
assert isinstance(body, dict)
|
|
|
|
def test_extract_missing_schema_returns_422(self, client: TestClient) -> None:
|
|
"""Extract with just URL and no schema may still pass validation."""
|
|
resp = client.post("/v1/extract", json={"url": "https://example.com"})
|
|
# Schema may be optional; just verify it doesn't 500
|
|
assert resp.status_code in (200, 422)
|
|
|
|
|
|
# ── Config Endpoints ─────────────────────────────────────────────
|
|
|
|
|
|
class TestConfigEndpoint:
|
|
"""GET /v1/config — runtime configuration."""
|
|
|
|
def test_config_success(self, client: TestClient) -> None:
|
|
resp = client.get("/v1/config")
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert isinstance(body, dict)
|
|
|
|
|
|
# ── Stats Endpoints ──────────────────────────────────────────────
|
|
|
|
|
|
class TestStatsEndpoint:
|
|
"""GET /v1/stats — usage statistics."""
|
|
|
|
def test_stats_success(self, client: TestClient) -> None:
|
|
resp = client.get("/v0/stats")
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert isinstance(body, dict)
|
|
|
|
|
|
# ── Templates Endpoints ──────────────────────────────────────────
|
|
|
|
|
|
class TestTemplatesEndpoint:
|
|
"""Template listing and retrieval."""
|
|
|
|
def test_list_templates(self, client: TestClient) -> None:
|
|
resp = client.get("/v1/templates")
|
|
# 404 or 200 depending on whether template listing exists
|
|
assert resp.status_code in (200, 404)
|
|
|
|
|
|
# ── Error Handling ───────────────────────────────────────────────
|
|
|
|
|
|
class TestErrorHandling:
|
|
"""API returns consistent error shapes for HTTP errors."""
|
|
|
|
def test_404_returns_json(self, client: TestClient) -> None:
|
|
resp = client.get("/nonexistent-route-12345")
|
|
assert resp.status_code == 404
|
|
body = resp.json()
|
|
assert "detail" in body
|
|
|
|
def test_405_on_wrong_method(self, client: TestClient) -> None:
|
|
resp = client.get("/v1/scrape")
|
|
assert resp.status_code == 405
|
|
|
|
def test_422_on_invalid_json(self, client: TestClient) -> None:
|
|
resp = client.post(
|
|
"/v1/scrape", data="not-json", headers={"Content-Type": "application/json"}
|
|
)
|
|
assert resp.status_code == 422
|
|
|
|
|
|
# ── Auth Gating ──────────────────────────────────────────────────
|
|
|
|
|
|
class TestAuth:
|
|
"""PRY_API_KEY gating on protected endpoints."""
|
|
|
|
def test_health_is_public(self, client_auth: TestClient) -> None:
|
|
"""Health endpoints should not require auth."""
|
|
resp = client_auth.get("/health")
|
|
assert resp.status_code in (200, 503)
|
|
|
|
def test_scrape_requires_auth(self, client_auth: TestClient) -> None:
|
|
"""Scraping endpoints require valid Bearer token when api_key is set."""
|
|
resp = client_auth.post(
|
|
"/v1/scrape",
|
|
json={"url": "https://example.com"},
|
|
)
|
|
assert resp.status_code == 401
|
|
|
|
def test_scrape_with_valid_auth(self, client_auth: TestClient) -> None:
|
|
"""Valid Bearer token should pass auth."""
|
|
resp = client_auth.post(
|
|
"/v1/scrape",
|
|
json={"url": "https://example.com"},
|
|
headers={"authorization": "Bearer test-api-key"},
|
|
)
|
|
assert resp.status_code == 200
|
|
|
|
def test_scrape_with_wrong_auth(self, client_auth: TestClient) -> None:
|
|
"""Wrong Bearer token should get 401."""
|
|
resp = client_auth.post(
|
|
"/v1/scrape",
|
|
json={"url": "https://example.com"},
|
|
headers={"authorization": "Bearer wrong-key"},
|
|
)
|
|
assert resp.status_code == 401
|
|
|
|
|
|
# ── CORS Headers ─────────────────────────────────────────────────
|
|
|
|
|
|
class TestCORS:
|
|
"""CORS middleware should set broad allow-origin."""
|
|
|
|
def test_cors_headers_present(self, client: TestClient) -> None:
|
|
resp = client.options(
|
|
"/v1/scrape",
|
|
headers={
|
|
"Origin": "https://example.com",
|
|
"Access-Control-Request-Method": "POST",
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
headers = resp.headers
|
|
assert headers.get("access-control-allow-origin") == "*"
|
|
assert "POST" in headers.get("access-control-allow-methods", "")
|
|
|
|
|
|
class TestMetricsEndpoint:
|
|
"""GET /metrics — Prometheus metrics endpoint."""
|
|
|
|
def test_metrics_returns_200(self, client: TestClient) -> None:
|
|
resp = client.get("/metrics")
|
|
assert resp.status_code == 200
|
|
assert "text/plain" in resp.headers["content-type"]
|
|
body = resp.text
|
|
# Should contain at least some metric names
|
|
assert "pry_" in body or "# HELP" in body
|
|
|
|
def test_metrics_contains_request_count(self, client: TestClient) -> None:
|
|
"""After one scrape request, request count metric should exist."""
|
|
# Trigger a request first
|
|
client.post("/v1/scrape", json={"url": "https://example.com"})
|
|
resp = client.get("/metrics")
|
|
assert resp.status_code == 200
|
|
# The metric may or may not have been recorded depending on the
|
|
# middleware (it might not use prometheus_client.Counter).
|
|
# Just verify the endpoint works.
|
|
assert len(resp.text) > 0
|