diff --git a/cds-feature-sap-document-ai/README.md b/cds-feature-sap-document-ai/README.md index 01b9a64..9ddaee4 100644 --- a/cds-feature-sap-document-ai/README.md +++ b/cds-feature-sap-document-ai/README.md @@ -187,13 +187,39 @@ Submit a document via your application. The plugin logs progress at `INFO` level --- -## Usage +## Multi-Tenancy -> **Note:** In the current version, document extraction can only be triggered programmatically via event emission, as shown in the [Integration Guide](#integration-guide). Annotation-based triggering (e.g. declaratively marking an entity field or action to trigger extraction) is not yet supported and is planned for a future release. +The plugin supports multi-tenancy for SAP BTP SaaS applications. When multi-tenancy is detected at startup, each subscriber tenant's documents are fully isolated within the shared Document AI service instance. -## Multi-Tenancy +### How It Works + +Multi-tenancy is detected automatically at startup — no configuration flag is needed. The plugin checks for either: +- A CAP MTX sidecar URL (`cds.multitenancy.sidecar.url`) +- The presence of a `DeploymentService` in the CDS service catalog + +When detected: +- **Data isolation** — every Document AI API call includes a `clientId` query parameter set to the tenant ID (`?clientId=` on submit, `&clientId=` on poll). The Document AI service uses this to isolate each tenant's documents, schemas, and jobs within the shared service instance. Authentication uses the provider's service binding credentials. +- **Per-tenant polling** — the polling handler groups active jobs by tenant and switches the CDS request context per group, ensuring database operations are correctly scoped to each tenant's schema. +- **Tenant lifecycle** — on tenant unsubscribe, all active jobs (`PENDING`, `SUBMITTED`, `RUNNING`) belonging to that tenant are automatically marked `FAILED` so they do not generate poll errors after the tenant is removed. + +### MTX Sidecar Configuration + +If your app uses a CAP MTX sidecar, configure its URL in `application.yaml`: + +```yaml +cds: + multitenancy: + sidecar: + url: <> +``` -Multi-tenancy is not implemented in the current version and is planned for a future release. The `tenantId` field is stored on the `ExtractionJob` entity as groundwork. +If you use an embedded MTX setup (no sidecar URL), multi-tenancy is detected automatically via the presence of `DeploymentService` in the CDS service catalog — no additional configuration is needed. + +--- + +## Usage + +> **Note:** In the current version, document extraction can only be triggered programmatically via event emission, as shown in the [Integration Guide](#integration-guide). Annotation-based triggering (e.g. declaratively marking an entity field or action to trigger extraction) is not yet supported and is planned for a future release. ### CDS Model @@ -389,7 +415,6 @@ The plugin communicates with the SAP Document Information Extraction service via ## Known Limitations -- **Multi-tenancy** — not implemented; all jobs run in a single-tenant context. Planned for a future release. - **Annotation-based triggering** — document extraction can only be initiated programmatically via event emission; declarative triggering is not yet supported. --- diff --git a/cds-feature-sap-document-ai/docs/architecture.md b/cds-feature-sap-document-ai/docs/architecture.md index 146f830..a948d96 100644 --- a/cds-feature-sap-document-ai/docs/architecture.md +++ b/cds-feature-sap-document-ai/docs/architecture.md @@ -86,12 +86,14 @@ The `ExtractionJob` entity uses `cuid` (auto-generated UUID primary key) and `ma At startup it: +- Detects multi-tenancy by checking for a CAP MTX sidecar URL (`cds.multitenancy.sidecar.url`) or the presence of `DeploymentService` in the service catalog. - Registers `ExtractionServiceImpl` as a named CDS service in the service catalog. - Resolves the Document AI service binding from the environment by the label `sap-document-information-extraction`. - Constructs an OAuth2-authenticated HTTP destination via the SAP Cloud SDK if a binding is found. - Wires all resolved dependencies into `ExtractionServiceImpl`. - Registers `DocumentSubmissionHandler` unconditionally. - Registers `ExtractionPollingHandler` only when a valid Document AI client was successfully built. +- Registers `DocumentAiSetupHandler` only when multi-tenancy is enabled. If no binding is found or the destination cannot be initialised, the plugin starts in degraded mode - events are accepted and jobs are queued as `PENDING`, but no extraction processing occurs. @@ -99,8 +101,9 @@ If no binding is found or the destination cannot be initialised, the plugin star | Handler | Description | | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `DocumentSubmissionHandler` | Listens for `DocumentExtraction` events on any `ApplicationService`. Service-name-agnostic by design - consumers emit events from their own service without coupling to the plugin's internal service name. Delegates to `ExtractionService` and completes the event context. | -| `ExtractionPollingHandler` | Registered against the persistent unordered outbox. Polls the Document AI service for all active jobs on each invocation. Self-reschedules after the configured interval if jobs remain active. Stops automatically when all jobs reach a terminal status. | +| `DocumentSubmissionHandler` | Listens for `DocumentExtraction` events on any `ApplicationService`. Service-name-agnostic by design - consumers emit events from their own service without coupling to the plugin's internal service name. Delegates to `ExtractionService` and completes the event context. Emits a `DocumentExtractionResult` event immediately if submission fails. | +| `ExtractionPollingHandler` | Registered against the persistent unordered outbox. In multi-tenant mode, groups active jobs by tenant and switches the CDS request context per group so each job uses the correct subscriber OAuth token. Self-reschedules after the configured interval if jobs remain active. Stops automatically when all jobs reach a terminal status. | +| `DocumentAiSetupHandler` | Registered only when multi-tenancy is enabled. Hooks into the CAP MTX `DeploymentService` lifecycle. On subscribe: logs tenant onboarding. On unsubscribe: marks all active jobs for that tenant as `FAILED` to prevent poll errors after credentials are revoked. | ### Services @@ -204,11 +207,12 @@ Unit tests are located in `sap-document-ai/src/test/java`. Each production class | Test Class | What is tested | | ------------------------------------ | --------------------------------------------------------------------------------------------------------- | -| `DocumentSubmissionHandlerTest` | Event handler delegation, PENDING and FAILED logging | +| `DocumentSubmissionHandlerTest` | Event handler delegation, PENDING and FAILED logging, result event emission on submission failure | | `ExtractionServiceImplTest` | Job creation, submission flow, concurrent update handling, failure marking, outbox scheduling | -| `ExtractionPollingHandlerTest` | Poll cycle logic, Document AI status mapping, result emission, self-rescheduling, per-job error isolation | -| `DefaultDocumentAiClientTest` | HTTP submit and poll calls, response parsing, error wrapping for all three exception types | -| `DocumentAiServiceConfigurationTest` | Startup wiring, binding resolution, conditional handler registration | +| `ExtractionPollingHandlerTest` | Poll cycle logic, Document AI status mapping, result emission, self-rescheduling, per-job error isolation, per-tenant context switching in multi-tenant mode | +| `DocumentAiSetupHandlerTest` | Subscribe logging, unsubscribe job marking, zero-job unsubscribe | +| `DefaultDocumentAiClientTest` | HTTP submit and poll calls, `clientId` query param appended per tenant, response parsing, error wrapping | +| `DocumentAiServiceConfigurationTest` | Startup wiring, binding resolution, multi-tenancy detection, conditional handler registration | | `StatusTransitionValidatorTest` | All valid and invalid transitions | | `ExceptionsTest` | Exception message and cause propagation | diff --git a/cds-feature-sap-document-ai/docs/handover.md b/cds-feature-sap-document-ai/docs/handover.md index 91d377b..6577e22 100644 --- a/cds-feature-sap-document-ai/docs/handover.md +++ b/cds-feature-sap-document-ai/docs/handover.md @@ -29,12 +29,13 @@ The plugin is a CAP Java plugin that lets any CAP Spring Boot application send d ### Current Maturity: Alpha / MVP | Implemented | Not yet implemented | -| ------------------------------------------------------------------- | --------------------------- | -| Core asynchronous extraction workflow | Multitenancy | -| Event-based API (`DocumentExtraction` / `DocumentExtractionResult`) | Annotation-based triggering | -| Persistent outbox polling with configurable interval | Automatic field mapping | -| Unit and integration tests (85% coverage enforced) | Job recovery on restart | -| Working reference app (`bookshop`) | Richer local mock mode | +|---------------------------------------------------------------------| --------------------------- | +| Core asynchronous extraction workflow | Annotation-based triggering | +| Multi-tenancy | Automatic field mapping | +| Event-based API (`DocumentExtraction` / `DocumentExtractionResult`) | Job recovery on restart | +| Persistent outbox polling with configurable interval | Richer local mock mode | +| Unit and integration tests (85% coverage enforced) | | +| Working reference app (`bookshop`) | | --- @@ -44,25 +45,24 @@ This is an alpha release. The core extraction pipeline works end-to-end, and the | # | Area | Where things stand | | --- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| S1 | **Multitenancy** | `tenantId` is stored on `ExtractionJob` as groundwork, but polling and the HTTP client are not yet tenant-aware. Single-tenant use only for now. | -| S2 | **Triggering** | Programmatic triggering (emit `DocumentExtraction` from code) works fully. Declarative triggering via a CDS annotation + Fiori Elements button is not yet implemented. | -| S3 | **Document storage** | The plugin is focused on extraction only. Applications are responsible for storing documents using their preferred mechanism.
**Design Decision**: The plugin accepts document bytes directly on the `DocumentExtraction` event and has no knowledge of where those bytes came from. The plugin's job is extraction, not storage. A hard dependency on `@cap-java/cds-feature-attachments` would couple two independent plugins and create version compatibility overhead. Keeping the plugin storage-agnostic means it works with any document source - the Attachments plugin, a custom entity, an external store, or a direct upload. Each consuming application can choose its own storage strategy and feed documents into the plugin through the same event API regardless. | -| S4 | **Custom schema sync** | Standard document types work out of the box. CDS-annotation-driven sync of custom extraction schemas to Document AI is not yet implemented. | -| S5 | **Field mapping** | Results are delivered as raw JSON for maximum flexibility. Automatic mapping to CDS entity properties and Fiori form pre-fill is not yet implemented. | -| S6 | **Job recovery** | Graceful degradation works (no binding → jobs stay `PENDING`). Automatic recovery of stuck or in-flight jobs on startup is not yet implemented. | -| S7 | **Local development** | Degraded mode works without a binding. A richer mock that returns configurable static results is not yet implemented. | -| S8 | **Malware scanning** | Not yet assessed or implemented. | -| S9 | **CLI scaffolding** | Setup is manual and documented in the README. A `cds add document-ai` command is not yet implemented. | -| S10 | **OData API** | REST API works across all plans. OData support for higher-tier plans is a future enhancement. | -| S11 | **Job cleanup** | Job records are kept for observability. A configurable retention policy is not yet implemented. | +| S1 | **Triggering** | Programmatic triggering (emit `DocumentExtraction` from code) works fully. Declarative triggering via a CDS annotation + Fiori Elements button is not yet implemented. | +| S2 | **Document storage** | The plugin is focused on extraction only. Applications are responsible for storing documents using their preferred mechanism.
**Design Decision**: The plugin accepts document bytes directly on the `DocumentExtraction` event and has no knowledge of where those bytes came from. The plugin's job is extraction, not storage. A hard dependency on `@cap-java/cds-feature-attachments` would couple two independent plugins and create version compatibility overhead. Keeping the plugin storage-agnostic means it works with any document source - the Attachments plugin, a custom entity, an external store, or a direct upload. Each consuming application can choose its own storage strategy and feed documents into the plugin through the same event API regardless. | +| S3 | **Custom schema sync** | Standard document types work out of the box. CDS-annotation-driven sync of custom extraction schemas to Document AI is not yet implemented. | +| S4 | **Field mapping** | Results are delivered as raw JSON for maximum flexibility. Automatic mapping to CDS entity properties and Fiori form pre-fill is not yet implemented. | +| S5 | **Job recovery** | Graceful degradation works (no binding → jobs stay `PENDING`). Automatic recovery of stuck or in-flight jobs on startup is not yet implemented. | +| S6 | **Local development** | Degraded mode works without a binding. A richer mock that returns configurable static results is not yet implemented. | +| S7 | **Malware scanning** | Not yet assessed or implemented. | +| S8 | **CLI scaffolding** | Setup is manual and documented in the README. A `cds add document-ai` command is not yet implemented. | +| S9 | **OData API** | REST API works across all plans. OData support for higher-tier plans is a future enhancement. | +| S10 | **Job cleanup** | Job records are kept for observability. A configurable retention policy is not yet implemented. | --- ## Implementation Notes -### Poll cycle queries all tenants +### Poll cycle is tenant-aware -The poll query fetches all active jobs across all tenants. In a multitenant deployment, jobs from different tenants get polled under the same credentials - which is incorrect. This is something to keep in mind if multitenancy becomes a priority. +In multi-tenant mode, the poll handler groups active jobs by `tenantId` and switches the CDS request context per group via `runtime.requestContext().systemUser(tenantId).run(...)`. This ensures each job is polled with the correct subscriber OAuth token. In single-tenant mode the grouping is skipped. ### Service binding is resolved once at startup @@ -78,37 +78,31 @@ If a binding is added after the app starts, it won't be picked up until a restar These are ideas and suggestions and not a fixed plan. The ordering reflects what felt most important at the time of writing, but the incoming team should feel free to reprioritise based on their own context and stakeholder needs. -### 1. Multitenancy _(S1)_ - Priority - -A natural first area to tackle would be making each tenant use its own Document AI credentials with isolated jobs. The polling logic and HTTP client would need to become tenant-aware - the `tenantId` field is already on `ExtractionJob`, so no schema migration is needed. - -**Tracking issue:** [#98](https://github.com/cap-java/cds-ai/issues/98) - -### 2. Annotation-Based Triggering _(S2)_ - Priority +### 1. Annotation-Based Triggering _(S1)_ - Priority One possible enhancement is to allow developers to annotate a CDS entity field with `@DocumentAI` to automatically trigger extraction - removing the need for boilerplate event emission. This could cover both the backend (plugin reacts to annotated field writes) and the Fiori Elements UI (an "Upload & Extract" button injected automatically on the Object Page). **Tracking issue:** [#97](https://github.com/cap-java/cds-ai/issues/97) -### 3. Job Recovery on Startup _(S6)_ +### 2. Job Recovery on Startup _(S5)_ A useful addition could be a startup check for any jobs left in `PENDING`, `SUBMITTED`, or `RUNNING` status from before a restart, resuming polling for them automatically rather than waiting for a new submission to arrive. **Tracking issue:** [#100](https://github.com/cap-java/cds-ai/issues/100) -### 4. Extraction Progress Indicator +### 3. Extraction Progress Indicator The backend already tracks `SUBMITTED` and `RUNNING` states - it could be worth surfacing that status in the Fiori Elements Object Page as a visible progress indicator or status strip so users have feedback while extraction is running. **Tracking issue:** [#108](https://github.com/cap-java/cds-ai/issues/108) -### 5. Automatic Field Mapping _(S5)_ +### 4. Automatic Field Mapping _(S4)_ One idea is to have the plugin match extracted fields to CDS entity properties by name convention and pre-fill the Fiori form automatically. Fields below a configurable confidence threshold could be visually flagged (e.g. amber highlight) so users know what to double-check before saving. **Tracking issue:** [#101](https://github.com/cap-java/cds-ai/issues/101) -### 6. Document Viewer with Extraction Highlights and Human-in-the-Loop Verification +### 5. Document Viewer with Extraction Highlights and Human-in-the-Loop Verification A more exploratory idea is to visualise extracted fields on the document itself - bounding boxes colour-coded by confidence level, with click-to-focus between the document viewer and the form. Bounding box coordinates come back from Document AI and would need to be stored alongside the extraction result and exposed to a UI viewer component. @@ -118,55 +112,55 @@ A further possibility is a human-in-the-loop confirmation step: the user reviews **Tracking issue:** [#109](https://github.com/cap-java/cds-ai/issues/109) -### 7. Document AI Outbound Channels - Push-Based Result Delivery +### 6. Document AI Outbound Channels - Push-Based Result Delivery Document AI supports outbound channels at the schema level: notification channels (status pushes) and extension channels (callbacks triggered after prediction). One option worth exploring is registering the plugin as a target so Document AI pushes results to it directly, eliminating the need to poll. The `DocumentAiClient` interface and `ExtractionService.updateExtractionResult()` are already the right place to plug this in. **Tracking issue:** [#106](https://github.com/cap-java/cds-ai/issues/106) -### 8. Custom Schema Synchronisation _(S4)_ +### 7. Custom Schema Synchronisation _(S3)_ One possible enhancement is to let developers define custom document type extraction schemas in the CDS model via annotations, with the plugin syncing these to Document AI automatically at deploy time or startup - removing the need for manual configuration in the Document AI workspace. **Tracking issue:** [#107](https://github.com/cap-java/cds-ai/issues/107) -### 9. Customisable Extraction Templates +### 8. Customisable Extraction Templates Right now, every submission requires constructing the Document AI `options` JSON by hand. A template mechanism could let developers define named configurations - document type, schema ID, field selection, confidence thresholds - declaratively in the CDS model or `application.yaml`, and just reference the template name at submission time. **Tracking issue:** [#116](https://github.com/cap-java/cds-ai/issues/116) -### 10. Local Mock Mode _(S7)_ +### 9. Local Mock Mode _(S6)_ A mock mode returning configurable static extraction results without a real Document AI binding would make local development more convenient. **Tracking issue:** [#102](https://github.com/cap-java/cds-ai/issues/102) -### 11. Configurable result delivery channels +### 10. Configurable result delivery channels The plugin currently delivers results only via the `DocumentExtractionResult` CDS event. It could be worth exploring additional delivery channels so consuming applications can receive results through whatever channel fits their architecture. **Tracking issue:** [#115](https://github.com/cap-java/cds-ai/issues/115) -### 12. `cds add document-ai` Scaffold Command _(S9)_ +### 11. `cds add document-ai` Scaffold Command _(S8)_ A `cds add document-ai` CLI command could set up the Document AI service binding in `mta.yaml`, enable the persistent outbox in `application.yaml`, and generate boilerplate handler stubs - lowering the barrier significantly for new adopters. **Tracking issue:** [#105](https://github.com/cap-java/cds-ai/issues/105) -### 13. OData API Support _(S10)_ +### 12. OData API Support _(S9)_ For applications on higher-tier plans, it could be worth exploring the Document AI OData API as an alternative transport, enabling richer querying and result navigation. **Tracking issue:** [#99](https://github.com/cap-java/cds-ai/issues/99) -### 14. Terminal Job Cleanup _(S11)_ +### 13. Terminal Job Cleanup _(S10)_ A configurable retention policy that deletes or archives `ExtractionJob` rows after they've been in `DONE` or `FAILED` status for a set period would prevent unbounded table growth on high-volume deployments. **Tracking issue:** [#103](https://github.com/cap-java/cds-ai/issues/103) -### 15. Malware Scanning _(S8)_ +### 14. Malware Scanning _(S7)_ It may be worth assessing whether documents should be scanned via SAP Malware Scanning Service before being forwarded to Document AI - particularly for multitenant deployments where uploaded content is less trusted. diff --git a/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/configuration/DocumentAiServiceConfiguration.java b/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/configuration/DocumentAiServiceConfiguration.java index 1917294..73850a8 100644 --- a/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/configuration/DocumentAiServiceConfiguration.java +++ b/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/configuration/DocumentAiServiceConfiguration.java @@ -3,6 +3,7 @@ */ package com.sap.cds.feature.documentai.configuration; +import com.sap.cds.feature.documentai.handlers.DocumentAiSetupHandler; import com.sap.cds.feature.documentai.handlers.DocumentSubmissionHandler; import com.sap.cds.feature.documentai.handlers.ExtractionPollingHandler; import com.sap.cds.feature.documentai.service.DefaultDocumentAiProcessingService; @@ -12,6 +13,8 @@ import com.sap.cds.feature.documentai.service.client.DocumentAiClient; import com.sap.cds.services.ServiceCatalog; import com.sap.cds.services.environment.CdsEnvironment; +import com.sap.cds.services.environment.CdsProperties; +import com.sap.cds.services.mt.DeploymentService; import com.sap.cds.services.outbox.OutboxService; import com.sap.cds.services.persistence.PersistenceService; import com.sap.cds.services.runtime.CdsRuntime; @@ -82,6 +85,7 @@ public void services(CdsRuntimeConfigurer configurer) { public void eventHandlers(CdsRuntimeConfigurer configurer) { CdsRuntime runtime = configurer.getCdsRuntime(); ServiceCatalog serviceCatalog = runtime.getServiceCatalog(); + boolean multiTenancyEnabled = detectMultiTenancy(runtime); // framework-managed dependency PersistenceService persistenceService = @@ -114,6 +118,12 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { configurer.eventHandler(new DocumentSubmissionHandler(extractionService)); + if (multiTenancyEnabled) { + configurer.eventHandler(new DocumentAiSetupHandler(persistenceService)); + logger.debug( + "[sap-document-ai] Registered DocumentAiSetupHandler for MTX subscribe/unsubscribe."); + } + // polling handler — only registered when a DIE binding is present if (documentAiClient != null) { configurer.eventHandler( @@ -123,7 +133,8 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { documentAiClient, outboxService, runtime, - pollDelay)); + pollDelay, + multiTenancyEnabled)); } } @@ -176,4 +187,16 @@ static DocumentAiClient buildDocumentAi(CdsEnvironment environment) { return null; } } + + static boolean detectMultiTenancy(CdsRuntime runtime) { + CdsProperties props = runtime.getEnvironment().getCdsProperties(); + String sidecarUrl = props.getMultiTenancy().getSidecar().getUrl(); + if (sidecarUrl != null && !sidecarUrl.isBlank()) { + return true; + } + return runtime + .getServiceCatalog() + .getService(DeploymentService.class, DeploymentService.DEFAULT_NAME) + != null; + } } diff --git a/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/handlers/DocumentAiSetupHandler.java b/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/handlers/DocumentAiSetupHandler.java new file mode 100644 index 0000000..726f4d1 --- /dev/null +++ b/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/handlers/DocumentAiSetupHandler.java @@ -0,0 +1,82 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.documentai.handlers; + +import com.sap.cds.Result; +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.ExtractionJob; +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.ExtractionJob_; +import com.sap.cds.feature.documentai.service.ExtractionStatus; +import com.sap.cds.ql.Update; +import com.sap.cds.services.handler.EventHandler; +import com.sap.cds.services.handler.annotations.After; +import com.sap.cds.services.handler.annotations.Before; +import com.sap.cds.services.handler.annotations.HandlerOrder; +import com.sap.cds.services.handler.annotations.ServiceName; +import com.sap.cds.services.mt.DeploymentService; +import com.sap.cds.services.mt.SubscribeEventContext; +import com.sap.cds.services.mt.UnsubscribeEventContext; +import com.sap.cds.services.persistence.PersistenceService; +import java.util.Arrays; +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Hooks into the MTX tenant lifecycle to react to subscribe and unsubscribe events. + * + *

