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:
parent
ce639d934b
commit
cfc5bb5c35
31 changed files with 994 additions and 3 deletions
|
|
@ -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/
|
||||||
|
|
|
||||||
66
.github/workflows/ci.yml
vendored
66
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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
2
e2e/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
e2e/target/
|
||||||
|
e2e/target/
|
||||||
71
e2e/README.md
Normal file
71
e2e/README.md
Normal 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
78
e2e/generate-test-data.py
Normal 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
58
e2e/pom.xml
Normal 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>
|
||||||
17
e2e/src/test/java/E2ERunner.java
Normal file
17
e2e/src/test/java/E2ERunner.java
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
48
e2e/src/test/resources/analyses/batch-progress.feature
Normal file
48
e2e/src/test/resources/analyses/batch-progress.feature
Normal 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)' }
|
||||||
40
e2e/src/test/resources/analyses/cancel.feature
Normal file
40
e2e/src/test/resources/analyses/cancel.feature
Normal 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
|
||||||
66
e2e/src/test/resources/analyses/create-and-poll.feature
Normal file
66
e2e/src/test/resources/analyses/create-and-poll.feature
Normal 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 == '#[]'
|
||||||
30
e2e/src/test/resources/analyses/pipeline-options.feature
Normal file
30
e2e/src/test/resources/analyses/pipeline-options.feature
Normal 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') |
|
||||||
49
e2e/src/test/resources/analyses/rechunk.feature
Normal file
49
e2e/src/test/resources/analyses/rechunk.feature
Normal 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
|
||||||
3
e2e/src/test/resources/common/data/generated/.gitignore
vendored
Normal file
3
e2e/src/test/resources/common/data/generated/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Generated test data — recreated by generate-test-data.py
|
||||||
|
*
|
||||||
|
!.gitignore
|
||||||
17
e2e/src/test/resources/common/data/schemas/analysis.json
Normal file
17
e2e/src/test/resources/common/data/schemas/analysis.json
Normal 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"
|
||||||
|
}
|
||||||
8
e2e/src/test/resources/common/data/schemas/document.json
Normal file
8
e2e/src/test/resources/common/data/schemas/document.json
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"id": "#string",
|
||||||
|
"filename": "#string",
|
||||||
|
"contentType": "#string",
|
||||||
|
"fileSize": "#number",
|
||||||
|
"pageCount": "##number",
|
||||||
|
"createdAt": "#string"
|
||||||
|
}
|
||||||
7
e2e/src/test/resources/common/data/schemas/health.json
Normal file
7
e2e/src/test/resources/common/data/schemas/health.json
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"status": "#? _ == 'ok' || _ == 'degraded'",
|
||||||
|
"version": "#string",
|
||||||
|
"engine": "#? _ == 'local' || _ == 'remote'",
|
||||||
|
"deploymentMode": "#? _ == 'self-hosted' || _ == 'huggingface'",
|
||||||
|
"database": "#? _ == 'ok' || _ == 'error'"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"description": "non-PDF file",
|
||||||
|
"file": "not-a-pdf.txt",
|
||||||
|
"contentType": "text/plain",
|
||||||
|
"expectedStatus": 400,
|
||||||
|
"expectedDetail": "PDF"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
26
e2e/src/test/resources/common/helpers/analyze.feature
Normal file
26
e2e/src/test/resources/common/helpers/analyze.feature
Normal 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
|
||||||
12
e2e/src/test/resources/common/helpers/cleanup.feature
Normal file
12
e2e/src/test/resources/common/helpers/cleanup.feature
Normal 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
|
||||||
14
e2e/src/test/resources/common/helpers/upload.feature
Normal file
14
e2e/src/test/resources/common/helpers/upload.feature
Normal 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
|
||||||
55
e2e/src/test/resources/documents/crud.feature
Normal file
55
e2e/src/test/resources/documents/crud.feature
Normal 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
|
||||||
45
e2e/src/test/resources/documents/preview.feature
Normal file
45
e2e/src/test/resources/documents/preview.feature
Normal 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
|
||||||
22
e2e/src/test/resources/documents/size-validation.feature
Normal file
22
e2e/src/test/resources/documents/size-validation.feature
Normal 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'
|
||||||
41
e2e/src/test/resources/documents/upload.feature
Normal file
41
e2e/src/test/resources/documents/upload.feature
Normal 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
|
||||||
22
e2e/src/test/resources/health/health.feature
Normal file
22
e2e/src/test/resources/health/health.feature
Normal 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'
|
||||||
12
e2e/src/test/resources/karate-config.js
Normal file
12
e2e/src/test/resources/karate-config.js
Normal 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;
|
||||||
|
}
|
||||||
59
e2e/src/test/resources/workflows/concurrent-analyses.feature
Normal file
59
e2e/src/test/resources/workflows/concurrent-analyses.feature
Normal 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)' }
|
||||||
79
e2e/src/test/resources/workflows/full-happy-path.feature
Normal file
79
e2e/src/test/resources/workflows/full-happy-path.feature
Normal 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
|
||||||
Loading…
Reference in a new issue