test(#74): E2E Karate test — PDF → chunks indexed in OpenSearch
Full ingestion workflow: upload → analyze → rechunk → POST /api/ingestion → assert chunksIndexed > 0 and embeddingDimension > 0. Idempotency verified with a second ingest. Failure scenario for non-COMPLETED job. @ingestion tag for selective CI execution.
This commit is contained in:
parent
7616dbc28d
commit
d341851818
2 changed files with 133 additions and 1 deletions
|
|
@ -4,7 +4,14 @@ class E2ERunner {
|
|||
|
||||
@Karate.Test
|
||||
Karate testAll() {
|
||||
return Karate.run("classpath:health", "classpath:documents", "classpath:analyses", "classpath:workflows")
|
||||
return Karate.run("classpath:health", "classpath:documents", "classpath:analyses", "classpath:workflows", "classpath:ingestion")
|
||||
.relativeTo(getClass());
|
||||
}
|
||||
|
||||
@Karate.Test
|
||||
Karate testIngestion() {
|
||||
return Karate.run("classpath:ingestion")
|
||||
.tags("@ingestion")
|
||||
.relativeTo(getClass());
|
||||
}
|
||||
|
||||
|
|
|
|||
125
e2e/api/src/test/resources/ingestion/ingestion-pipeline.feature
Normal file
125
e2e/api/src/test/resources/ingestion/ingestion-pipeline.feature
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
@e2e @ingestion
|
||||
Feature: Ingestion pipeline — PDF → chunks indexed in OpenSearch
|
||||
|
||||
Tests the complete ingestion workflow:
|
||||
upload PDF → analyze → chunk → ingest → verify in OpenSearch.
|
||||
|
||||
Requires the full dev stack (OpenSearch + embedding service running).
|
||||
Skip by excluding the @ingestion tag when OpenSearch is not available.
|
||||
|
||||
Background:
|
||||
* url baseUrl
|
||||
|
||||
Scenario: Full ingestion workflow — PDF becomes searchable chunks in OpenSearch
|
||||
|
||||
# ---- Step 1: Check ingestion pipeline is available ----
|
||||
Given path '/api/ingestion/status'
|
||||
When method GET
|
||||
Then status 200
|
||||
And match response.available == true
|
||||
|
||||
# ---- Step 2: Upload a PDF ----
|
||||
Given path '/api/documents/upload'
|
||||
And multipart file file = { read: 'classpath:common/data/generated/medium.pdf', filename: 'ingest-test.pdf', contentType: 'application/pdf' }
|
||||
When method POST
|
||||
Then status 200
|
||||
And match response.id == '#string'
|
||||
And match response.filename == 'ingest-test.pdf'
|
||||
* def docId = response.id
|
||||
|
||||
# ---- Step 3: Run analysis (with document JSON for chunking) ----
|
||||
Given path '/api/analyses'
|
||||
And request { documentId: '#(docId)', pipelineOptions: { doOcr: false, tableMode: 'fast' } }
|
||||
When method POST
|
||||
Then status 200
|
||||
And match response.status == 'PENDING'
|
||||
* def jobId = response.id
|
||||
|
||||
# ---- Step 4: Poll until analysis is 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.hasDocumentJson == true
|
||||
|
||||
# ---- Step 5: Run chunking to produce chunk list ----
|
||||
Given path '/api/analyses', jobId, 'rechunk'
|
||||
And request { chunkingOptions: { chunkerType: 'hybrid', maxTokens: 512, mergePeers: true } }
|
||||
When method POST
|
||||
Then status 200
|
||||
And match response == '#[_ > 0]'
|
||||
* def chunkCount = response.length
|
||||
|
||||
# ---- Step 6: Trigger ingestion ----
|
||||
Given path '/api/ingestion', jobId
|
||||
When method POST
|
||||
Then status 200
|
||||
And match response.docId == '#string'
|
||||
And match response.chunksIndexed == '#number'
|
||||
And match response.embeddingDimension == '#number'
|
||||
And match response.chunksIndexed > 0
|
||||
And match response.embeddingDimension > 0
|
||||
* def indexedCount = response.chunksIndexed
|
||||
* def embDim = response.embeddingDimension
|
||||
|
||||
# Verify at least as many chunks as returned by rechunk
|
||||
* assert indexedCount >= 1
|
||||
|
||||
# Embedding dimension must be a valid vector size (e.g. 384 for all-MiniLM-L6-v2)
|
||||
* assert embDim >= 128
|
||||
|
||||
# ---- Step 7: Re-ingest is idempotent ----
|
||||
Given path '/api/ingestion', jobId
|
||||
When method POST
|
||||
Then status 200
|
||||
And match response.chunksIndexed == '#number'
|
||||
And match response.chunksIndexed > 0
|
||||
|
||||
# ---- Step 8: Delete indexed chunks ----
|
||||
Given path '/api/ingestion', docId
|
||||
When method DELETE
|
||||
Then status 204
|
||||
|
||||
# ---- Step 9: Cleanup — delete analysis and document ----
|
||||
Given path '/api/analyses', jobId
|
||||
When method DELETE
|
||||
Then status 204
|
||||
|
||||
Given path '/api/documents', docId
|
||||
When method DELETE
|
||||
Then status 204
|
||||
|
||||
Scenario: Ingestion fails gracefully when job is not COMPLETED
|
||||
|
||||
# Upload a document
|
||||
Given path '/api/documents/upload'
|
||||
And multipart file file = { read: 'classpath:common/data/generated/medium.pdf', filename: 'ingest-fail-test.pdf', contentType: 'application/pdf' }
|
||||
When method POST
|
||||
Then status 200
|
||||
* def docId = response.id
|
||||
|
||||
# Create analysis but do NOT wait for completion
|
||||
Given path '/api/analyses'
|
||||
And request { documentId: '#(docId)' }
|
||||
When method POST
|
||||
Then status 200
|
||||
* def jobId = response.id
|
||||
|
||||
# Immediately attempt ingestion — should fail with 422 (job not COMPLETED or no chunks)
|
||||
Given path '/api/ingestion', jobId
|
||||
When method POST
|
||||
* def sc = responseStatus
|
||||
Then assert sc == 422 || sc == 503
|
||||
|
||||
# Cleanup
|
||||
Given path '/api/documents', docId
|
||||
When method DELETE
|
||||
* def ignored = responseStatus
|
||||
|
||||
Scenario: Ingestion status — available when OpenSearch is configured
|
||||
|
||||
Given path '/api/ingestion/status'
|
||||
When method GET
|
||||
Then status 200
|
||||
And match response == { available: '#boolean', reason: '#string' }
|
||||
Loading…
Reference in a new issue