haiku.rag/tests/ingester/test_pipeline.py
2026-06-15 08:41:26 +03:00

489 lines
16 KiB
Python

from datetime import UTC, datetime
from unittest.mock import AsyncMock
import httpx
import pytest
from haiku.rag.client import HaikuRAG
from haiku.rag.ingester.exceptions import PermanentError, TransientError
from haiku.rag.ingester.queue.models import Job, JobOp, JobStatus
from haiku.rag.ingester.sources.base import FetchResult, FileTooLargeError, Source
from haiku.rag.ingester.workers.pipeline import run_job
from haiku.rag.store.models.document import Document
def _job(
*,
op: JobOp = JobOp.UPSERT,
uri: str = "https://example.com/a.pdf",
extra: dict | None = None,
attempts: int = 0,
) -> Job:
now = datetime.now(UTC)
return Job(
id="job-1",
source_id="src",
uri=uri,
op=op,
status=JobStatus.CLAIMED,
attempts=attempts,
max_attempts=5,
extra=extra,
enqueued_at=now,
scheduled_at=now,
)
def _mock_client() -> AsyncMock:
return AsyncMock(spec=HaikuRAG)
@pytest.mark.asyncio
async def test_upsert_calls_create_document_from_source_and_returns_metadata():
client = _mock_client()
client.create_document_from_source.return_value = Document(
id="doc-42",
content="x",
uri="https://example.com/a.pdf",
metadata={
"md5": "abcd",
"source_revision": "xyz",
"content_type": "application/pdf",
},
)
result = await run_job(client, _job())
assert result.document_id == "doc-42"
assert result.revision == "xyz"
assert result.content_hash == "abcd"
assert result.deleted is False
client.create_document_from_source.assert_awaited_once_with(
"https://example.com/a.pdf", sources=None, source_id="src", metadata=None
)
@pytest.mark.asyncio
async def test_upsert_threads_configured_sources_to_client():
"""The list of configured Source adapters reaches the client so
resolve_fetcher can pick the right one by source_id."""
from haiku.rag.ingester.sources.http import HTTPSource
client = _mock_client()
client.create_document_from_source.return_value = Document(
id="d", content="x", uri="u", metadata={}
)
configured = HTTPSource(source_id="urls", headers={"Authorization": "Bearer abc"})
await run_job(client, _job(), sources=[configured])
client.create_document_from_source.assert_awaited_once_with(
"https://example.com/a.pdf",
sources=[configured],
source_id="src",
metadata=None,
)
class _MetadataProvider:
"""Provider double returning scripted metadata, or raising."""
def __init__(self, metadata: dict | None = None, *, error: Exception | None = None):
self._metadata = metadata or {}
self._error = error
async def __call__(self, source_id: str, uri: str) -> dict:
if self._error is not None:
raise self._error
return {**self._metadata, "source": source_id}
@pytest.mark.asyncio
async def test_provider_metadata_passed_to_client():
client = _mock_client()
client.create_document_from_source.return_value = Document(
id="d", content="x", uri="u", metadata={}
)
providers = {"src": _MetadataProvider({"classification": "secret"})}
await run_job(client, _job(), metadata_providers=providers)
client.create_document_from_source.assert_awaited_once_with(
"https://example.com/a.pdf",
sources=None,
source_id="src",
metadata={"classification": "secret", "source": "src"},
)
@pytest.mark.asyncio
async def test_provider_cannot_override_system_keys():
"""Reserved source-derived keys are stripped from provider output so the
metadata-only refresh path can't let a provider overwrite md5 /
source_revision / content_type (which would corrupt sync_state)."""
client = _mock_client()
client.create_document_from_source.return_value = Document(
id="d", content="x", uri="u", metadata={}
)
providers = {
"src": _MetadataProvider(
{
"md5": "spoof",
"source_revision": "spoof",
"content_type": "text/spoof",
"classification": "secret",
}
)
}
await run_job(client, _job(), metadata_providers=providers)
client.create_document_from_source.assert_awaited_once_with(
"https://example.com/a.pdf",
sources=None,
source_id="src",
metadata={"classification": "secret", "source": "src"},
)
@pytest.mark.asyncio
async def test_no_provider_for_source_passes_no_metadata():
"""A provider registered for a different source must not apply here."""
client = _mock_client()
client.create_document_from_source.return_value = Document(
id="d", content="x", uri="u", metadata={}
)
providers = {"other": _MetadataProvider({"classification": "secret"})}
await run_job(client, _job(), metadata_providers=providers)
client.create_document_from_source.assert_awaited_once_with(
"https://example.com/a.pdf", sources=None, source_id="src", metadata=None
)
@pytest.mark.asyncio
async def test_provider_error_is_classified_and_blocks_ingest():
client = _mock_client()
providers = {"src": _MetadataProvider(error=httpx.ConnectError("provider down"))}
with pytest.raises(TransientError):
await run_job(client, _job(), metadata_providers=providers)
client.create_document_from_source.assert_not_awaited()
@pytest.mark.asyncio
async def test_provider_not_called_for_delete():
client = _mock_client()
client.get_document_by_uri.return_value = Document(id="doc-9", content="", uri="u")
provider = _MetadataProvider(error=AssertionError("must not run on DELETE"))
result = await run_job(
client, _job(op=JobOp.DELETE), metadata_providers={"src": provider}
)
assert result.deleted is True
@pytest.mark.asyncio
async def test_delete_calls_delete_document_when_present():
client = _mock_client()
client.get_document_by_uri.return_value = Document(id="doc-9", content="", uri="u")
result = await run_job(client, _job(op=JobOp.DELETE))
assert result.deleted is True
assert result.document_id is None
client.delete_document.assert_awaited_once_with("doc-9")
@pytest.mark.asyncio
async def test_delete_is_noop_when_document_missing():
client = _mock_client()
client.get_document_by_uri.return_value = None
result = await run_job(client, _job(op=JobOp.DELETE))
assert result.deleted is True
client.delete_document.assert_not_awaited()
class _StubSource:
"""Source double whose head() returns a scripted revision or raises."""
def __init__(
self,
source_id: str,
revision: str | None,
*,
head_error: Exception | None = None,
):
self.source_id = source_id
self._revision = revision
self._head_error = head_error
def supports(self, uri: str) -> bool:
return True
async def head(self, uri: str) -> str | None:
if self._head_error is not None:
raise self._head_error
return self._revision
async def aclose(self) -> None: ...
async def fetch(self, uri: str) -> FetchResult: # pragma: no cover - unused
raise NotImplementedError
async def discover(self, since=None, *, known_uris=None): # pragma: no cover
raise NotImplementedError
yield
@pytest.mark.asyncio
async def test_delete_skipped_when_resource_restored_on_source():
"""The file is back on disk by the time the DELETE runs, so head()
returns a revision and the delete is skipped — otherwise a live document
gets blackholed until the next sweep."""
client = _mock_client()
client.get_document_by_uri.return_value = Document(id="doc-9", content="", uri="u")
sources: list[Source] = [_StubSource("src", "12345")]
result = await run_job(client, _job(op=JobOp.DELETE), sources=sources)
assert result.deleted is False
client.delete_document.assert_not_awaited()
@pytest.mark.asyncio
async def test_delete_proceeds_when_resource_absent_on_source():
client = _mock_client()
client.get_document_by_uri.return_value = Document(id="doc-9", content="", uri="u")
sources: list[Source] = [_StubSource("src", None)]
result = await run_job(client, _job(op=JobOp.DELETE), sources=sources)
assert result.deleted is True
client.delete_document.assert_awaited_once_with("doc-9")
@pytest.mark.asyncio
async def test_delete_proceeds_when_source_unresolvable():
"""No configured source for the job: the probe can't run, so the delete
proceeds exactly as before."""
client = _mock_client()
client.get_document_by_uri.return_value = Document(id="doc-9", content="", uri="u")
result = await run_job(client, _job(op=JobOp.DELETE), sources=[])
assert result.deleted is True
client.delete_document.assert_awaited_once_with("doc-9")
@pytest.mark.asyncio
async def test_delete_proceeds_when_head_probe_raises():
"""A failing head() probe must not block the delete."""
client = _mock_client()
client.get_document_by_uri.return_value = Document(id="doc-9", content="", uri="u")
sources: list[Source] = [_StubSource("src", None, head_error=OSError("boom"))]
result = await run_job(client, _job(op=JobOp.DELETE), sources=sources)
assert result.deleted is True
client.delete_document.assert_awaited_once_with("doc-9")
@pytest.mark.asyncio
async def test_unsupported_extension_classified_permanent():
from haiku.rag.client.exceptions import UnsupportedSourceError
client = _mock_client()
client.create_document_from_source.side_effect = UnsupportedSourceError(
"Unsupported file extension: .xyz"
)
with pytest.raises(PermanentError, match="Unsupported"):
await run_job(client, _job())
@pytest.mark.asyncio
async def test_unsupported_content_type_classified_permanent():
from haiku.rag.client.exceptions import UnsupportedSourceError
client = _mock_client()
client.create_document_from_source.side_effect = UnsupportedSourceError(
"Unsupported content type/extension: application/octet-stream/.bin"
)
with pytest.raises(PermanentError):
await run_job(client, _job())
@pytest.mark.asyncio
async def test_invalid_s3_uri_classified_permanent():
from haiku.rag.client.exceptions import UnsupportedSourceError
client = _mock_client()
client.create_document_from_source.side_effect = UnsupportedSourceError(
"Invalid S3 URI: s3:///bad"
)
with pytest.raises(PermanentError):
await run_job(client, _job())
@pytest.mark.asyncio
async def test_missing_file_classified_permanent():
from haiku.rag.client.exceptions import UnsupportedSourceError
client = _mock_client()
client.create_document_from_source.side_effect = UnsupportedSourceError(
"File does not exist: /nope"
)
with pytest.raises(PermanentError):
await run_job(client, _job())
@pytest.mark.asyncio
async def test_other_value_error_classified_transient():
client = _mock_client()
client.create_document_from_source.side_effect = ValueError("DB busy")
with pytest.raises(TransientError):
await run_job(client, _job())
@pytest.mark.parametrize("status", [401, 403, 404, 410])
@pytest.mark.asyncio
async def test_http_4xx_classified_permanent(status):
client = _mock_client()
response = httpx.Response(
status, request=httpx.Request("GET", "https://example.com/a")
)
client.create_document_from_source.side_effect = httpx.HTTPStatusError(
"err", request=response.request, response=response
)
with pytest.raises(PermanentError):
await run_job(client, _job())
@pytest.mark.parametrize("status", [408, 429, 500, 502, 503])
@pytest.mark.asyncio
async def test_http_408_429_5xx_classified_transient(status):
client = _mock_client()
response = httpx.Response(
status, request=httpx.Request("GET", "https://example.com/a")
)
client.create_document_from_source.side_effect = httpx.HTTPStatusError(
"err", request=response.request, response=response
)
with pytest.raises(TransientError):
await run_job(client, _job())
@pytest.mark.asyncio
async def test_connect_error_classified_transient():
client = _mock_client()
client.create_document_from_source.side_effect = httpx.ConnectError("boom")
with pytest.raises(TransientError):
await run_job(client, _job())
@pytest.mark.parametrize(
"exc_factory",
[
lambda: TimeoutError("slow"),
lambda: OSError("io"),
],
)
@pytest.mark.asyncio
async def test_timeout_and_io_errors_classified_transient(exc_factory):
"""TimeoutError / OSError both classify as transient. Without this
branch they'd fall through to the generic "unexpected" wrapper."""
client = _mock_client()
client.create_document_from_source.side_effect = exc_factory()
with pytest.raises(TransientError, match="timeout/io"):
await run_job(client, _job())
@pytest.mark.parametrize(
"exc_factory",
[
lambda: httpx.ConnectTimeout("slow connect"),
lambda: httpx.ReadTimeout("slow read"),
lambda: httpx.WriteTimeout("slow write"),
lambda: httpx.PoolTimeout("pool"),
lambda: httpx.ProxyError("proxy"),
],
)
@pytest.mark.asyncio
async def test_transport_subclasses_classified_transient(exc_factory):
"""The classifier umbrellas on httpx.TransportError so every transport-
layer subclass routes to TransientError, not the generic 'unexpected'
fallback."""
client = _mock_client()
client.create_document_from_source.side_effect = exc_factory()
with pytest.raises(TransientError, match="network"):
await run_job(client, _job())
@pytest.mark.asyncio
async def test_unknown_exception_classified_transient():
client = _mock_client()
client.create_document_from_source.side_effect = RuntimeError("???")
with pytest.raises(TransientError):
await run_job(client, _job())
@pytest.mark.asyncio
async def test_directory_result_classified_permanent():
client = _mock_client()
client.create_document_from_source.return_value = [
Document(id="a", content="", uri="u1"),
Document(id="b", content="", uri="u2"),
]
with pytest.raises(PermanentError, match="directory"):
await run_job(client, _job())
@pytest.mark.asyncio
async def test_existing_permanent_error_passes_through_unchanged():
client = _mock_client()
sentinel = PermanentError("explicit")
client.create_document_from_source.side_effect = sentinel
with pytest.raises(PermanentError) as excinfo:
await run_job(client, _job())
assert excinfo.value is sentinel
@pytest.mark.asyncio
async def test_file_not_found_classified_as_permanent():
"""A deleted file should go straight to the DLQ, not retry."""
client = _mock_client()
client.create_document_from_source.side_effect = FileNotFoundError("gone")
with pytest.raises(PermanentError, match="file not found"):
await run_job(client, _job())
@pytest.mark.asyncio
async def test_permission_error_classified_as_permanent():
"""An unreadable file should go straight to the DLQ, not retry."""
client = _mock_client()
client.create_document_from_source.side_effect = PermissionError("no access")
with pytest.raises(PermanentError, match="permission denied"):
await run_job(client, _job())
@pytest.mark.asyncio
@pytest.mark.parametrize("exc_class", [IsADirectoryError, NotADirectoryError])
async def test_directory_errors_classified_as_permanent(exc_class):
"""Pointing at a directory instead of a file should DLQ immediately."""
client = _mock_client()
client.create_document_from_source.side_effect = exc_class("not a file")
with pytest.raises(PermanentError, match="path error"):
await run_job(client, _job())
@pytest.mark.asyncio
async def test_file_too_large_classified_as_permanent():
client = _mock_client()
client.create_document_from_source.side_effect = FileTooLargeError("too big")
with pytest.raises(PermanentError, match="too big"):
await run_job(client, _job())