"""Tests for the ingestion API endpoints (api.ingestion)."""
from __future__ import annotations
from unittest.mock import AsyncMock
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from api.ingestion import router
from domain.models import AnalysisJob
from services.ingestion_service import IngestionResult
@pytest.fixture
def mock_ingestion_service() -> AsyncMock:
svc = AsyncMock()
svc.ingest.return_value = IngestionResult(
doc_id="doc-1", chunks_indexed=5, embedding_dimension=384
)
svc.delete_document.return_value = 3
return svc
@pytest.fixture
def mock_analysis_service() -> AsyncMock:
svc = AsyncMock()
job = AnalysisJob(document_id="doc-1")
job.document_filename = "test.pdf"
job.mark_running()
job.mark_completed(
markdown="# Test",
html="
Test
",
pages_json="[]",
document_json='{"doc": true}',
chunks_json='[{"text": "hello"}]',
)
svc.find_by_id.return_value = job
return svc
@pytest.fixture
def client(mock_ingestion_service: AsyncMock, mock_analysis_service: AsyncMock) -> TestClient:
app = FastAPI()
app.include_router(router)
app.state.ingestion_service = mock_ingestion_service
app.state.analysis_service = mock_analysis_service
return TestClient(app)
class TestIngestAnalysis:
def test_ingest_success(self, client: TestClient) -> None:
resp = client.post("/api/ingestion/job-1")
assert resp.status_code == 200
data = resp.json()
assert data["docId"] == "doc-1"
assert data["chunksIndexed"] == 5
assert data["embeddingDimension"] == 384
def test_ingest_not_found(self, client: TestClient, mock_analysis_service: AsyncMock) -> None:
mock_analysis_service.find_by_id.return_value = None
resp = client.post("/api/ingestion/missing")
assert resp.status_code == 404
def test_ingest_not_completed(
self, client: TestClient, mock_analysis_service: AsyncMock
) -> None:
job = AnalysisJob(document_id="doc-1")
mock_analysis_service.find_by_id.return_value = job
resp = client.post("/api/ingestion/job-1")
assert resp.status_code == 400
def test_ingest_no_chunks(self, client: TestClient, mock_analysis_service: AsyncMock) -> None:
job = AnalysisJob(document_id="doc-1")
job.mark_running()
job.mark_completed(markdown="x", html="x", pages_json="[]")
mock_analysis_service.find_by_id.return_value = job
resp = client.post("/api/ingestion/job-1")
assert resp.status_code == 400
class TestDeleteIngested:
def test_delete_success(self, client: TestClient) -> None:
resp = client.delete("/api/ingestion/doc-1")
assert resp.status_code == 204
class TestIngestionStatus:
def test_available(self, client: TestClient) -> None:
resp = client.get("/api/ingestion/status")
assert resp.status_code == 200
assert resp.json()["available"] is True
def test_not_available(self) -> None:
app = FastAPI()
app.include_router(router)
app.state.ingestion_service = None
app.state.analysis_service = AsyncMock()
tc = TestClient(app)
resp = tc.get("/api/ingestion/status")
assert resp.status_code == 200
assert resp.json()["available"] is False
class TestIngestionDisabled:
def test_router_not_mounted_returns_404(self) -> None:
"""When ingestion service is None, router should not be mounted → 404."""
app = FastAPI()
# Do NOT include ingestion router — simulates main.py conditional mount
app.state.ingestion_service = None
app.state.analysis_service = AsyncMock()
tc = TestClient(app)
resp = tc.post("/api/ingestion/job-1")
assert resp.status_code == 404
def test_status_still_returns_503_when_router_mounted_but_service_none(self) -> None:
"""If router is mounted but service is None, endpoints return 503."""
app = FastAPI()
app.include_router(router)
app.state.ingestion_service = None
app.state.analysis_service = AsyncMock()
tc = TestClient(app)
resp = tc.post("/api/ingestion/job-1")
assert resp.status_code == 503
class TestStatusOpenSearch:
def test_status_with_opensearch_connected(
self, client: TestClient, mock_ingestion_service: AsyncMock
) -> None:
mock_ingestion_service.ping.return_value = True
resp = client.get("/api/ingestion/status")
assert resp.status_code == 200
data = resp.json()
assert data["available"] is True
assert data["opensearchConnected"] is True
def test_status_with_opensearch_disconnected(
self, client: TestClient, mock_ingestion_service: AsyncMock
) -> None:
mock_ingestion_service.ping.return_value = False
resp = client.get("/api/ingestion/status")
assert resp.status_code == 200
data = resp.json()
assert data["available"] is True
assert data["opensearchConnected"] is False
class TestSearchEndpoint:
def test_search_success(self, client: TestClient, mock_ingestion_service: AsyncMock) -> None:
from domain.vector_schema import IndexedChunk, SearchResult
chunk = IndexedChunk(
doc_id="doc-1",
filename="test.pdf",
content="hello world",
embedding=[],
chunk_index=0,
chunk_type="text",
page_number=1,
headings=["Intro"],
)
mock_ingestion_service.search_fulltext.return_value = [
SearchResult(chunk=chunk, score=0.95)
]
resp = client.get("/api/ingestion/search", params={"q": "hello"})
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 1
assert data["query"] == "hello"
assert data["results"][0]["content"] == "hello world"
assert data["results"][0]["score"] == 0.95
def test_search_empty_query(self, client: TestClient) -> None:
resp = client.get("/api/ingestion/search", params={"q": ""})
assert resp.status_code == 422
def test_search_with_doc_filter(
self, client: TestClient, mock_ingestion_service: AsyncMock
) -> None:
mock_ingestion_service.search_fulltext.return_value = []
resp = client.get("/api/ingestion/search", params={"q": "test", "doc_id": "doc-1"})
assert resp.status_code == 200
mock_ingestion_service.search_fulltext.assert_awaited_once_with(
"test", k=20, doc_id="doc-1"
)