On subscribe: logs that the tenant is onboarded. No provisioning is needed because the DIE + * service auto-creates a {@code clientId} namespace on first use. + * + *

On unsubscribe: marks all active jobs ({@code PENDING}, {@code SUBMITTED}, {@code RUNNING}) + * belonging to the tenant as {@code FAILED}. Without this, those jobs would remain in a + * non-terminal state and generate poll errors indefinitely after the tenant's credentials are + * revoked. + */ +@ServiceName(DeploymentService.DEFAULT_NAME) +public class DocumentAiSetupHandler implements EventHandler { + + private static final Logger logger = LoggerFactory.getLogger(DocumentAiSetupHandler.class); + + private final PersistenceService persistenceService; + + public DocumentAiSetupHandler(PersistenceService persistenceService) { + this.persistenceService = persistenceService; + } + + @After + @HandlerOrder(HandlerOrder.LATE) + public void afterSubscribe(SubscribeEventContext context) { + logger.info( + "[sap-document-ai] Tenant {} subscribed — DIE clientId namespace will be created on first use", + context.getTenant()); + } + + @Before + @HandlerOrder(HandlerOrder.EARLY) + public void beforeUnsubscribe(UnsubscribeEventContext context) { + String tenantId = context.getTenant(); + logger.info( + "[sap-document-ai] Tenant {} unsubscribing — marking active jobs as FAILED", tenantId); + + ExtractionJob failedStatus = ExtractionJob.create(); + failedStatus.setStatus(ExtractionStatus.FAILED.name()); + + List activeStatuses = + Arrays.stream(ExtractionStatus.values()) + .filter(s -> !s.isTerminal()) + .map(ExtractionStatus::name) + .toList(); + + Result result = + persistenceService.run( + Update.entity(ExtractionJob_.class) + .where(j -> j.tenantId().eq(tenantId).and(j.status().in(activeStatuses))) + .entry(failedStatus)); + + logger.info( + "[sap-document-ai] Marked {} active job(s) as FAILED for tenant {}", + result.rowCount(), + tenantId); + } +} diff --git a/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/handlers/DocumentSubmissionHandler.java b/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/handlers/DocumentSubmissionHandler.java index 2e2ea40..eff8daf 100644 --- a/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/handlers/DocumentSubmissionHandler.java +++ b/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/handlers/DocumentSubmissionHandler.java @@ -5,6 +5,8 @@ import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.documentaiservice.DocumentExtraction; import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.documentaiservice.DocumentExtractionContext; +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.documentaiservice.DocumentExtractionResult; +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.documentaiservice.DocumentExtractionResultContext; import com.sap.cds.feature.documentai.service.ExtractionService; import com.sap.cds.feature.documentai.service.model.ExtractionResult; import com.sap.cds.services.cds.ApplicationService; @@ -61,10 +63,24 @@ public void onDocumentExtraction(DocumentExtractionContext context) { if (result.status() == ExtractionResult.Status.FAILED) { logger.error("[sap-document-ai] Extraction failed for fileName={}", event.getFileName()); + if (result.internalJobId() != null) { + emitExtractionResult(context, result.internalJobId(), null); + } } else if (result.status() == ExtractionResult.Status.PENDING) { logger.warn("[sap-document-ai] Document AI unavailable, left as PENDING"); } context.setCompleted(); } + + private void emitExtractionResult( + DocumentExtractionContext context, String jobId, String extractionResult) { + DocumentExtractionResult eventData = DocumentExtractionResult.create(); + eventData.setJobId(jobId); + eventData.setExtractionResult(extractionResult); + DocumentExtractionResultContext eventContext = DocumentExtractionResultContext.create(); + eventContext.setData(eventData); + context.getService().emit(eventContext); + logger.info("[sap-document-ai] Emitted DocumentExtractionResult for jobId={}", jobId); + } } diff --git a/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/handlers/ExtractionPollingHandler.java b/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/handlers/ExtractionPollingHandler.java index 0ba4a70..74a18b6 100644 --- a/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/handlers/ExtractionPollingHandler.java +++ b/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/handlers/ExtractionPollingHandler.java @@ -22,9 +22,13 @@ import com.sap.cds.services.outbox.OutboxService; import com.sap.cds.services.outbox.Schedule; import com.sap.cds.services.persistence.PersistenceService; +import com.sap.cds.services.request.RequestContext; import com.sap.cds.services.runtime.CdsRuntime; import java.time.Duration; import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -60,6 +64,7 @@ public class ExtractionPollingHandler implements EventHandler { private static final Logger logger = LoggerFactory.getLogger(ExtractionPollingHandler.class); + private final boolean multiTenancyEnabled; private final PersistenceService persistenceService; private final ExtractionService extractionService; private final DocumentAiClient documentAiClient; @@ -81,13 +86,15 @@ public ExtractionPollingHandler( DocumentAiClient documentAiClient, OutboxService outboxService, CdsRuntime runtime, - Duration pollDelay) { + Duration pollDelay, + boolean multiTenancyEnabled) { this.persistenceService = persistenceService; this.extractionService = extractionService; this.documentAiClient = documentAiClient; this.outboxService = outboxService; this.runtime = runtime; this.pollDelay = pollDelay; + this.multiTenancyEnabled = multiTenancyEnabled; } /** @@ -117,8 +124,17 @@ public void pollExtractionJobs(OutboxMessageEventContext context) { return; } - for (ExtractionJob job : activeJobs) { - processJob(job); + if (multiTenancyEnabled) { + Map> byTenant = + activeJobs.stream() + .collect(Collectors.groupingBy(j -> j.getTenantId() != null ? j.getTenantId() : "")); + byTenant.forEach( + (key, jobs) -> { + String tenantId = key.isEmpty() ? null : key; + processJobsForTenant(tenantId, jobs); + }); + } else { + activeJobs.forEach(job -> processJob(job, null)); } if (outboxService != null) { @@ -133,7 +149,7 @@ public void pollExtractionJobs(OutboxMessageEventContext context) { context.setCompleted(); } - private void processJob(ExtractionJob job) { + private void processJob(ExtractionJob job, String tenantId) { String jobId = job.getId(); String dieJobId = job.getDocumentAiJobId(); @@ -143,7 +159,7 @@ private void processJob(ExtractionJob job) { } try { - ExtractionData result = documentAiClient.getJobResult(dieJobId); + ExtractionData result = documentAiClient.getJobResult(dieJobId, tenantId); ExtractionStatus newStatus = mapDieStatus(result.dieStatus()); if (newStatus == null) { @@ -170,6 +186,17 @@ private void processJob(ExtractionJob job) { } } + private void processJobsForTenant(String tenantId, List jobs) { + if (tenantId == null) { + jobs.forEach(job -> processJob(job, null)); + return; + } + runtime + .requestContext() + .systemUser(tenantId) + .run((Consumer) ctx -> jobs.forEach(job -> processJob(job, tenantId))); + } + private void emitExtractionCompleted(String jobId, String extractionResult) { ApplicationService documentAiService = runtime diff --git a/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/DefaultDocumentAiProcessingService.java b/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/DefaultDocumentAiProcessingService.java index ec4a73f..0f418d6 100644 --- a/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/DefaultDocumentAiProcessingService.java +++ b/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/DefaultDocumentAiProcessingService.java @@ -26,9 +26,9 @@ public DefaultDocumentAiProcessingService(DocumentAiClient documentAiClient) { } @Override - public String processDocument(String jobId, DocumentInput documentInput) { + public String processDocument(String jobId, DocumentInput documentInput, String tenantId) { try { - String documentAiJobId = documentAiClient.submitDocument(documentInput); + String documentAiJobId = documentAiClient.submitDocument(documentInput, tenantId); return documentAiJobId; } catch (Exception e) { throw new DocumentAiException.Processing("Failed to process document for jobId=" + jobId, e); diff --git a/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/DocumentAiProcessingService.java b/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/DocumentAiProcessingService.java index 3972446..46c65fd 100644 --- a/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/DocumentAiProcessingService.java +++ b/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/DocumentAiProcessingService.java @@ -30,5 +30,5 @@ public interface DocumentAiProcessingService { * @throws com.sap.cds.feature.documentai.service.exceptions.DocumentAiException if submission or * response parsing fails */ - String processDocument(String jobId, DocumentInput documentInput); + String processDocument(String jobId, DocumentInput documentInput, String tenantId); } diff --git a/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/ExtractionServiceImpl.java b/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/ExtractionServiceImpl.java index 2216619..b39ec19 100644 --- a/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/ExtractionServiceImpl.java +++ b/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/ExtractionServiceImpl.java @@ -114,7 +114,8 @@ public void updateExtractionResult( private ExtractionResult performExtraction( String jobId, String fileName, DocumentInput documentInput, String tenantId) { try { - String documentAiJobId = documentAiProcessingService.processDocument(jobId, documentInput); + String documentAiJobId = + documentAiProcessingService.processDocument(jobId, documentInput, tenantId); updateExtractionJob(jobId, SUBMITTED, documentAiJobId, null); schedulePolling(); return new ExtractionResult(jobId, Status.SUCCESS, documentAiJobId); diff --git a/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/ExtractionStatus.java b/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/ExtractionStatus.java index cfd2322..24192d2 100644 --- a/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/ExtractionStatus.java +++ b/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/ExtractionStatus.java @@ -27,6 +27,11 @@ public enum ExtractionStatus { /** Processing failed at any stage. */ FAILED; + /** Returns {@code true} if this status is terminal — no further transitions are permitted. */ + public boolean isTerminal() { + return this == DONE || this == FAILED; + } + /** * Converts a persisted string value back to an {@link ExtractionStatus}. * diff --git a/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/client/DefaultDocumentAiClient.java b/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/client/DefaultDocumentAiClient.java index 21057fa..2a88d75 100644 --- a/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/client/DefaultDocumentAiClient.java +++ b/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/client/DefaultDocumentAiClient.java @@ -12,6 +12,8 @@ import com.sap.cloud.sdk.cloudplatform.connectivity.HttpDestination; import java.io.IOException; import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import org.apache.hc.client5.http.classic.HttpClient; import org.apache.hc.client5.http.classic.methods.HttpGet; import org.apache.hc.client5.http.classic.methods.HttpPost; @@ -44,6 +46,7 @@ public class DefaultDocumentAiClient implements DocumentAiClient { private static final ObjectMapper objectMapper = new ObjectMapper(); private static final String DOCUMENT_AI_API_PATH = "document-information-extraction/v1"; public static final String DOCUMENT_JOBS = "/document/jobs"; + public static final String CLIENT_ID_PARAM = "clientId"; public static final String EXTRACTED_VALUES_TRUE = "?extractedValues=true"; private final HttpDestination destination; private final HttpClient httpClient; @@ -59,23 +62,39 @@ public DefaultDocumentAiClient(HttpDestination destination, HttpClient httpClien } @Override - public String submitDocument(DocumentInput documentInput) { - URI submitUri = buildUri(DOCUMENT_AI_API_PATH + DOCUMENT_JOBS); + public String submitDocument(DocumentInput documentInput, String tenantId) { + URI submitUri = buildUriWithClientId(DOCUMENT_AI_API_PATH + DOCUMENT_JOBS, tenantId); HttpPost request = buildSubmitRequest(documentInput, submitUri); String body = executeRequest(request, submitUri); return extractJobId(body); } @Override - public ExtractionData getJobResult(String dieJobId) { - URI uri = - buildUri(DOCUMENT_AI_API_PATH + DOCUMENT_JOBS + "/" + dieJobId + EXTRACTED_VALUES_TRUE); + public ExtractionData getJobResult(String dieJobId, String tenantId) { + String path = DOCUMENT_AI_API_PATH + DOCUMENT_JOBS + "/" + dieJobId + EXTRACTED_VALUES_TRUE; + if (tenantId != null) { + path = + path + "&" + CLIENT_ID_PARAM + "=" + URLEncoder.encode(tenantId, StandardCharsets.UTF_8); + } + URI uri = buildUri(path); logger.info("[sap-document-ai] Polling DIE for dieJobId={}", dieJobId); HttpGet request = new HttpGet(uri); String body = executeRequest(request, uri); return parseJobResult(dieJobId, body); } + private URI buildUriWithClientId(String path, String tenantId) { + String fullPath = + (tenantId != null) + ? path + + "?" + + CLIENT_ID_PARAM + + "=" + + URLEncoder.encode(tenantId, StandardCharsets.UTF_8) + : path; + return buildUri(fullPath); + } + private URI buildUri(String path) { String base = destination.getUri().toString(); String prefix = base.endsWith("/") ? base : base + "/"; diff --git a/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/client/DocumentAiClient.java b/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/client/DocumentAiClient.java index cd327c4..277ca96 100644 --- a/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/client/DocumentAiClient.java +++ b/cds-feature-sap-document-ai/src/main/java/com/sap/cds/feature/documentai/service/client/DocumentAiClient.java @@ -22,7 +22,7 @@ public interface DocumentAiClient { * @throws com.sap.cds.feature.documentai.service.exceptions.DocumentAiException if the HTTP call * fails or the response cannot be parsed */ - String submitDocument(DocumentInput documentInput); + String submitDocument(DocumentInput documentInput, String tenantId); /** * Polls the DIE service for the current status and result of a previously submitted job. @@ -32,5 +32,5 @@ public interface DocumentAiClient { * @throws com.sap.cds.feature.documentai.service.exceptions.DocumentAiException if the HTTP call * fails or the response cannot be parsed */ - ExtractionData getJobResult(String dieJobId); + ExtractionData getJobResult(String dieJobId, String tenantId); } diff --git a/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/configuration/DocumentAiServiceConfigurationTest.java b/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/configuration/DocumentAiServiceConfigurationTest.java index fbfade8..5f0b354 100644 --- a/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/configuration/DocumentAiServiceConfigurationTest.java +++ b/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/configuration/DocumentAiServiceConfigurationTest.java @@ -8,6 +8,7 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; +import com.sap.cds.feature.documentai.handlers.DocumentAiSetupHandler; import com.sap.cds.feature.documentai.handlers.DocumentSubmissionHandler; import com.sap.cds.feature.documentai.service.DefaultDocumentAiProcessingService; import com.sap.cds.feature.documentai.service.ExtractionServiceImpl; @@ -15,7 +16,9 @@ import com.sap.cds.services.Service; import com.sap.cds.services.ServiceCatalog; import com.sap.cds.services.environment.CdsEnvironment; +import com.sap.cds.services.environment.CdsProperties; import com.sap.cds.services.handler.EventHandler; +import com.sap.cds.services.mt.DeploymentService; import com.sap.cds.services.persistence.PersistenceService; import com.sap.cds.services.runtime.CdsRuntime; import com.sap.cds.services.runtime.CdsRuntimeConfigurer; @@ -38,6 +41,9 @@ class DocumentAiServiceConfigurationTest { @Mock ServiceCatalog serviceCatalog; @Mock PersistenceService persistenceService; @Mock CdsEnvironment environment; + @Mock CdsProperties cdsProperties; + @Mock CdsProperties.MultiTenancy multiTenancy; + @Mock CdsProperties.MultiTenancy.Sidecar sidecar; DocumentAiServiceConfiguration registration; @@ -66,6 +72,13 @@ void eventHandlersRegistersDocumentSubmissionHandler() { .thenReturn(3); when(serviceCatalog.getService(PersistenceService.class, PersistenceService.DEFAULT_NAME)) .thenReturn(persistenceService); + // stub MT detection + when(environment.getCdsProperties()).thenReturn(cdsProperties); + when(cdsProperties.getMultiTenancy()).thenReturn(multiTenancy); + when(multiTenancy.getSidecar()).thenReturn(sidecar); + when(sidecar.getUrl()).thenReturn(null); + when(serviceCatalog.getService(DeploymentService.class, DeploymentService.DEFAULT_NAME)) + .thenReturn(null); registration.services(configurer); registration.eventHandlers(configurer); @@ -108,4 +121,55 @@ void buildDocumentAi_bindingFound_destinationCreationFails_returnsNull() { assertThat(result).isNull(); } } + + @Test + void detectMultiTenancy_withSidecarUrl_returnsTrue() { + when(cdsRuntime.getEnvironment()).thenReturn(environment); + when(environment.getCdsProperties()).thenReturn(cdsProperties); + when(cdsProperties.getMultiTenancy()).thenReturn(multiTenancy); + when(multiTenancy.getSidecar()).thenReturn(sidecar); + when(sidecar.getUrl()).thenReturn("https://sidecar.example.com"); + + assertThat(DocumentAiServiceConfiguration.detectMultiTenancy(cdsRuntime)).isTrue(); + } + + @Test + void detectMultiTenancy_withDeploymentService_returnsTrue() { + when(cdsRuntime.getEnvironment()).thenReturn(environment); + when(cdsRuntime.getServiceCatalog()).thenReturn(serviceCatalog); + when(environment.getCdsProperties()).thenReturn(cdsProperties); + when(cdsProperties.getMultiTenancy()).thenReturn(multiTenancy); + when(multiTenancy.getSidecar()).thenReturn(sidecar); + when(sidecar.getUrl()).thenReturn(null); + when(serviceCatalog.getService(DeploymentService.class, DeploymentService.DEFAULT_NAME)) + .thenReturn(mock(DeploymentService.class)); + + assertThat(DocumentAiServiceConfiguration.detectMultiTenancy(cdsRuntime)).isTrue(); + } + + @Test + void eventHandlers_multiTenancyEnabled_registersSetupHandler() { + when(configurer.getCdsRuntime()).thenReturn(cdsRuntime); + when(cdsRuntime.getServiceCatalog()).thenReturn(serviceCatalog); + when(cdsRuntime.getEnvironment()).thenReturn(environment); + when(environment.getServiceBindings()).thenReturn(Stream.empty()); + when(environment.getProperty( + eq("cds.document-ai.polling.interval-seconds"), eq(Integer.class), any())) + .thenReturn(3); + when(serviceCatalog.getService(PersistenceService.class, PersistenceService.DEFAULT_NAME)) + .thenReturn(persistenceService); + // MT enabled via sidecar URL + when(environment.getCdsProperties()).thenReturn(cdsProperties); + when(cdsProperties.getMultiTenancy()).thenReturn(multiTenancy); + when(multiTenancy.getSidecar()).thenReturn(sidecar); + when(sidecar.getUrl()).thenReturn("https://sidecar.example.com"); + + registration.services(configurer); + registration.eventHandlers(configurer); + + ArgumentCaptor captor = ArgumentCaptor.forClass(EventHandler.class); + verify(configurer, atLeast(2)).eventHandler(captor.capture()); + + assertThat(captor.getAllValues()).anyMatch(h -> h instanceof DocumentAiSetupHandler); + } } diff --git a/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/handlers/DocumentAiSetupHandlerTest.java b/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/handlers/DocumentAiSetupHandlerTest.java new file mode 100644 index 0000000..09d89c8 --- /dev/null +++ b/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/handlers/DocumentAiSetupHandlerTest.java @@ -0,0 +1,72 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.documentai.handlers; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import com.sap.cds.Result; +import com.sap.cds.Struct; +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.ExtractionJob; +import com.sap.cds.feature.documentai.service.ExtractionStatus; +import com.sap.cds.ql.cqn.CqnUpdate; +import com.sap.cds.services.mt.SubscribeEventContext; +import com.sap.cds.services.mt.UnsubscribeEventContext; +import com.sap.cds.services.persistence.PersistenceService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class DocumentAiSetupHandlerTest { + + @Mock PersistenceService persistenceService; + @Mock SubscribeEventContext subscribeContext; + @Mock UnsubscribeEventContext unsubscribeContext; + @Mock Result updateResult; + + DocumentAiSetupHandler handler; + + @BeforeEach + void setUp() { + handler = new DocumentAiSetupHandler(persistenceService); + } + + @Test + void afterSubscribe_doesNotInteractWithDatabase() { + when(subscribeContext.getTenant()).thenReturn("tenant-a"); + + handler.afterSubscribe(subscribeContext); + + verify(persistenceService, never()).run(any(CqnUpdate.class)); + } + + @Test + void beforeUnsubscribe_marksActiveJobsAsFailed() { + when(unsubscribeContext.getTenant()).thenReturn("tenant-a"); + when(updateResult.rowCount()).thenReturn(3L); + ArgumentCaptor captor = ArgumentCaptor.forClass(CqnUpdate.class); + when(persistenceService.run(captor.capture())).thenReturn(updateResult); + + handler.beforeUnsubscribe(unsubscribeContext); + + ExtractionJob entry = Struct.access(captor.getValue().entries().get(0)).as(ExtractionJob.class); + assertThat(entry.getStatus()).isEqualTo(ExtractionStatus.FAILED.name()); + } + + @Test + void beforeUnsubscribe_noActiveJobs_doesNotThrow() { + when(unsubscribeContext.getTenant()).thenReturn("tenant-a"); + when(updateResult.rowCount()).thenReturn(0L); + when(persistenceService.run(any(CqnUpdate.class))).thenReturn(updateResult); + + handler.beforeUnsubscribe(unsubscribeContext); + + verify(persistenceService).run(any(CqnUpdate.class)); + } +} diff --git a/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/handlers/DocumentSubmissionHandlerTest.java b/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/handlers/DocumentSubmissionHandlerTest.java index 90be694..74fa4e6 100644 --- a/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/handlers/DocumentSubmissionHandlerTest.java +++ b/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/handlers/DocumentSubmissionHandlerTest.java @@ -9,8 +9,10 @@ import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.documentaiservice.DocumentExtraction; import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.documentaiservice.DocumentExtractionContext; +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.documentaiservice.DocumentExtractionResultContext; import com.sap.cds.feature.documentai.service.ExtractionService; import com.sap.cds.feature.documentai.service.model.ExtractionResult; +import com.sap.cds.services.cds.ApplicationService; import com.sap.cds.services.request.UserInfo; import java.io.ByteArrayInputStream; import java.io.InputStream; @@ -30,6 +32,7 @@ class DocumentSubmissionHandlerTest { @Mock ExtractionService extractionService; @Mock DocumentExtractionContext eventContext; @Mock UserInfo userInfo; + @Mock ApplicationService applicationService; DocumentSubmissionHandler handler; @@ -72,19 +75,20 @@ void onDocumentExtraction_logsPendingWhenServiceUnavailable() { handler.onDocumentExtraction(eventContext); - verify(extractionService).triggerExtraction(any(), any(), any(), any(), any()); + verify(eventContext, never()).getService(); } @Test - void onDocumentExtraction_logsFailedWhenExtractionFails() { + void onDocumentExtraction_emitsResultEventWhenExtractionFails() { when(eventContext.getUserInfo()).thenReturn(userInfo); when(userInfo.getTenant()).thenReturn(TENANT_ID); when(eventContext.getData()).thenReturn(createEvent()); when(extractionService.triggerExtraction(any(), any(), any(), any(), any())) .thenReturn(new ExtractionResult("job-123", ExtractionResult.Status.FAILED, null)); + when(eventContext.getService()).thenReturn(applicationService); handler.onDocumentExtraction(eventContext); - verify(extractionService).triggerExtraction(any(), any(), any(), any(), any()); + verify(applicationService).emit(any(DocumentExtractionResultContext.class)); } } diff --git a/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/handlers/ExtractionPollingHandlerTest.java b/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/handlers/ExtractionPollingHandlerTest.java index 610dc8b..53f6b0e 100644 --- a/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/handlers/ExtractionPollingHandlerTest.java +++ b/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/handlers/ExtractionPollingHandlerTest.java @@ -22,8 +22,10 @@ import com.sap.cds.services.outbox.Schedule; import com.sap.cds.services.persistence.PersistenceService; import com.sap.cds.services.runtime.CdsRuntime; +import com.sap.cds.services.runtime.RequestContextRunner; import java.time.Duration; import java.util.List; +import java.util.function.Consumer; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -46,6 +48,7 @@ class ExtractionPollingHandlerTest { @Mock ApplicationService documentAiService; @Mock OutboxMessageEventContext context; @Mock Result queryResult; + @Mock RequestContextRunner requestContextRunner; ExtractionPollingHandler handler; @@ -58,7 +61,8 @@ void setUp() { documentAiClient, outboxService, runtime, - Duration.ofSeconds(ExtractionPollingHandler.DEFAULT_POLL_INTERVAL_SECONDS)); + Duration.ofSeconds(ExtractionPollingHandler.DEFAULT_POLL_INTERVAL_SECONDS), + false); } private void mockEmit() { @@ -81,7 +85,7 @@ void pollStopsAndSetsCompletedWhenNoActiveJobs() { @Test void pollReschedulesWhenActiveJobsExist() { mockActiveJob(DIE_JOB_ID); - when(documentAiClient.getJobResult(DIE_JOB_ID)) + when(documentAiClient.getJobResult(DIE_JOB_ID, null)) .thenReturn(new ExtractionData(DIE_JOB_ID, "RUNNING", null)); handler.pollExtractionJobs(context); @@ -97,13 +101,13 @@ void pollSkipsJobWithNoDieJobId() { handler.pollExtractionJobs(context); - verify(documentAiClient, never()).getJobResult(any()); + verify(documentAiClient, never()).getJobResult(any(), any()); } @Test void pollDoesNotUpdateStatusWhenDieReturnsPending() { mockActiveJob(DIE_JOB_ID); - when(documentAiClient.getJobResult(DIE_JOB_ID)) + when(documentAiClient.getJobResult(DIE_JOB_ID, null)) .thenReturn(new ExtractionData(DIE_JOB_ID, "PENDING", null)); handler.pollExtractionJobs(context); @@ -114,7 +118,7 @@ void pollDoesNotUpdateStatusWhenDieReturnsPending() { @Test void pollUpdatesStatusToRunningWithoutEmittingEvent() { mockActiveJob(DIE_JOB_ID); - when(documentAiClient.getJobResult(DIE_JOB_ID)) + when(documentAiClient.getJobResult(DIE_JOB_ID, null)) .thenReturn(new ExtractionData(DIE_JOB_ID, "RUNNING", null)); handler.pollExtractionJobs(context); @@ -127,7 +131,7 @@ void pollUpdatesStatusToRunningWithoutEmittingEvent() { @Test void pollUpdatesStatusToFailedWithoutEmittingEvent() { mockActiveJob(DIE_JOB_ID); - when(documentAiClient.getJobResult(DIE_JOB_ID)) + when(documentAiClient.getJobResult(DIE_JOB_ID, null)) .thenReturn(new ExtractionData(DIE_JOB_ID, "FAILED", null)); handler.pollExtractionJobs(context); @@ -141,7 +145,7 @@ void pollUpdatesStatusToFailedWithoutEmittingEvent() { void pollUpdatesStatusToDoneAndEmitsEvent() { mockEmit(); mockActiveJob(DIE_JOB_ID); - when(documentAiClient.getJobResult(DIE_JOB_ID)) + when(documentAiClient.getJobResult(DIE_JOB_ID, null)) .thenReturn(new ExtractionData(DIE_JOB_ID, "DONE", RAW_RESULT)); handler.pollExtractionJobs(context); @@ -164,8 +168,9 @@ void pollContinuesToNextJobWhenOneThrows() { when(persistenceService.run(any(CqnSelect.class))).thenReturn(queryResult); when(queryResult.listOf(ExtractionJob.class)).thenReturn(List.of(failingJob, goodJob)); - when(documentAiClient.getJobResult("die-fail")).thenThrow(new RuntimeException("timeout")); - when(documentAiClient.getJobResult(DIE_JOB_ID)) + when(documentAiClient.getJobResult("die-fail", null)) + .thenThrow(new RuntimeException("timeout")); + when(documentAiClient.getJobResult(DIE_JOB_ID, null)) .thenReturn(new ExtractionData(DIE_JOB_ID, "RUNNING", null)); handler.pollExtractionJobs(context); @@ -175,6 +180,99 @@ void pollContinuesToNextJobWhenOneThrows() { verify(context).setCompleted(); } + @Test + @SuppressWarnings("unchecked") + void poll_multiTenancy_groupsJobsByTenant() { + ExtractionPollingHandler mtHandler = + new ExtractionPollingHandler( + persistenceService, + extractionService, + documentAiClient, + outboxService, + runtime, + Duration.ofSeconds(ExtractionPollingHandler.DEFAULT_POLL_INTERVAL_SECONDS), + true); + + ExtractionJob jobA = ExtractionJob.create(); + jobA.setId("job-a"); + jobA.setDocumentAiJobId("die-a"); + jobA.setTenantId("tenant-a"); + + ExtractionJob jobB = ExtractionJob.create(); + jobB.setId("job-b"); + jobB.setDocumentAiJobId("die-b"); + jobB.setTenantId("tenant-b"); + + when(persistenceService.run(any(CqnSelect.class))).thenReturn(queryResult); + when(queryResult.listOf(ExtractionJob.class)).thenReturn(List.of(jobA, jobB)); + when(documentAiClient.getJobResult(any(), any())) + .thenReturn(new ExtractionData("die-x", "RUNNING", null)); + + when(runtime.requestContext()).thenReturn(requestContextRunner); + when(requestContextRunner.systemUser(any())).thenReturn(requestContextRunner); + doAnswer( + inv -> { + inv.getArgument(0, Consumer.class).accept(null); + return null; + }) + .when(requestContextRunner) + .run(any(Consumer.class)); + + mtHandler.pollExtractionJobs(context); + + verify(requestContextRunner).systemUser("tenant-a"); + verify(requestContextRunner).systemUser("tenant-b"); + verify(documentAiClient).getJobResult("die-a", "tenant-a"); + verify(documentAiClient).getJobResult("die-b", "tenant-b"); + } + + @Test + @SuppressWarnings("unchecked") + void poll_multiTenancy_nullTenantId_processedWithoutContextSwitch() { + ExtractionPollingHandler mtHandler = + new ExtractionPollingHandler( + persistenceService, + extractionService, + documentAiClient, + outboxService, + runtime, + Duration.ofSeconds(ExtractionPollingHandler.DEFAULT_POLL_INTERVAL_SECONDS), + true); + + ExtractionJob job = ExtractionJob.create(); + job.setId(JOB_ID); + job.setDocumentAiJobId(DIE_JOB_ID); + // no tenantId set — null + + when(persistenceService.run(any(CqnSelect.class))).thenReturn(queryResult); + when(queryResult.listOf(ExtractionJob.class)).thenReturn(List.of(job)); + when(documentAiClient.getJobResult(DIE_JOB_ID, null)) + .thenReturn(new ExtractionData(DIE_JOB_ID, "RUNNING", null)); + + mtHandler.pollExtractionJobs(context); + + verify(runtime, never()).requestContext(); + verify(documentAiClient).getJobResult(DIE_JOB_ID, null); + } + + @Test + void poll_singleTenant_doesNotCallRequestContext() { + // handler is constructed with multiTenancyEnabled=false in setUp() + ExtractionJob job = ExtractionJob.create(); + job.setId(JOB_ID); + job.setDocumentAiJobId(DIE_JOB_ID); + job.setTenantId("tenant-a"); // has a tenantId, but MT is disabled + + when(persistenceService.run(any(CqnSelect.class))).thenReturn(queryResult); + when(queryResult.listOf(ExtractionJob.class)).thenReturn(List.of(job)); + when(documentAiClient.getJobResult(DIE_JOB_ID, null)) + .thenReturn(new ExtractionData(DIE_JOB_ID, "RUNNING", null)); + + handler.pollExtractionJobs(context); + + verify(runtime, never()).requestContext(); + } + private void mockActiveJob(String dieJobId) { ExtractionJob job = ExtractionJob.create(); job.setId(JOB_ID); diff --git a/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/DefaultDocumentAiProcessingServiceTest.java b/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/DefaultDocumentAiProcessingServiceTest.java index 212a68c..eea31f1 100644 --- a/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/DefaultDocumentAiProcessingServiceTest.java +++ b/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/DefaultDocumentAiProcessingServiceTest.java @@ -32,7 +32,7 @@ class DefaultDocumentAiProcessingServiceTest { @BeforeEach void setUp() { documentAiClient = Mockito.mock(DocumentAiClient.class); - Mockito.when(documentAiClient.submitDocument(any())).thenReturn(MOCK_RESULT); + Mockito.when(documentAiClient.submitDocument(any(), any())).thenReturn(MOCK_RESULT); service = new DefaultDocumentAiProcessingService(documentAiClient); documentInput = new DocumentInput( @@ -56,15 +56,22 @@ void isAvailableReturnsFalseWhenClientNull() { // ------- processDocument() ------- @Test void processDocumentCompletesWithoutException() { - Assertions.assertThatCode(() -> service.processDocument(JOB_1, documentInput)) + Assertions.assertThatCode(() -> service.processDocument(JOB_1, documentInput, null)) .doesNotThrowAnyException(); } @Test void processDocumentThrowsWhenSubmitDocumentFails() { - Mockito.when(documentAiClient.submitDocument(any())) + Mockito.when(documentAiClient.submitDocument(any(), any())) .thenThrow(new RuntimeException("submit failed")); - Assertions.assertThatThrownBy(() -> service.processDocument(JOB_1, documentInput)) + Assertions.assertThatThrownBy(() -> service.processDocument(JOB_1, documentInput, null)) .isInstanceOf(DocumentAiException.Processing.class); } + + @Test + void processDocumentPassesTenantIdToClient() { + service.processDocument(JOB_1, documentInput, "tenant-a"); + + Mockito.verify(documentAiClient).submitDocument(any(), Mockito.eq("tenant-a")); + } } diff --git a/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/ExtractionServiceImplTest.java b/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/ExtractionServiceImplTest.java index 3f05fad..c46b123 100644 --- a/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/ExtractionServiceImplTest.java +++ b/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/ExtractionServiceImplTest.java @@ -73,7 +73,7 @@ void triggerExtractionCreatesJobWithCorrectFields() { mockAllDatabaseCalls(); Result statusResult = resultWithJobStatus(PENDING); lenient().when(persistenceService.run(any(CqnSelect.class))).thenReturn(statusResult); - when(documentAiProcessingService.processDocument(any(), any())).thenReturn(DIE_JOB_ID); + when(documentAiProcessingService.processDocument(any(), any(), any())).thenReturn(DIE_JOB_ID); extractionService.triggerExtraction(TEST_PDF, CONTENT_TYPE, contentStream(), null, TENANT_1); @@ -91,7 +91,7 @@ void triggerExtractionSubmitsDocumentAndUpdatesStatusToSubmitted() { mockAllDatabaseCalls(); Result statusResult = resultWithJobStatus(PENDING); lenient().when(persistenceService.run(any(CqnSelect.class))).thenReturn(statusResult); - when(documentAiProcessingService.processDocument(any(), any())).thenReturn(DIE_JOB_ID); + when(documentAiProcessingService.processDocument(any(), any(), any())).thenReturn(DIE_JOB_ID); ExtractionResult result = extractionService.triggerExtraction( @@ -111,7 +111,7 @@ void triggerExtractionDoesNotThrowWhenOutboxIsNull() { mockAllDatabaseCalls(); Result statusResult = resultWithJobStatus(PENDING); lenient().when(persistenceService.run(any(CqnSelect.class))).thenReturn(statusResult); - when(documentAiProcessingService.processDocument(any(), any())).thenReturn(DIE_JOB_ID); + when(documentAiProcessingService.processDocument(any(), any(), any())).thenReturn(DIE_JOB_ID); ExtractionResult result = extractionService.triggerExtraction( @@ -128,7 +128,7 @@ void triggerExtractionMarksJobFailedOnProcessingError() { lenient().when(persistenceService.run(any(CqnSelect.class))).thenReturn(statusResult); doThrow(new RuntimeException("simulated failure")) .when(documentAiProcessingService) - .processDocument(any(), any()); + .processDocument(any(), any(), any()); ExtractionResult result = extractionService.triggerExtraction( @@ -144,7 +144,7 @@ void triggerExtractionReturnSuccessOnConcurrentUpdate() { mockAllDatabaseCalls(); Result statusResult = resultWithJobStatus(PENDING); lenient().when(persistenceService.run(any(CqnSelect.class))).thenReturn(statusResult); - when(documentAiProcessingService.processDocument(any(), any())).thenReturn(DIE_JOB_ID); + when(documentAiProcessingService.processDocument(any(), any(), any())).thenReturn(DIE_JOB_ID); Result zeroRowResult = mock(Result.class); when(zeroRowResult.rowCount()).thenReturn(0L); when(persistenceService.run(any(CqnUpdate.class))).thenReturn(zeroRowResult); @@ -164,7 +164,7 @@ void triggerExtractionThrowsOnInvalidStatusTransition() { lenient().when(persistenceService.run(any(CqnSelect.class))).thenReturn(statusResult); doThrow(new IllegalStatusTransitionException("invalid transition")) .when(documentAiProcessingService) - .processDocument(any(), any()); + .processDocument(any(), any(), any()); assertThrows( IllegalStatusTransitionException.class, @@ -180,7 +180,7 @@ void updateStatusWithSameStateSkipsUpdate() { mockInsertDatabaseCalls(); Result statusResult = resultWithJobStatus(SUBMITTED); lenient().when(persistenceService.run(any(CqnSelect.class))).thenReturn(statusResult); - when(documentAiProcessingService.processDocument(any(), any())).thenReturn(DIE_JOB_ID); + when(documentAiProcessingService.processDocument(any(), any(), any())).thenReturn(DIE_JOB_ID); extractionService.triggerExtraction(TEST_PDF, CONTENT_TYPE, contentStream(), null, TENANT_1); diff --git a/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/client/DefaultDocumentAiClientTest.java b/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/client/DefaultDocumentAiClientTest.java index 9a2226c..9508865 100644 --- a/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/client/DefaultDocumentAiClientTest.java +++ b/cds-feature-sap-document-ai/src/test/java/com/sap/cds/feature/documentai/service/client/DefaultDocumentAiClientTest.java @@ -59,7 +59,7 @@ void setUp() { void submitDocumentReturnsJobIdOnSuccess() throws IOException { mockHttpResponse(200, "{\"id\":\"" + JOB_ID + "\"}"); - String result = client.submitDocument(documentInput); + String result = client.submitDocument(documentInput, null); assertThat(result).isEqualTo(JOB_ID); } @@ -68,7 +68,7 @@ void submitDocumentReturnsJobIdOnSuccess() throws IOException { void submitDocumentThrowsRequestExceptionOnNon2xxResponse() throws IOException { mockHttpResponse(400, "Bad Request"); - assertThatThrownBy(() -> client.submitDocument(documentInput)) + assertThatThrownBy(() -> client.submitDocument(documentInput, null)) .isInstanceOf(DocumentAiException.Request.class) .hasMessageContaining("400"); } @@ -78,7 +78,7 @@ void submitDocumentThrowsConnectivityExceptionOnIoFailure() throws IOException { when(httpClient.execute(any(HttpUriRequestBase.class), any(HttpClientResponseHandler.class))) .thenThrow(new IOException("timeout")); - assertThatThrownBy(() -> client.submitDocument(documentInput)) + assertThatThrownBy(() -> client.submitDocument(documentInput, null)) .isInstanceOf(DocumentAiException.Connectivity.class) .hasMessageContaining(BASE_URL); } @@ -87,7 +87,7 @@ void submitDocumentThrowsConnectivityExceptionOnIoFailure() throws IOException { void submitDocumentThrowsWhenResponseHasNoIdField() throws IOException { mockHttpResponse(200, "{\"status\":\"ok\"}"); - assertThatThrownBy(() -> client.submitDocument(documentInput)) + assertThatThrownBy(() -> client.submitDocument(documentInput, null)) .isInstanceOf(DocumentAiException.Processing.class) .hasMessageContaining("Unexpected DIE response"); } @@ -96,7 +96,7 @@ void submitDocumentThrowsWhenResponseHasNoIdField() throws IOException { void submitDocumentThrowsWhenResponseIsNotValidJson() throws IOException { mockHttpResponse(200, "not-json{{{{"); - assertThatThrownBy(() -> client.submitDocument(documentInput)) + assertThatThrownBy(() -> client.submitDocument(documentInput, null)) .isInstanceOf(DocumentAiException.Processing.class) .hasMessageContaining("Failed to parse DIE response"); } @@ -106,7 +106,7 @@ void getJobResultReturnsStatusAndRawBody() throws IOException { String responseBody = "{\"id\":\"" + JOB_ID + "\",\"status\":\"DONE\",\"extraction\":{}}"; mockHttpResponse(200, responseBody); - ExtractionData result = client.getJobResult(JOB_ID); + ExtractionData result = client.getJobResult(JOB_ID, null); assertThat(result.dieJobId()).isEqualTo(JOB_ID); assertThat(result.dieStatus()).isEqualTo("DONE"); @@ -117,7 +117,7 @@ void getJobResultReturnsStatusAndRawBody() throws IOException { void getJobResultThrowsWhenStatusFieldMissing() throws IOException { mockHttpResponse(200, "{\"id\":\"" + JOB_ID + "\",\"extraction\":{}}"); - assertThatThrownBy(() -> client.getJobResult(JOB_ID)) + assertThatThrownBy(() -> client.getJobResult(JOB_ID, null)) .isInstanceOf(DocumentAiException.Processing.class) .hasMessageContaining("missing 'status' field"); } @@ -126,7 +126,7 @@ void getJobResultThrowsWhenStatusFieldMissing() throws IOException { void getJobResultThrowsRequestExceptionOnNon2xxResponse() throws IOException { mockHttpResponse(404, "Not Found"); - assertThatThrownBy(() -> client.getJobResult(JOB_ID)) + assertThatThrownBy(() -> client.getJobResult(JOB_ID, null)) .isInstanceOf(DocumentAiException.Request.class) .hasMessageContaining("404"); } @@ -136,7 +136,7 @@ void getJobResultThrowsConnectivityExceptionOnIoFailure() throws IOException { when(httpClient.execute(any(HttpUriRequestBase.class), any(HttpClientResponseHandler.class))) .thenThrow(new IOException("timeout")); - assertThatThrownBy(() -> client.getJobResult(JOB_ID)) + assertThatThrownBy(() -> client.getJobResult(JOB_ID, null)) .isInstanceOf(DocumentAiException.Connectivity.class); } @@ -144,7 +144,7 @@ void getJobResultThrowsConnectivityExceptionOnIoFailure() throws IOException { void getJobResultThrowsWhenResponseIsNotValidJson() throws IOException { mockHttpResponse(200, "not-json{{{{"); - assertThatThrownBy(() -> client.getJobResult(JOB_ID)) + assertThatThrownBy(() -> client.getJobResult(JOB_ID, null)) .isInstanceOf(DocumentAiException.Processing.class) .hasMessageContaining("Failed to parse DIE job result response"); } @@ -155,7 +155,7 @@ void submitDocumentUsesOctetStreamWhenMimeTypeIsNull() throws IOException { new DocumentInput("invoice.pdf", null, new ByteArrayInputStream("bytes".getBytes()), null); mockHttpResponse(200, "{\"id\":\"" + JOB_ID + "\"}"); - String result = client.submitDocument(documentInput); + String result = client.submitDocument(documentInput, null); assertThat(result).isEqualTo(JOB_ID); } @@ -176,7 +176,7 @@ void submitDocumentUsesEmptyJsonWhenOptionsIsNull() throws IOException { return handler.handleResponse(response); }); - client.submitDocument(documentInput); + client.submitDocument(documentInput, null); ByteArrayOutputStream buffer = new ByteArrayOutputStream(); requestCaptor.getValue().getEntity().writeTo(buffer); @@ -184,6 +184,50 @@ void submitDocumentUsesEmptyJsonWhenOptionsIsNull() throws IOException { assertThat(requestBody).contains("{}").contains("options"); } + @Test + void submitDocument_withTenantId_appendsClientIdQueryParam() throws Exception { + mockHttpResponse(200, "{\"id\":\"" + JOB_ID + "\"}"); + + client.submitDocument(documentInput, "tenant-a"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(HttpUriRequestBase.class); + verify(httpClient).execute(captor.capture(), any(HttpClientResponseHandler.class)); + assertThat(captor.getValue().getUri().toString()).contains("?clientId=tenant-a"); + } + + @Test + void submitDocument_withNullTenantId_noClientIdQueryParam() throws Exception { + mockHttpResponse(200, "{\"id\":\"" + JOB_ID + "\"}"); + + client.submitDocument(documentInput, null); + + ArgumentCaptor captor = ArgumentCaptor.forClass(HttpUriRequestBase.class); + verify(httpClient).execute(captor.capture(), any(HttpClientResponseHandler.class)); + assertThat(captor.getValue().getUri().toString()).doesNotContain("clientId"); + } + + @Test + void getJobResult_withTenantId_appendsClientIdQueryParam() throws Exception { + mockHttpResponse(200, "{\"id\":\"" + JOB_ID + "\",\"status\":\"DONE\"}"); + + client.getJobResult(JOB_ID, "tenant-a"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(HttpUriRequestBase.class); + verify(httpClient).execute(captor.capture(), any(HttpClientResponseHandler.class)); + assertThat(captor.getValue().getUri().toString()).contains("&clientId=tenant-a"); + } + + @Test + void getJobResult_withNullTenantId_noClientIdQueryParam() throws Exception { + mockHttpResponse(200, "{\"id\":\"" + JOB_ID + "\",\"status\":\"DONE\"}"); + + client.getJobResult(JOB_ID, null); + + ArgumentCaptor captor = ArgumentCaptor.forClass(HttpUriRequestBase.class); + verify(httpClient).execute(captor.capture(), any(HttpClientResponseHandler.class)); + assertThat(captor.getValue().getUri().toString()).doesNotContain("clientId"); + } + @SuppressWarnings("unchecked") private void mockHttpResponse(int statusCode, String body) throws IOException { when(response.getCode()).thenReturn(statusCode); diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/BaseIntegrationTest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/BaseIntegrationTest.java index 19a1372..3e8761d 100644 --- a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/BaseIntegrationTest.java +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/BaseIntegrationTest.java @@ -22,8 +22,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.test.web.servlet.MockMvc; @SpringBootTest diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/AbstractDocumentAiTest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/AbstractDocumentAiTest.java index 36f2b7f..b982eb9 100644 --- a/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/AbstractDocumentAiTest.java +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/AbstractDocumentAiTest.java @@ -46,7 +46,8 @@ void runPollCycle( pollingClient(jobResultFn), null, cdsRuntime, - Duration.ZERO); + Duration.ZERO, + false); OutboxMessageEventContext ctx = EventContext.create(OutboxMessageEventContext.class, ExtractionPollingHandler.POLL_EVENT); @@ -56,12 +57,12 @@ void runPollCycle( private DocumentAiClient pollingClient(Function jobResultFn) { return new DocumentAiClient() { @Override - public String submitDocument(DocumentInput input) { + public String submitDocument(DocumentInput input, String tenantId) { throw new UnsupportedOperationException("Submission is not supported by this test client."); } @Override - public ExtractionData getJobResult(String dieJobId) { + public ExtractionData getJobResult(String dieJobId, String tenantId) { return jobResultFn.apply(dieJobId); } }; diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/DocumentAiSetupHandlerTest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/DocumentAiSetupHandlerTest.java new file mode 100644 index 0000000..e6b0c5f --- /dev/null +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/documentai/integrationtest/DocumentAiSetupHandlerTest.java @@ -0,0 +1,88 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.documentai.integrationtest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.ExtractionJob; +import com.sap.cds.feature.documentai.generated.cds4j.sap.document.ai.ExtractionJob_; +import com.sap.cds.feature.documentai.handlers.DocumentAiSetupHandler; +import com.sap.cds.feature.documentai.service.ExtractionService; +import com.sap.cds.feature.documentai.service.ExtractionStatus; +import com.sap.cds.ql.Select; +import com.sap.cds.services.mt.UnsubscribeEventContext; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +class DocumentAiSetupHandlerTest extends AbstractDocumentAiTest { + + @Autowired ExtractionService extractionService; + + @Test + void beforeUnsubscribe_marksActiveJobsAsFailed() { + extractionService.triggerExtraction("doc1.pdf", "application/pdf", null, null, "tenant-a"); + extractionService.triggerExtraction("doc2.pdf", "application/pdf", null, null, "tenant-a"); + extractionService.triggerExtraction("doc3.pdf", "application/pdf", null, null, "tenant-b"); + + fireUnsubscribe("tenant-a"); + + List tenantAJobs = + persistenceService + .run(Select.from(ExtractionJob_.class).where(j -> j.tenantId().eq("tenant-a"))) + .listOf(ExtractionJob.class); + + List tenantBJobs = + persistenceService + .run(Select.from(ExtractionJob_.class).where(j -> j.tenantId().eq("tenant-b"))) + .listOf(ExtractionJob.class); + + assertThat(tenantAJobs) + .hasSize(2) + .allSatisfy(job -> assertThat(job.getStatus()).isEqualTo(ExtractionStatus.FAILED.name())); + + assertThat(tenantBJobs) + .hasSize(1) + .allSatisfy(job -> assertThat(job.getStatus()).isEqualTo(ExtractionStatus.PENDING.name())); + } + + @Test + void beforeUnsubscribe_doesNotAffectTerminalJobs() { + String activeJobId = + extractionService + .triggerExtraction("active.pdf", "application/pdf", null, null, "tenant-a") + .internalJobId(); + String doneJobId = + extractionService + .triggerExtraction("done.pdf", "application/pdf", null, null, "tenant-a") + .internalJobId(); + + extractionService.updateExtractionResult( + doneJobId, ExtractionStatus.SUBMITTED, "die-job-1", null); + extractionService.updateExtractionResult( + doneJobId, ExtractionStatus.DONE, "die-job-1", "{\"result\":{}}"); + + fireUnsubscribe("tenant-a"); + + ExtractionJob activeJob = + persistenceService + .run(Select.from(ExtractionJob_.class).byId(activeJobId)) + .single(ExtractionJob.class); + ExtractionJob doneJob = + persistenceService + .run(Select.from(ExtractionJob_.class).byId(doneJobId)) + .single(ExtractionJob.class); + + assertThat(activeJob.getStatus()).isEqualTo(ExtractionStatus.FAILED.name()); + assertThat(doneJob.getStatus()).isEqualTo(ExtractionStatus.DONE.name()); + } + + private void fireUnsubscribe(String tenantId) { + UnsubscribeEventContext ctx = mock(UnsubscribeEventContext.class); + when(ctx.getTenant()).thenReturn(tenantId); + new DocumentAiSetupHandler(persistenceService).beforeUnsubscribe(ctx); + } +} diff --git a/samples/bookshop/app/index.html b/samples/bookshop/app/index.html index 70f6315..1e10179 100644 --- a/samples/bookshop/app/index.html +++ b/samples/bookshop/app/index.html @@ -15,7 +15,7 @@ -