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
This commit is contained in:
Pier-Jean Malandrino 2026-04-08 13:47:03 +02:00
parent ce639d934b
commit cfc5bb5c35
31 changed files with 994 additions and 3 deletions

View file

@ -18,3 +18,6 @@ document-parser/.mypy_cache/
document-parser/.ruff_cache/ document-parser/.ruff_cache/
document-parser/data/ document-parser/data/
document-parser/uploads/ document-parser/uploads/
# E2E tests (JVM/Maven — not needed in Docker images)
e2e/

View file

@ -2,9 +2,9 @@ name: CI
on: on:
push: push:
branches: [main] branches: [main, 'release/**']
pull_request: pull_request:
branches: [main] branches: [main, 'release/**']
# Cancel previous runs on the same branch/PR # Cancel previous runs on the same branch/PR
concurrency: concurrency:
@ -73,3 +73,65 @@ jobs:
- name: Build - name: Build
run: npm run 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

View file

@ -21,6 +21,7 @@ class Settings:
max_page_count: int = 0 # 0 = unlimited (upload validation) max_page_count: int = 0 # 0 = unlimited (upload validation)
max_file_size: int = 0 # 0 = unlimited (Docling-level, bytes) max_file_size: int = 0 # 0 = unlimited (Docling-level, bytes)
max_file_size_mb: int = 50 # upload limit in MB (0 = unlimited) 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 batch_page_size: int = 0 # 0 = disabled, > 0 = pages per batch
upload_dir: str = "./uploads" upload_dir: str = "./uploads"
db_path: str = "./data/docling_studio.db" 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})") errors.append(f"max_file_size must be >= 0 (got {self.max_file_size})")
if self.max_file_size_mb < 0: if self.max_file_size_mb < 0:
errors.append(f"max_file_size_mb must be >= 0 (got {self.max_file_size_mb})") 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: if self.batch_page_size < 0:
errors.append(f"batch_page_size must be >= 0 (got {self.batch_page_size})") errors.append(f"batch_page_size must be >= 0 (got {self.batch_page_size})")
if self.default_table_mode not in ("accurate", "fast"): 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_page_count=int(os.environ.get("MAX_PAGE_COUNT", "0")),
max_file_size=int(os.environ.get("MAX_FILE_SIZE", "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")), 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")), batch_page_size=int(os.environ.get("BATCH_PAGE_SIZE", "0")),
upload_dir=os.environ.get("UPLOAD_DIR", "./uploads"), upload_dir=os.environ.get("UPLOAD_DIR", "./uploads"),
db_path=os.environ.get("DB_PATH", "./data/docling_studio.db"), db_path=os.environ.get("DB_PATH", "./data/docling_studio.db"),

View file

@ -95,7 +95,12 @@ app.add_middleware(
allow_methods=["GET", "POST", "DELETE", "OPTIONS"], allow_methods=["GET", "POST", "DELETE", "OPTIONS"],
allow_headers=["Content-Type", "Authorization"], 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(documents_router)
app.include_router(analyses_router) app.include_router(analyses_router)

2
e2e/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
e2e/target/
e2e/target/

71
e2e/README.md Normal file
View file

@ -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/`.

78
e2e/generate-test-data.py Normal file
View file

@ -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()

58
e2e/pom.xml Normal file
View file

@ -0,0 +1,58 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.scub.docling-studio</groupId>
<artifactId>e2e-tests</artifactId>
<version>0.3.1</version>
<packaging>jar</packaging>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<karate.version>1.5.0</karate.version>
</properties>
<dependencies>
<dependency>
<groupId>io.karatelabs</groupId>
<artifactId>karate-core</artifactId>
<version>${karate.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.karatelabs</groupId>
<artifactId>karate-junit5</artifactId>
<version>${karate.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<testResources>
<testResource>
<directory>src/test/resources</directory>
</testResource>
</testResources>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
<configuration>
<argLine>-Dfile.encoding=UTF-8</argLine>
<includes>
<include>**/*Runner.java</include>
</includes>
<systemPropertyVariables>
<karate.options>${karate.options}</karate.options>
</systemPropertyVariables>
</configuration>
</plugin>
</plugins>
</build>
</project>

View file

@ -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());
}
}

View file

@ -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)' }

View file

@ -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

View file

@ -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 == '#[]'

View file

@ -0,0 +1,30 @@
@regression
Feature: Analysis with different pipeline options (data-driven)
Background:
* url baseUrl
Scenario Outline: Analysis completes with <description>
# 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') |

View file

@ -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

View file

@ -0,0 +1,3 @@
# Generated test data — recreated by generate-test-data.py
*
!.gitignore

View file

@ -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"
}

View file

@ -0,0 +1,8 @@
{
"id": "#string",
"filename": "#string",
"contentType": "#string",
"fileSize": "#number",
"pageCount": "##number",
"createdAt": "#string"
}

View file

@ -0,0 +1,7 @@
{
"status": "#? _ == 'ok' || _ == 'degraded'",
"version": "#string",
"engine": "#? _ == 'local' || _ == 'remote'",
"deploymentMode": "#? _ == 'self-hosted' || _ == 'huggingface'",
"database": "#? _ == 'ok' || _ == 'error'"
}

View file

@ -0,0 +1,9 @@
[
{
"description": "non-PDF file",
"file": "not-a-pdf.txt",
"contentType": "text/plain",
"expectedStatus": 400,
"expectedDetail": "PDF"
}
]

View file

@ -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
}
}
]

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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'

View file

@ -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

View file

@ -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'

View file

@ -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;
}

View file

@ -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)' }

View file

@ -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