Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions ai-services/Makefile
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
REGISTRY ?= icr.io/ai-services-private
IMAGE = ai-services
TAG ?= v0.0.449
CONTAINER_BUILDER ?= podman
REGISTRY?=icr.io/ai-services-private
IMAGE=ai-services
TAG?=v0.0.450
CONTAINER_BUILDER?=podman
CREDS_ARG := $(if $(and $(REGISTRY_USER),$(REGISTRY_PASSWORD)),--creds="$(REGISTRY_USER):$(REGISTRY_PASSWORD)")

GO := go
Expand Down
2 changes: 1 addition & 1 deletion ai-services/assets/catalog/openshift/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ ui:
cpu: "500m"

backend:
image: icr.io/ai-services-cicd/ai-services:v0.0.449
image: icr.io/ai-services-cicd/ai-services:v0.0.450
runtime: "openshift"
adminPasswordHash: ""
# @generate:password length=32, special=false
Expand Down
2 changes: 1 addition & 1 deletion ai-services/assets/catalog/podman/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ ui:

backend:
port: ""
image: icr.io/ai-services-cicd/ai-services:v0.0.449
image: icr.io/ai-services-cicd/ai-services:v0.0.450
runtime: ""
adminPasswordHash: ""
# @generate:password length=32, special=false
Expand Down
2 changes: 1 addition & 1 deletion ai-services/assets/worker/openshift/values.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
worker:
image: icr.io/ai-services-cicd/ai-services:v0.0.449
image: icr.io/ai-services-cicd/ai-services:v0.0.450
runtime: "openshift"
token: ""
gatewayAddr: ""
Expand Down
2 changes: 1 addition & 1 deletion ai-services/assets/worker/podman/values.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
worker:
image: icr.io/ai-services-cicd/ai-services:v0.0.449
image: icr.io/ai-services-cicd/ai-services:v0.0.450
runtime: "podman"
token: ""
gatewayAddr: ""
Expand Down
48 changes: 48 additions & 0 deletions ai-services/tests/e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,54 @@ ginkgo -r -tags "$TAGS" \
./tests/e2e -- --app-name=<appname> --template=summarize --runtime=podman
```

## Running Entity Extraction Tests

The Entity Extraction Tests validate the `extract-api` service end-to-end, covering both the synchronous extraction API (`POST /v1/extract`) and the asynchronous jobs API (`POST /v1/extract/jobs`).

The suite manages the full application lifecycle: it runs `catalog configure`, `catalog login`, creates a fresh `extract-app-<runID>`, runs all extraction specs, then deletes the app.

### Prerequisites

- Registry credentials must be set so the suite can pull the extract service image.
- `CATALOG_PASSWORD` must be set — the suite runs `catalog configure` and `catalog login` automatically.
- The Ginkgo CLI version must match the module version (`v2.28.1`). Install with:

```bash
go install github.com/onsi/ginkgo/v2/ginkgo@v2.28.1
export PATH=$PATH:$(go env GOPATH)/bin
```

### Environment variables

```bash
# Required
export CATALOG_PASSWORD="<your-catalog-admin-password>"
export REGISTRY_URL="icr.io"
export REGISTRY_USER_NAME="<your-icr-username>"
export REGISTRY_PASSWORD="<your-icr-api-key>"

# Optional — defaults to true (self-signed certs)
export CATALOG_INSECURE=true

# Optional — override default extract service port (default: 9500)
# export EXTRACT_PORT=9500
```

### Commands to Run Entity Extraction Tests

```bash
# Using make (recommended — build tags applied automatically)
make test TEST_ARGS="--label-filter=extraction-tests --timeout=3h" APP_RUNTIME=podman

