diff --git a/document-parser/tests/test_ingestion_api.py b/document-parser/tests/test_ingestion_api.py new file mode 100644 index 0000000..ee124a8 --- /dev/null +++ b/document-parser/tests/test_ingestion_api.py @@ -0,0 +1,114 @@ +"""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_returns_503_when_disabled(self) -> None: + 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 diff --git a/e2e/api/src/test/resources/ingestion/ingest-and-verify.feature b/e2e/api/src/test/resources/ingestion/ingest-and-verify.feature new file mode 100644 index 0000000..46ad687 --- /dev/null +++ b/e2e/api/src/test/resources/ingestion/ingest-and-verify.feature @@ -0,0 +1,59 @@ +@e2e @ingestion +Feature: Ingestion pipeline — PDF → chunks → embeddings → OpenSearch + + Background: + * url baseUrl + + Scenario: Upload PDF, analyze with chunking, ingest into OpenSearch, verify + + # Step 1: Check ingestion is available + Given path '/api/ingestion/status' + When method GET + Then status 200 + And match response.available == true + + # Step 2: Upload a PDF + Given path '/api/documents/upload' + And multipart file file = { read: 'classpath:common/data/generated/medium.pdf', filename: 'medium.pdf', contentType: 'application/pdf' } + When method POST + Then status 200 + * def docId = response.id + + # Step 3: Create analysis with chunking + Given path '/api/analyses' + And request { documentId: '#(docId)', pipelineOptions: { doOcr: true, tableMode: 'fast' }, chunkingOptions: { chunkerType: 'hybrid', maxTokens: 256 } } + When method POST + Then status 200 + * def jobId = response.id + + # Step 4: Poll until completed + Given path '/api/analyses', jobId + And retry until response.status == 'COMPLETED' || response.status == 'FAILED' + When method GET + Then status 200 + And match response.status == 'COMPLETED' + And match response.chunksJson == '#string' + + # Step 5: Trigger ingestion + Given path '/api/ingestion', jobId + When method POST + Then status 200 + And match response.docId == docId + And match response.chunksIndexed == '#number' + And assert response.chunksIndexed > 0 + And match response.embeddingDimension == '#number' + And assert response.embeddingDimension > 0 + + # Step 6: Cleanup — delete ingested data + Given path '/api/ingestion', docId + When method DELETE + Then status 204 + + # Step 7: Cleanup — delete analysis and document + Given path '/api/analyses', jobId + When method DELETE + Then status 204 + + Given path '/api/documents', docId + When method DELETE + Then status 204