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