# Using ginkgo CLI directly (from ai-services/)
export TAGS="exclude_graphdriver_btrfs containers_image_openpgp remote"
ginkgo -r --procs=1 -tags "$TAGS" \
--label-filter="extraction-tests" \
--timeout=3h --v \
./tests/e2e \
-- -template=extract -runtime=podman
```

## Running Golden Dataset Validation Independently

The RAG Golden Dataset Validation can be executed independently from the full E2E lifecycle. This allows validating an already running RAG application without creating or deleting an application during the test run.
Expand Down
6 changes: 6 additions & 0 deletions ai-services/tests/e2e/cli/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ const (
svcDigitizeBackend = "digitize-backend"
svcSimilarityAPI = "similarity-api"
svcSummarizeAPI = "summarize-api"
svcExtractAPI = "extract-api"
)

// ptyWinRows and ptyWinCols define the PTY window size used by runWithPTY.
Expand Down Expand Up @@ -497,6 +498,11 @@ func ExtractCatalogSummarizeURL(infoOutput string) string {
return extractURLBySubstring(infoOutput, svcSummarizeAPI)
}

// ExtractExtractURL parses the 'application info' output for the entity extraction service URL.
func ExtractExtractURL(infoOutput string) string {
return extractURLBySubstring(infoOutput, svcExtractAPI)
}

// getOpenShiftRouteURLs fetches OpenShift routes for the given application namespace
// via 'oc get routes' and returns the HTTPS URLs found in the output.
// This is the fallback for OpenShift when 'application info' does not embed URLs.
Expand Down
102 changes: 102 additions & 0 deletions ai-services/tests/e2e/common/api.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package common

import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"time"
)

// PaginationInfo holds page metadata for list responses.
type PaginationInfo struct {
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}

// HTTPConfigForTimeout returns an HTTP client config with the given timeout and no connection pooling.
func HTTPConfigForTimeout(timeout time.Duration) HTTPClientConfig {
return HTTPClientConfig{
Timeout: timeout,
InsecureSkipVerify: true,
PoolConnections: false,
}
}

// DeleteURL sends a DELETE request and accepts 200 or 204 as success.
func DeleteURL(ctx context.Context, rawURL string, config HTTPClientConfig) error {
_, statusCode, err := DoDELETE(ctx, rawURL, config)
if err != nil {
return err
}

if statusCode != http.StatusOK && statusCode != http.StatusNoContent {
return fmt.Errorf("unexpected status %d", statusCode)
}

return nil
}

// AppendQueryParams returns rawURL with the non-empty params added as an encoded query string.
func AppendQueryParams(rawURL string, params map[string]string) string {
query := url.Values{}

for key, val := range params {
if val != "" {
query.Set(key, val)
}
}

if len(query) == 0 {
return rawURL
}

return rawURL + "?" + query.Encode()
}

// GetJSON sends a GET request, requires HTTP 200 and decodes the JSON body into a T.
func GetJSON[T any](ctx context.Context, rawURL string, config HTTPClientConfig) (*T, error) {
respBody, statusCode, err := DoGET(ctx, rawURL, config)
if err != nil {
return nil, err
}

if statusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status %d: %s", statusCode, string(respBody))
}

var out T
if err := json.Unmarshal(respBody, &out); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}

return &out, nil
}

// FieldHasValue reports whether data has key set to a non-nil, non-empty-string value.
func FieldHasValue(data map[string]interface{}, key string) bool {
val, ok := data[key]
if !ok || val == nil {
return false
}

if s, isStr := val.(string); isStr {
return s != ""
}

return true
}

// AllFieldsPresent reports whether every key in expected exists in data.
// When one is missing it returns that key and false.
func AllFieldsPresent(data map[string]interface{}, expected []string) (string, bool) {
for _, key := range expected {
if _, ok := data[key]; !ok {
return key, false
}
}

return "", true
}
19 changes: 19 additions & 0 deletions ai-services/tests/e2e/common/files.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,22 @@ func RemoveDirContents(dir string) error {

return nil
}

// CreateTempFile writes content to a new temporary file whose name matches pattern
// (e.g. "extract-text-*.txt") and returns its path. The caller must remove the file.
func CreateTempFile(pattern, content string) (string, error) {
f, err := os.CreateTemp("", pattern)
if err != nil {
return "", fmt.Errorf("failed to create temp file: %w", err)
}

defer func() { _ = f.Close() }()

if _, err := f.WriteString(content); err != nil {
_ = os.Remove(f.Name())

return "", fmt.Errorf("failed to write temp file: %w", err)
}

return f.Name(), nil
}
102 changes: 102 additions & 0 deletions ai-services/tests/e2e/common/jobs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package common

import (
"context"
"fmt"
"time"

"github.com/project-ai-services/ai-services/internal/pkg/logger"
)

// JobStatus is the lifecycle state of an async job.
type JobStatus string

const (
JobStatusAccepted JobStatus = "accepted"
JobStatusInProgress JobStatus = "in_progress"
JobStatusCompleted JobStatus = "completed"
JobStatusCompletedWithErrors JobStatus = "completed_with_errors"
JobStatusFailed JobStatus = "failed"
)

// JobDocStatus is the lifecycle state of a single document inside a job.
type JobDocStatus string

const (
JobDocStatusPending JobDocStatus = "pending"
JobDocStatusInProgress JobDocStatus = "in_progress"
JobDocStatusCompleted JobDocStatus = "completed"
JobDocStatusFailed JobDocStatus = "failed"
)

// JobPollConfig controls how WaitForJob polls a job.
type JobPollConfig struct {
Timeout time.Duration
Interval time.Duration
LogPrefix string
}

// JobState is the part of a job response that WaitForJob needs to decide whether to keep polling.
type JobState struct {
Status JobStatus
Error *string
}

// classifyJobState reports whether polling is finished and the error, if any, for the given state.
func classifyJobState(state JobState) (bool, error) {
switch state.Status {
case JobStatusCompleted, JobStatusCompletedWithErrors:
return true, nil
case JobStatusFailed:
errMsg := "unknown error"
if state.Error != nil {
errMsg = *state.Error
}

return true, fmt.Errorf("job failed: %s", errMsg)
case JobStatusAccepted, JobStatusInProgress:
return false, nil
default:
return true, fmt.Errorf("unexpected job status: %s", state.Status)
}
}

// WaitForJob polls fetch until the job reaches a terminal state, ctx is cancelled or the timeout elapses.
// stateOf extracts the status and error message from the fetched response.
func WaitForJob[T any](
ctx context.Context,
jobID string,
cfg JobPollConfig,
fetch func(context.Context) (*T, error),
stateOf func(*T) JobState,
) (*T, error) {
deadline := time.Now().Add(cfg.Timeout)
ticker := time.NewTicker(cfg.Interval)

defer ticker.Stop()

for {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-ticker.C:
if time.Now().After(deadline) {
return nil, fmt.Errorf("timed out waiting for job %s", jobID)
}

job, err := fetch(ctx)
if err != nil {
logger.Warningf("[%s] get job detail failed: %v", cfg.LogPrefix, err)

continue
}

state := stateOf(job)
logger.Infof("[%s] job %s status: %s", cfg.LogPrefix, jobID, state.Status)

if done, jobErr := classifyJobState(state); done {
return job, jobErr
}
}
}
}
15 changes: 4 additions & 11 deletions ai-services/tests/e2e/digitization/digitize.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,15 +99,8 @@ type JobStatusResponse struct {

// JobsListResponse represents the response when listing jobs.
type JobsListResponse struct {
Data []JobStatusResponse `json:"data"`
Pagination PaginationInfo `json:"pagination"`
}

// PaginationInfo represents pagination metadata.
type PaginationInfo struct {
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
Data []JobStatusResponse `json:"data"`
Pagination common.PaginationInfo `json:"pagination"`
}

// DocumentDetailResponse represents a document returned by both the list and detail endpoints (same JSON shape).
Expand All @@ -129,8 +122,8 @@ type DocumentListItem = DocumentDetailResponse

// DocumentsListResponse represents the response when listing documents.
type DocumentsListResponse struct {
Data []DocumentListItem `json:"data"`
Pagination PaginationInfo `json:"pagination"`
Data []DocumentListItem `json:"data"`
Pagination common.PaginationInfo `json:"pagination"`
}

// DocumentContentResponse represents the document content.
Expand Down
Loading
Loading