From cfc5bb5c35828ac4974df713e5f2a4bd580fa105 Mon Sep 17 00:00:00 2001 From: Pier-Jean Malandrino Date: Wed, 8 Apr 2026 13:47:03 +0200 Subject: [PATCH] feat: add E2E API tests with Karate V2 Set up a full E2E test suite (39 scenarios) using Karate against the real API stack. Hybrid architecture: domain-based features + cross-domain workflows, with data-driven testing and callable helpers. Structure: - e2e/pom.xml: Maven + karate-core 1.5 - 3 helpers (upload, analyze+poll, cleanup) - 3 JSON schemas (health, document, analysis) - 12 feature files across health, documents, analyses, workflows - Tags: @smoke (2), @regression (35), @e2e (2) - generate-test-data.py: fpdf2-based PDF generation (no binaries) Also adds: - RATE_LIMIT_RPM env var to make rate limiter configurable (0=disabled) - CI job e2e with needs: [backend, frontend] - e2e/ in .dockerignore Closes #119 --- .dockerignore | 3 + .github/workflows/ci.yml | 66 +++++++++++++++- document-parser/infra/settings.py | 4 + document-parser/main.py | 7 +- e2e/.gitignore | 2 + e2e/README.md | 71 +++++++++++++++++ e2e/generate-test-data.py | 78 ++++++++++++++++++ e2e/pom.xml | 58 ++++++++++++++ e2e/src/test/java/E2ERunner.java | 17 ++++ .../resources/analyses/batch-progress.feature | 48 +++++++++++ .../test/resources/analyses/cancel.feature | 40 ++++++++++ .../analyses/create-and-poll.feature | 66 ++++++++++++++++ .../analyses/pipeline-options.feature | 30 +++++++ .../test/resources/analyses/rechunk.feature | 49 ++++++++++++ .../common/data/generated/.gitignore | 3 + .../common/data/schemas/analysis.json | 17 ++++ .../common/data/schemas/document.json | 8 ++ .../resources/common/data/schemas/health.json | 7 ++ .../data/test-cases/invalid-uploads.json | 9 +++ .../data/test-cases/pipeline-options.json | 27 +++++++ .../resources/common/helpers/analyze.feature | 26 ++++++ .../resources/common/helpers/cleanup.feature | 12 +++ .../resources/common/helpers/upload.feature | 14 ++++ e2e/src/test/resources/documents/crud.feature | 55 +++++++++++++ .../test/resources/documents/preview.feature | 45 +++++++++++ .../documents/size-validation.feature | 22 ++++++ .../test/resources/documents/upload.feature | 41 ++++++++++ e2e/src/test/resources/health/health.feature | 22 ++++++ e2e/src/test/resources/karate-config.js | 12 +++ .../workflows/concurrent-analyses.feature | 59 ++++++++++++++ .../workflows/full-happy-path.feature | 79 +++++++++++++++++++ 31 files changed, 994 insertions(+), 3 deletions(-) create mode 100644 e2e/.gitignore create mode 100644 e2e/README.md create mode 100644 e2e/generate-test-data.py create mode 100644 e2e/pom.xml create mode 100644 e2e/src/test/java/E2ERunner.java create mode 100644 e2e/src/test/resources/analyses/batch-progress.feature create mode 100644 e2e/src/test/resources/analyses/cancel.feature create mode 100644 e2e/src/test/resources/analyses/create-and-poll.feature create mode 100644 e2e/src/test/resources/analyses/pipeline-options.feature create mode 100644 e2e/src/test/resources/analyses/rechunk.feature create mode 100644 e2e/src/test/resources/common/data/generated/.gitignore create mode 100644 e2e/src/test/resources/common/data/schemas/analysis.json create mode 100644 e2e/src/test/resources/common/data/schemas/document.json create mode 100644 e2e/src/test/resources/common/data/schemas/health.json create mode 100644 e2e/src/test/resources/common/data/test-cases/invalid-uploads.json create mode 100644 e2e/src/test/resources/common/data/test-cases/pipeline-options.json create mode 100644 e2e/src/test/resources/common/helpers/analyze.feature create mode 100644 e2e/src/test/resources/common/helpers/cleanup.feature create mode 100644 e2e/src/test/resources/common/helpers/upload.feature create mode 100644 e2e/src/test/resources/documents/crud.feature create mode 100644 e2e/src/test/resources/documents/preview.feature create mode 100644 e2e/src/test/resources/documents/size-validation.feature create mode 100644 e2e/src/test/resources/documents/upload.feature create mode 100644 e2e/src/test/resources/health/health.feature create mode 100644 e2e/src/test/resources/karate-config.js create mode 100644 e2e/src/test/resources/workflows/concurrent-analyses.feature create mode 100644 e2e/src/test/resources/workflows/full-happy-path.feature diff --git a/.dockerignore b/.dockerignore index 8837ce6..36db745 100644 --- a/.dockerignore +++ b/.dockerignore @@ -18,3 +18,6 @@ document-parser/.mypy_cache/ document-parser/.ruff_cache/ document-parser/data/ document-parser/uploads/ + +# E2E tests (JVM/Maven — not needed in Docker images) +e2e/ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6877f38..659e799 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [main] + branches: [main, 'release/**'] pull_request: - branches: [main] + branches: [main, 'release/**'] # Cancel previous runs on the same branch/PR concurrency: @@ -73,3 +73,65 @@ jobs: - name: Build run: npm run build + + e2e: + name: E2E API tests (Karate) + needs: [backend, frontend] + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - uses: actions/setup-java@v4 + with: + java-version: "17" + distribution: temurin + cache: maven + + - name: Generate test PDFs + run: | + pip install pypdfium2 + python e2e/generate-test-data.py + + - name: Start stack + run: docker compose up -d --wait --build + timeout-minutes: 10 + env: + RATE_LIMIT_RPM: "0" + + - name: Wait for health + run: | + for i in $(seq 1 30); do + if curl -sf http://localhost:8000/api/health > /dev/null 2>&1; then + echo "Backend healthy" + exit 0 + fi + echo "Waiting for backend... ($i/30)" + sleep 5 + done + echo "Backend failed to start" + docker compose logs + exit 1 + + - name: Run E2E tests + run: | + KARATE_TAGS="@smoke" + if [[ "${{ github.base_ref }}" == release/* ]] || [[ "${{ github.ref }}" == refs/heads/release/* ]]; then + KARATE_TAGS="@smoke,@regression,@e2e" + fi + mvn test -f e2e/pom.xml -Dkarate.options="--tags ${KARATE_TAGS}" + + - name: Upload Karate reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: karate-reports + path: e2e/target/karate-reports/ + + - name: Tear down + if: always() + run: docker compose down diff --git a/document-parser/infra/settings.py b/document-parser/infra/settings.py index 3fba8f2..843ea59 100644 --- a/document-parser/infra/settings.py +++ b/document-parser/infra/settings.py @@ -21,6 +21,7 @@ class Settings: max_page_count: int = 0 # 0 = unlimited (upload validation) max_file_size: int = 0 # 0 = unlimited (Docling-level, bytes) max_file_size_mb: int = 50 # upload limit in MB (0 = unlimited) + rate_limit_rpm: int = 100 # requests per minute per IP (0 = disabled) batch_page_size: int = 0 # 0 = disabled, > 0 = pages per batch upload_dir: str = "./uploads" db_path: str = "./data/docling_studio.db" @@ -46,6 +47,8 @@ class Settings: errors.append(f"max_file_size must be >= 0 (got {self.max_file_size})") if self.max_file_size_mb < 0: errors.append(f"max_file_size_mb must be >= 0 (got {self.max_file_size_mb})") + if self.rate_limit_rpm < 0: + errors.append(f"rate_limit_rpm must be >= 0 (got {self.rate_limit_rpm})") if self.batch_page_size < 0: errors.append(f"batch_page_size must be >= 0 (got {self.batch_page_size})") if self.default_table_mode not in ("accurate", "fast"): @@ -85,6 +88,7 @@ class Settings: max_page_count=int(os.environ.get("MAX_PAGE_COUNT", "0")), max_file_size=int(os.environ.get("MAX_FILE_SIZE", "0")), max_file_size_mb=int(os.environ.get("MAX_FILE_SIZE_MB", "50")), + rate_limit_rpm=int(os.environ.get("RATE_LIMIT_RPM", "100")), batch_page_size=int(os.environ.get("BATCH_PAGE_SIZE", "0")), upload_dir=os.environ.get("UPLOAD_DIR", "./uploads"), db_path=os.environ.get("DB_PATH", "./data/docling_studio.db"), diff --git a/document-parser/main.py b/document-parser/main.py index 7c5defd..60b9ae5 100644 --- a/document-parser/main.py +++ b/document-parser/main.py @@ -95,7 +95,12 @@ app.add_middleware( allow_methods=["GET", "POST", "DELETE", "OPTIONS"], allow_headers=["Content-Type", "Authorization"], ) -app.add_middleware(RateLimiterMiddleware, requests_per_window=100, window_seconds=60) +if settings.rate_limit_rpm > 0: + app.add_middleware( + RateLimiterMiddleware, + requests_per_window=settings.rate_limit_rpm, + window_seconds=60, + ) app.include_router(documents_router) app.include_router(analyses_router) diff --git a/e2e/.gitignore b/e2e/.gitignore new file mode 100644 index 0000000..c8d04f8 --- /dev/null +++ b/e2e/.gitignore @@ -0,0 +1,2 @@ +e2e/target/ +e2e/target/ diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 0000000..ee2b8a8 --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,71 @@ +# E2E API Tests — Karate + +End-to-end API tests for Docling Studio using [Karate](https://karatelabs.github.io/karate/). + +## Prerequisites + +- **JDK 17+** (e.g. `brew install openjdk@17`) +- **Maven 3.9+** (e.g. `brew install maven`) +- **Python 3.12+** (for test data generation) +- **Docker** (for running the full stack) + +## Quick start + +```bash +# 1. Generate test PDFs +python e2e/generate-test-data.py + +# 2. Start the stack +docker compose up -d --wait + +# 3. Run all tests +mvn test -f e2e/pom.xml + +# 4. Tear down +docker compose down +``` + +## Run by tag + +```bash +# Smoke only (~30s) +mvn test -f e2e/pom.xml -Dkarate.options="--tags @smoke" + +# Regression (~2min) +mvn test -f e2e/pom.xml -Dkarate.options="--tags @regression" + +# Full E2E workflows (~5min) +mvn test -f e2e/pom.xml -Dkarate.options="--tags @e2e" +``` + +## Custom base URL + +```bash +mvn test -f e2e/pom.xml -DbaseUrl=http://your-host:8000 +``` + +## Structure + +``` +e2e/ +├── generate-test-data.py # Generates test PDFs (no binaries in repo) +├── pom.xml # Maven + Karate dependency +├── src/test/java/ +│ └── E2ERunner.java # JUnit5 Karate runner +└── src/test/resources/ + ├── karate-config.js # Base URL, timeouts, retry config + ├── common/ + │ ├── helpers/ # Callable features (upload, analyze, cleanup) + │ └── data/ + │ ├── schemas/ # JSON match schemas + │ ├── test-cases/ # Data-driven JSON files + │ └── generated/ # Generated PDFs (gitignored) + ├── health/ # @smoke + ├── documents/ # @regression + ├── analyses/ # @regression + └── workflows/ # @e2e (cross-domain) +``` + +## Reports + +After a run, Karate HTML reports are in `e2e/target/karate-reports/`. diff --git a/e2e/generate-test-data.py b/e2e/generate-test-data.py new file mode 100644 index 0000000..fb03190 --- /dev/null +++ b/e2e/generate-test-data.py @@ -0,0 +1,78 @@ +"""Generate deterministic test PDFs for E2E tests. + +Uses fpdf2 to create valid PDFs with real text content so Docling +can extract and chunk them. No binary files committed to the repo. + +Usage: + python e2e/generate-test-data.py + +Dependencies: + pip install fpdf2 +""" + +from __future__ import annotations + +import os + +from fpdf import FPDF + +OUTPUT_DIR = os.path.join( + os.path.dirname(__file__), "src", "test", "resources", "common", "data", "generated" +) + +_PARAGRAPHS = [ + "Document processing is a critical step in building retrieval-augmented generation systems.", + "Docling Studio provides tools for analyzing PDF documents and extracting structured content.", + "The conversion pipeline supports OCR, table detection, and formula enrichment features.", + "Chunking splits document content into semantically meaningful segments for vector indexing.", + "Each chunk preserves metadata such as page number, bounding boxes, and heading hierarchy.", + "The hybrid chunker combines hierarchical document structure with token-based splitting.", + "Vector stores like OpenSearch enable fast similarity search over embedded chunk vectors.", + "Quality control requires visual inspection of chunk boundaries and extracted text accuracy.", + "Batch processing of large documents uses page ranges to prevent memory exhaustion.", + "Progress reporting allows users to monitor long-running document conversion tasks.", +] + + +def _make_pdf(page_count: int, path: str) -> None: + """Create a valid PDF with N pages containing text paragraphs.""" + pdf = FPDF() + pdf.set_auto_page_break(auto=True, margin=25) + + for page_num in range(page_count): + pdf.add_page() + pdf.set_font("Helvetica", "B", 16) + pdf.cell(0, 10, f"Page {page_num + 1} of {page_count}", new_x="LMARGIN", new_y="NEXT") + pdf.ln(5) + pdf.set_font("Helvetica", "", 11) + for i in range(5): + para = _PARAGRAPHS[(page_num * 5 + i) % len(_PARAGRAPHS)] + pdf.multi_cell(0, 6, para) + pdf.ln(3) + + pdf.output(path) + size_kb = os.path.getsize(path) / 1024 + print(f" {os.path.basename(path)}: {page_count} pages, {size_kb:.1f} KB") + + +def _make_non_pdf(path: str) -> None: + """Create a non-PDF file for negative testing.""" + with open(path, "wb") as f: + f.write(b"This is not a PDF file.\n") + print(f" {os.path.basename(path)}: not-a-pdf") + + +def main() -> None: + os.makedirs(OUTPUT_DIR, exist_ok=True) + print(f"Generating test data in {OUTPUT_DIR}") + + _make_pdf(1, os.path.join(OUTPUT_DIR, "small.pdf")) + _make_pdf(5, os.path.join(OUTPUT_DIR, "medium.pdf")) + _make_pdf(25, os.path.join(OUTPUT_DIR, "large.pdf")) + _make_non_pdf(os.path.join(OUTPUT_DIR, "not-a-pdf.txt")) + + print("Done.") + + +if __name__ == "__main__": + main() diff --git a/e2e/pom.xml b/e2e/pom.xml new file mode 100644 index 0000000..35613d6 --- /dev/null +++ b/e2e/pom.xml @@ -0,0 +1,58 @@ + + + 4.0.0 + + com.scub.docling-studio + e2e-tests + 0.3.1 + jar + + + 17 + 17 + UTF-8 + 1.5.0 + + + + + io.karatelabs + karate-core + ${karate.version} + test + + + io.karatelabs + karate-junit5 + ${karate.version} + test + + + + + + + src/test/resources + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + -Dfile.encoding=UTF-8 + + **/*Runner.java + + + ${karate.options} + + + + + + diff --git a/e2e/src/test/java/E2ERunner.java b/e2e/src/test/java/E2ERunner.java new file mode 100644 index 0000000..ed0304b --- /dev/null +++ b/e2e/src/test/java/E2ERunner.java @@ -0,0 +1,17 @@ +import com.intuit.karate.junit5.Karate; + +class E2ERunner { + + @Karate.Test + Karate testAll() { + return Karate.run("classpath:health", "classpath:documents", "classpath:analyses", "classpath:workflows") + .relativeTo(getClass()); + } + + @Karate.Test + Karate testSmoke() { + return Karate.run("classpath:health") + .tags("@smoke") + .relativeTo(getClass()); + } +} diff --git a/e2e/src/test/resources/analyses/batch-progress.feature b/e2e/src/test/resources/analyses/batch-progress.feature new file mode 100644 index 0000000..f193d26 --- /dev/null +++ b/e2e/src/test/resources/analyses/batch-progress.feature @@ -0,0 +1,48 @@ +@regression +Feature: Batched conversion with progress reporting + + # Requires BATCH_PAGE_SIZE > 0 in the server configuration. + # If BATCH_PAGE_SIZE is 0 (disabled), the batch-specific assertions + # are skipped gracefully — the test still validates completion. + + Background: + * url baseUrl + + Scenario: Large PDF completes and reports page count + * def uploaded = call read('classpath:common/helpers/upload.feature') { file: 'large.pdf' } + + # Create analysis + Given path '/api/analyses' + And request { documentId: '#(uploaded.docId)' } + When method POST + Then status 200 + * def jobId = response.id + + # 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.contentMarkdown == '#string' + And match response.pagesJson == '#string' + + # Cleanup + * call read('classpath:common/helpers/cleanup.feature') { docId: '#(uploaded.docId)' } + + Scenario: Batched conversion disables document_json + # Only meaningful when BATCH_PAGE_SIZE > 0 and pages > BATCH_PAGE_SIZE + # Check health to see if batching is possible + Given path '/api/health' + When method GET + Then status 200 + + * def uploaded = call read('classpath:common/helpers/upload.feature') { file: 'large.pdf' } + * def analysis = call read('classpath:common/helpers/analyze.feature') { docId: '#(uploaded.docId)' } + * match analysis.response.status == 'COMPLETED' + + # If batched, hasDocumentJson should be false + # If not batched (BATCH_PAGE_SIZE=0), hasDocumentJson should be true + * match analysis.response.hasDocumentJson == '#boolean' + + * call read('classpath:common/helpers/cleanup.feature') { docId: '#(uploaded.docId)' } diff --git a/e2e/src/test/resources/analyses/cancel.feature b/e2e/src/test/resources/analyses/cancel.feature new file mode 100644 index 0000000..fd331ef --- /dev/null +++ b/e2e/src/test/resources/analyses/cancel.feature @@ -0,0 +1,40 @@ +@regression +Feature: Delete and cancel analysis jobs + + Background: + * url baseUrl + + Scenario: Delete completed analysis + * def uploaded = call read('classpath:common/helpers/upload.feature') { file: 'small.pdf' } + * def analysis = call read('classpath:common/helpers/analyze.feature') { docId: '#(uploaded.docId)' } + + # Delete the analysis + Given path '/api/analyses', analysis.jobId + When method DELETE + Then status 204 + + # Verify it's gone + Given path '/api/analyses', analysis.jobId + When method GET + Then status 404 + + * call read('classpath:common/helpers/cleanup.feature') { docId: '#(uploaded.docId)' } + + Scenario: Delete non-existent analysis returns 404 + Given path '/api/analyses', 'non-existent-id' + When method DELETE + Then status 404 + + Scenario: Delete document cascades to analyses + * def uploaded = call read('classpath:common/helpers/upload.feature') { file: 'small.pdf' } + * def analysis = call read('classpath:common/helpers/analyze.feature') { docId: '#(uploaded.docId)' } + + # Delete the document (should cascade) + Given path '/api/documents', uploaded.docId + When method DELETE + Then status 204 + + # Analysis should also be gone + Given path '/api/analyses', analysis.jobId + When method GET + Then status 404 diff --git a/e2e/src/test/resources/analyses/create-and-poll.feature b/e2e/src/test/resources/analyses/create-and-poll.feature new file mode 100644 index 0000000..67af34c --- /dev/null +++ b/e2e/src/test/resources/analyses/create-and-poll.feature @@ -0,0 +1,66 @@ +@regression +Feature: Create analysis and poll until completion + + Background: + * url baseUrl + * def analysisSchema = read('classpath:common/data/schemas/analysis.json') + + Scenario: Create analysis returns PENDING then completes + # Upload document + * def uploaded = call read('classpath:common/helpers/upload.feature') { file: 'small.pdf' } + + # Create analysis + Given path '/api/analyses' + And request { documentId: '#(uploaded.docId)' } + When method POST + Then status 200 + And match response contains analysisSchema + And match response.status == 'PENDING' + And match response.documentId == uploaded.docId + * def jobId = response.id + + # Poll until terminal state + Given path '/api/analyses', jobId + And retry until response.status == 'COMPLETED' || response.status == 'FAILED' + When method GET + Then status 200 + + # Verify completed result + And match response.status == 'COMPLETED' + And match response.contentMarkdown == '#string' + And match response.contentHtml == '#string' + And match response.pagesJson == '#string' + And match response.startedAt == '#string' + And match response.completedAt == '#string' + + # Cleanup + * call read('classpath:common/helpers/cleanup.feature') { docId: '#(uploaded.docId)' } + + Scenario: Create analysis for non-existent document returns 404 + Given path '/api/analyses' + And request { documentId: 'non-existent-id' } + When method POST + Then status 404 + + Scenario: Get analysis by ID + * def uploaded = call read('classpath:common/helpers/upload.feature') { file: 'small.pdf' } + * def analysis = call read('classpath:common/helpers/analyze.feature') { docId: '#(uploaded.docId)' } + + Given path '/api/analyses', analysis.jobId + When method GET + Then status 200 + And match response contains analysisSchema + And match response.id == analysis.jobId + + * call read('classpath:common/helpers/cleanup.feature') { docId: '#(uploaded.docId)' } + + Scenario: Get non-existent analysis returns 404 + Given path '/api/analyses', 'non-existent-id' + When method GET + Then status 404 + + Scenario: List analyses returns array + Given path '/api/analyses' + When method GET + Then status 200 + And match response == '#[]' diff --git a/e2e/src/test/resources/analyses/pipeline-options.feature b/e2e/src/test/resources/analyses/pipeline-options.feature new file mode 100644 index 0000000..c5b8e15 --- /dev/null +++ b/e2e/src/test/resources/analyses/pipeline-options.feature @@ -0,0 +1,30 @@ +@regression +Feature: Analysis with different pipeline options (data-driven) + + Background: + * url baseUrl + + Scenario Outline: Analysis completes with + # Upload + * def uploaded = call read('classpath:common/helpers/upload.feature') { file: 'small.pdf' } + + # Create analysis with specific options + Given path '/api/analyses' + And request { documentId: '#(uploaded.docId)', pipelineOptions: #(options) } + When method POST + Then status 200 + * def jobId = response.id + + # Poll until terminal state + 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.contentMarkdown == '#string' + + # Cleanup + * call read('classpath:common/helpers/cleanup.feature') { docId: '#(uploaded.docId)' } + + Examples: + | read('classpath:common/data/test-cases/pipeline-options.json') | diff --git a/e2e/src/test/resources/analyses/rechunk.feature b/e2e/src/test/resources/analyses/rechunk.feature new file mode 100644 index 0000000..e44fa55 --- /dev/null +++ b/e2e/src/test/resources/analyses/rechunk.feature @@ -0,0 +1,49 @@ +@regression +Feature: Re-chunking a completed analysis + + Background: + * url baseUrl + + Scenario: Rechunk with default options returns chunks + * def uploaded = call read('classpath:common/helpers/upload.feature') { file: 'medium.pdf' } + * def analysis = call read('classpath:common/helpers/analyze.feature') { docId: '#(uploaded.docId)' } + * assert analysis.response.status == 'COMPLETED' + * assert analysis.response.hasDocumentJson == true + + Given path '/api/analyses', analysis.jobId, 'rechunk' + And request { chunkingOptions: { chunkerType: 'hybrid', maxTokens: 512 } } + When method POST + Then status 200 + And match response == '#[_ > 0]' + And match each response contains { text: '#string', tokenCount: '#number' } + + * call read('classpath:common/helpers/cleanup.feature') { docId: '#(uploaded.docId)' } + + Scenario: Rechunk with different max tokens changes results + * def uploaded = call read('classpath:common/helpers/upload.feature') { file: 'medium.pdf' } + * def analysis = call read('classpath:common/helpers/analyze.feature') { docId: '#(uploaded.docId)' } + + # Rechunk with small tokens + Given path '/api/analyses', analysis.jobId, 'rechunk' + And request { chunkingOptions: { chunkerType: 'hybrid', maxTokens: 64 } } + When method POST + Then status 200 + * def smallChunks = response + + # Rechunk with large tokens + Given path '/api/analyses', analysis.jobId, 'rechunk' + And request { chunkingOptions: { chunkerType: 'hybrid', maxTokens: 2048 } } + When method POST + Then status 200 + * def largeChunks = response + + # Smaller max tokens should produce more or equal chunks + * assert smallChunks.length >= largeChunks.length + + * call read('classpath:common/helpers/cleanup.feature') { docId: '#(uploaded.docId)' } + + Scenario: Rechunk non-existent analysis returns 400 + Given path '/api/analyses', 'non-existent-id', 'rechunk' + And request { chunkingOptions: { chunkerType: 'hybrid', maxTokens: 512 } } + When method POST + Then status 400 diff --git a/e2e/src/test/resources/common/data/generated/.gitignore b/e2e/src/test/resources/common/data/generated/.gitignore new file mode 100644 index 0000000..cd7403b --- /dev/null +++ b/e2e/src/test/resources/common/data/generated/.gitignore @@ -0,0 +1,3 @@ +# Generated test data — recreated by generate-test-data.py +* +!.gitignore diff --git a/e2e/src/test/resources/common/data/schemas/analysis.json b/e2e/src/test/resources/common/data/schemas/analysis.json new file mode 100644 index 0000000..ade4294 --- /dev/null +++ b/e2e/src/test/resources/common/data/schemas/analysis.json @@ -0,0 +1,17 @@ +{ + "id": "#string", + "documentId": "#string", + "documentFilename": "#string", + "status": "#? _ == 'PENDING' || _ == 'RUNNING' || _ == 'COMPLETED' || _ == 'FAILED'", + "contentMarkdown": "##string", + "contentHtml": "##string", + "pagesJson": "##string", + "chunksJson": "##string", + "hasDocumentJson": "#boolean", + "errorMessage": "##string", + "progressCurrent": "##number", + "progressTotal": "##number", + "startedAt": "##string", + "completedAt": "##string", + "createdAt": "#string" +} diff --git a/e2e/src/test/resources/common/data/schemas/document.json b/e2e/src/test/resources/common/data/schemas/document.json new file mode 100644 index 0000000..bb2f725 --- /dev/null +++ b/e2e/src/test/resources/common/data/schemas/document.json @@ -0,0 +1,8 @@ +{ + "id": "#string", + "filename": "#string", + "contentType": "#string", + "fileSize": "#number", + "pageCount": "##number", + "createdAt": "#string" +} diff --git a/e2e/src/test/resources/common/data/schemas/health.json b/e2e/src/test/resources/common/data/schemas/health.json new file mode 100644 index 0000000..adf2359 --- /dev/null +++ b/e2e/src/test/resources/common/data/schemas/health.json @@ -0,0 +1,7 @@ +{ + "status": "#? _ == 'ok' || _ == 'degraded'", + "version": "#string", + "engine": "#? _ == 'local' || _ == 'remote'", + "deploymentMode": "#? _ == 'self-hosted' || _ == 'huggingface'", + "database": "#? _ == 'ok' || _ == 'error'" +} diff --git a/e2e/src/test/resources/common/data/test-cases/invalid-uploads.json b/e2e/src/test/resources/common/data/test-cases/invalid-uploads.json new file mode 100644 index 0000000..d5ab6d3 --- /dev/null +++ b/e2e/src/test/resources/common/data/test-cases/invalid-uploads.json @@ -0,0 +1,9 @@ +[ + { + "description": "non-PDF file", + "file": "not-a-pdf.txt", + "contentType": "text/plain", + "expectedStatus": 400, + "expectedDetail": "PDF" + } +] diff --git a/e2e/src/test/resources/common/data/test-cases/pipeline-options.json b/e2e/src/test/resources/common/data/test-cases/pipeline-options.json new file mode 100644 index 0000000..6468719 --- /dev/null +++ b/e2e/src/test/resources/common/data/test-cases/pipeline-options.json @@ -0,0 +1,27 @@ +[ + { + "description": "default options", + "options": {} + }, + { + "description": "table mode fast", + "options": { "tableMode": "fast" } + }, + { + "description": "OCR disabled", + "options": { "doOcr": false } + }, + { + "description": "table structure disabled", + "options": { "doTableStructure": false } + }, + { + "description": "all enrichments enabled", + "options": { + "doOcr": true, + "doTableStructure": true, + "doCodeEnrichment": true, + "doFormulaEnrichment": true + } + } +] diff --git a/e2e/src/test/resources/common/helpers/analyze.feature b/e2e/src/test/resources/common/helpers/analyze.feature new file mode 100644 index 0000000..7cd4a7a --- /dev/null +++ b/e2e/src/test/resources/common/helpers/analyze.feature @@ -0,0 +1,26 @@ +@ignore +Feature: Helper — Create analysis and poll until terminal state + + # Callable feature: returns { jobId, response } + # Usage: * def result = call read('classpath:common/helpers/analyze.feature') { docId: '#(docId)' } + # Optional: { docId: '...', pipelineOptions: { tableMode: 'fast' } } + + Scenario: + * def opts = karate.get('pipelineOptions', null) + * def body = { documentId: '#(docId)' } + * if (opts != null) body.pipelineOptions = opts + + # Create analysis + Given url baseUrl + And path '/api/analyses' + And request body + When method POST + Then status 200 + * def jobId = response.id + + # Poll until COMPLETED or FAILED + Given url baseUrl + And path '/api/analyses', jobId + And retry until response.status == 'COMPLETED' || response.status == 'FAILED' + When method GET + Then status 200 diff --git a/e2e/src/test/resources/common/helpers/cleanup.feature b/e2e/src/test/resources/common/helpers/cleanup.feature new file mode 100644 index 0000000..f6f3f53 --- /dev/null +++ b/e2e/src/test/resources/common/helpers/cleanup.feature @@ -0,0 +1,12 @@ +@ignore +Feature: Helper — Delete a document and its analyses + + # Callable feature: cleans up test data + # Usage: * call read('classpath:common/helpers/cleanup.feature') { docId: '#(docId)' } + + Scenario: + Given url baseUrl + And path '/api/documents', docId + When method DELETE + # Accept both 204 (deleted) and 404 (already gone) + Then assert responseStatus == 204 || responseStatus == 404 diff --git a/e2e/src/test/resources/common/helpers/upload.feature b/e2e/src/test/resources/common/helpers/upload.feature new file mode 100644 index 0000000..63832e9 --- /dev/null +++ b/e2e/src/test/resources/common/helpers/upload.feature @@ -0,0 +1,14 @@ +@ignore +Feature: Helper — Upload a PDF document + + # Callable feature: returns { docId, response } + # Usage: * def result = call read('classpath:common/helpers/upload.feature') { file: 'small.pdf' } + + Scenario: + * def filePath = 'classpath:common/data/generated/' + file + Given url baseUrl + And path '/api/documents/upload' + And multipart file file = { read: '#(filePath)', filename: '#(file)', contentType: 'application/pdf' } + When method POST + Then status 200 + * def docId = response.id diff --git a/e2e/src/test/resources/documents/crud.feature b/e2e/src/test/resources/documents/crud.feature new file mode 100644 index 0000000..9b87e8e --- /dev/null +++ b/e2e/src/test/resources/documents/crud.feature @@ -0,0 +1,55 @@ +@regression +Feature: Document CRUD operations + + Background: + * url baseUrl + * def docSchema = read('classpath:common/data/schemas/document.json') + + Scenario: List documents includes uploaded document + # Upload + * def uploaded = call read('classpath:common/helpers/upload.feature') { file: 'small.pdf' } + + # List + Given path '/api/documents' + When method GET + Then status 200 + And match response == '#[]' + And match response[*].id contains uploaded.docId + + # Cleanup + * call read('classpath:common/helpers/cleanup.feature') { docId: '#(uploaded.docId)' } + + Scenario: Get document by ID + * def uploaded = call read('classpath:common/helpers/upload.feature') { file: 'small.pdf' } + + Given path '/api/documents', uploaded.docId + When method GET + Then status 200 + And match response contains docSchema + And match response.id == uploaded.docId + And match response.filename == 'small.pdf' + + * call read('classpath:common/helpers/cleanup.feature') { docId: '#(uploaded.docId)' } + + Scenario: Get non-existent document returns 404 + Given path '/api/documents', 'non-existent-id' + When method GET + Then status 404 + + Scenario: Delete document returns 204 then 404 on re-fetch + * def uploaded = call read('classpath:common/helpers/upload.feature') { file: 'small.pdf' } + + # Delete + Given path '/api/documents', uploaded.docId + When method DELETE + Then status 204 + + # Verify gone + Given path '/api/documents', uploaded.docId + When method GET + Then status 404 + + Scenario: Delete non-existent document returns 404 + Given path '/api/documents', 'non-existent-id' + When method DELETE + Then status 404 diff --git a/e2e/src/test/resources/documents/preview.feature b/e2e/src/test/resources/documents/preview.feature new file mode 100644 index 0000000..e978f82 --- /dev/null +++ b/e2e/src/test/resources/documents/preview.feature @@ -0,0 +1,45 @@ +@regression +Feature: Document page preview + + Background: + * url baseUrl + + Scenario: Preview page 1 returns PNG + * def uploaded = call read('classpath:common/helpers/upload.feature') { file: 'small.pdf' } + + Given path '/api/documents', uploaded.docId, 'preview' + And param page = 1 + When method GET + Then status 200 + And match header content-type contains 'image/png' + And match responseBytes == '#? _.length > 0' + + * call read('classpath:common/helpers/cleanup.feature') { docId: '#(uploaded.docId)' } + + Scenario: Preview with custom DPI + * def uploaded = call read('classpath:common/helpers/upload.feature') { file: 'small.pdf' } + + Given path '/api/documents', uploaded.docId, 'preview' + And param page = 1 + And param dpi = 72 + When method GET + Then status 200 + And match header content-type contains 'image/png' + + * call read('classpath:common/helpers/cleanup.feature') { docId: '#(uploaded.docId)' } + + Scenario: Preview page out of range returns 400 + * def uploaded = call read('classpath:common/helpers/upload.feature') { file: 'small.pdf' } + + Given path '/api/documents', uploaded.docId, 'preview' + And param page = 999 + When method GET + Then status 400 + And match response.detail contains 'out of range' + + * call read('classpath:common/helpers/cleanup.feature') { docId: '#(uploaded.docId)' } + + Scenario: Preview non-existent document returns 404 + Given path '/api/documents', 'non-existent-id', 'preview' + When method GET + Then status 404 diff --git a/e2e/src/test/resources/documents/size-validation.feature b/e2e/src/test/resources/documents/size-validation.feature new file mode 100644 index 0000000..b383633 --- /dev/null +++ b/e2e/src/test/resources/documents/size-validation.feature @@ -0,0 +1,22 @@ +@regression +Feature: Document size validation + + Background: + * url baseUrl + + Scenario: File within size limit is accepted + Given path '/api/documents/upload' + And multipart file file = { read: 'classpath:common/data/generated/small.pdf', filename: 'small.pdf', contentType: 'application/pdf' } + When method POST + Then status 200 + And match response.id == '#string' + # Cleanup + * def docId = response.id + * call read('classpath:common/helpers/cleanup.feature') { docId: '#(docId)' } + + Scenario: Health endpoint exposes max file size + Given path '/api/health' + When method GET + Then status 200 + # maxFileSizeMb is present when > 0 + And match response.maxFileSizeMb == '#? _ == null || _ > 0' diff --git a/e2e/src/test/resources/documents/upload.feature b/e2e/src/test/resources/documents/upload.feature new file mode 100644 index 0000000..7ca7637 --- /dev/null +++ b/e2e/src/test/resources/documents/upload.feature @@ -0,0 +1,41 @@ +@regression +Feature: Document upload validation + + Background: + * url baseUrl + * def docSchema = read('classpath:common/data/schemas/document.json') + + Scenario: Upload valid single-page PDF + Given path '/api/documents/upload' + And multipart file file = { read: 'classpath:common/data/generated/small.pdf', filename: 'small.pdf', contentType: 'application/pdf' } + When method POST + Then status 200 + And match response contains docSchema + And match response.filename == 'small.pdf' + And match response.pageCount == 1 + And match response.fileSize == '#? _ > 0' + # Cleanup + * def docId = response.id + * call read('classpath:common/helpers/cleanup.feature') { docId: '#(docId)' } + + Scenario: Upload valid multi-page 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 + And match response.pageCount == 5 + # Cleanup + * def docId = response.id + * call read('classpath:common/helpers/cleanup.feature') { docId: '#(docId)' } + + Scenario: Reject non-PDF file + Given path '/api/documents/upload' + And multipart file file = { read: 'classpath:common/data/generated/not-a-pdf.txt', filename: 'not-a-pdf.txt', contentType: 'text/plain' } + When method POST + Then status 400 + And match response.detail contains 'PDF' + + Scenario: Reject upload without file + Given path '/api/documents/upload' + When method POST + Then status 422 diff --git a/e2e/src/test/resources/health/health.feature b/e2e/src/test/resources/health/health.feature new file mode 100644 index 0000000..bf9705d --- /dev/null +++ b/e2e/src/test/resources/health/health.feature @@ -0,0 +1,22 @@ +@smoke +Feature: Health endpoint + + Background: + * url baseUrl + * def healthSchema = read('classpath:common/data/schemas/health.json') + + Scenario: Health returns OK with expected fields + Given path '/api/health' + When method GET + Then status 200 + And match response contains healthSchema + And match response.status == 'ok' + And match response.database == 'ok' + + Scenario: Health exposes configuration limits when set + Given path '/api/health' + When method GET + Then status 200 + # maxFileSizeMb and maxPageCount are conditional (only if > 0) + And match response.engine == '#? _ == "local" || _ == "remote"' + And match response.version == '#string' diff --git a/e2e/src/test/resources/karate-config.js b/e2e/src/test/resources/karate-config.js new file mode 100644 index 0000000..fa6ad56 --- /dev/null +++ b/e2e/src/test/resources/karate-config.js @@ -0,0 +1,12 @@ +function fn() { + var config = { + baseUrl: karate.properties['baseUrl'] || 'http://localhost:8000', + pollInterval: 2000, + pollTimeout: 120000, + batchPollTimeout: 300000 + }; + karate.configure('connectTimeout', 10000); + karate.configure('readTimeout', 30000); + karate.configure('retry', { count: 60, interval: config.pollInterval }); + return config; +} diff --git a/e2e/src/test/resources/workflows/concurrent-analyses.feature b/e2e/src/test/resources/workflows/concurrent-analyses.feature new file mode 100644 index 0000000..8c8882d --- /dev/null +++ b/e2e/src/test/resources/workflows/concurrent-analyses.feature @@ -0,0 +1,59 @@ +@e2e +Feature: Concurrent analysis execution + + Background: + * url baseUrl + + Scenario: Multiple analyses complete successfully in parallel + # Upload 3 documents + * def doc1 = call read('classpath:common/helpers/upload.feature') { file: 'small.pdf' } + * def doc2 = call read('classpath:common/helpers/upload.feature') { file: 'small.pdf' } + * def doc3 = call read('classpath:common/helpers/upload.feature') { file: 'small.pdf' } + + # Create 3 analyses + Given path '/api/analyses' + And request { documentId: '#(doc1.docId)' } + When method POST + Then status 200 + * def job1 = response.id + + Given path '/api/analyses' + And request { documentId: '#(doc2.docId)' } + When method POST + Then status 200 + * def job2 = response.id + + Given path '/api/analyses' + And request { documentId: '#(doc3.docId)' } + When method POST + Then status 200 + * def job3 = response.id + + # Poll all three until terminal state + Given path '/api/analyses', job1 + And retry until response.status == 'COMPLETED' || response.status == 'FAILED' + When method GET + Then status 200 + * def status1 = response.status + + Given path '/api/analyses', job2 + And retry until response.status == 'COMPLETED' || response.status == 'FAILED' + When method GET + Then status 200 + * def status2 = response.status + + Given path '/api/analyses', job3 + And retry until response.status == 'COMPLETED' || response.status == 'FAILED' + When method GET + Then status 200 + * def status3 = response.status + + # All three should have completed + * match status1 == 'COMPLETED' + * match status2 == 'COMPLETED' + * match status3 == 'COMPLETED' + + # Cleanup + * call read('classpath:common/helpers/cleanup.feature') { docId: '#(doc1.docId)' } + * call read('classpath:common/helpers/cleanup.feature') { docId: '#(doc2.docId)' } + * call read('classpath:common/helpers/cleanup.feature') { docId: '#(doc3.docId)' } diff --git a/e2e/src/test/resources/workflows/full-happy-path.feature b/e2e/src/test/resources/workflows/full-happy-path.feature new file mode 100644 index 0000000..7b524e7 --- /dev/null +++ b/e2e/src/test/resources/workflows/full-happy-path.feature @@ -0,0 +1,79 @@ +@e2e +Feature: Full happy path — upload to cleanup + + Background: + * url baseUrl + + Scenario: Complete workflow — upload, analyze, verify, rechunk, cleanup + # Step 1: 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 + And match response.pageCount == 5 + * def docId = response.id + + # Step 2: Verify document appears in list + Given path '/api/documents' + When method GET + Then status 200 + And match response[*].id contains docId + + # Step 3: Get preview + Given path '/api/documents', docId, 'preview' + And param page = 1 + When method GET + Then status 200 + And match header content-type contains 'image/png' + + # Step 4: Create analysis + Given path '/api/analyses' + And request { documentId: '#(docId)', pipelineOptions: { doOcr: true, tableMode: 'fast' } } + When method POST + Then status 200 + And match response.status == 'PENDING' + * def jobId = response.id + + # Step 5: 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.contentMarkdown == '#string' + And match response.contentHtml == '#string' + And match response.pagesJson == '#string' + And match response.hasDocumentJson == true + + # Step 6: Re-chunk the completed analysis + Given path '/api/analyses', jobId, 'rechunk' + And request { chunkingOptions: { chunkerType: 'hybrid', maxTokens: 256, mergePeers: true } } + When method POST + Then status 200 + And match response == '#[_ > 0]' + And match each response contains { text: '#string', tokenCount: '#number' } + + # Step 7: Verify analysis appears in list + Given path '/api/analyses' + When method GET + Then status 200 + And match response[*].id contains jobId + + # Step 8: Delete analysis + Given path '/api/analyses', jobId + When method DELETE + Then status 204 + + # Step 9: Delete document + Given path '/api/documents', docId + When method DELETE + Then status 204 + + # Step 10: Verify both are gone + Given path '/api/documents', docId + When method GET + Then status 404 + + Given path '/api/analyses', jobId + When method GET + Then status 404