Merged in feature/batch-status (pull request #215)
Implement and test the batch status feature * working
This commit is contained in:
@@ -6,6 +6,7 @@ ignore: |
|
||||
out/
|
||||
node_modules/
|
||||
requirements/node_modules/
|
||||
.serena/
|
||||
rules:
|
||||
line-length:
|
||||
max: 150
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"queryorchestration/internal/database/repository"
|
||||
"queryorchestration/internal/document/batch/outcome"
|
||||
documentclean "queryorchestration/internal/document/clean"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -11,30 +13,60 @@ import (
|
||||
|
||||
const Name = "docCleanRunner"
|
||||
|
||||
// Services holds the dependencies for the docCleanRunner.
|
||||
//
|
||||
// Fields:
|
||||
// - Clean: service for cleaning/validating documents
|
||||
// - Outcome: reporter for writing batch pipeline outcomes
|
||||
type Services struct {
|
||||
Clean *documentclean.Service
|
||||
Clean *documentclean.Service
|
||||
Outcome *outcome.Reporter
|
||||
}
|
||||
|
||||
// Runner processes document clean messages from the SQS queue.
|
||||
type Runner struct {
|
||||
svc *Services
|
||||
}
|
||||
|
||||
// New creates a new docCleanRunner.
|
||||
//
|
||||
// Parameters:
|
||||
// - svc: the runner's service dependencies
|
||||
//
|
||||
// Returns:
|
||||
// - Runner: the runner instance
|
||||
func New(svc *Services) Runner {
|
||||
return Runner{
|
||||
svc: svc,
|
||||
}
|
||||
}
|
||||
|
||||
// Body is the SQS message body for docCleanRunner.
|
||||
type Body struct {
|
||||
DocumentID uuid.UUID `json:"id" validate:"required,uuid"`
|
||||
}
|
||||
|
||||
// Process handles a single document clean message. Returns true to acknowledge
|
||||
// the message (success or validation failure), or false to requeue (infrastructure error).
|
||||
// Validation failures are acknowledged (not requeued) to prevent infinite retry loops
|
||||
// for permanently-invalid documents.
|
||||
func (s Runner) Process(ctx context.Context, body Body) bool {
|
||||
err := s.svc.Clean.Clean(ctx, body.DocumentID)
|
||||
result, err := s.svc.Clean.Clean(ctx, body.DocumentID)
|
||||
if err != nil {
|
||||
slog.Error("unable to process", "error", err)
|
||||
return false
|
||||
return false // infrastructure error -> requeue
|
||||
}
|
||||
|
||||
return true
|
||||
outcomeName := repository.BatchOutcomeStatusCleanPassed
|
||||
var errorDetail *string
|
||||
if !result.Passed {
|
||||
outcomeName = repository.BatchOutcomeStatusCleanFailed
|
||||
errorDetail = result.FailReason
|
||||
}
|
||||
if updateErr := s.svc.Outcome.Update(ctx, body.DocumentID, outcomeName, errorDetail); updateErr != nil {
|
||||
slog.Error("failed to update batch outcome",
|
||||
"document_id", body.DocumentID, "outcome", outcomeName, "error", updateErr)
|
||||
}
|
||||
|
||||
return true // validation pass or fail -> acknowledge (don't requeue bad PDFs)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
// Package doccleanrunner_test contains integration tests for the docCleanRunner.
|
||||
// Tests use real database and S3 via testcontainers (no mocks).
|
||||
package doccleanrunner_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
doccleanrunner "queryorchestration/api/docCleanRunner"
|
||||
"queryorchestration/internal/database/repository"
|
||||
"queryorchestration/internal/document/batch/outcome"
|
||||
documentclean "queryorchestration/internal/document/clean"
|
||||
"queryorchestration/internal/server/runner"
|
||||
"queryorchestration/internal/serviceconfig/objectstore"
|
||||
"queryorchestration/internal/serviceconfig/queue"
|
||||
"queryorchestration/internal/test"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type DocCleanConfig struct {
|
||||
runner.BaseConfig[doccleanrunner.Body]
|
||||
queue.QueueConfig
|
||||
objectstore.ObjectStoreConfig
|
||||
}
|
||||
|
||||
// TestDocCleanRunner_ReportsCleanPassed tests that for a document that has
|
||||
// already been cleaned (HasDocumentCleanEntry returns true), the runner
|
||||
// reports clean_passed.
|
||||
func TestDocCleanRunner_ReportsCleanPassed(t *testing.T) {
|
||||
cfg := &DocCleanConfig{}
|
||||
test.CreateDB(t, cfg)
|
||||
ctx := t.Context()
|
||||
|
||||
clientID := "clean_passed_" + uuid.New().String()[:8]
|
||||
err := cfg.GetDBQueries().CreateClient(ctx, &repository.CreateClientParams{
|
||||
Clientid: clientID,
|
||||
Name: "Test Clean Passed",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create batch
|
||||
batchID, err := cfg.GetDBQueries().CreateBatchUpload(ctx, &repository.CreateBatchUploadParams{
|
||||
ClientID: clientID,
|
||||
OriginalFilename: "test.zip",
|
||||
TotalDocuments: 1,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create document
|
||||
docID, err := cfg.GetDBQueries().CreateDocument(ctx, &repository.CreateDocumentParams{
|
||||
Clientid: clientID,
|
||||
Hash: "hash_clean_pass_" + uuid.New().String()[:8],
|
||||
BatchID: &batchID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a successful clean entry so HasDocumentCleanEntry returns true.
|
||||
// This takes the shortcut path in Clean() -> returns {Passed: true}
|
||||
bucket := "bucket"
|
||||
key := "key"
|
||||
hash := "hash"
|
||||
cleanID, err := cfg.GetDBQueries().AddDocumentClean(ctx, &repository.AddDocumentCleanParams{
|
||||
Documentid: docID,
|
||||
Bucket: &bucket,
|
||||
Key: &key,
|
||||
Hash: &hash,
|
||||
Mimetype: repository.NullCleanmimetype{
|
||||
Cleanmimetype: repository.CleanmimetypeApplicationPdf,
|
||||
Valid: true,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
err = cfg.GetDBQueries().AddDocumentCleanEntry(ctx, &repository.AddDocumentCleanEntryParams{
|
||||
Cleanid: cleanID,
|
||||
Version: 1,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Insert sync_complete outcome (the state before clean)
|
||||
err = cfg.GetDBQueries().InsertBatchDocumentOutcome(ctx, &repository.InsertBatchDocumentOutcomeParams{
|
||||
BatchID: batchID,
|
||||
Filename: "invoice.pdf",
|
||||
Column3: repository.BatchOutcomeStatusSyncComplete,
|
||||
DocumentID: &docID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
cleanRunner := doccleanrunner.New(&doccleanrunner.Services{
|
||||
Clean: documentclean.New(cfg),
|
||||
Outcome: outcome.New(cfg.GetDBQueries()),
|
||||
})
|
||||
|
||||
assert.True(t, cleanRunner.Process(ctx, doccleanrunner.Body{DocumentID: docID}))
|
||||
|
||||
// Verify outcome updated to clean_passed
|
||||
rows, err := cfg.GetDBQueries().ListBatchDocumentOutcomes(ctx, batchID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, rows, 1)
|
||||
assert.Equal(t, repository.BatchOutcomeStatusCleanPassed, rows[0].Outcome)
|
||||
}
|
||||
|
||||
// TestDocCleanRunner_ReportsCleanFailed tests that a document failing clean
|
||||
// validation gets the clean_failed outcome.
|
||||
func TestDocCleanRunner_ReportsCleanFailed(t *testing.T) {
|
||||
cfg := &DocCleanConfig{}
|
||||
test.CreateDB(t, cfg)
|
||||
test.CreateAWSResources(t, cfg)
|
||||
ctx := t.Context()
|
||||
|
||||
clientID := "clean_failed_" + uuid.New().String()[:8]
|
||||
err := cfg.GetDBQueries().CreateClient(ctx, &repository.CreateClientParams{
|
||||
Clientid: clientID,
|
||||
Name: "Test Clean Failed",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create batch
|
||||
batchID, err := cfg.GetDBQueries().CreateBatchUpload(ctx, &repository.CreateBatchUploadParams{
|
||||
ClientID: clientID,
|
||||
OriginalFilename: "test.zip",
|
||||
TotalDocuments: 1,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Upload a non-PDF file to S3 (plain text content that will fail mime type check)
|
||||
fileContent := []byte("This is NOT a PDF file. It has no PDF signature.")
|
||||
location := objectstore.BucketKey{
|
||||
Location: objectstore.Import,
|
||||
ClientID: clientID,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
EntityID: uuid.New(),
|
||||
}
|
||||
|
||||
_, err = cfg.GetStoreClient().PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: aws.String(cfg.GetBucket()),
|
||||
Key: aws.String(location.String()),
|
||||
Body: bytes.NewReader(fileContent),
|
||||
ContentType: aws.String("text/plain"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get the ETag so we can use it as the hash for IfMatch
|
||||
headResp, err := cfg.GetStoreClient().HeadObject(ctx, &s3.HeadObjectInput{
|
||||
Bucket: aws.String(cfg.GetBucket()),
|
||||
Key: aws.String(location.String()),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
etag := *headResp.ETag
|
||||
|
||||
// Create document with hash matching the ETag
|
||||
docID, err := cfg.GetDBQueries().CreateDocument(ctx, &repository.CreateDocumentParams{
|
||||
Clientid: clientID,
|
||||
Hash: etag,
|
||||
BatchID: &batchID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Add document entry pointing to the S3 file
|
||||
err = cfg.GetDBQueries().AddDocumentEntry(ctx, &repository.AddDocumentEntryParams{
|
||||
Documentid: docID,
|
||||
Bucket: cfg.GetBucket(),
|
||||
Key: location.String(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Insert sync_complete outcome
|
||||
err = cfg.GetDBQueries().InsertBatchDocumentOutcome(ctx, &repository.InsertBatchDocumentOutcomeParams{
|
||||
BatchID: batchID,
|
||||
Filename: "bad_file.pdf",
|
||||
Column3: repository.BatchOutcomeStatusSyncComplete,
|
||||
DocumentID: &docID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
cleanRunner := doccleanrunner.New(&doccleanrunner.Services{
|
||||
Clean: documentclean.New(cfg),
|
||||
Outcome: outcome.New(cfg.GetDBQueries()),
|
||||
})
|
||||
|
||||
// The runner should return true (acknowledge), even for validation failures
|
||||
result := cleanRunner.Process(ctx, doccleanrunner.Body{DocumentID: docID})
|
||||
assert.True(t, result, "validation failures should be acknowledged (not requeued)")
|
||||
|
||||
// Verify outcome updated to clean_failed
|
||||
rows, err := cfg.GetDBQueries().ListBatchDocumentOutcomes(ctx, batchID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, rows, 1)
|
||||
assert.Equal(t, repository.BatchOutcomeStatusCleanFailed, rows[0].Outcome)
|
||||
}
|
||||
|
||||
// TestDocCleanRunner_RequeuesOnInfraError tests that an infrastructure error
|
||||
// (e.g., document not found in DB) causes the runner to return false (requeue).
|
||||
func TestDocCleanRunner_RequeuesOnInfraError(t *testing.T) {
|
||||
cfg := &DocCleanConfig{}
|
||||
test.CreateDB(t, cfg)
|
||||
ctx := t.Context()
|
||||
|
||||
cleanRunner := doccleanrunner.New(&doccleanrunner.Services{
|
||||
Clean: documentclean.New(cfg),
|
||||
Outcome: outcome.New(cfg.GetDBQueries()),
|
||||
})
|
||||
|
||||
// Process a non-existent document -- should fail with DB error
|
||||
result := cleanRunner.Process(ctx, doccleanrunner.Body{DocumentID: uuid.New()})
|
||||
assert.False(t, result, "infrastructure error should return false for requeue")
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"log/slog"
|
||||
|
||||
"queryorchestration/internal/database/repository"
|
||||
"queryorchestration/internal/document/batch/outcome"
|
||||
documentinit "queryorchestration/internal/document/init"
|
||||
"queryorchestration/internal/serviceconfig/objectstore"
|
||||
|
||||
@@ -15,27 +16,45 @@ import (
|
||||
|
||||
const Name = "docInitRunner"
|
||||
|
||||
// Services holds the dependencies for the docInitRunner.
|
||||
//
|
||||
// Fields:
|
||||
// - Document: service for creating/deduplicating documents
|
||||
// - Queries: SQLC-generated database queries
|
||||
// - Outcome: reporter for writing batch pipeline outcomes
|
||||
type Services struct {
|
||||
Document *documentinit.Service
|
||||
Queries *repository.Queries
|
||||
Outcome *outcome.Reporter
|
||||
}
|
||||
|
||||
// Runner processes document init messages from the SQS queue.
|
||||
type Runner struct {
|
||||
svc *Services
|
||||
}
|
||||
|
||||
// New creates a new docInitRunner.
|
||||
//
|
||||
// Parameters:
|
||||
// - svc: the runner's service dependencies
|
||||
//
|
||||
// Returns:
|
||||
// - Runner: the runner instance
|
||||
func New(svc *Services) Runner {
|
||||
return Runner{
|
||||
svc: svc,
|
||||
}
|
||||
}
|
||||
|
||||
// Body is the SQS message body for docInitRunner.
|
||||
type Body struct {
|
||||
Bucket string `json:"bucket" validate:"required"`
|
||||
Key string `json:"key" validate:"required"`
|
||||
Hash string `json:"hash" validate:"required"`
|
||||
}
|
||||
|
||||
// Process handles a single document init message. Returns true to acknowledge
|
||||
// the message (success), or false to requeue (infrastructure error).
|
||||
func (s Runner) Process(ctx context.Context, body Body) bool {
|
||||
key, err := objectstore.ParseBucketKey(body.Key)
|
||||
if err != nil {
|
||||
@@ -61,7 +80,7 @@ func (s Runner) Process(ctx context.Context, body Body) bool {
|
||||
folderID = upload.FolderID
|
||||
}
|
||||
|
||||
id, err := s.svc.Document.Create(ctx, &documentinit.Create{
|
||||
result, err := s.svc.Document.Create(ctx, &documentinit.Create{
|
||||
Key: key,
|
||||
Bucket: body.Bucket,
|
||||
Hash: body.Hash,
|
||||
@@ -72,7 +91,23 @@ func (s Runner) Process(ctx context.Context, body Body) bool {
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
slog.Debug("created document", "id", id, "filename", filename, "folder_id", folderID)
|
||||
slog.Debug("created document", "id", result.ID, "filename", filename, "folder_id", folderID)
|
||||
|
||||
// Report outcome for batch documents
|
||||
outcomeName := repository.BatchOutcomeStatusInitComplete
|
||||
if result.IsDuplicate {
|
||||
outcomeName = repository.BatchOutcomeStatusInitDuplicate
|
||||
}
|
||||
var resolveFilename string
|
||||
if result.Filename != nil {
|
||||
resolveFilename = *result.Filename
|
||||
}
|
||||
if resolveErr := s.svc.Outcome.Resolve(ctx, result.BatchID, resolveFilename,
|
||||
result.ID, outcomeName, nil); resolveErr != nil {
|
||||
slog.Error("failed to resolve batch outcome",
|
||||
"document_id", result.ID, "outcome", outcomeName, "error", resolveErr)
|
||||
// Non-fatal: document was created successfully
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
docinitrunner "queryorchestration/api/docInitRunner"
|
||||
"queryorchestration/internal/database/repository"
|
||||
"queryorchestration/internal/document/batch/outcome"
|
||||
documentinit "queryorchestration/internal/document/init"
|
||||
"queryorchestration/internal/server/runner"
|
||||
"queryorchestration/internal/serviceconfig/objectstore"
|
||||
@@ -51,6 +52,7 @@ func TestDocInitRunner(t *testing.T) {
|
||||
runnerInstance := docinitrunner.New(&docinitrunner.Services{
|
||||
Document: documentinit.New(cfg),
|
||||
Queries: cfg.GetDBQueries(),
|
||||
Outcome: outcome.New(cfg.GetDBQueries()),
|
||||
})
|
||||
|
||||
err := cfg.GetDBQueries().CreateClient(t.Context(), &repository.CreateClientParams{
|
||||
@@ -101,3 +103,242 @@ func TestDocInitRunner(t *testing.T) {
|
||||
|
||||
test.AssertMessageBody(t, cfg, cfg.GetDocumentSyncURL(), regexp.MustCompile(`{"id":".+"}`))
|
||||
}
|
||||
|
||||
// setupDocInitTest creates shared infrastructure (DB, S3, SQS) and returns
|
||||
// the config, runner, and a cleanup-style helper.
|
||||
func setupDocInitTest(t *testing.T) *DocInitConfig {
|
||||
t.Helper()
|
||||
cfg := &DocInitConfig{}
|
||||
test.CreateDB(t, cfg)
|
||||
acfg := test.CreateAWSContainer(t, cfg)
|
||||
|
||||
test.SetQueueClient(t, t.Context(), cfg, acfg.ExternalEndpoint)
|
||||
cfg.DocumentSyncURL = test.CreateQueue(t, cfg, test.DocSyncRunnerName)
|
||||
|
||||
test.SetStoreClient(t, t.Context(), cfg, acfg.ExternalEndpoint)
|
||||
test.CreateBucket(t, cfg)
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
// uploadTestFileToS3 uploads a test file and returns the BucketKey.
|
||||
// If batchID is non-nil, it is embedded in the key so ParseBucketKey can extract it.
|
||||
func uploadTestFileToS3(t *testing.T, cfg *DocInitConfig, clientID string, batchID *uuid.UUID) objectstore.BucketKey {
|
||||
t.Helper()
|
||||
location := objectstore.BucketKey{
|
||||
Location: objectstore.Import,
|
||||
ClientID: clientID,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
EntityID: uuid.New(),
|
||||
BatchID: batchID,
|
||||
}
|
||||
|
||||
testContent := []byte("test doc content " + uuid.New().String())
|
||||
_, err := cfg.GetStoreClient().PutObject(t.Context(), &s3.PutObjectInput{
|
||||
Bucket: aws.String(cfg.GetBucket()),
|
||||
Key: aws.String(location.String()),
|
||||
Body: bytes.NewReader(testContent),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
return location
|
||||
}
|
||||
|
||||
// TestDocInitRunner_ReportsInitComplete processes a batch document and verifies
|
||||
// the outcome row is updated to init_complete with the assigned document_id.
|
||||
func TestDocInitRunner_ReportsInitComplete(t *testing.T) {
|
||||
cfg := setupDocInitTest(t)
|
||||
ctx := t.Context()
|
||||
|
||||
clientID := "batch_init_complete_" + uuid.New().String()[:8]
|
||||
err := cfg.GetDBQueries().CreateClient(ctx, &repository.CreateClientParams{
|
||||
Clientid: clientID,
|
||||
Name: "Batch Init Complete",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create batch
|
||||
batchID, err := cfg.GetDBQueries().CreateBatchUpload(ctx, &repository.CreateBatchUploadParams{
|
||||
ClientID: clientID,
|
||||
OriginalFilename: "test.zip",
|
||||
TotalDocuments: 1,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
filename := "invoice.pdf"
|
||||
// Insert submitted outcome
|
||||
err = cfg.GetDBQueries().InsertBatchDocumentOutcome(ctx, &repository.InsertBatchDocumentOutcomeParams{
|
||||
BatchID: batchID,
|
||||
Filename: filename,
|
||||
Column3: repository.BatchOutcomeStatusSubmitted,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Upload test file and create upload record with batch ID embedded in key
|
||||
location := uploadTestFileToS3(t, cfg, clientID, &batchID)
|
||||
|
||||
uploadID := uuid.New()
|
||||
err = cfg.GetDBQueries().AddDocumentUpload(ctx, &repository.AddDocumentUploadParams{
|
||||
ID: uploadID,
|
||||
Clientid: clientID,
|
||||
Bucket: cfg.GetBucket(),
|
||||
Key: location.String(),
|
||||
Part: 1,
|
||||
Createdat: pgtype.Timestamp{
|
||||
Valid: true,
|
||||
Time: time.Now().UTC(),
|
||||
},
|
||||
Filename: &filename,
|
||||
BatchID: &batchID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
runnerInstance := docinitrunner.New(&docinitrunner.Services{
|
||||
Document: documentinit.New(cfg),
|
||||
Queries: cfg.GetDBQueries(),
|
||||
Outcome: outcome.New(cfg.GetDBQueries()),
|
||||
})
|
||||
|
||||
doc := docinitrunner.Body{
|
||||
Bucket: cfg.GetBucket(),
|
||||
Key: location.String(),
|
||||
Hash: "unique_hash_init_complete_" + uuid.New().String()[:8],
|
||||
}
|
||||
|
||||
assert.True(t, runnerInstance.Process(ctx, doc))
|
||||
|
||||
// Verify outcome was updated to init_complete with document_id set
|
||||
rows, err := cfg.GetDBQueries().ListBatchDocumentOutcomes(ctx, batchID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, rows, 1)
|
||||
assert.Equal(t, repository.BatchOutcomeStatusInitComplete, rows[0].Outcome)
|
||||
assert.NotNil(t, rows[0].DocumentID, "document_id should be set after init")
|
||||
}
|
||||
|
||||
// TestDocInitRunner_ReportsInitDuplicate processes a batch document whose hash
|
||||
// matches an existing document and verifies the outcome is init_duplicate.
|
||||
func TestDocInitRunner_ReportsInitDuplicate(t *testing.T) {
|
||||
cfg := setupDocInitTest(t)
|
||||
ctx := t.Context()
|
||||
|
||||
clientID := "batch_init_dup_" + uuid.New().String()[:8]
|
||||
err := cfg.GetDBQueries().CreateClient(ctx, &repository.CreateClientParams{
|
||||
Clientid: clientID,
|
||||
Name: "Batch Init Duplicate",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create an existing document with a known hash
|
||||
existingHash := "existing_hash_" + uuid.New().String()[:8]
|
||||
_, err = cfg.GetDBQueries().CreateDocument(ctx, &repository.CreateDocumentParams{
|
||||
Clientid: clientID,
|
||||
Hash: existingHash,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create batch
|
||||
batchID, err := cfg.GetDBQueries().CreateBatchUpload(ctx, &repository.CreateBatchUploadParams{
|
||||
ClientID: clientID,
|
||||
OriginalFilename: "test.zip",
|
||||
TotalDocuments: 1,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
filename := "duplicate.pdf"
|
||||
// Insert submitted outcome
|
||||
err = cfg.GetDBQueries().InsertBatchDocumentOutcome(ctx, &repository.InsertBatchDocumentOutcomeParams{
|
||||
BatchID: batchID,
|
||||
Filename: filename,
|
||||
Column3: repository.BatchOutcomeStatusSubmitted,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Upload test file and create upload record with batch ID in key
|
||||
location := uploadTestFileToS3(t, cfg, clientID, &batchID)
|
||||
|
||||
uploadID := uuid.New()
|
||||
err = cfg.GetDBQueries().AddDocumentUpload(ctx, &repository.AddDocumentUploadParams{
|
||||
ID: uploadID,
|
||||
Clientid: clientID,
|
||||
Bucket: cfg.GetBucket(),
|
||||
Key: location.String(),
|
||||
Part: 1,
|
||||
Createdat: pgtype.Timestamp{
|
||||
Valid: true,
|
||||
Time: time.Now().UTC(),
|
||||
},
|
||||
Filename: &filename,
|
||||
BatchID: &batchID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
runnerInstance := docinitrunner.New(&docinitrunner.Services{
|
||||
Document: documentinit.New(cfg),
|
||||
Queries: cfg.GetDBQueries(),
|
||||
Outcome: outcome.New(cfg.GetDBQueries()),
|
||||
})
|
||||
|
||||
// Use the same hash as the existing document to trigger duplicate
|
||||
doc := docinitrunner.Body{
|
||||
Bucket: cfg.GetBucket(),
|
||||
Key: location.String(),
|
||||
Hash: existingHash,
|
||||
}
|
||||
|
||||
assert.True(t, runnerInstance.Process(ctx, doc))
|
||||
|
||||
// Verify outcome was updated to init_duplicate
|
||||
rows, err := cfg.GetDBQueries().ListBatchDocumentOutcomes(ctx, batchID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, rows, 1)
|
||||
assert.Equal(t, repository.BatchOutcomeStatusInitDuplicate, rows[0].Outcome)
|
||||
assert.NotNil(t, rows[0].DocumentID, "document_id should be set for duplicate")
|
||||
}
|
||||
|
||||
// TestDocInitRunner_NoOpForNonBatchDocument verifies that processing a non-batch
|
||||
// document does not cause outcome errors (the Resolve call is a no-op for nil batchID).
|
||||
func TestDocInitRunner_NoOpForNonBatchDocument(t *testing.T) {
|
||||
cfg := setupDocInitTest(t)
|
||||
ctx := t.Context()
|
||||
|
||||
clientID := "nonbatch_init_" + uuid.New().String()[:8]
|
||||
err := cfg.GetDBQueries().CreateClient(ctx, &repository.CreateClientParams{
|
||||
Clientid: clientID,
|
||||
Name: "NonBatch Init",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
location := uploadTestFileToS3(t, cfg, clientID, nil)
|
||||
|
||||
uploadID := uuid.New()
|
||||
fname := "regular.pdf"
|
||||
err = cfg.GetDBQueries().AddDocumentUpload(ctx, &repository.AddDocumentUploadParams{
|
||||
ID: uploadID,
|
||||
Clientid: clientID,
|
||||
Bucket: cfg.GetBucket(),
|
||||
Key: location.String(),
|
||||
Part: 1,
|
||||
Createdat: pgtype.Timestamp{
|
||||
Valid: true,
|
||||
Time: time.Now().UTC(),
|
||||
},
|
||||
Filename: &fname,
|
||||
BatchID: nil, // Non-batch
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
runnerInstance := docinitrunner.New(&docinitrunner.Services{
|
||||
Document: documentinit.New(cfg),
|
||||
Queries: cfg.GetDBQueries(),
|
||||
Outcome: outcome.New(cfg.GetDBQueries()),
|
||||
})
|
||||
|
||||
doc := docinitrunner.Body{
|
||||
Bucket: cfg.GetBucket(),
|
||||
Key: location.String(),
|
||||
Hash: "nonbatch_hash_" + uuid.New().String()[:8],
|
||||
}
|
||||
|
||||
// Should process successfully without any outcome errors
|
||||
assert.True(t, runnerInstance.Process(ctx, doc))
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"queryorchestration/internal/database/repository"
|
||||
"queryorchestration/internal/document/batch/outcome"
|
||||
documentsync "queryorchestration/internal/document/sync"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -11,30 +13,56 @@ import (
|
||||
|
||||
const Name = "docSyncRunner"
|
||||
|
||||
// Services holds the dependencies for the docSyncRunner.
|
||||
//
|
||||
// Fields:
|
||||
// - Document: service for syncing documents
|
||||
// - Outcome: reporter for writing batch pipeline outcomes
|
||||
type Services struct {
|
||||
Document *documentsync.Service
|
||||
Outcome *outcome.Reporter
|
||||
}
|
||||
|
||||
// Runner processes document sync messages from the SQS queue.
|
||||
type Runner struct {
|
||||
svc *Services
|
||||
}
|
||||
|
||||
// New creates a new docSyncRunner.
|
||||
//
|
||||
// Parameters:
|
||||
// - svc: the runner's service dependencies
|
||||
//
|
||||
// Returns:
|
||||
// - Runner: the runner instance
|
||||
func New(svc *Services) Runner {
|
||||
return Runner{
|
||||
svc: svc,
|
||||
}
|
||||
}
|
||||
|
||||
// Body is the SQS message body for docSyncRunner.
|
||||
type Body struct {
|
||||
DocumentID uuid.UUID `json:"id" validate:"required,uuid"`
|
||||
}
|
||||
|
||||
// Process handles a single document sync message. Returns true to acknowledge
|
||||
// the message (success), or false to requeue (infrastructure error).
|
||||
func (s Runner) Process(ctx context.Context, body Body) bool {
|
||||
err := s.svc.Document.Sync(ctx, body.DocumentID)
|
||||
result, err := s.svc.Document.Sync(ctx, body.DocumentID)
|
||||
if err != nil {
|
||||
slog.Error("unable to process", "error", err)
|
||||
return false
|
||||
}
|
||||
|
||||
outcomeName := repository.BatchOutcomeStatusSyncComplete
|
||||
if result.Skipped {
|
||||
outcomeName = repository.BatchOutcomeStatusSyncSkipped
|
||||
}
|
||||
if updateErr := s.svc.Outcome.Update(ctx, body.DocumentID, outcomeName, nil); updateErr != nil {
|
||||
slog.Error("failed to update batch outcome",
|
||||
"document_id", body.DocumentID, "outcome", outcomeName, "error", updateErr)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"queryorchestration/internal/client"
|
||||
"queryorchestration/internal/database/repository"
|
||||
"queryorchestration/internal/document"
|
||||
"queryorchestration/internal/document/batch/outcome"
|
||||
documentsync "queryorchestration/internal/document/sync"
|
||||
"queryorchestration/internal/server/runner"
|
||||
"queryorchestration/internal/serviceconfig/objectstore"
|
||||
@@ -42,6 +43,7 @@ func TestDocSyncRunner(t *testing.T) {
|
||||
Document: document.New(cfg),
|
||||
Client: client.New(cfg),
|
||||
}),
|
||||
Outcome: outcome.New(cfg.GetDBQueries()),
|
||||
})
|
||||
|
||||
err := cfg.GetDBQueries().CreateClient(t.Context(), &repository.CreateClientParams{
|
||||
@@ -85,3 +87,131 @@ func TestDocSyncRunner(t *testing.T) {
|
||||
|
||||
test.AssertMessageBody(t, cfg, cfg.GetDocumentCleanURL(), regexp.MustCompile(fmt.Sprintf("{\"id\":\"%s\"}", docId)))
|
||||
}
|
||||
|
||||
// setupDocSyncTest creates shared infrastructure and returns the config.
|
||||
func setupDocSyncTest(t *testing.T) *DocSyncConfig {
|
||||
t.Helper()
|
||||
cfg := &DocSyncConfig{}
|
||||
test.CreateDB(t, cfg)
|
||||
acfg := test.CreateAWSContainer(t, cfg)
|
||||
|
||||
test.SetQueueClient(t, t.Context(), cfg, acfg.ExternalEndpoint)
|
||||
cfg.DocumentCleanURL = test.CreateQueue(t, cfg, test.DocCleanRunnerName)
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
// setupBatchSyncTest creates a client, batch, document, outcome row, and
|
||||
// returns all IDs needed for a sync runner test.
|
||||
func setupBatchSyncTest(t *testing.T, cfg *DocSyncConfig, canSync bool) (uuid.UUID, uuid.UUID) {
|
||||
t.Helper()
|
||||
ctx := t.Context()
|
||||
|
||||
clientID := fmt.Sprintf("sync_%s_%s", fmt.Sprintf("%v", canSync), uuid.New().String()[:8])
|
||||
|
||||
// Create client and set sync flag
|
||||
err := cfg.GetDBQueries().CreateClient(ctx, &repository.CreateClientParams{
|
||||
Clientid: clientID,
|
||||
Name: "Test " + clientID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
err = cfg.GetDBQueries().AddClientCanSync(ctx, &repository.AddClientCanSyncParams{
|
||||
Clientid: clientID,
|
||||
Cansync: canSync,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create batch (client must exist first due to FK)
|
||||
batchID, err := cfg.GetDBQueries().CreateBatchUpload(ctx, &repository.CreateBatchUploadParams{
|
||||
ClientID: clientID,
|
||||
OriginalFilename: "test.zip",
|
||||
TotalDocuments: 1,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create document linked to batch
|
||||
docID, err := cfg.GetDBQueries().CreateDocument(ctx, &repository.CreateDocumentParams{
|
||||
Clientid: clientID,
|
||||
Hash: "hash_" + uuid.New().String()[:8],
|
||||
BatchID: &batchID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
part := uint16(1)
|
||||
filetype := "pdf"
|
||||
key := objectstore.BucketKey{
|
||||
CreatedAt: time.Now().UTC(),
|
||||
ClientID: clientID,
|
||||
EntityID: uuid.New(),
|
||||
Location: objectstore.Import,
|
||||
Part: &part,
|
||||
FileType: &filetype,
|
||||
}
|
||||
err = cfg.GetDBQueries().AddDocumentEntry(ctx, &repository.AddDocumentEntryParams{
|
||||
Documentid: docID,
|
||||
Bucket: "bucket",
|
||||
Key: key.String(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Insert init_complete outcome (the state before sync)
|
||||
err = cfg.GetDBQueries().InsertBatchDocumentOutcome(ctx, &repository.InsertBatchDocumentOutcomeParams{
|
||||
BatchID: batchID,
|
||||
Filename: "test.pdf",
|
||||
Column3: repository.BatchOutcomeStatusInitComplete,
|
||||
DocumentID: &docID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
return batchID, docID
|
||||
}
|
||||
|
||||
// TestDocSyncRunner_ReportsSyncComplete verifies that processing a batch document
|
||||
// with can_sync=true updates the outcome to sync_complete.
|
||||
func TestDocSyncRunner_ReportsSyncComplete(t *testing.T) {
|
||||
cfg := setupDocSyncTest(t)
|
||||
ctx := t.Context()
|
||||
|
||||
batchID, docID := setupBatchSyncTest(t, cfg, true)
|
||||
|
||||
syncRunner := docsyncrunner.New(&docsyncrunner.Services{
|
||||
Document: documentsync.New(cfg, &documentsync.Services{
|
||||
Document: document.New(cfg),
|
||||
Client: client.New(cfg),
|
||||
}),
|
||||
Outcome: outcome.New(cfg.GetDBQueries()),
|
||||
})
|
||||
|
||||
assert.True(t, syncRunner.Process(ctx, docsyncrunner.Body{DocumentID: docID}))
|
||||
|
||||
// Verify outcome updated to sync_complete
|
||||
rows, err := cfg.GetDBQueries().ListBatchDocumentOutcomes(ctx, batchID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, rows, 1)
|
||||
assert.Equal(t, repository.BatchOutcomeStatusSyncComplete, rows[0].Outcome)
|
||||
}
|
||||
|
||||
// TestDocSyncRunner_ReportsSyncSkipped verifies that processing a batch document
|
||||
// with can_sync=false updates the outcome to sync_skipped.
|
||||
func TestDocSyncRunner_ReportsSyncSkipped(t *testing.T) {
|
||||
cfg := setupDocSyncTest(t)
|
||||
ctx := t.Context()
|
||||
|
||||
batchID, docID := setupBatchSyncTest(t, cfg, false)
|
||||
|
||||
syncRunner := docsyncrunner.New(&docsyncrunner.Services{
|
||||
Document: documentsync.New(cfg, &documentsync.Services{
|
||||
Document: document.New(cfg),
|
||||
Client: client.New(cfg),
|
||||
}),
|
||||
Outcome: outcome.New(cfg.GetDBQueries()),
|
||||
})
|
||||
|
||||
assert.True(t, syncRunner.Process(ctx, docsyncrunner.Body{DocumentID: docID}))
|
||||
|
||||
// Verify outcome updated to sync_skipped
|
||||
rows, err := cfg.GetDBQueries().ListBatchDocumentOutcomes(ctx, batchID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, rows, 1)
|
||||
assert.Equal(t, repository.BatchOutcomeStatusSyncSkipped, rows[0].Outcome)
|
||||
}
|
||||
|
||||
+501
-437
File diff suppressed because it is too large
Load Diff
+44
-16
@@ -254,13 +254,13 @@ func (s *Controllers) UploadDocumentBatch(ctx echo.Context, clientId ClientID) e
|
||||
return ctx.JSON(http.StatusAccepted, response)
|
||||
}
|
||||
|
||||
// GetDocumentBatch returns batch upload status
|
||||
// GetDocumentBatch returns batch upload status including per-file document outcomes.
|
||||
func (s *Controllers) GetDocumentBatch(ctx echo.Context, clientId ClientID, batchId BatchID) error {
|
||||
// BatchID is already a UUID type
|
||||
batchUUID := uuid.UUID(batchId)
|
||||
|
||||
// Get batch details
|
||||
batch, err := s.svc.DocumentBatch.Get(ctx.Request().Context(), clientId, batchUUID)
|
||||
// Get batch details (includes document outcomes)
|
||||
batchDetails, err := s.svc.DocumentBatch.Get(ctx.Request().Context(), clientId, batchUUID)
|
||||
if err != nil {
|
||||
slog.Error("unable to get batch", "error", err, "batchId", batchId)
|
||||
return echo.NewHTTPError(http.StatusNotFound, "batch not found")
|
||||
@@ -268,22 +268,50 @@ func (s *Controllers) GetDocumentBatch(ctx echo.Context, clientId ClientID, batc
|
||||
|
||||
// Convert to API response format
|
||||
details := BatchUploadDetails{
|
||||
BatchId: BatchID(batch.ID),
|
||||
ClientId: ClientID(batch.ClientID),
|
||||
OriginalFilename: batch.OriginalFilename,
|
||||
Status: BatchStatus(batch.Status),
|
||||
TotalDocuments: batch.TotalDocuments,
|
||||
ProcessedDocuments: batch.ProcessedDocuments,
|
||||
FailedDocuments: batch.FailedDocuments,
|
||||
InvalidTypeDocuments: batch.InvalidTypeDocuments,
|
||||
ProgressPercent: batch.ProgressPercent,
|
||||
FailedFilenames: &batch.FailedFilenames,
|
||||
CreatedAt: batch.CreatedAt,
|
||||
BatchId: BatchID(batchDetails.ID),
|
||||
ClientId: ClientID(batchDetails.ClientID),
|
||||
OriginalFilename: batchDetails.OriginalFilename,
|
||||
Status: BatchStatus(batchDetails.Status),
|
||||
TotalDocuments: batchDetails.TotalDocuments,
|
||||
ProcessedDocuments: batchDetails.ProcessedDocuments,
|
||||
FailedDocuments: batchDetails.FailedDocuments,
|
||||
InvalidTypeDocuments: batchDetails.InvalidTypeDocuments,
|
||||
ProgressPercent: batchDetails.ProgressPercent,
|
||||
FailedFilenames: &batchDetails.FailedFilenames,
|
||||
CreatedAt: batchDetails.CreatedAt,
|
||||
}
|
||||
|
||||
// Handle nullable completed at
|
||||
if batch.CompletedAt != nil {
|
||||
details.CompletedAt = nullable.NewNullableWithValue(*batch.CompletedAt)
|
||||
if batchDetails.CompletedAt != nil {
|
||||
details.CompletedAt = nullable.NewNullableWithValue(*batchDetails.CompletedAt)
|
||||
}
|
||||
|
||||
// Map document outcomes to API response format
|
||||
if len(batchDetails.DocumentOutcomes) > 0 {
|
||||
outcomes := make([]BatchDocumentOutcome, len(batchDetails.DocumentOutcomes))
|
||||
for i, o := range batchDetails.DocumentOutcomes {
|
||||
outcome := BatchDocumentOutcome{
|
||||
Filename: o.Filename,
|
||||
Outcome: BatchDocumentOutcomeOutcome(o.Outcome),
|
||||
CreatedAt: &o.CreatedAt,
|
||||
UpdatedAt: &o.UpdatedAt,
|
||||
}
|
||||
|
||||
if o.ErrorDetail != nil {
|
||||
outcome.ErrorDetail = nullable.NewNullableWithValue(*o.ErrorDetail)
|
||||
}
|
||||
|
||||
if o.DocumentID != nil {
|
||||
outcome.DocumentId = nullable.NewNullableWithValue(openapi_types.UUID(*o.DocumentID))
|
||||
}
|
||||
|
||||
if o.CleanFail != nil {
|
||||
outcome.CleanFailReason = nullable.NewNullableWithValue(*o.CleanFail)
|
||||
}
|
||||
|
||||
outcomes[i] = outcome
|
||||
}
|
||||
details.DocumentOutcomes = &outcomes
|
||||
}
|
||||
|
||||
return ctx.JSON(http.StatusOK, details)
|
||||
|
||||
@@ -418,6 +418,94 @@ func TestGetDocumentBatch(t *testing.T) {
|
||||
assert.Equal(t, "test.zip", response.OriginalFilename)
|
||||
}
|
||||
|
||||
// TestGetDocumentBatch_IncludesOutcomes verifies that GetDocumentBatch returns
|
||||
// the document_outcomes array populated with per-file outcome rows.
|
||||
func TestGetDocumentBatch_IncludesOutcomes(t *testing.T) {
|
||||
cfg := &ControllerConfig{}
|
||||
test.CreateDB(t, cfg)
|
||||
initializeTestConfig(t, cfg)
|
||||
|
||||
svc := createControllerServices(cfg)
|
||||
cons := queryapi.NewControllers(svc, cfg)
|
||||
|
||||
clientID := fmt.Sprintf("outcome_client_%s", uuid.New().String()[:8])
|
||||
test.CreateTestClient(t, cfg, clientID, "Test Outcomes Client")
|
||||
|
||||
// Create batch
|
||||
batchID, err := cfg.GetDBQueries().CreateBatchUpload(t.Context(), &repository.CreateBatchUploadParams{
|
||||
ClientID: clientID,
|
||||
OriginalFilename: "outcomes.zip",
|
||||
TotalDocuments: 3,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a document so we can reference it in outcome rows
|
||||
docID, err := cfg.GetDBQueries().CreateDocument(t.Context(), &repository.CreateDocumentParams{
|
||||
Clientid: clientID,
|
||||
Hash: "outcome_hash_" + uuid.New().String()[:8],
|
||||
BatchID: &batchID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Insert outcomes with different statuses
|
||||
err = cfg.GetDBQueries().InsertBatchDocumentOutcome(t.Context(), &repository.InsertBatchDocumentOutcomeParams{
|
||||
BatchID: batchID,
|
||||
Filename: "invoice.pdf",
|
||||
Column3: repository.BatchOutcomeStatusCleanPassed,
|
||||
DocumentID: &docID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
invalidErr := "not a PDF"
|
||||
err = cfg.GetDBQueries().InsertBatchDocumentOutcome(t.Context(), &repository.InsertBatchDocumentOutcomeParams{
|
||||
BatchID: batchID,
|
||||
Filename: "spreadsheet.xlsx",
|
||||
Column3: repository.BatchOutcomeStatusInvalidType,
|
||||
ErrorDetail: &invalidErr,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = cfg.GetDBQueries().InsertBatchDocumentOutcome(t.Context(), &repository.InsertBatchDocumentOutcomeParams{
|
||||
BatchID: batchID,
|
||||
Filename: "receipt.pdf",
|
||||
Column3: repository.BatchOutcomeStatusSubmitted,
|
||||
DocumentID: &docID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Call GetDocumentBatch
|
||||
ctx, rec := createContext(t)
|
||||
err = cons.GetDocumentBatch(ctx, clientID, queryapi.BatchID(batchID))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var response queryapi.BatchUploadDetails
|
||||
err = json.Unmarshal(rec.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify document_outcomes array is populated
|
||||
require.NotNil(t, response.DocumentOutcomes, "document_outcomes should be present")
|
||||
require.Len(t, *response.DocumentOutcomes, 3, "should have 3 outcome entries")
|
||||
|
||||
// Build a map by filename for easier assertion
|
||||
outcomeMap := make(map[string]queryapi.BatchDocumentOutcome)
|
||||
for _, o := range *response.DocumentOutcomes {
|
||||
outcomeMap[o.Filename] = o
|
||||
}
|
||||
|
||||
// Verify each outcome
|
||||
invoiceOutcome := outcomeMap["invoice.pdf"]
|
||||
assert.Equal(t, queryapi.BatchDocumentOutcomeOutcome("clean_passed"), invoiceOutcome.Outcome)
|
||||
assert.True(t, invoiceOutcome.DocumentId.IsSpecified(), "clean_passed outcome should have document_id")
|
||||
|
||||
xlsxOutcome := outcomeMap["spreadsheet.xlsx"]
|
||||
assert.Equal(t, queryapi.BatchDocumentOutcomeOutcome("invalid_type"), xlsxOutcome.Outcome)
|
||||
assert.True(t, xlsxOutcome.ErrorDetail.IsSpecified(), "invalid_type outcome should have error_detail")
|
||||
|
||||
receiptOutcome := outcomeMap["receipt.pdf"]
|
||||
assert.Equal(t, queryapi.BatchDocumentOutcomeOutcome("submitted"), receiptOutcome.Outcome)
|
||||
}
|
||||
|
||||
func TestUploadDocumentBatch_Part1_Success(t *testing.T) {
|
||||
// Setup: Create testcontainers (database + localstack)
|
||||
cfg := &ControllerConfig{}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"os"
|
||||
|
||||
doccleanrunner "queryorchestration/api/docCleanRunner"
|
||||
"queryorchestration/internal/document/batch/outcome"
|
||||
documentclean "queryorchestration/internal/document/clean"
|
||||
"queryorchestration/internal/server/runner"
|
||||
"queryorchestration/internal/serviceconfig/build"
|
||||
@@ -39,7 +40,8 @@ func main() {
|
||||
clean := documentclean.New(cfg)
|
||||
|
||||
return doccleanrunner.New(&doccleanrunner.Services{
|
||||
Clean: clean,
|
||||
Clean: clean,
|
||||
Outcome: outcome.New(cfg.GetDBQueries()),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"os"
|
||||
|
||||
docinitrunner "queryorchestration/api/docInitRunner"
|
||||
"queryorchestration/internal/document/batch/outcome"
|
||||
documentinit "queryorchestration/internal/document/init"
|
||||
"queryorchestration/internal/server/runner"
|
||||
"queryorchestration/internal/serviceconfig/build"
|
||||
@@ -50,6 +51,7 @@ func main() {
|
||||
return docinitrunner.New(&docinitrunner.Services{
|
||||
Document: docinit,
|
||||
Queries: cfg.GetDBQueries(),
|
||||
Outcome: outcome.New(cfg.GetDBQueries()),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
docsyncrunner "queryorchestration/api/docSyncRunner"
|
||||
"queryorchestration/internal/client"
|
||||
"queryorchestration/internal/document"
|
||||
"queryorchestration/internal/document/batch/outcome"
|
||||
documentsync "queryorchestration/internal/document/sync"
|
||||
"queryorchestration/internal/server/runner"
|
||||
"queryorchestration/internal/serviceconfig/build"
|
||||
@@ -48,6 +49,7 @@ func main() {
|
||||
|
||||
return docsyncrunner.New(&docsyncrunner.Services{
|
||||
Document: docsync,
|
||||
Outcome: outcome.New(cfg.GetDBQueries()),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -484,7 +484,7 @@ Lists batch uploads for a client with pagination support.
|
||||
|
||||
### `GET /client/{id}/document/batch/{batch_id}`
|
||||
|
||||
Retrieves detailed status information for a specific batch upload.
|
||||
Retrieves detailed status information for a specific batch upload, including per-file outcome tracking through the document processing pipeline.
|
||||
|
||||
**Security**: Requires JWT authentication
|
||||
|
||||
@@ -500,17 +500,89 @@ Retrieves detailed status information for a specific batch upload.
|
||||
"client_id": "AAA",
|
||||
"original_filename": "documents.zip",
|
||||
"status": "completed",
|
||||
"total_documents": 100,
|
||||
"processed_documents": 100,
|
||||
"failed_documents": 2,
|
||||
"total_documents": 5,
|
||||
"processed_documents": 3,
|
||||
"failed_documents": 1,
|
||||
"invalid_type_documents": 1,
|
||||
"progress_percent": 100,
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"completed_at": "2024-01-01T00:05:00Z",
|
||||
"failed_filenames": ["doc3.pdf", "doc17.pdf"]
|
||||
"failed_filenames": ["corrupt.pdf"],
|
||||
"document_outcomes": [
|
||||
{
|
||||
"filename": "invoice.pdf",
|
||||
"outcome": "clean_passed",
|
||||
"document_id": "019580e0-1111-7676-8de9-000000000001",
|
||||
"created_at": "2024-01-01T00:00:01Z",
|
||||
"updated_at": "2024-01-01T00:04:50Z"
|
||||
},
|
||||
{
|
||||
"filename": "receipt.pdf",
|
||||
"outcome": "clean_passed",
|
||||
"document_id": "019580e0-2222-7676-8de9-000000000002",
|
||||
"created_at": "2024-01-01T00:00:01Z",
|
||||
"updated_at": "2024-01-01T00:04:52Z"
|
||||
},
|
||||
{
|
||||
"filename": "duplicate_invoice.pdf",
|
||||
"outcome": "duplicate",
|
||||
"document_id": "019580e0-1111-7676-8de9-000000000001",
|
||||
"created_at": "2024-01-01T00:00:01Z",
|
||||
"updated_at": "2024-01-01T00:00:01Z"
|
||||
},
|
||||
{
|
||||
"filename": "corrupt.pdf",
|
||||
"outcome": "clean_failed",
|
||||
"error_detail": "PDF validation failed",
|
||||
"document_id": "019580e0-3333-7676-8de9-000000000003",
|
||||
"clean_fail_reason": "invalid PDF structure: missing xref table",
|
||||
"created_at": "2024-01-01T00:00:01Z",
|
||||
"updated_at": "2024-01-01T00:04:55Z"
|
||||
},
|
||||
{
|
||||
"filename": "spreadsheet.xlsx",
|
||||
"outcome": "invalid_type",
|
||||
"error_detail": null,
|
||||
"document_id": null,
|
||||
"created_at": "2024-01-01T00:00:01Z",
|
||||
"updated_at": "2024-01-01T00:00:01Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The `document_outcomes` array provides per-file tracking through the entire processing pipeline. Each file in the ZIP archive gets one outcome entry, starting at extraction time and updating as the file progresses through the document runners (init, sync, clean). When all outcomes reach a terminal state, the batch is fully processed.
|
||||
|
||||
**Document Outcome Values**:
|
||||
|
||||
| Outcome | Meaning | Terminal? |
|
||||
| ------------------ | ------------------------------------------------------ | --------- |
|
||||
| `submitted` | File extracted and uploaded, waiting for processing | No |
|
||||
| `duplicate` | File hash matches an existing document for this client | Yes |
|
||||
| `failed_open` | Could not open file from ZIP archive | Yes |
|
||||
| `failed_read` | Could not read file content from ZIP archive | Yes |
|
||||
| `failed_s3_upload` | Could not upload extracted file to S3 | Yes |
|
||||
| `failed_upload` | Upload handler returned an error | Yes |
|
||||
| `invalid_type` | File is not a PDF | Yes |
|
||||
| `init_complete` | Document record created, forwarded to sync | No |
|
||||
| `init_duplicate` | Duplicate detected during init processing | Yes |
|
||||
| `sync_complete` | Sync check passed, forwarded to clean | No |
|
||||
| `sync_skipped` | Client sync is disabled | Yes |
|
||||
| `clean_passed` | Document passed all validation checks | Yes |
|
||||
| `clean_failed` | Document failed validation (see `clean_fail_reason`) | Yes |
|
||||
|
||||
**Outcome Fields**:
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------------- | -------------- | ------------------------------------------------------------------- |
|
||||
| `filename` | string | Original filename from the ZIP archive |
|
||||
| `outcome` | string | Current processing status (see table above) |
|
||||
| `error_detail` | string or null | Human-readable error message for failure outcomes |
|
||||
| `document_id` | uuid or null | Assigned document ID (null for files that never became documents) |
|
||||
| `clean_fail_reason` | string or null | Specific validation failure reason when `outcome` is `clean_failed` |
|
||||
| `created_at` | datetime | When the file was first extracted from the ZIP |
|
||||
| `updated_at` | datetime | When the outcome was last updated by a pipeline stage |
|
||||
|
||||
---
|
||||
|
||||
## Folder Operations (FolderService)
|
||||
|
||||
@@ -23,6 +23,7 @@ erDiagram
|
||||
|
||||
batch_uploads ||--o{ documents : "contains"
|
||||
batch_uploads ||--o{ documentUploads : "tracks uploads"
|
||||
batch_uploads ||--o{ batch_document_outcomes : "tracks outcomes"
|
||||
|
||||
folders ||--o{ documents : "contains"
|
||||
folders ||--o{ folders : "has children"
|
||||
@@ -457,6 +458,46 @@ CREATE INDEX idx_batch_uploads_status ON batch_uploads(status);
|
||||
- Indexed by client_id and status for efficient queries
|
||||
- Note: Uses snake_case naming convention (unlike other tables)
|
||||
|
||||
### Batch Document Outcomes (`batch_document_outcomes`)
|
||||
Tracks per-file extraction and pipeline processing outcomes for batch uploads. One row per file in the ZIP, updated in place as the document progresses through the processing pipeline (init, sync, clean runners).
|
||||
|
||||
```sql
|
||||
CREATE TYPE batch_document_outcome AS ENUM (
|
||||
'submitted', 'duplicate', 'failed_open', 'failed_read',
|
||||
'failed_s3_upload', 'failed_upload', 'invalid_type',
|
||||
'init_complete', 'init_duplicate', 'sync_complete',
|
||||
'sync_skipped', 'clean_passed', 'clean_failed'
|
||||
);
|
||||
|
||||
CREATE TABLE batch_document_outcomes (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v7(),
|
||||
batch_id uuid NOT NULL,
|
||||
filename text NOT NULL,
|
||||
outcome batch_document_outcome NOT NULL,
|
||||
error_detail text,
|
||||
document_id uuid,
|
||||
updated_at timestamp NOT NULL DEFAULT NOW(),
|
||||
created_at timestamp NOT NULL DEFAULT NOW(),
|
||||
FOREIGN KEY (batch_id) REFERENCES batch_uploads(id),
|
||||
FOREIGN KEY (document_id) REFERENCES documents(id),
|
||||
UNIQUE (batch_id, filename)
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX idx_batch_doc_outcomes_batch_id ON batch_document_outcomes(batch_id);
|
||||
CREATE INDEX idx_batch_doc_outcomes_document_id ON batch_document_outcomes(document_id);
|
||||
```
|
||||
|
||||
**Batch Document Outcome Features**:
|
||||
- Single source of truth for per-file batch status (extraction outcomes and pipeline progress)
|
||||
- ENUM type covers both extraction-time outcomes (submitted, duplicate, failed_*, invalid_type) and pipeline outcomes (init_*, sync_*, clean_*)
|
||||
- Unique constraint on (batch_id, filename) enables idempotent upserts for batch retries
|
||||
- Foreign key to documents table links outcome rows to their document records once created
|
||||
- Rows for files that never became documents (extraction failures) have null document_id
|
||||
- LEFT JOIN to `currentCleanEntries` provides specific clean failure reasons when queried
|
||||
- Indexed by batch_id for efficient batch detail lookups and by document_id for pipeline updates
|
||||
- Added in migration 124
|
||||
|
||||
### Folders (`folders`)
|
||||
Virtual folder hierarchy for document organization. Folders are database-only entities with no direct relationship to S3 storage locations.
|
||||
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
# Batch Outcome Tracking Guide
|
||||
|
||||
This guide walks through the batch document outcome tracking API, showing how to upload a batch of documents, monitor per-file processing progress, and interpret the results.
|
||||
|
||||
For API reference details, see [API Documentation - Batch Document Operations](./03-api-documentation.md#batch-document-operations-documentsservice).
|
||||
|
||||
## 1. Upload a Batch
|
||||
|
||||
Submit a ZIP archive containing PDF documents for batch processing.
|
||||
|
||||
```
|
||||
POST /client/AAA/document/batch
|
||||
Content-Type: multipart/form-data
|
||||
|
||||
file: @mixed_documents.zip
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"batch_id": "019580df-ef65-7676-8de9-94435a93337a",
|
||||
"status": "processing",
|
||||
"status_url": "/client/AAA/document/batch/019580df-ef65-7676-8de9-94435a93337a"
|
||||
}
|
||||
```
|
||||
|
||||
Use the `status_url` to poll for progress.
|
||||
|
||||
## 2. Poll for Extraction Results (Immediate)
|
||||
|
||||
```
|
||||
GET /client/AAA/document/batch/019580df-ef65-7676-8de9-94435a93337a
|
||||
```
|
||||
|
||||
Response while the ZIP is being extracted:
|
||||
|
||||
```json
|
||||
{
|
||||
"batch_id": "019580df-ef65-7676-8de9-94435a93337a",
|
||||
"status": "processing",
|
||||
"progress_percent": 40,
|
||||
"total_documents": 5,
|
||||
"processed_documents": 0,
|
||||
"document_outcomes": [
|
||||
{ "filename": "invoice.pdf", "outcome": "submitted", "document_id": null },
|
||||
{ "filename": "receipt.pdf", "outcome": "submitted", "document_id": null }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
At this point, `submitted` means the file was extracted from the ZIP and uploaded to S3 but has not yet been processed by the document pipeline. Files that have not yet been extracted will not appear in `document_outcomes`.
|
||||
|
||||
## 3. Extraction Complete, Pipeline in Progress
|
||||
|
||||
Once ZIP extraction finishes, `status` changes to `completed` and all files appear in `document_outcomes`. Individual documents may still be progressing through the pipeline runners.
|
||||
|
||||
```json
|
||||
{
|
||||
"batch_id": "019580df-ef65-7676-8de9-94435a93337a",
|
||||
"status": "completed",
|
||||
"progress_percent": 100,
|
||||
"total_documents": 5,
|
||||
"processed_documents": 3,
|
||||
"failed_documents": 1,
|
||||
"invalid_type_documents": 1,
|
||||
"document_outcomes": [
|
||||
{
|
||||
"filename": "invoice.pdf",
|
||||
"outcome": "init_complete",
|
||||
"document_id": "...0001"
|
||||
},
|
||||
{
|
||||
"filename": "receipt.pdf",
|
||||
"outcome": "sync_complete",
|
||||
"document_id": "...0002"
|
||||
},
|
||||
{
|
||||
"filename": "duplicate_invoice.pdf",
|
||||
"outcome": "duplicate",
|
||||
"document_id": "...0001"
|
||||
},
|
||||
{
|
||||
"filename": "corrupt.pdf",
|
||||
"outcome": "init_complete",
|
||||
"document_id": "...0003"
|
||||
},
|
||||
{
|
||||
"filename": "spreadsheet.xlsx",
|
||||
"outcome": "invalid_type",
|
||||
"document_id": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Note: `status: "completed"` means the ZIP extraction is done. Individual documents may still be progressing through the pipeline. Check each file's `outcome` for its current stage.
|
||||
|
||||
## 4. Pipeline Fully Complete
|
||||
|
||||
When all documents have reached a terminal outcome, the batch is fully processed.
|
||||
|
||||
```json
|
||||
{
|
||||
"document_outcomes": [
|
||||
{
|
||||
"filename": "invoice.pdf",
|
||||
"outcome": "clean_passed",
|
||||
"document_id": "...0001"
|
||||
},
|
||||
{
|
||||
"filename": "receipt.pdf",
|
||||
"outcome": "clean_passed",
|
||||
"document_id": "...0002"
|
||||
},
|
||||
{
|
||||
"filename": "duplicate_invoice.pdf",
|
||||
"outcome": "duplicate",
|
||||
"document_id": "...0001"
|
||||
},
|
||||
{
|
||||
"filename": "corrupt.pdf",
|
||||
"outcome": "clean_failed",
|
||||
"document_id": "...0003",
|
||||
"error_detail": "PDF validation failed",
|
||||
"clean_fail_reason": "invalid PDF structure: missing xref table"
|
||||
},
|
||||
{
|
||||
"filename": "spreadsheet.xlsx",
|
||||
"outcome": "invalid_type",
|
||||
"document_id": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 5. Interpreting Results for Resubmission
|
||||
|
||||
Each terminal outcome indicates a specific action for the caller:
|
||||
|
||||
- **`clean_passed`** -- Success. The document is fully processed. Use the `document_id` to retrieve it via `GET /document/{id}`.
|
||||
|
||||
- **`duplicate`** -- The file hash matches a document already in the system for this client. Use the returned `document_id` to reference the existing document. No resubmission needed.
|
||||
|
||||
- **`clean_failed`** -- The PDF was corrupt or failed validation. Check `clean_fail_reason` for the specific failure detail. Fix the source file and resubmit.
|
||||
|
||||
- **`invalid_type`** -- The file is not a PDF. Only PDF files are accepted.
|
||||
|
||||
- **`failed_open`** / **`failed_read`** -- The file inside the ZIP was damaged or unreadable. Re-create the ZIP archive and resubmit.
|
||||
|
||||
- **`failed_s3_upload`** / **`failed_upload`** -- An infrastructure error occurred during processing. Retry the entire batch.
|
||||
|
||||
- **`sync_skipped`** -- The client's sync configuration prevents processing. Contact an administrator.
|
||||
|
||||
- **`init_duplicate`** -- A duplicate was detected during the init processing stage (rather than at extraction time). The returned `document_id` references the existing document. No resubmission needed.
|
||||
|
||||
## 6. Outcome Lifecycle
|
||||
|
||||
Each file in a batch starts at extraction and progresses through the document processing pipeline. Terminal outcomes (marked below) are final states that will not change.
|
||||
|
||||
```
|
||||
ZIP extracted
|
||||
|
|
||||
+-- invalid_type (not PDF) .............. TERMINAL
|
||||
+-- failed_open / failed_read ........... TERMINAL
|
||||
+-- failed_s3_upload / failed_upload .... TERMINAL
|
||||
+-- duplicate (hash match) .............. TERMINAL
|
||||
+-- submitted (uploaded to S3)
|
||||
|
|
||||
+-- [docInitRunner]
|
||||
| +-- init_duplicate .......... TERMINAL
|
||||
| +-- init_complete
|
||||
| |
|
||||
| +-- [docSyncRunner]
|
||||
| +-- sync_skipped TERMINAL
|
||||
| +-- sync_complete
|
||||
| |
|
||||
| +-- [docCleanRunner]
|
||||
| +-- clean_failed .. TERMINAL
|
||||
| +-- clean_passed .. TERMINAL
|
||||
```
|
||||
|
||||
Non-terminal outcomes (`submitted`, `init_complete`, `sync_complete`) indicate the document is still being processed. Poll the batch details endpoint to see updates as files progress through each stage.
|
||||
@@ -186,7 +186,8 @@ This document provides a comprehensive summary of all REST API endpoints availab
|
||||
- Returns: `{batches[], total_count}`
|
||||
|
||||
- **GET /client/{id}/document/batch/{batch_id}** - Get detailed status of a specific batch upload
|
||||
- Returns: `{batch_id, client_id, original_filename, status, total_documents, processed_documents, failed_documents, invalid_type_documents, progress_percent, created_at, completed_at?, failed_filenames[]}`
|
||||
- Returns: `{batch_id, client_id, original_filename, status, total_documents, processed_documents, failed_documents, invalid_type_documents, progress_percent, created_at, completed_at?, failed_filenames[], document_outcomes[]}`
|
||||
- `document_outcomes` provides per-file tracking through the processing pipeline (see [Batch Outcome Tracking Guide](./batch-outcome-tracking-guide.md))
|
||||
|
||||
## Label Service
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
DROP INDEX IF EXISTS idx_documents_batch_id_filename;
|
||||
DROP INDEX IF EXISTS idx_batch_doc_outcomes_document_id;
|
||||
DROP TABLE IF EXISTS batch_document_outcomes;
|
||||
DROP TYPE IF EXISTS batch_outcome_status;
|
||||
@@ -0,0 +1,35 @@
|
||||
CREATE TYPE batch_outcome_status AS ENUM (
|
||||
-- Extraction-time outcomes (set by batch worker)
|
||||
'submitted',
|
||||
'duplicate',
|
||||
'failed_open',
|
||||
'failed_read',
|
||||
'failed_s3_upload',
|
||||
'failed_upload',
|
||||
'invalid_type',
|
||||
-- Pipeline outcomes (set by runners, update the row in place)
|
||||
'init_complete',
|
||||
'init_duplicate',
|
||||
'sync_complete',
|
||||
'sync_skipped',
|
||||
'clean_passed',
|
||||
'clean_failed'
|
||||
);
|
||||
|
||||
CREATE TABLE batch_document_outcomes (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v7(),
|
||||
batch_id uuid NOT NULL,
|
||||
filename text NOT NULL,
|
||||
outcome batch_outcome_status NOT NULL,
|
||||
error_detail text,
|
||||
document_id uuid,
|
||||
updated_at timestamp NOT NULL DEFAULT NOW(),
|
||||
created_at timestamp NOT NULL DEFAULT NOW(),
|
||||
FOREIGN KEY (batch_id) REFERENCES batch_uploads(id),
|
||||
FOREIGN KEY (document_id) REFERENCES documents(id),
|
||||
UNIQUE (batch_id, filename)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_batch_doc_outcomes_batch_id ON batch_document_outcomes(batch_id);
|
||||
CREATE INDEX idx_batch_doc_outcomes_document_id ON batch_document_outcomes(document_id);
|
||||
CREATE INDEX idx_documents_batch_id_filename ON documents(batch_id, filename);
|
||||
@@ -68,9 +68,61 @@ FROM documents
|
||||
WHERE batch_id = $1;
|
||||
|
||||
-- name: GetUnprocessedBatches :many
|
||||
SELECT id, client_id, original_filename, total_documents,
|
||||
SELECT id, client_id, original_filename, total_documents,
|
||||
processed_documents, failed_documents, invalid_type_documents,
|
||||
status, progress_percent, created_at, completed_at
|
||||
FROM batch_uploads
|
||||
FROM batch_uploads
|
||||
WHERE status = 'processing'
|
||||
ORDER BY created_at ASC;
|
||||
ORDER BY created_at ASC;
|
||||
|
||||
-- name: InsertBatchDocumentOutcome :exec
|
||||
-- Upsert: if a row for this (batch_id, filename) already exists (e.g. batch
|
||||
-- retry after partial failure), overwrite it with the latest outcome.
|
||||
INSERT INTO batch_document_outcomes (batch_id, filename, outcome, error_detail, document_id)
|
||||
VALUES ($1, $2, $3::batch_outcome_status, $4, $5)
|
||||
ON CONFLICT (batch_id, filename) DO UPDATE
|
||||
SET outcome = EXCLUDED.outcome,
|
||||
error_detail = EXCLUDED.error_detail,
|
||||
document_id = EXCLUDED.document_id,
|
||||
updated_at = NOW();
|
||||
|
||||
-- name: UpdateBatchDocumentOutcomeByDocumentID :exec
|
||||
-- Updates the outcome for a document progressing through the pipeline.
|
||||
-- Only updates rows in non-terminal pipeline states to prevent overwriting
|
||||
-- terminal outcomes (e.g. a 'duplicate' row from a different batch that
|
||||
-- shares the same document_id).
|
||||
UPDATE batch_document_outcomes
|
||||
SET outcome = $2::batch_outcome_status,
|
||||
error_detail = $3,
|
||||
updated_at = NOW()
|
||||
WHERE document_id = $1
|
||||
AND outcome IN ('submitted', 'init_complete', 'sync_complete');
|
||||
|
||||
-- name: ResolveBatchDocumentOutcome :exec
|
||||
-- Called by docInitRunner to set the document_id on a previously-submitted
|
||||
-- outcome row and update the outcome to an init-stage result.
|
||||
-- Finds the row by batch_id + filename since document_id is NULL at this point.
|
||||
UPDATE batch_document_outcomes
|
||||
SET document_id = $3,
|
||||
outcome = $4::batch_outcome_status,
|
||||
error_detail = $5,
|
||||
updated_at = NOW()
|
||||
WHERE batch_id = $1 AND filename = $2 AND document_id IS NULL;
|
||||
|
||||
-- name: ListBatchDocumentOutcomes :many
|
||||
-- Returns all outcome rows for a batch, with clean failure detail
|
||||
-- joined from currentCleanEntries when applicable.
|
||||
SELECT
|
||||
bdo.id,
|
||||
bdo.batch_id,
|
||||
bdo.filename,
|
||||
bdo.outcome,
|
||||
bdo.error_detail,
|
||||
bdo.document_id,
|
||||
bdo.created_at,
|
||||
bdo.updated_at,
|
||||
cce.fail as clean_fail
|
||||
FROM batch_document_outcomes bdo
|
||||
LEFT JOIN currentCleanEntries cce ON cce.documentId = bdo.document_id
|
||||
WHERE bdo.batch_id = $1
|
||||
ORDER BY bdo.created_at ASC;
|
||||
@@ -282,10 +282,10 @@ func (q *Queries) GetDocumentsByBatchId(ctx context.Context, batchID *uuid.UUID)
|
||||
}
|
||||
|
||||
const getUnprocessedBatches = `-- name: GetUnprocessedBatches :many
|
||||
SELECT id, client_id, original_filename, total_documents,
|
||||
SELECT id, client_id, original_filename, total_documents,
|
||||
processed_documents, failed_documents, invalid_type_documents,
|
||||
status, progress_percent, created_at, completed_at
|
||||
FROM batch_uploads
|
||||
FROM batch_uploads
|
||||
WHERE status = 'processing'
|
||||
ORDER BY created_at ASC
|
||||
`
|
||||
@@ -344,6 +344,121 @@ func (q *Queries) GetUnprocessedBatches(ctx context.Context) ([]*GetUnprocessedB
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const insertBatchDocumentOutcome = `-- name: InsertBatchDocumentOutcome :exec
|
||||
INSERT INTO batch_document_outcomes (batch_id, filename, outcome, error_detail, document_id)
|
||||
VALUES ($1, $2, $3::batch_outcome_status, $4, $5)
|
||||
ON CONFLICT (batch_id, filename) DO UPDATE
|
||||
SET outcome = EXCLUDED.outcome,
|
||||
error_detail = EXCLUDED.error_detail,
|
||||
document_id = EXCLUDED.document_id,
|
||||
updated_at = NOW()
|
||||
`
|
||||
|
||||
type InsertBatchDocumentOutcomeParams struct {
|
||||
BatchID uuid.UUID `db:"batch_id"`
|
||||
Filename string `db:"filename"`
|
||||
Column3 BatchOutcomeStatus `db:"column_3"`
|
||||
ErrorDetail *string `db:"error_detail"`
|
||||
DocumentID *uuid.UUID `db:"document_id"`
|
||||
}
|
||||
|
||||
// Upsert: if a row for this (batch_id, filename) already exists (e.g. batch
|
||||
// retry after partial failure), overwrite it with the latest outcome.
|
||||
//
|
||||
// INSERT INTO batch_document_outcomes (batch_id, filename, outcome, error_detail, document_id)
|
||||
// VALUES ($1, $2, $3::batch_outcome_status, $4, $5)
|
||||
// ON CONFLICT (batch_id, filename) DO UPDATE
|
||||
// SET outcome = EXCLUDED.outcome,
|
||||
// error_detail = EXCLUDED.error_detail,
|
||||
// document_id = EXCLUDED.document_id,
|
||||
// updated_at = NOW()
|
||||
func (q *Queries) InsertBatchDocumentOutcome(ctx context.Context, arg *InsertBatchDocumentOutcomeParams) error {
|
||||
_, err := q.db.Exec(ctx, insertBatchDocumentOutcome,
|
||||
arg.BatchID,
|
||||
arg.Filename,
|
||||
arg.Column3,
|
||||
arg.ErrorDetail,
|
||||
arg.DocumentID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const listBatchDocumentOutcomes = `-- name: ListBatchDocumentOutcomes :many
|
||||
SELECT
|
||||
bdo.id,
|
||||
bdo.batch_id,
|
||||
bdo.filename,
|
||||
bdo.outcome,
|
||||
bdo.error_detail,
|
||||
bdo.document_id,
|
||||
bdo.created_at,
|
||||
bdo.updated_at,
|
||||
cce.fail as clean_fail
|
||||
FROM batch_document_outcomes bdo
|
||||
LEFT JOIN currentCleanEntries cce ON cce.documentId = bdo.document_id
|
||||
WHERE bdo.batch_id = $1
|
||||
ORDER BY bdo.created_at ASC
|
||||
`
|
||||
|
||||
type ListBatchDocumentOutcomesRow struct {
|
||||
ID uuid.UUID `db:"id"`
|
||||
BatchID uuid.UUID `db:"batch_id"`
|
||||
Filename string `db:"filename"`
|
||||
Outcome BatchOutcomeStatus `db:"outcome"`
|
||||
ErrorDetail *string `db:"error_detail"`
|
||||
DocumentID *uuid.UUID `db:"document_id"`
|
||||
CreatedAt pgtype.Timestamp `db:"created_at"`
|
||||
UpdatedAt pgtype.Timestamp `db:"updated_at"`
|
||||
CleanFail NullCleanfailtype `db:"clean_fail"`
|
||||
}
|
||||
|
||||
// Returns all outcome rows for a batch, with clean failure detail
|
||||
// joined from currentCleanEntries when applicable.
|
||||
//
|
||||
// SELECT
|
||||
// bdo.id,
|
||||
// bdo.batch_id,
|
||||
// bdo.filename,
|
||||
// bdo.outcome,
|
||||
// bdo.error_detail,
|
||||
// bdo.document_id,
|
||||
// bdo.created_at,
|
||||
// bdo.updated_at,
|
||||
// cce.fail as clean_fail
|
||||
// FROM batch_document_outcomes bdo
|
||||
// LEFT JOIN currentCleanEntries cce ON cce.documentId = bdo.document_id
|
||||
// WHERE bdo.batch_id = $1
|
||||
// ORDER BY bdo.created_at ASC
|
||||
func (q *Queries) ListBatchDocumentOutcomes(ctx context.Context, batchID uuid.UUID) ([]*ListBatchDocumentOutcomesRow, error) {
|
||||
rows, err := q.db.Query(ctx, listBatchDocumentOutcomes, batchID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []*ListBatchDocumentOutcomesRow{}
|
||||
for rows.Next() {
|
||||
var i ListBatchDocumentOutcomesRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.BatchID,
|
||||
&i.Filename,
|
||||
&i.Outcome,
|
||||
&i.ErrorDetail,
|
||||
&i.DocumentID,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.CleanFail,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, &i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listBatchUploads = `-- name: ListBatchUploads :many
|
||||
SELECT id, client_id, original_filename, total_documents, processed_documents,
|
||||
failed_documents, invalid_type_documents, status, progress_percent,
|
||||
@@ -415,6 +530,75 @@ func (q *Queries) ListBatchUploads(ctx context.Context, arg *ListBatchUploadsPar
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const resolveBatchDocumentOutcome = `-- name: ResolveBatchDocumentOutcome :exec
|
||||
UPDATE batch_document_outcomes
|
||||
SET document_id = $3,
|
||||
outcome = $4::batch_outcome_status,
|
||||
error_detail = $5,
|
||||
updated_at = NOW()
|
||||
WHERE batch_id = $1 AND filename = $2 AND document_id IS NULL
|
||||
`
|
||||
|
||||
type ResolveBatchDocumentOutcomeParams struct {
|
||||
BatchID uuid.UUID `db:"batch_id"`
|
||||
Filename string `db:"filename"`
|
||||
DocumentID *uuid.UUID `db:"document_id"`
|
||||
Column4 BatchOutcomeStatus `db:"column_4"`
|
||||
ErrorDetail *string `db:"error_detail"`
|
||||
}
|
||||
|
||||
// Called by docInitRunner to set the document_id on a previously-submitted
|
||||
// outcome row and update the outcome to an init-stage result.
|
||||
// Finds the row by batch_id + filename since document_id is NULL at this point.
|
||||
//
|
||||
// UPDATE batch_document_outcomes
|
||||
// SET document_id = $3,
|
||||
// outcome = $4::batch_outcome_status,
|
||||
// error_detail = $5,
|
||||
// updated_at = NOW()
|
||||
// WHERE batch_id = $1 AND filename = $2 AND document_id IS NULL
|
||||
func (q *Queries) ResolveBatchDocumentOutcome(ctx context.Context, arg *ResolveBatchDocumentOutcomeParams) error {
|
||||
_, err := q.db.Exec(ctx, resolveBatchDocumentOutcome,
|
||||
arg.BatchID,
|
||||
arg.Filename,
|
||||
arg.DocumentID,
|
||||
arg.Column4,
|
||||
arg.ErrorDetail,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateBatchDocumentOutcomeByDocumentID = `-- name: UpdateBatchDocumentOutcomeByDocumentID :exec
|
||||
UPDATE batch_document_outcomes
|
||||
SET outcome = $2::batch_outcome_status,
|
||||
error_detail = $3,
|
||||
updated_at = NOW()
|
||||
WHERE document_id = $1
|
||||
AND outcome IN ('submitted', 'init_complete', 'sync_complete')
|
||||
`
|
||||
|
||||
type UpdateBatchDocumentOutcomeByDocumentIDParams struct {
|
||||
DocumentID *uuid.UUID `db:"document_id"`
|
||||
Column2 BatchOutcomeStatus `db:"column_2"`
|
||||
ErrorDetail *string `db:"error_detail"`
|
||||
}
|
||||
|
||||
// Updates the outcome for a document progressing through the pipeline.
|
||||
// Only updates rows in non-terminal pipeline states to prevent overwriting
|
||||
// terminal outcomes (e.g. a 'duplicate' row from a different batch that
|
||||
// shares the same document_id).
|
||||
//
|
||||
// UPDATE batch_document_outcomes
|
||||
// SET outcome = $2::batch_outcome_status,
|
||||
// error_detail = $3,
|
||||
// updated_at = NOW()
|
||||
// WHERE document_id = $1
|
||||
// AND outcome IN ('submitted', 'init_complete', 'sync_complete')
|
||||
func (q *Queries) UpdateBatchDocumentOutcomeByDocumentID(ctx context.Context, arg *UpdateBatchDocumentOutcomeByDocumentIDParams) error {
|
||||
_, err := q.db.Exec(ctx, updateBatchDocumentOutcomeByDocumentID, arg.DocumentID, arg.Column2, arg.ErrorDetail)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateBatchProgress = `-- name: UpdateBatchProgress :exec
|
||||
UPDATE batch_uploads
|
||||
SET processed_documents = $2,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// sqlc v1.27.0
|
||||
|
||||
package repository
|
||||
|
||||
@@ -12,6 +12,79 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
type BatchOutcomeStatus string
|
||||
|
||||
const (
|
||||
BatchOutcomeStatusSubmitted BatchOutcomeStatus = "submitted"
|
||||
BatchOutcomeStatusDuplicate BatchOutcomeStatus = "duplicate"
|
||||
BatchOutcomeStatusFailedOpen BatchOutcomeStatus = "failed_open"
|
||||
BatchOutcomeStatusFailedRead BatchOutcomeStatus = "failed_read"
|
||||
BatchOutcomeStatusFailedS3Upload BatchOutcomeStatus = "failed_s3_upload"
|
||||
BatchOutcomeStatusFailedUpload BatchOutcomeStatus = "failed_upload"
|
||||
BatchOutcomeStatusInvalidType BatchOutcomeStatus = "invalid_type"
|
||||
BatchOutcomeStatusInitComplete BatchOutcomeStatus = "init_complete"
|
||||
BatchOutcomeStatusInitDuplicate BatchOutcomeStatus = "init_duplicate"
|
||||
BatchOutcomeStatusSyncComplete BatchOutcomeStatus = "sync_complete"
|
||||
BatchOutcomeStatusSyncSkipped BatchOutcomeStatus = "sync_skipped"
|
||||
BatchOutcomeStatusCleanPassed BatchOutcomeStatus = "clean_passed"
|
||||
BatchOutcomeStatusCleanFailed BatchOutcomeStatus = "clean_failed"
|
||||
)
|
||||
|
||||
func (e *BatchOutcomeStatus) Scan(src interface{}) error {
|
||||
switch s := src.(type) {
|
||||
case []byte:
|
||||
*e = BatchOutcomeStatus(s)
|
||||
case string:
|
||||
*e = BatchOutcomeStatus(s)
|
||||
default:
|
||||
return fmt.Errorf("unsupported scan type for BatchOutcomeStatus: %T", src)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type NullBatchOutcomeStatus struct {
|
||||
BatchOutcomeStatus BatchOutcomeStatus
|
||||
Valid bool // Valid is true if BatchOutcomeStatus is not NULL
|
||||
}
|
||||
|
||||
// Scan implements the Scanner interface.
|
||||
func (ns *NullBatchOutcomeStatus) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
ns.BatchOutcomeStatus, ns.Valid = "", false
|
||||
return nil
|
||||
}
|
||||
ns.Valid = true
|
||||
return ns.BatchOutcomeStatus.Scan(value)
|
||||
}
|
||||
|
||||
// Value implements the driver Valuer interface.
|
||||
func (ns NullBatchOutcomeStatus) Value() (driver.Value, error) {
|
||||
if !ns.Valid {
|
||||
return nil, nil
|
||||
}
|
||||
return string(ns.BatchOutcomeStatus), nil
|
||||
}
|
||||
|
||||
func (e BatchOutcomeStatus) Valid() bool {
|
||||
switch e {
|
||||
case BatchOutcomeStatusSubmitted,
|
||||
BatchOutcomeStatusDuplicate,
|
||||
BatchOutcomeStatusFailedOpen,
|
||||
BatchOutcomeStatusFailedRead,
|
||||
BatchOutcomeStatusFailedS3Upload,
|
||||
BatchOutcomeStatusFailedUpload,
|
||||
BatchOutcomeStatusInvalidType,
|
||||
BatchOutcomeStatusInitComplete,
|
||||
BatchOutcomeStatusInitDuplicate,
|
||||
BatchOutcomeStatusSyncComplete,
|
||||
BatchOutcomeStatusSyncSkipped,
|
||||
BatchOutcomeStatusCleanPassed,
|
||||
BatchOutcomeStatusCleanFailed:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type BatchStatus string
|
||||
|
||||
const (
|
||||
@@ -181,6 +254,17 @@ func (e Cleanmimetype) Valid() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
type BatchDocumentOutcome struct {
|
||||
ID uuid.UUID `db:"id"`
|
||||
BatchID uuid.UUID `db:"batch_id"`
|
||||
Filename string `db:"filename"`
|
||||
Outcome BatchOutcomeStatus `db:"outcome"`
|
||||
ErrorDetail *string `db:"error_detail"`
|
||||
DocumentID *uuid.UUID `db:"document_id"`
|
||||
UpdatedAt pgtype.Timestamp `db:"updated_at"`
|
||||
CreatedAt pgtype.Timestamp `db:"created_at"`
|
||||
}
|
||||
|
||||
type BatchUpload struct {
|
||||
ID uuid.UUID `db:"id"`
|
||||
ClientID string `db:"client_id"`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// sqlc v1.27.0
|
||||
// source: uisettings.sql
|
||||
|
||||
package repository
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// Package outcome provides a shared reporter that runners use to write
|
||||
// pipeline stage outcomes to the batch_document_outcomes table.
|
||||
// For non-batch documents (no outcome row exists), updates are a natural
|
||||
// no-op (0 rows affected).
|
||||
package outcome
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"queryorchestration/internal/database/repository"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Reporter writes pipeline stage outcomes to batch_document_outcomes.
|
||||
// Used by runners to report their processing results for batch-uploaded documents.
|
||||
// For non-batch documents (no outcome row exists), updates are a no-op (0 rows affected).
|
||||
//
|
||||
// Fields:
|
||||
// - queries: SQLC-generated database queries
|
||||
type Reporter struct {
|
||||
queries *repository.Queries
|
||||
}
|
||||
|
||||
// New creates an outcome Reporter.
|
||||
//
|
||||
// Parameters:
|
||||
// - q: SQLC-generated queries instance (from cfg.GetDBQueries())
|
||||
//
|
||||
// Returns:
|
||||
// - *Reporter: the reporter instance
|
||||
func New(q *repository.Queries) *Reporter {
|
||||
return &Reporter{queries: q}
|
||||
}
|
||||
|
||||
// Resolve sets the document_id on a batch outcome row and updates the outcome.
|
||||
// Called by docInitRunner after creating or finding the document.
|
||||
// Matches on batch_id + filename since document_id is NULL before init.
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: request context
|
||||
// - batchID: the batch this file belongs to (from the document's batch_id)
|
||||
// - filename: original filename from the ZIP (from the document record)
|
||||
// - documentID: the created or found document ID
|
||||
// - outcomeStatus: the init-stage outcome ("init_complete" or "init_duplicate")
|
||||
// - errorDetail: optional error message (nil for success)
|
||||
//
|
||||
// Returns:
|
||||
// - error: database error, or nil
|
||||
//
|
||||
// If batchID is nil, this is a no-op (document was not from a batch upload).
|
||||
func (r *Reporter) Resolve(ctx context.Context, batchID *uuid.UUID, filename string,
|
||||
documentID uuid.UUID, outcomeStatus repository.BatchOutcomeStatus, errorDetail *string) error {
|
||||
if batchID == nil {
|
||||
return nil
|
||||
}
|
||||
return r.queries.ResolveBatchDocumentOutcome(ctx, &repository.ResolveBatchDocumentOutcomeParams{
|
||||
BatchID: *batchID,
|
||||
Filename: filename,
|
||||
DocumentID: &documentID,
|
||||
Column4: outcomeStatus,
|
||||
ErrorDetail: errorDetail,
|
||||
})
|
||||
}
|
||||
|
||||
// Update updates the outcome for a document that already has document_id set.
|
||||
// Called by docSyncRunner and docCleanRunner after processing.
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: request context
|
||||
// - documentID: the document being processed
|
||||
// - outcomeStatus: the pipeline outcome (e.g. "sync_complete", "clean_passed")
|
||||
// - errorDetail: optional error message (nil for success)
|
||||
//
|
||||
// Returns:
|
||||
// - error: database error, or nil
|
||||
//
|
||||
// If no outcome row exists for this document_id (non-batch document), this
|
||||
// affects 0 rows and is a no-op.
|
||||
func (r *Reporter) Update(ctx context.Context, documentID uuid.UUID,
|
||||
outcomeStatus repository.BatchOutcomeStatus, errorDetail *string) error {
|
||||
return r.queries.UpdateBatchDocumentOutcomeByDocumentID(ctx, &repository.UpdateBatchDocumentOutcomeByDocumentIDParams{
|
||||
DocumentID: &documentID,
|
||||
Column2: outcomeStatus,
|
||||
ErrorDetail: errorDetail,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// Package outcome_test contains integration tests for the batch outcome reporter.
|
||||
// Tests use real database via testcontainers (no mocks).
|
||||
package outcome_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"queryorchestration/internal/database/repository"
|
||||
"queryorchestration/internal/document/batch/outcome"
|
||||
"queryorchestration/internal/serviceconfig"
|
||||
"queryorchestration/internal/test"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type TestConfig struct {
|
||||
serviceconfig.BaseConfig
|
||||
}
|
||||
|
||||
// createBatchWithOutcome is a helper that creates a client, batch, and inserts
|
||||
// a submitted outcome row. Returns the batchID and the unique clientID used.
|
||||
func createBatchWithOutcome(t *testing.T, cfg *TestConfig, clientPrefix, filename string) (uuid.UUID, string) {
|
||||
t.Helper()
|
||||
ctx := t.Context()
|
||||
|
||||
// Use a unique clientID to avoid collisions when tests share the DB
|
||||
clientID := clientPrefix + "_" + uuid.New().String()[:8]
|
||||
err := cfg.GetDBQueries().CreateClient(ctx, &repository.CreateClientParams{
|
||||
Clientid: clientID,
|
||||
Name: "Test Client " + clientID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
batchID, err := cfg.GetDBQueries().CreateBatchUpload(ctx, &repository.CreateBatchUploadParams{
|
||||
ClientID: clientID,
|
||||
OriginalFilename: "test.zip",
|
||||
TotalDocuments: 5,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = cfg.GetDBQueries().InsertBatchDocumentOutcome(ctx, &repository.InsertBatchDocumentOutcomeParams{
|
||||
BatchID: batchID,
|
||||
Filename: filename,
|
||||
Column3: repository.BatchOutcomeStatusSubmitted,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
return batchID, clientID
|
||||
}
|
||||
|
||||
// TestResolve_SetsDocumentIDAndOutcome inserts a submitted row, resolves it
|
||||
// with a document_id, and verifies the update took effect.
|
||||
func TestResolve_SetsDocumentIDAndOutcome(t *testing.T) {
|
||||
cfg := &TestConfig{}
|
||||
test.CreateDB(t, cfg)
|
||||
ctx := t.Context()
|
||||
|
||||
filename := "invoice.pdf"
|
||||
batchID, clientID := createBatchWithOutcome(t, cfg, "test_resolve_sets", filename)
|
||||
|
||||
// Create a real document so FK constraint is satisfied
|
||||
docID, err := cfg.GetDBQueries().CreateDocument(ctx, &repository.CreateDocumentParams{
|
||||
Clientid: clientID,
|
||||
Hash: "hash_resolve_test",
|
||||
BatchID: &batchID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
reporter := outcome.New(cfg.GetDBQueries())
|
||||
|
||||
err = reporter.Resolve(ctx, &batchID, filename, docID,
|
||||
repository.BatchOutcomeStatusInitComplete, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the row was updated
|
||||
rows, err := cfg.GetDBQueries().ListBatchDocumentOutcomes(ctx, batchID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, rows, 1)
|
||||
|
||||
assert.Equal(t, repository.BatchOutcomeStatusInitComplete, rows[0].Outcome)
|
||||
require.NotNil(t, rows[0].DocumentID)
|
||||
assert.Equal(t, docID, *rows[0].DocumentID)
|
||||
}
|
||||
|
||||
// TestResolve_NoOpForNilBatchID verifies that calling Resolve with a nil batchID
|
||||
// returns no error and makes no DB changes.
|
||||
func TestResolve_NoOpForNilBatchID(t *testing.T) {
|
||||
cfg := &TestConfig{}
|
||||
test.CreateDB(t, cfg)
|
||||
ctx := t.Context()
|
||||
|
||||
reporter := outcome.New(cfg.GetDBQueries())
|
||||
|
||||
// Call with nil batchID -- should be a no-op
|
||||
err := reporter.Resolve(ctx, nil, "anything.pdf", uuid.New(),
|
||||
repository.BatchOutcomeStatusInitComplete, nil)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// TestUpdate_UpdatesOutcomeByDocumentID inserts a resolved row (with document_id),
|
||||
// then updates it to sync_complete and verifies.
|
||||
func TestUpdate_UpdatesOutcomeByDocumentID(t *testing.T) {
|
||||
cfg := &TestConfig{}
|
||||
test.CreateDB(t, cfg)
|
||||
ctx := t.Context()
|
||||
|
||||
filename := "receipt.pdf"
|
||||
batchID, clientID := createBatchWithOutcome(t, cfg, "test_update_docid", filename)
|
||||
|
||||
// Create a real document so FK constraint is satisfied
|
||||
docID, err := cfg.GetDBQueries().CreateDocument(ctx, &repository.CreateDocumentParams{
|
||||
Clientid: clientID,
|
||||
Hash: "hash_update_test",
|
||||
BatchID: &batchID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
reporter := outcome.New(cfg.GetDBQueries())
|
||||
|
||||
// Resolve first (set document_id and advance to init_complete)
|
||||
err = reporter.Resolve(ctx, &batchID, filename, docID,
|
||||
repository.BatchOutcomeStatusInitComplete, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Now update to sync_complete
|
||||
err = reporter.Update(ctx, docID, repository.BatchOutcomeStatusSyncComplete, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the outcome was advanced
|
||||
rows, err := cfg.GetDBQueries().ListBatchDocumentOutcomes(ctx, batchID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, rows, 1)
|
||||
assert.Equal(t, repository.BatchOutcomeStatusSyncComplete, rows[0].Outcome)
|
||||
}
|
||||
|
||||
// TestUpdate_NoOpForNonBatchDocument verifies that calling Update for a
|
||||
// document_id that has no outcome row produces no error (0 rows affected).
|
||||
func TestUpdate_NoOpForNonBatchDocument(t *testing.T) {
|
||||
cfg := &TestConfig{}
|
||||
test.CreateDB(t, cfg)
|
||||
ctx := t.Context()
|
||||
|
||||
reporter := outcome.New(cfg.GetDBQueries())
|
||||
|
||||
// Call Update with a random document_id that has no outcome row
|
||||
err := reporter.Update(ctx, uuid.New(), repository.BatchOutcomeStatusSyncComplete, nil)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
@@ -2,7 +2,9 @@ package batch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
@@ -36,13 +38,40 @@ type BatchUploadSummary struct {
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
}
|
||||
|
||||
// BatchUploadDetails includes full details with failed filenames
|
||||
// BatchDocumentOutcome represents the per-file result from batch extraction
|
||||
// and pipeline processing. This is the single source of truth for a file's
|
||||
// status within a batch.
|
||||
//
|
||||
// Fields:
|
||||
// - ID: unique outcome record ID
|
||||
// - BatchID: the batch this file belongs to
|
||||
// - Filename: original filename from the ZIP
|
||||
// - Outcome: current status (extraction outcome or pipeline stage)
|
||||
// - ErrorDetail: human-readable error/failure message, nil for success states
|
||||
// - DocumentID: resolved document ID (nil if document not yet created)
|
||||
// - CleanFail: specific clean failure reason from documentCleans table (nil if N/A)
|
||||
// - CreatedAt: when the outcome row was created (extraction time)
|
||||
// - UpdatedAt: when the outcome was last changed (pipeline progression)
|
||||
type BatchDocumentOutcome struct {
|
||||
ID uuid.UUID
|
||||
BatchID uuid.UUID
|
||||
Filename string
|
||||
Outcome string
|
||||
ErrorDetail *string
|
||||
DocumentID *uuid.UUID
|
||||
CleanFail *string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// BatchUploadDetails includes full details with failed filenames and document outcomes
|
||||
type BatchUploadDetails struct {
|
||||
BatchUploadSummary
|
||||
FailedFilenames []string `json:"failed_filenames"`
|
||||
ArchiveBucket string `json:"archive_bucket,omitempty"`
|
||||
ArchiveKey string `json:"archive_key,omitempty"`
|
||||
FileSizeBytes int64 `json:"file_size_bytes,omitempty"`
|
||||
FailedFilenames []string `json:"failed_filenames"`
|
||||
DocumentOutcomes []*BatchDocumentOutcome `json:"document_outcomes,omitempty"`
|
||||
ArchiveBucket string `json:"archive_bucket,omitempty"`
|
||||
ArchiveKey string `json:"archive_key,omitempty"`
|
||||
FileSizeBytes int64 `json:"file_size_bytes,omitempty"`
|
||||
}
|
||||
|
||||
// Service handles batch upload operations
|
||||
@@ -84,9 +113,18 @@ func (s *Service) CreateWithStorage(ctx context.Context, clientID string, filena
|
||||
return result.ID, nil
|
||||
}
|
||||
|
||||
// Get retrieves batch upload details
|
||||
// Get retrieves batch upload details including per-file document outcomes.
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: request context
|
||||
// - clientID: the client identifier
|
||||
// - batchID: the batch UUID
|
||||
//
|
||||
// Returns:
|
||||
// - *BatchUploadDetails: full batch details with document outcomes
|
||||
// - error: database error, or nil
|
||||
func (s *Service) Get(ctx context.Context, clientID string, batchID uuid.UUID) (*BatchUploadDetails, error) {
|
||||
batch, err := s.cfg.GetDBQueries().GetBatchUploadWithStorage(ctx, &repository.GetBatchUploadWithStorageParams{
|
||||
batchRow, err := s.cfg.GetDBQueries().GetBatchUploadWithStorage(ctx, &repository.GetBatchUploadWithStorageParams{
|
||||
ID: batchID,
|
||||
ClientID: clientID,
|
||||
})
|
||||
@@ -94,7 +132,16 @@ func (s *Service) Get(ctx context.Context, clientID string, batchID uuid.UUID) (
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.convertToDetailsWithStorage(batch), nil
|
||||
details := s.convertToDetailsWithStorage(batchRow)
|
||||
|
||||
// Populate document outcomes
|
||||
outcomes, err := s.ListOutcomes(ctx, batchID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list batch outcomes: %w", err)
|
||||
}
|
||||
details.DocumentOutcomes = outcomes
|
||||
|
||||
return details, nil
|
||||
}
|
||||
|
||||
// List retrieves all batch uploads for a client with pagination
|
||||
@@ -272,6 +319,94 @@ func (s *Service) convertToDetailsWithStorage(batch *repository.GetBatchUploadWi
|
||||
return details
|
||||
}
|
||||
|
||||
// RecordOutcome inserts or upserts a per-file outcome row for a batch document.
|
||||
// Called by the batch worker during ZIP extraction to record the initial outcome
|
||||
// for each file.
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: request context
|
||||
// - batchID: the batch this file belongs to
|
||||
// - filename: original filename from the ZIP
|
||||
// - outcome: the typed outcome status (e.g. repository.BatchOutcomeStatusSubmitted)
|
||||
// - errorDetail: optional error message for failure outcomes
|
||||
// - documentID: optional document ID (set for duplicates)
|
||||
//
|
||||
// Returns:
|
||||
// - error: database error, or nil
|
||||
func (s *Service) RecordOutcome(ctx context.Context, batchID uuid.UUID, filename string,
|
||||
outcome repository.BatchOutcomeStatus, errorDetail *string, documentID *uuid.UUID) error {
|
||||
return s.cfg.GetDBQueries().InsertBatchDocumentOutcome(ctx, &repository.InsertBatchDocumentOutcomeParams{
|
||||
BatchID: batchID,
|
||||
Filename: filename,
|
||||
Column3: outcome,
|
||||
ErrorDetail: errorDetail,
|
||||
DocumentID: documentID,
|
||||
})
|
||||
}
|
||||
|
||||
// ListOutcomes retrieves all per-file outcome rows for a batch, with clean
|
||||
// failure detail joined from the currentCleanEntries view.
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: request context
|
||||
// - batchID: the batch UUID
|
||||
//
|
||||
// Returns:
|
||||
// - []*BatchDocumentOutcome: list of outcome rows ordered by creation time
|
||||
// - error: database error, or nil
|
||||
func (s *Service) ListOutcomes(ctx context.Context, batchID uuid.UUID) ([]*BatchDocumentOutcome, error) {
|
||||
rows, err := s.cfg.GetDBQueries().ListBatchDocumentOutcomes(ctx, batchID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
outcomes := make([]*BatchDocumentOutcome, len(rows))
|
||||
for i, row := range rows {
|
||||
o := &BatchDocumentOutcome{
|
||||
ID: row.ID,
|
||||
BatchID: row.BatchID,
|
||||
Filename: row.Filename,
|
||||
Outcome: string(row.Outcome),
|
||||
ErrorDetail: row.ErrorDetail,
|
||||
DocumentID: row.DocumentID,
|
||||
CreatedAt: s.pgTimestampToTime(row.CreatedAt),
|
||||
UpdatedAt: s.pgTimestampToTime(row.UpdatedAt),
|
||||
}
|
||||
if row.CleanFail.Valid {
|
||||
failStr := string(row.CleanFail.Cleanfailtype)
|
||||
o.CleanFail = &failStr
|
||||
}
|
||||
outcomes[i] = o
|
||||
}
|
||||
|
||||
return outcomes, nil
|
||||
}
|
||||
|
||||
// CheckDuplicate checks if a document with the given hash already exists for
|
||||
// the specified client. Returns the existing document's UUID if found, nil otherwise.
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: request context
|
||||
// - clientID: the client identifier
|
||||
// - hash: the SHA-256 hash of the file content
|
||||
//
|
||||
// Returns:
|
||||
// - *uuid.UUID: existing document ID if a duplicate exists, nil otherwise
|
||||
// - error: database error (other than not-found), or nil
|
||||
func (s *Service) CheckDuplicate(ctx context.Context, clientID string, hash string) (*uuid.UUID, error) {
|
||||
id, err := s.cfg.GetDBQueries().GetDocumentIDByHash(ctx, &repository.GetDocumentIDByHashParams{
|
||||
Clientid: clientID,
|
||||
Hash: hash,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &id, nil
|
||||
}
|
||||
|
||||
// ListUnprocessed retrieves all unprocessed batch uploads across all clients
|
||||
func (s *Service) ListUnprocessed(ctx context.Context) ([]*BatchUploadSummary, error) {
|
||||
batches, err := s.cfg.GetDBQueries().GetUnprocessedBatches(ctx)
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"queryorchestration/internal/test"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -559,3 +560,194 @@ func TestListUnprocessed_DatabaseError(t *testing.T) {
|
||||
assert.Nil(t, unprocessedBatches)
|
||||
assert.Contains(t, err.Error(), "failed to query unprocessed batches")
|
||||
}
|
||||
|
||||
// TestRecordOutcome verifies that RecordOutcome inserts a per-file outcome row
|
||||
// and that it can be read back via ListOutcomes.
|
||||
func TestRecordOutcome(t *testing.T) {
|
||||
cfg := &TestConfig{}
|
||||
test.CreateDB(t, cfg)
|
||||
ctx := t.Context()
|
||||
|
||||
service := documentbatch.New(cfg)
|
||||
|
||||
clientID := "test_record_outcome_" + uuid.New().String()[:8]
|
||||
err := cfg.GetDBQueries().CreateClient(ctx, &repository.CreateClientParams{
|
||||
Clientid: clientID,
|
||||
Name: "Test Client Record Outcome",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
batchID, err := service.Create(ctx, clientID, "outcomes.zip", 3)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Insert multiple outcomes
|
||||
err = service.RecordOutcome(ctx, batchID, "good.pdf", repository.BatchOutcomeStatusSubmitted, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
errMsg := "file is not a PDF"
|
||||
err = service.RecordOutcome(ctx, batchID, "notes.txt", repository.BatchOutcomeStatusInvalidType, &errMsg, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify persistence via ListOutcomes
|
||||
outcomes, err := service.ListOutcomes(ctx, batchID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, outcomes, 2)
|
||||
|
||||
// Verify the outcome details
|
||||
found := map[string]string{}
|
||||
for _, o := range outcomes {
|
||||
found[o.Filename] = o.Outcome
|
||||
}
|
||||
assert.Equal(t, "submitted", found["good.pdf"])
|
||||
assert.Equal(t, "invalid_type", found["notes.txt"])
|
||||
}
|
||||
|
||||
// TestListOutcomes inserts outcomes for various states and verifies the listing.
|
||||
func TestListOutcomes(t *testing.T) {
|
||||
cfg := &TestConfig{}
|
||||
test.CreateDB(t, cfg)
|
||||
ctx := t.Context()
|
||||
|
||||
service := documentbatch.New(cfg)
|
||||
|
||||
clientID := "test_list_outcomes_" + uuid.New().String()[:8]
|
||||
err := cfg.GetDBQueries().CreateClient(ctx, &repository.CreateClientParams{
|
||||
Clientid: clientID,
|
||||
Name: "Test Client List Outcomes",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
batchID, err := service.Create(ctx, clientID, "list.zip", 5)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Insert a variety of outcomes
|
||||
err = service.RecordOutcome(ctx, batchID, "a.pdf", repository.BatchOutcomeStatusSubmitted, nil, nil)
|
||||
require.NoError(t, err)
|
||||
err = service.RecordOutcome(ctx, batchID, "b.pdf", repository.BatchOutcomeStatusSubmitted, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
errOpen := "cannot open"
|
||||
err = service.RecordOutcome(ctx, batchID, "c.pdf", repository.BatchOutcomeStatusFailedOpen, &errOpen, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = service.RecordOutcome(ctx, batchID, "d.txt", repository.BatchOutcomeStatusInvalidType, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
dupID := uuid.New()
|
||||
// Create a real document for the duplicate reference
|
||||
realDocID, createErr := cfg.GetDBQueries().CreateDocument(ctx, &repository.CreateDocumentParams{
|
||||
Clientid: clientID,
|
||||
Hash: "hash_dup",
|
||||
BatchID: &batchID,
|
||||
})
|
||||
require.NoError(t, createErr)
|
||||
_ = dupID // not needed since we use the real ID
|
||||
err = service.RecordOutcome(ctx, batchID, "e.pdf", repository.BatchOutcomeStatusDuplicate, nil, &realDocID)
|
||||
require.NoError(t, err)
|
||||
|
||||
outcomes, err := service.ListOutcomes(ctx, batchID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, outcomes, 5)
|
||||
|
||||
// Verify ordering is by created_at ASC
|
||||
expectedFilenames := []string{"a.pdf", "b.pdf", "c.pdf", "d.txt", "e.pdf"}
|
||||
for i, o := range outcomes {
|
||||
assert.Equal(t, expectedFilenames[i], o.Filename)
|
||||
}
|
||||
|
||||
// Verify the duplicate has document_id set
|
||||
assert.NotNil(t, outcomes[4].DocumentID)
|
||||
assert.Equal(t, realDocID, *outcomes[4].DocumentID)
|
||||
}
|
||||
|
||||
// TestListOutcomes_WithCleanFail verifies that the clean_fail column from
|
||||
// the currentCleanEntries view is joined when outcome is clean_failed.
|
||||
func TestListOutcomes_WithCleanFail(t *testing.T) {
|
||||
cfg := &TestConfig{}
|
||||
test.CreateDB(t, cfg)
|
||||
ctx := t.Context()
|
||||
|
||||
service := documentbatch.New(cfg)
|
||||
|
||||
clientID := "test_clean_fail_" + uuid.New().String()[:8]
|
||||
err := cfg.GetDBQueries().CreateClient(ctx, &repository.CreateClientParams{
|
||||
Clientid: clientID,
|
||||
Name: "Test Client Clean Fail",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
batchID, err := service.Create(ctx, clientID, "cleanfail.zip", 1)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a real document
|
||||
docID, err := cfg.GetDBQueries().CreateDocument(ctx, &repository.CreateDocumentParams{
|
||||
Clientid: clientID,
|
||||
Hash: "hash_clean_fail",
|
||||
BatchID: &batchID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Insert a clean_failed outcome
|
||||
err = service.RecordOutcome(ctx, batchID, "corrupt.pdf", repository.BatchOutcomeStatusCleanFailed, nil, &docID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a documentClean with fail type and a documentCleanEntry to populate
|
||||
// the currentCleanEntries view
|
||||
cleanID, err := cfg.GetDBQueries().AddDocumentClean(ctx, &repository.AddDocumentCleanParams{
|
||||
Documentid: docID,
|
||||
Fail: repository.NullCleanfailtype{
|
||||
Cleanfailtype: repository.CleanfailtypeInvalidMimetype,
|
||||
Valid: true,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
err = cfg.GetDBQueries().AddDocumentCleanEntry(ctx, &repository.AddDocumentCleanEntryParams{
|
||||
Cleanid: cleanID,
|
||||
Version: 1,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
outcomes, err := service.ListOutcomes(ctx, batchID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, outcomes, 1)
|
||||
|
||||
assert.Equal(t, "clean_failed", outcomes[0].Outcome)
|
||||
require.NotNil(t, outcomes[0].CleanFail)
|
||||
assert.Equal(t, "invalid_mimetype", *outcomes[0].CleanFail)
|
||||
}
|
||||
|
||||
// TestCheckDuplicate creates a document, then verifies that CheckDuplicate
|
||||
// returns its ID when the hash matches.
|
||||
func TestCheckDuplicate(t *testing.T) {
|
||||
cfg := &TestConfig{}
|
||||
test.CreateDB(t, cfg)
|
||||
ctx := t.Context()
|
||||
|
||||
service := documentbatch.New(cfg)
|
||||
|
||||
clientID := "test_check_dup_" + uuid.New().String()[:8]
|
||||
err := cfg.GetDBQueries().CreateClient(ctx, &repository.CreateClientParams{
|
||||
Clientid: clientID,
|
||||
Name: "Test Client Check Dup",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a document with a known hash
|
||||
knownHash := "sha256_known_hash_abcdef"
|
||||
docID, err := cfg.GetDBQueries().CreateDocument(ctx, &repository.CreateDocumentParams{
|
||||
Clientid: clientID,
|
||||
Hash: knownHash,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check duplicate with the same hash -- should find it
|
||||
existingID, err := service.CheckDuplicate(ctx, clientID, knownHash)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, existingID)
|
||||
assert.Equal(t, docID, *existingID)
|
||||
|
||||
// Check duplicate with a different hash -- should return nil
|
||||
noMatch, err := service.CheckDuplicate(ctx, clientID, "totally_different_hash")
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, noMatch)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"queryorchestration/internal/database/repository"
|
||||
@@ -82,23 +81,34 @@ func (s *Service) executeCleanTasks(ctx context.Context, params *CleanParams) (*
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) clean(ctx context.Context, id uuid.UUID) error {
|
||||
// clean runs the document validation and stores the result. Returns a CleanResult
|
||||
// indicating whether the document passed validation. Infrastructure errors (DB, S3)
|
||||
// are returned as the error value; validation failures are returned via CleanResult.
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: request context
|
||||
// - id: the document UUID to clean
|
||||
//
|
||||
// Returns:
|
||||
// - *CleanResult: result with Passed=true on success, or Passed=false with FailReason
|
||||
// - error: only for infrastructure errors
|
||||
func (s *Service) clean(ctx context.Context, id uuid.UUID) (*CleanResult, error) {
|
||||
slog.Debug("cleaning document", "id", id.String())
|
||||
docId := id
|
||||
|
||||
doc, err := s.cfg.GetDBQueries().GetDocumentSummary(ctx, docId)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entry, err := s.cfg.GetDBQueries().GetDocumentEntry(ctx, docId)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
key, err := objectstore.ParseBucketKey(entry.Key)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out, err := s.executeCleanTasks(ctx, &CleanParams{
|
||||
@@ -108,18 +118,31 @@ func (s *Service) clean(ctx context.Context, id uuid.UUID) error {
|
||||
Key: key,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = s.storeClean(ctx, id, out)
|
||||
result, err := s.storeClean(ctx, id, out)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return nil
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) storeClean(ctx context.Context, id uuid.UUID, out *ExecuteCleanResponse) error {
|
||||
// storeClean persists the clean result to the database. Returns a CleanResult
|
||||
// indicating whether the document passed validation. Validation failures are
|
||||
// returned via CleanResult (not as errors) so callers can distinguish them
|
||||
// from infrastructure errors.
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: request context
|
||||
// - id: the document UUID
|
||||
// - out: the result from executeCleanTasks
|
||||
//
|
||||
// Returns:
|
||||
// - *CleanResult: Passed=true if validation succeeded, Passed=false with FailReason otherwise
|
||||
// - error: only for infrastructure errors (DB transaction failures)
|
||||
func (s *Service) storeClean(ctx context.Context, id uuid.UUID, out *ExecuteCleanResponse) (*CleanResult, error) {
|
||||
docId := id
|
||||
version := build.GetVersionUnixTimestamp()
|
||||
|
||||
@@ -164,14 +187,15 @@ func (s *Service) storeClean(ctx context.Context, id uuid.UUID, out *ExecuteClea
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if out.failReason != nil {
|
||||
return fmt.Errorf("%s", *out.failReason)
|
||||
failStr := string(*out.failReason)
|
||||
return &CleanResult{Passed: false, FailReason: &failStr}, nil
|
||||
}
|
||||
|
||||
return nil
|
||||
return &CleanResult{Passed: true}, nil
|
||||
}
|
||||
|
||||
func (s *Service) isNewClean(lastEntry *repository.GetMostRecentDocumentCleanEntryRow, out *ExecuteCleanResponse) bool {
|
||||
|
||||
@@ -10,21 +10,41 @@ import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// CleanResult contains the result of the clean operation.
|
||||
//
|
||||
// Fields:
|
||||
// - Passed: true if the document passed all validation checks
|
||||
// - FailReason: the specific failure reason if Passed is false, nil otherwise
|
||||
type CleanResult struct {
|
||||
Passed bool
|
||||
FailReason *string
|
||||
}
|
||||
|
||||
// Clean processes a document by cleaning it. Previously this also triggered
|
||||
// text extraction, but that feature has been removed.
|
||||
func (s *Service) Clean(ctx context.Context, documentId uuid.UUID) error {
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: request context
|
||||
// - documentId: the document UUID to clean
|
||||
//
|
||||
// Returns:
|
||||
// - *CleanResult: result indicating whether validation passed and any failure reason
|
||||
// - error: only for infrastructure errors (DB, S3). Validation failures are
|
||||
// returned via CleanResult.Passed=false, not as errors.
|
||||
func (s *Service) Clean(ctx context.Context, documentId uuid.UUID) (*CleanResult, error) {
|
||||
isclean, err := s.cfg.GetDBQueries().HasDocumentCleanEntry(ctx, documentId)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to verify if document has been cleaned: %w", err)
|
||||
return nil, fmt.Errorf("unable to verify if document has been cleaned: %w", err)
|
||||
}
|
||||
|
||||
if !isclean {
|
||||
err = s.clean(ctx, documentId)
|
||||
result, err := s.clean(ctx, documentId)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to clean document: %w", err)
|
||||
return nil, fmt.Errorf("unable to clean document: %w", err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Note: Text extraction has been removed. Document pipeline ends here.
|
||||
return nil
|
||||
return &CleanResult{Passed: true}, nil
|
||||
}
|
||||
|
||||
@@ -3,18 +3,26 @@
|
||||
package documentclean_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"queryorchestration/internal/database/repository"
|
||||
documentclean "queryorchestration/internal/document/clean"
|
||||
"queryorchestration/internal/serviceconfig"
|
||||
"queryorchestration/internal/serviceconfig/objectstore"
|
||||
"queryorchestration/internal/test"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type DocCleanConfig struct {
|
||||
serviceconfig.BaseConfig
|
||||
objectstore.ConfigProvider
|
||||
objectstore.ObjectStoreConfig
|
||||
}
|
||||
|
||||
func TestService(t *testing.T) {
|
||||
@@ -22,3 +30,104 @@ func TestService(t *testing.T) {
|
||||
svc := documentclean.New(cfg)
|
||||
assert.NotNil(t, svc)
|
||||
}
|
||||
|
||||
// TestDocumentCleanClean_ReturnsCleanResult verifies that Clean() returns a
|
||||
// *CleanResult with correct Passed and FailReason fields.
|
||||
//
|
||||
// Two scenarios are tested:
|
||||
// 1. Already-cleaned document (HasDocumentCleanEntry=true) returns Passed=true
|
||||
// 2. Non-PDF file returns Passed=false with a non-nil FailReason
|
||||
func TestDocumentCleanClean_ReturnsCleanResult(t *testing.T) {
|
||||
cfg := &DocCleanConfig{}
|
||||
test.CreateDB(t, cfg)
|
||||
test.CreateAWSResources(t, cfg)
|
||||
ctx := t.Context()
|
||||
|
||||
svc := documentclean.New(cfg)
|
||||
|
||||
// Test 1: Already cleaned document returns Passed=true (shortcut path)
|
||||
clientID := "clean_result_" + uuid.New().String()[:8]
|
||||
err := cfg.GetDBQueries().CreateClient(ctx, &repository.CreateClientParams{
|
||||
Clientid: clientID,
|
||||
Name: "Test Clean Result",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
docID, err := cfg.GetDBQueries().CreateDocument(ctx, &repository.CreateDocumentParams{
|
||||
Clientid: clientID,
|
||||
Hash: "hash_clean_result_" + uuid.New().String()[:8],
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a clean entry so HasDocumentCleanEntry returns true
|
||||
bucket := "bucket"
|
||||
key := "key"
|
||||
hash := "hash"
|
||||
cleanID, err := cfg.GetDBQueries().AddDocumentClean(ctx, &repository.AddDocumentCleanParams{
|
||||
Documentid: docID,
|
||||
Bucket: &bucket,
|
||||
Key: &key,
|
||||
Hash: &hash,
|
||||
Mimetype: repository.NullCleanmimetype{
|
||||
Cleanmimetype: repository.CleanmimetypeApplicationPdf,
|
||||
Valid: true,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
err = cfg.GetDBQueries().AddDocumentCleanEntry(ctx, &repository.AddDocumentCleanEntryParams{
|
||||
Cleanid: cleanID,
|
||||
Version: 1,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := svc.Clean(ctx, docID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
assert.True(t, result.Passed, "already-cleaned document should return Passed=true")
|
||||
assert.Nil(t, result.FailReason, "Passed document should have nil FailReason")
|
||||
|
||||
// Test 2: Non-PDF file returns Passed=false with FailReason
|
||||
// Upload a non-PDF file to S3 first to get its ETag
|
||||
fileContent := []byte("This is NOT a PDF file. It has no PDF signature.")
|
||||
location := objectstore.BucketKey{
|
||||
Location: objectstore.Import,
|
||||
ClientID: clientID,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
EntityID: uuid.New(),
|
||||
}
|
||||
_, err = cfg.GetStoreClient().PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: aws.String(cfg.GetBucket()),
|
||||
Key: aws.String(location.String()),
|
||||
Body: bytes.NewReader(fileContent),
|
||||
ContentType: aws.String("text/plain"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get ETag to use as the document hash (IfMatch requires it)
|
||||
headResp, err := cfg.GetStoreClient().HeadObject(ctx, &s3.HeadObjectInput{
|
||||
Bucket: aws.String(cfg.GetBucket()),
|
||||
Key: aws.String(location.String()),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
etag := *headResp.ETag
|
||||
|
||||
// Create document with hash matching the ETag
|
||||
docID2, err := cfg.GetDBQueries().CreateDocument(ctx, &repository.CreateDocumentParams{
|
||||
Clientid: clientID,
|
||||
Hash: etag,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = cfg.GetDBQueries().AddDocumentEntry(ctx, &repository.AddDocumentEntryParams{
|
||||
Documentid: docID2,
|
||||
Bucket: cfg.GetBucket(),
|
||||
Key: location.String(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
failResult, err := svc.Clean(ctx, docID2)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, failResult)
|
||||
assert.False(t, failResult.Passed, "non-PDF document should fail validation")
|
||||
assert.NotNil(t, failResult.FailReason, "failed document should have a FailReason")
|
||||
}
|
||||
|
||||
@@ -26,15 +26,39 @@ type Create struct {
|
||||
FolderID *uuid.UUID // Optional folder ID if folder hierarchy was pre-created
|
||||
}
|
||||
|
||||
func (s *Service) Create(ctx context.Context, doc *Create) (uuid.UUID, error) {
|
||||
// CreateResult contains the result of document creation.
|
||||
//
|
||||
// Fields:
|
||||
// - ID: the document's UUID (new or existing)
|
||||
// - IsDuplicate: true if a document with the same hash already existed
|
||||
// - BatchID: the batch this document belongs to (nil for non-batch uploads)
|
||||
// - Filename: the original filename (nil for non-batch uploads)
|
||||
type CreateResult struct {
|
||||
ID uuid.UUID
|
||||
IsDuplicate bool
|
||||
BatchID *uuid.UUID
|
||||
Filename *string
|
||||
}
|
||||
|
||||
// Create processes a new document upload by checking for duplicates, creating
|
||||
// database records, and forwarding new documents to the sync queue.
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: request context
|
||||
// - doc: document creation parameters (bucket, key, hash, filename, etc.)
|
||||
//
|
||||
// Returns:
|
||||
// - *CreateResult: result containing document ID, duplicate status, and batch info
|
||||
// - error: if any infrastructure operation (DB, S3, queue) fails
|
||||
func (s *Service) Create(ctx context.Context, doc *Create) (*CreateResult, error) {
|
||||
params, err := s.getCreateParams(ctx, doc)
|
||||
if err != nil {
|
||||
return uuid.Nil, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
id, err := s.submitCreate(ctx, params)
|
||||
if err != nil {
|
||||
return uuid.Nil, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if params.ID == nil {
|
||||
@@ -45,11 +69,16 @@ func (s *Service) Create(ctx context.Context, doc *Create) (uuid.UUID, error) {
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return uuid.Nil, err
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return id, nil
|
||||
return &CreateResult{
|
||||
ID: id,
|
||||
IsDuplicate: params.ID != nil,
|
||||
BatchID: doc.Key.BatchID,
|
||||
Filename: doc.Filename,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type createDocumentParams struct {
|
||||
|
||||
@@ -6,7 +6,10 @@ import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"queryorchestration/internal/database/repository"
|
||||
"queryorchestration/internal/serviceconfig/objectstore"
|
||||
"queryorchestration/internal/test"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
@@ -105,3 +108,130 @@ func TestMeasureFileSize_Integration_NonExistentKey(t *testing.T) {
|
||||
assert.Error(t, err, "measureFileSize should return an error for non-existent key")
|
||||
assert.Contains(t, err.Error(), "HeadObject failed", "error should indicate HeadObject failure")
|
||||
}
|
||||
|
||||
// TestDocumentInitCreate_ReturnsCreateResult verifies that Create() returns a
|
||||
// *CreateResult with correct IsDuplicate, BatchID, and Filename fields for both
|
||||
// new and duplicate documents.
|
||||
func TestDocumentInitCreate_ReturnsCreateResult(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
|
||||
// Set up DB, S3, and SQS
|
||||
cfg := &DocInitConfig{}
|
||||
test.CreateDB(t, cfg)
|
||||
acfg := test.CreateAWSContainer(t, cfg)
|
||||
|
||||
test.SetQueueClient(t, ctx, cfg, acfg.ExternalEndpoint)
|
||||
cfg.DocumentSyncURL = test.CreateQueue(t, cfg, test.DocSyncRunnerName)
|
||||
|
||||
test.SetStoreClient(t, ctx, cfg, acfg.ExternalEndpoint)
|
||||
test.CreateBucket(t, cfg)
|
||||
|
||||
svc := New(cfg)
|
||||
|
||||
clientID := "create_result_" + uuid.New().String()[:8]
|
||||
err := cfg.GetDBQueries().CreateClient(ctx, &repository.CreateClientParams{
|
||||
Clientid: clientID,
|
||||
Name: "Test CreateResult",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a batch
|
||||
batchID, err := cfg.GetDBQueries().CreateBatchUpload(ctx, &repository.CreateBatchUploadParams{
|
||||
ClientID: clientID,
|
||||
OriginalFilename: "test.zip",
|
||||
TotalDocuments: 2,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Upload a file to S3
|
||||
location := objectstore.BucketKey{
|
||||
Location: objectstore.Import,
|
||||
ClientID: clientID,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
EntityID: uuid.New(),
|
||||
BatchID: &batchID,
|
||||
}
|
||||
testContent := []byte("test content for create result")
|
||||
_, err = cfg.GetStoreClient().PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: aws.String(cfg.GetBucket()),
|
||||
Key: aws.String(location.String()),
|
||||
Body: bytes.NewReader(testContent),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
filename := "invoice.pdf"
|
||||
uniqueHash := "create_result_hash_" + uuid.New().String()[:8]
|
||||
|
||||
// Test 1: New document returns IsDuplicate=false with correct fields
|
||||
result, err := svc.Create(ctx, &Create{
|
||||
Bucket: cfg.GetBucket(),
|
||||
Key: location,
|
||||
Hash: uniqueHash,
|
||||
Filename: &filename,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
assert.NotEqual(t, uuid.Nil, result.ID, "new document should have a valid ID")
|
||||
assert.False(t, result.IsDuplicate, "new document should not be a duplicate")
|
||||
assert.NotNil(t, result.BatchID, "batch document should have BatchID set")
|
||||
assert.Equal(t, batchID, *result.BatchID, "BatchID should match the batch")
|
||||
assert.NotNil(t, result.Filename, "Filename should be set")
|
||||
assert.Equal(t, filename, *result.Filename, "Filename should match")
|
||||
|
||||
// Test 2: Duplicate document (same hash) returns IsDuplicate=true
|
||||
location2 := objectstore.BucketKey{
|
||||
Location: objectstore.Import,
|
||||
ClientID: clientID,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
EntityID: uuid.New(),
|
||||
BatchID: &batchID,
|
||||
}
|
||||
_, err = cfg.GetStoreClient().PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: aws.String(cfg.GetBucket()),
|
||||
Key: aws.String(location2.String()),
|
||||
Body: bytes.NewReader(testContent),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
dupResult, err := svc.Create(ctx, &Create{
|
||||
Bucket: cfg.GetBucket(),
|
||||
Key: location2,
|
||||
Hash: uniqueHash, // Same hash triggers duplicate
|
||||
Filename: &filename,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, dupResult)
|
||||
|
||||
assert.Equal(t, result.ID, dupResult.ID, "duplicate should reference the original document ID")
|
||||
assert.True(t, dupResult.IsDuplicate, "second document with same hash should be duplicate")
|
||||
assert.NotNil(t, dupResult.BatchID, "duplicate should still have BatchID")
|
||||
assert.Equal(t, batchID, *dupResult.BatchID, "duplicate BatchID should match")
|
||||
|
||||
// Test 3: Non-batch document has nil BatchID
|
||||
location3 := objectstore.BucketKey{
|
||||
Location: objectstore.Import,
|
||||
ClientID: clientID,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
EntityID: uuid.New(),
|
||||
// No BatchID
|
||||
}
|
||||
_, err = cfg.GetStoreClient().PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: aws.String(cfg.GetBucket()),
|
||||
Key: aws.String(location3.String()),
|
||||
Body: bytes.NewReader(testContent),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
nonBatchResult, err := svc.Create(ctx, &Create{
|
||||
Bucket: cfg.GetBucket(),
|
||||
Key: location3,
|
||||
Hash: "nonbatch_hash_" + uuid.New().String()[:8],
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, nonBatchResult)
|
||||
|
||||
assert.False(t, nonBatchResult.IsDuplicate, "non-batch new document should not be duplicate")
|
||||
assert.Nil(t, nonBatchResult.BatchID, "non-batch document should have nil BatchID")
|
||||
assert.Nil(t, nonBatchResult.Filename, "non-batch document should have nil Filename")
|
||||
}
|
||||
|
||||
@@ -2,20 +2,132 @@ package documentsync_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"queryorchestration/internal/client"
|
||||
"queryorchestration/internal/database/repository"
|
||||
"queryorchestration/internal/document"
|
||||
documentsync "queryorchestration/internal/document/sync"
|
||||
"queryorchestration/internal/serviceconfig"
|
||||
"queryorchestration/internal/serviceconfig/objectstore"
|
||||
"queryorchestration/internal/serviceconfig/queue"
|
||||
"queryorchestration/internal/serviceconfig/queue/documentclean"
|
||||
"queryorchestration/internal/test"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type DocSyncConfig struct {
|
||||
serviceconfig.BaseConfig
|
||||
documentclean.DocCleanConfig
|
||||
queue.QueueConfig
|
||||
objectstore.ObjectStoreConfig
|
||||
}
|
||||
|
||||
func TestNewDocumentSyncService(t *testing.T) {
|
||||
svc := documentsync.New(&DocSyncConfig{}, &documentsync.Services{})
|
||||
assert.NotNil(t, svc)
|
||||
}
|
||||
|
||||
// TestDocumentSyncSync_ReturnsSyncResult verifies that Sync() returns a
|
||||
// *SyncResult with correct Synced and Skipped fields based on the client's
|
||||
// can_sync configuration.
|
||||
func TestDocumentSyncSync_ReturnsSyncResult(t *testing.T) {
|
||||
cfg := &DocSyncConfig{}
|
||||
test.CreateDB(t, cfg)
|
||||
acfg := test.CreateAWSContainer(t, cfg)
|
||||
|
||||
test.SetQueueClient(t, t.Context(), cfg, acfg.ExternalEndpoint)
|
||||
cfg.DocumentCleanURL = test.CreateQueue(t, cfg, test.DocCleanRunnerName)
|
||||
|
||||
ctx := t.Context()
|
||||
|
||||
// Test 1: can_sync=true -> Synced=true, Skipped=false
|
||||
syncClientID := "sync_result_true_" + uuid.New().String()[:8]
|
||||
err := cfg.GetDBQueries().CreateClient(ctx, &repository.CreateClientParams{
|
||||
Clientid: syncClientID,
|
||||
Name: "Test Sync True",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
err = cfg.GetDBQueries().AddClientCanSync(ctx, &repository.AddClientCanSyncParams{
|
||||
Clientid: syncClientID,
|
||||
Cansync: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
docID, err := cfg.GetDBQueries().CreateDocument(ctx, &repository.CreateDocumentParams{
|
||||
Clientid: syncClientID,
|
||||
Hash: "hash_sync_true_" + uuid.New().String()[:8],
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
part := uint16(1)
|
||||
filetype := "pdf"
|
||||
key := objectstore.BucketKey{
|
||||
CreatedAt: time.Now().UTC(),
|
||||
ClientID: syncClientID,
|
||||
EntityID: uuid.New(),
|
||||
Location: objectstore.Import,
|
||||
Part: &part,
|
||||
FileType: &filetype,
|
||||
}
|
||||
err = cfg.GetDBQueries().AddDocumentEntry(ctx, &repository.AddDocumentEntryParams{
|
||||
Documentid: docID,
|
||||
Bucket: "bucket",
|
||||
Key: key.String(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
svc := documentsync.New(cfg, &documentsync.Services{
|
||||
Document: document.New(cfg),
|
||||
Client: client.New(cfg),
|
||||
})
|
||||
|
||||
result, err := svc.Sync(ctx, docID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
assert.True(t, result.Synced, "should be synced when can_sync=true")
|
||||
assert.False(t, result.Skipped, "should not be skipped when can_sync=true")
|
||||
|
||||
// Test 2: can_sync=false -> Synced=false, Skipped=true
|
||||
skipClientID := "sync_result_false_" + uuid.New().String()[:8]
|
||||
err = cfg.GetDBQueries().CreateClient(ctx, &repository.CreateClientParams{
|
||||
Clientid: skipClientID,
|
||||
Name: "Test Sync False",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
err = cfg.GetDBQueries().AddClientCanSync(ctx, &repository.AddClientCanSyncParams{
|
||||
Clientid: skipClientID,
|
||||
Cansync: false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
skipDocID, err := cfg.GetDBQueries().CreateDocument(ctx, &repository.CreateDocumentParams{
|
||||
Clientid: skipClientID,
|
||||
Hash: "hash_sync_false_" + uuid.New().String()[:8],
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
key2 := objectstore.BucketKey{
|
||||
CreatedAt: time.Now().UTC(),
|
||||
ClientID: skipClientID,
|
||||
EntityID: uuid.New(),
|
||||
Location: objectstore.Import,
|
||||
Part: &part,
|
||||
FileType: &filetype,
|
||||
}
|
||||
err = cfg.GetDBQueries().AddDocumentEntry(ctx, &repository.AddDocumentEntryParams{
|
||||
Documentid: skipDocID,
|
||||
Bucket: "bucket",
|
||||
Key: key2.String(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
skipResult, err := svc.Sync(ctx, skipDocID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, skipResult)
|
||||
assert.False(t, skipResult.Synced, "should not be synced when can_sync=false")
|
||||
assert.True(t, skipResult.Skipped, "should be skipped when can_sync=false")
|
||||
}
|
||||
|
||||
@@ -10,20 +10,40 @@ import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func (s *Service) Sync(ctx context.Context, id uuid.UUID) error {
|
||||
// SyncResult contains the result of the sync check.
|
||||
//
|
||||
// Fields:
|
||||
// - Synced: true if the document was forwarded to the clean queue
|
||||
// - Skipped: true if the client's can_sync flag is false
|
||||
type SyncResult struct {
|
||||
Synced bool
|
||||
Skipped bool
|
||||
}
|
||||
|
||||
// Sync checks if a document is eligible for syncing based on the client's
|
||||
// can_sync configuration, and forwards eligible documents to the clean queue.
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: request context
|
||||
// - id: the document UUID to sync
|
||||
//
|
||||
// Returns:
|
||||
// - *SyncResult: result indicating whether sync was performed or skipped
|
||||
// - error: if any infrastructure operation (DB, queue) fails
|
||||
func (s *Service) Sync(ctx context.Context, id uuid.UUID) (*SyncResult, error) {
|
||||
doc, err := s.svc.Document.GetSummary(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
j, err := s.svc.Client.Get(ctx, doc.ClientID)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !j.CanSync {
|
||||
slog.Debug("not syncing document", "id", id.String())
|
||||
return nil
|
||||
return &SyncResult{Skipped: true}, nil
|
||||
}
|
||||
|
||||
slog.Debug("syncing document", "id", id.String())
|
||||
@@ -35,8 +55,8 @@ func (s *Service) Sync(ctx context.Context, id uuid.UUID) error {
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return nil
|
||||
return &SyncResult{Synced: true}, nil
|
||||
}
|
||||
|
||||
@@ -4,12 +4,15 @@ import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"queryorchestration/internal/database/repository"
|
||||
"queryorchestration/internal/document/batch"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
@@ -292,6 +295,8 @@ func processZipFile(
|
||||
if err != nil {
|
||||
logger.Error("Failed to open file in zip", "filename", filename, "error", err)
|
||||
_ = batchService.AddFailedFilename(ctx, batchInfo.ID, filename)
|
||||
errMsg := err.Error()
|
||||
_ = batchService.RecordOutcome(ctx, batchInfo.ID, filename, repository.BatchOutcomeStatusFailedOpen, &errMsg, nil)
|
||||
return 0, 1, 0
|
||||
}
|
||||
|
||||
@@ -300,6 +305,8 @@ func processZipFile(
|
||||
if err != nil {
|
||||
logger.Error("Failed to read file from zip", "filename", filename, "error", err)
|
||||
_ = batchService.AddFailedFilename(ctx, batchInfo.ID, filename)
|
||||
errMsg := err.Error()
|
||||
_ = batchService.RecordOutcome(ctx, batchInfo.ID, filename, repository.BatchOutcomeStatusFailedRead, &errMsg, nil)
|
||||
return 0, 1, 0
|
||||
}
|
||||
|
||||
@@ -308,6 +315,8 @@ func processZipFile(
|
||||
if err := uploadToS3(ctx, s3Client, bucket, extractedKey, fileData); err != nil {
|
||||
logger.Error("Failed to upload extracted file to S3", "filename", filename, "error", err)
|
||||
_ = batchService.AddFailedFilename(ctx, batchInfo.ID, filename)
|
||||
errMsg := err.Error()
|
||||
_ = batchService.RecordOutcome(ctx, batchInfo.ID, filename, repository.BatchOutcomeStatusFailedS3Upload, &errMsg, nil)
|
||||
return 0, 1, 0
|
||||
}
|
||||
|
||||
@@ -315,6 +324,7 @@ func processZipFile(
|
||||
if !strings.HasSuffix(strings.ToLower(filename), ".pdf") {
|
||||
logger.Warn("Invalid file type", "filename", filename)
|
||||
_ = batchService.AddFailedFilename(ctx, batchInfo.ID, filename)
|
||||
_ = batchService.RecordOutcome(ctx, batchInfo.ID, filename, repository.BatchOutcomeStatusInvalidType, nil, nil)
|
||||
return 0, 0, 1
|
||||
}
|
||||
|
||||
@@ -322,9 +332,21 @@ func processZipFile(
|
||||
if err := uploadHandler(ctx, batchInfo.ClientID, bytes.NewReader(fileData), filename, &batchInfo.ID); err != nil {
|
||||
logger.Error("Failed to upload document", "filename", filename, "error", err)
|
||||
_ = batchService.AddFailedFilename(ctx, batchInfo.ID, filename)
|
||||
errMsg := err.Error()
|
||||
_ = batchService.RecordOutcome(ctx, batchInfo.ID, filename, repository.BatchOutcomeStatusFailedUpload, &errMsg, nil)
|
||||
return 0, 1, 0
|
||||
}
|
||||
|
||||
// Check for duplicate by computing file hash and looking up existing document
|
||||
hash := sha256.Sum256(fileData)
|
||||
hashStr := hex.EncodeToString(hash[:])
|
||||
existingID, _ := batchService.CheckDuplicate(ctx, batchInfo.ClientID, hashStr)
|
||||
if existingID != nil {
|
||||
_ = batchService.RecordOutcome(ctx, batchInfo.ID, filename, repository.BatchOutcomeStatusDuplicate, nil, existingID)
|
||||
} else {
|
||||
_ = batchService.RecordOutcome(ctx, batchInfo.ID, filename, repository.BatchOutcomeStatusSubmitted, nil, nil)
|
||||
}
|
||||
|
||||
logger.Info("Successfully processed file", "filename", filename, "batch_id", batchInfo.ID)
|
||||
return 1, 0, 0
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
@@ -668,6 +670,221 @@ func TestProcessBatchWithPathTraversal(t *testing.T) {
|
||||
assert.Equal(t, 3, len(uploadedDocs), "Should have processed exactly 3 safe PDFs")
|
||||
}
|
||||
|
||||
// TestProcessZipFile_RecordsOutcomes tests that processZipFile records per-file
|
||||
// outcomes for each extracted file.
|
||||
func TestProcessZipFile_RecordsOutcomes(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
}
|
||||
|
||||
ctx := t.Context()
|
||||
cfg := &BaseConfig{}
|
||||
_ = serviceconfig.InitializeConfig(cfg)
|
||||
test.CreateDB(t, cfg)
|
||||
test.CreateAWSResources(t, cfg)
|
||||
|
||||
clientID := "test_outcomes_record"
|
||||
test.CreateTestClient(t, cfg, clientID, "Test Outcomes Record Client")
|
||||
|
||||
batchService := batch.New(cfg)
|
||||
s3ClientInterface := cfg.GetStoreClient()
|
||||
s3Client, ok := s3ClientInterface.(*s3.Client)
|
||||
require.True(t, ok)
|
||||
bucket := cfg.GetBucket()
|
||||
|
||||
// Create ZIP with 2 PDFs
|
||||
zipContent := createTestZIPForWorker(t, 2)
|
||||
archiveKey := fmt.Sprintf("test/%s/outcomes.zip", clientID)
|
||||
|
||||
_, err := s3Client.PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: aws.String(bucket),
|
||||
Key: aws.String(archiveKey),
|
||||
Body: bytes.NewReader(zipContent),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
batchID, err := batchService.CreateWithStorage(ctx, clientID, "outcomes.zip", 2, bucket, archiveKey, int64(len(zipContent)))
|
||||
require.NoError(t, err)
|
||||
|
||||
uploadHandler := func(ctx context.Context, clientID string, docData io.Reader, filename string, batchID *uuid.UUID) error {
|
||||
_, _ = io.ReadAll(docData)
|
||||
return nil
|
||||
}
|
||||
|
||||
workerConfig := map[string]any{
|
||||
ConfigKeyBatchService: batchService,
|
||||
ConfigKeyS3Client: s3Client,
|
||||
ConfigKeyBucket: bucket,
|
||||
ConfigKeyUploadHandler: uploadHandler,
|
||||
}
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
|
||||
err = processBatchWork(ctx, logger, workerConfig)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify outcomes were recorded for each file
|
||||
outcomes, err := batchService.ListOutcomes(ctx, batchID)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, outcomes, 2)
|
||||
|
||||
for _, o := range outcomes {
|
||||
assert.Equal(t, "submitted", o.Outcome, "each PDF should be recorded as submitted")
|
||||
assert.Contains(t, o.Filename, ".pdf")
|
||||
}
|
||||
}
|
||||
|
||||
// TestProcessZipFile_DetectsDuplicates verifies that when a file in the batch
|
||||
// has the same content hash as an existing document in the system, it is
|
||||
// recorded as a duplicate with the existing document's ID.
|
||||
func TestProcessZipFile_DetectsDuplicates(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
}
|
||||
|
||||
ctx := t.Context()
|
||||
cfg := &BaseConfig{}
|
||||
_ = serviceconfig.InitializeConfig(cfg)
|
||||
test.CreateDB(t, cfg)
|
||||
test.CreateAWSResources(t, cfg)
|
||||
|
||||
clientID := "test_outcomes_dup"
|
||||
test.CreateTestClient(t, cfg, clientID, "Test Outcomes Dup Client")
|
||||
|
||||
batchService := batch.New(cfg)
|
||||
s3ClientInterface := cfg.GetStoreClient()
|
||||
s3Client, ok := s3ClientInterface.(*s3.Client)
|
||||
require.True(t, ok)
|
||||
bucket := cfg.GetBucket()
|
||||
|
||||
// Pre-create an existing document with a known hash
|
||||
knownContent := []byte("%%PDF-1.4\n%%Already existing content\n%%%%EOF")
|
||||
knownHash := sha256Hash(knownContent)
|
||||
existingDocID, err := cfg.GetDBQueries().CreateDocument(ctx, &repository.CreateDocumentParams{
|
||||
Clientid: clientID,
|
||||
Hash: knownHash,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a ZIP with one file that has the same content as the existing doc
|
||||
var buf bytes.Buffer
|
||||
zipWriter := zip.NewWriter(&buf)
|
||||
w, err := zipWriter.Create("duplicate_of_existing.pdf")
|
||||
require.NoError(t, err)
|
||||
_, err = w.Write(knownContent)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, zipWriter.Close())
|
||||
zipContent := buf.Bytes()
|
||||
|
||||
archiveKey := fmt.Sprintf("test/%s/dup.zip", clientID)
|
||||
_, err = s3Client.PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: aws.String(bucket),
|
||||
Key: aws.String(archiveKey),
|
||||
Body: bytes.NewReader(zipContent),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
batchID, err := batchService.CreateWithStorage(ctx, clientID, "dup.zip", 1, bucket, archiveKey, int64(len(zipContent)))
|
||||
require.NoError(t, err)
|
||||
|
||||
uploadHandler := func(ctx context.Context, cID string, docData io.Reader, filename string, bID *uuid.UUID) error {
|
||||
_, _ = io.ReadAll(docData)
|
||||
return nil
|
||||
}
|
||||
|
||||
workerConfig := map[string]any{
|
||||
ConfigKeyBatchService: batchService,
|
||||
ConfigKeyS3Client: s3Client,
|
||||
ConfigKeyBucket: bucket,
|
||||
ConfigKeyUploadHandler: uploadHandler,
|
||||
}
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
|
||||
err = processBatchWork(ctx, logger, workerConfig)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify outcome is "duplicate" with the existing document's ID
|
||||
outcomes, err := batchService.ListOutcomes(ctx, batchID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, outcomes, 1)
|
||||
|
||||
assert.Equal(t, "duplicate", outcomes[0].Outcome)
|
||||
require.NotNil(t, outcomes[0].DocumentID)
|
||||
assert.Equal(t, existingDocID, *outcomes[0].DocumentID)
|
||||
}
|
||||
|
||||
// TestProcessZipFile_MixedOutcomes tests a ZIP with PDF + non-PDF + paths to
|
||||
// verify all outcome types (submitted, invalid_type) are correctly recorded.
|
||||
func TestProcessZipFile_MixedOutcomes(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
}
|
||||
|
||||
ctx := t.Context()
|
||||
cfg := &BaseConfig{}
|
||||
_ = serviceconfig.InitializeConfig(cfg)
|
||||
test.CreateDB(t, cfg)
|
||||
test.CreateAWSResources(t, cfg)
|
||||
|
||||
clientID := "test_outcomes_mixed"
|
||||
test.CreateTestClient(t, cfg, clientID, "Test Outcomes Mixed Client")
|
||||
|
||||
batchService := batch.New(cfg)
|
||||
s3ClientInterface := cfg.GetStoreClient()
|
||||
s3Client, ok := s3ClientInterface.(*s3.Client)
|
||||
require.True(t, ok)
|
||||
bucket := cfg.GetBucket()
|
||||
|
||||
// Create mixed ZIP
|
||||
zipContent := createMixedContentZIP(t)
|
||||
archiveKey := fmt.Sprintf("test/%s/mixed_outcomes.zip", clientID)
|
||||
|
||||
_, err := s3Client.PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: aws.String(bucket),
|
||||
Key: aws.String(archiveKey),
|
||||
Body: bytes.NewReader(zipContent),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
batchID, err := batchService.CreateWithStorage(ctx, clientID, "mixed_outcomes.zip", 3, bucket, archiveKey, int64(len(zipContent)))
|
||||
require.NoError(t, err)
|
||||
|
||||
uploadHandler := func(ctx context.Context, cID string, docData io.Reader, filename string, bID *uuid.UUID) error {
|
||||
_, _ = io.ReadAll(docData)
|
||||
return nil
|
||||
}
|
||||
|
||||
workerConfig := map[string]any{
|
||||
ConfigKeyBatchService: batchService,
|
||||
ConfigKeyS3Client: s3Client,
|
||||
ConfigKeyBucket: bucket,
|
||||
ConfigKeyUploadHandler: uploadHandler,
|
||||
}
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
|
||||
err = processBatchWork(ctx, logger, workerConfig)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify outcomes
|
||||
outcomes, err := batchService.ListOutcomes(ctx, batchID)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, outcomes, 3) // 1 PDF + 1 txt + 1 docx
|
||||
|
||||
outcomeMap := map[string]string{}
|
||||
for _, o := range outcomes {
|
||||
outcomeMap[o.Filename] = o.Outcome
|
||||
}
|
||||
|
||||
assert.Equal(t, "submitted", outcomeMap["document.pdf"])
|
||||
assert.Equal(t, "invalid_type", outcomeMap["notes.txt"])
|
||||
assert.Equal(t, "invalid_type", outcomeMap["report.docx"])
|
||||
}
|
||||
|
||||
// sha256Hash computes the hex-encoded SHA-256 hash of data.
|
||||
func sha256Hash(data []byte) string {
|
||||
h := sha256.Sum256(data)
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// TestProcessBatchWithDuplicateFilenames tests handling of duplicate filenames in different folders
|
||||
func TestProcessBatchWithDuplicateFilenames(t *testing.T) {
|
||||
if testing.Short() {
|
||||
|
||||
+901
-1
@@ -30,6 +30,23 @@ const (
|
||||
Enable AdminUserActionResponseAction = "enable"
|
||||
)
|
||||
|
||||
// Defines values for BatchDocumentOutcomeOutcome.
|
||||
const (
|
||||
CleanFailed BatchDocumentOutcomeOutcome = "clean_failed"
|
||||
CleanPassed BatchDocumentOutcomeOutcome = "clean_passed"
|
||||
Duplicate BatchDocumentOutcomeOutcome = "duplicate"
|
||||
FailedOpen BatchDocumentOutcomeOutcome = "failed_open"
|
||||
FailedRead BatchDocumentOutcomeOutcome = "failed_read"
|
||||
FailedS3Upload BatchDocumentOutcomeOutcome = "failed_s3_upload"
|
||||
FailedUpload BatchDocumentOutcomeOutcome = "failed_upload"
|
||||
InitComplete BatchDocumentOutcomeOutcome = "init_complete"
|
||||
InitDuplicate BatchDocumentOutcomeOutcome = "init_duplicate"
|
||||
InvalidType BatchDocumentOutcomeOutcome = "invalid_type"
|
||||
Submitted BatchDocumentOutcomeOutcome = "submitted"
|
||||
SyncComplete BatchDocumentOutcomeOutcome = "sync_complete"
|
||||
SyncSkipped BatchDocumentOutcomeOutcome = "sync_skipped"
|
||||
)
|
||||
|
||||
// Defines values for BatchStatus.
|
||||
const (
|
||||
BatchStatusCancelled BatchStatus = "cancelled"
|
||||
@@ -194,7 +211,7 @@ type AdminUserDetails struct {
|
||||
// LastName User's family name retrieved from Cognito
|
||||
LastName *string `json:"last_name,omitempty"`
|
||||
|
||||
// Roles Roles assigned in Permit.io
|
||||
// Roles Roles assigned in Permit.io. Only populated by GET /admin/users/{email}. Not populated by GET /admin/users (list endpoint) for performance reasons.
|
||||
Roles *[]string `json:"roles,omitempty"`
|
||||
|
||||
// Status Current Cognito user account status
|
||||
@@ -350,6 +367,33 @@ type ArrayFieldItem struct {
|
||||
UnitOfMeasure *string `json:"unitOfMeasure,omitempty"`
|
||||
}
|
||||
|
||||
// BatchDocumentOutcome Per-file outcome from batch extraction and pipeline processing
|
||||
type BatchDocumentOutcome struct {
|
||||
// CleanFailReason Specific clean validation failure reason when outcome is clean_failed. Null otherwise.
|
||||
CleanFailReason nullable.Nullable[string] `json:"clean_fail_reason,omitempty"`
|
||||
|
||||
// CreatedAt When the file was first extracted from the ZIP
|
||||
CreatedAt *time.Time `json:"created_at,omitempty"`
|
||||
|
||||
// DocumentId Assigned document ID (null for files that never became documents)
|
||||
DocumentId nullable.Nullable[openapi_types.UUID] `json:"document_id,omitempty"`
|
||||
|
||||
// ErrorDetail Human-readable error message for failure outcomes
|
||||
ErrorDetail nullable.Nullable[string] `json:"error_detail,omitempty"`
|
||||
|
||||
// Filename Original filename from the ZIP archive
|
||||
Filename string `json:"filename"`
|
||||
|
||||
// Outcome Current processing status
|
||||
Outcome BatchDocumentOutcomeOutcome `json:"outcome"`
|
||||
|
||||
// UpdatedAt When the outcome was last updated by a pipeline stage
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
// BatchDocumentOutcomeOutcome Current processing status
|
||||
type BatchDocumentOutcomeOutcome string
|
||||
|
||||
// BatchID The batch upload id.
|
||||
type BatchID = openapi_types.UUID
|
||||
|
||||
@@ -370,6 +414,9 @@ type BatchUploadDetails struct {
|
||||
// CreatedAt When the batch upload was created
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
// DocumentOutcomes Per-file outcome tracking for all files in the batch
|
||||
DocumentOutcomes *[]BatchDocumentOutcome `json:"document_outcomes,omitempty"`
|
||||
|
||||
// FailedDocuments Number of documents that failed processing
|
||||
FailedDocuments int32 `json:"failed_documents"`
|
||||
|
||||
@@ -1195,12 +1242,50 @@ type SingleFields struct {
|
||||
ProviderState *string `json:"providerState,omitempty"`
|
||||
}
|
||||
|
||||
// UISetting A UI setting record
|
||||
type UISetting struct {
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
Id openapi_types.UUID `json:"id"`
|
||||
IsDeleted bool `json:"isDeleted"`
|
||||
Key string `json:"key"`
|
||||
Namespace string `json:"namespace"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
Value map[string]interface{} `json:"value"`
|
||||
}
|
||||
|
||||
// UISettingCreate Request body for creating a UI setting
|
||||
type UISettingCreate struct {
|
||||
// Key Setting key within the namespace
|
||||
Key string `json:"key"`
|
||||
|
||||
// Namespace Scope for the setting (e.g. demo-dashboard:{userId}, feature-flags:global, saved-filters:{clientId})
|
||||
Namespace string `json:"namespace"`
|
||||
|
||||
// Value JSON value (object) for the setting
|
||||
Value map[string]interface{} `json:"value"`
|
||||
}
|
||||
|
||||
// UISettingListResponse List of UI settings
|
||||
type UISettingListResponse struct {
|
||||
Settings []UISetting `json:"settings"`
|
||||
TotalCount int32 `json:"totalCount"`
|
||||
}
|
||||
|
||||
// UISettingUpdate Request body for updating a UI setting
|
||||
type UISettingUpdate struct {
|
||||
// Value New JSON value for the setting
|
||||
Value map[string]interface{} `json:"value"`
|
||||
}
|
||||
|
||||
// Version The desired version.
|
||||
type Version = int32
|
||||
|
||||
// EulaVersionID defines model for EulaVersionID.
|
||||
type EulaVersionID = openapi_types.UUID
|
||||
|
||||
// UISettingID defines model for UISettingID.
|
||||
type UISettingID = openapi_types.UUID
|
||||
|
||||
// UserEmail defines model for UserEmail.
|
||||
type UserEmail = openapi_types.Email
|
||||
|
||||
@@ -1406,6 +1491,12 @@ type LoginCallbackParams struct {
|
||||
State string `form:"state" json:"state"`
|
||||
}
|
||||
|
||||
// ListUISettingsParams defines parameters for ListUISettings.
|
||||
type ListUISettingsParams struct {
|
||||
// Namespace Filter by namespace (omit to list all).
|
||||
Namespace *string `form:"namespace,omitempty" json:"namespace,omitempty"`
|
||||
}
|
||||
|
||||
// CreateEulaVersionJSONRequestBody defines body for CreateEulaVersion for application/json ContentType.
|
||||
type CreateEulaVersionJSONRequestBody = EulaVersionCreate
|
||||
|
||||
@@ -1448,6 +1539,12 @@ type CreateFolderJSONRequestBody = FolderCreate
|
||||
// RenameFolderJSONRequestBody defines body for RenameFolder for application/json ContentType.
|
||||
type RenameFolderJSONRequestBody = FolderRename
|
||||
|
||||
// CreateUISettingJSONRequestBody defines body for CreateUISetting for application/json ContentType.
|
||||
type CreateUISettingJSONRequestBody = UISettingCreate
|
||||
|
||||
// UpdateUISettingJSONRequestBody defines body for UpdateUISetting for application/json ContentType.
|
||||
type UpdateUISettingJSONRequestBody = UISettingUpdate
|
||||
|
||||
// RequestEditorFn is the function signature for the RequestEditor callback function
|
||||
type RequestEditorFn func(ctx context.Context, req *http.Request) error
|
||||
|
||||
@@ -1703,6 +1800,25 @@ type ClientInterface interface {
|
||||
|
||||
// Logout request
|
||||
Logout(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error)
|
||||
|
||||
// ListUISettings request
|
||||
ListUISettings(ctx context.Context, params *ListUISettingsParams, reqEditors ...RequestEditorFn) (*http.Response, error)
|
||||
|
||||
// CreateUISettingWithBody request with any body
|
||||
CreateUISettingWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error)
|
||||
|
||||
CreateUISetting(ctx context.Context, body CreateUISettingJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error)
|
||||
|
||||
// DeleteUISetting request
|
||||
DeleteUISetting(ctx context.Context, id UISettingID, reqEditors ...RequestEditorFn) (*http.Response, error)
|
||||
|
||||
// GetUISetting request
|
||||
GetUISetting(ctx context.Context, id UISettingID, reqEditors ...RequestEditorFn) (*http.Response, error)
|
||||
|
||||
// UpdateUISettingWithBody request with any body
|
||||
UpdateUISettingWithBody(ctx context.Context, id UISettingID, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error)
|
||||
|
||||
UpdateUISetting(ctx context.Context, id UISettingID, body UpdateUISettingJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error)
|
||||
}
|
||||
|
||||
func (c *Client) ListEulaVersions(ctx context.Context, params *ListEulaVersionsParams, reqEditors ...RequestEditorFn) (*http.Response, error) {
|
||||
@@ -2485,6 +2601,90 @@ func (c *Client) Logout(ctx context.Context, reqEditors ...RequestEditorFn) (*ht
|
||||
return c.Client.Do(req)
|
||||
}
|
||||
|
||||
func (c *Client) ListUISettings(ctx context.Context, params *ListUISettingsParams, reqEditors ...RequestEditorFn) (*http.Response, error) {
|
||||
req, err := NewListUISettingsRequest(c.Server, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.Client.Do(req)
|
||||
}
|
||||
|
||||
func (c *Client) CreateUISettingWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) {
|
||||
req, err := NewCreateUISettingRequestWithBody(c.Server, contentType, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.Client.Do(req)
|
||||
}
|
||||
|
||||
func (c *Client) CreateUISetting(ctx context.Context, body CreateUISettingJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) {
|
||||
req, err := NewCreateUISettingRequest(c.Server, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.Client.Do(req)
|
||||
}
|
||||
|
||||
func (c *Client) DeleteUISetting(ctx context.Context, id UISettingID, reqEditors ...RequestEditorFn) (*http.Response, error) {
|
||||
req, err := NewDeleteUISettingRequest(c.Server, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.Client.Do(req)
|
||||
}
|
||||
|
||||
func (c *Client) GetUISetting(ctx context.Context, id UISettingID, reqEditors ...RequestEditorFn) (*http.Response, error) {
|
||||
req, err := NewGetUISettingRequest(c.Server, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.Client.Do(req)
|
||||
}
|
||||
|
||||
func (c *Client) UpdateUISettingWithBody(ctx context.Context, id UISettingID, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) {
|
||||
req, err := NewUpdateUISettingRequestWithBody(c.Server, id, contentType, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.Client.Do(req)
|
||||
}
|
||||
|
||||
func (c *Client) UpdateUISetting(ctx context.Context, id UISettingID, body UpdateUISettingJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) {
|
||||
req, err := NewUpdateUISettingRequest(c.Server, id, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.Client.Do(req)
|
||||
}
|
||||
|
||||
// NewListEulaVersionsRequest generates requests for ListEulaVersions
|
||||
func NewListEulaVersionsRequest(server string, params *ListEulaVersionsParams) (*http.Request, error) {
|
||||
var err error
|
||||
@@ -4917,6 +5117,210 @@ func NewLogoutRequest(server string) (*http.Request, error) {
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// NewListUISettingsRequest generates requests for ListUISettings
|
||||
func NewListUISettingsRequest(server string, params *ListUISettingsParams) (*http.Request, error) {
|
||||
var err error
|
||||
|
||||
serverURL, err := url.Parse(server)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
operationPath := fmt.Sprintf("/ui-settings")
|
||||
if operationPath[0] == '/' {
|
||||
operationPath = "." + operationPath
|
||||
}
|
||||
|
||||
queryURL, err := serverURL.Parse(operationPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if params != nil {
|
||||
queryValues := queryURL.Query()
|
||||
|
||||
if params.Namespace != nil {
|
||||
|
||||
if queryFrag, err := runtime.StyleParamWithLocation("form", true, "namespace", runtime.ParamLocationQuery, *params.Namespace); err != nil {
|
||||
return nil, err
|
||||
} else if parsed, err := url.ParseQuery(queryFrag); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
for k, v := range parsed {
|
||||
for _, v2 := range v {
|
||||
queryValues.Add(k, v2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
queryURL.RawQuery = queryValues.Encode()
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", queryURL.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// NewCreateUISettingRequest calls the generic CreateUISetting builder with application/json body
|
||||
func NewCreateUISettingRequest(server string, body CreateUISettingJSONRequestBody) (*http.Request, error) {
|
||||
var bodyReader io.Reader
|
||||
buf, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bodyReader = bytes.NewReader(buf)
|
||||
return NewCreateUISettingRequestWithBody(server, "application/json", bodyReader)
|
||||
}
|
||||
|
||||
// NewCreateUISettingRequestWithBody generates requests for CreateUISetting with any type of body
|
||||
func NewCreateUISettingRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) {
|
||||
var err error
|
||||
|
||||
serverURL, err := url.Parse(server)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
operationPath := fmt.Sprintf("/ui-settings")
|
||||
if operationPath[0] == '/' {
|
||||
operationPath = "." + operationPath
|
||||
}
|
||||
|
||||
queryURL, err := serverURL.Parse(operationPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", queryURL.String(), body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Add("Content-Type", contentType)
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// NewDeleteUISettingRequest generates requests for DeleteUISetting
|
||||
func NewDeleteUISettingRequest(server string, id UISettingID) (*http.Request, error) {
|
||||
var err error
|
||||
|
||||
var pathParam0 string
|
||||
|
||||
pathParam0, err = runtime.StyleParamWithLocation("simple", false, "id", runtime.ParamLocationPath, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
serverURL, err := url.Parse(server)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
operationPath := fmt.Sprintf("/ui-settings/%s", pathParam0)
|
||||
if operationPath[0] == '/' {
|
||||
operationPath = "." + operationPath
|
||||
}
|
||||
|
||||
queryURL, err := serverURL.Parse(operationPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("DELETE", queryURL.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// NewGetUISettingRequest generates requests for GetUISetting
|
||||
func NewGetUISettingRequest(server string, id UISettingID) (*http.Request, error) {
|
||||
var err error
|
||||
|
||||
var pathParam0 string
|
||||
|
||||
pathParam0, err = runtime.StyleParamWithLocation("simple", false, "id", runtime.ParamLocationPath, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
serverURL, err := url.Parse(server)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
operationPath := fmt.Sprintf("/ui-settings/%s", pathParam0)
|
||||
if operationPath[0] == '/' {
|
||||
operationPath = "." + operationPath
|
||||
}
|
||||
|
||||
queryURL, err := serverURL.Parse(operationPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", queryURL.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// NewUpdateUISettingRequest calls the generic UpdateUISetting builder with application/json body
|
||||
func NewUpdateUISettingRequest(server string, id UISettingID, body UpdateUISettingJSONRequestBody) (*http.Request, error) {
|
||||
var bodyReader io.Reader
|
||||
buf, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bodyReader = bytes.NewReader(buf)
|
||||
return NewUpdateUISettingRequestWithBody(server, id, "application/json", bodyReader)
|
||||
}
|
||||
|
||||
// NewUpdateUISettingRequestWithBody generates requests for UpdateUISetting with any type of body
|
||||
func NewUpdateUISettingRequestWithBody(server string, id UISettingID, contentType string, body io.Reader) (*http.Request, error) {
|
||||
var err error
|
||||
|
||||
var pathParam0 string
|
||||
|
||||
pathParam0, err = runtime.StyleParamWithLocation("simple", false, "id", runtime.ParamLocationPath, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
serverURL, err := url.Parse(server)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
operationPath := fmt.Sprintf("/ui-settings/%s", pathParam0)
|
||||
if operationPath[0] == '/' {
|
||||
operationPath = "." + operationPath
|
||||
}
|
||||
|
||||
queryURL, err := serverURL.Parse(operationPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("PATCH", queryURL.String(), body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Add("Content-Type", contentType)
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func (c *Client) applyEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error {
|
||||
for _, r := range c.RequestEditors {
|
||||
if err := r(ctx, req); err != nil {
|
||||
@@ -5142,6 +5546,25 @@ type ClientWithResponsesInterface interface {
|
||||
|
||||
// LogoutWithResponse request
|
||||
LogoutWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*LogoutResponse, error)
|
||||
|
||||
// ListUISettingsWithResponse request
|
||||
ListUISettingsWithResponse(ctx context.Context, params *ListUISettingsParams, reqEditors ...RequestEditorFn) (*ListUISettingsResponse, error)
|
||||
|
||||
// CreateUISettingWithBodyWithResponse request with any body
|
||||
CreateUISettingWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateUISettingResponse, error)
|
||||
|
||||
CreateUISettingWithResponse(ctx context.Context, body CreateUISettingJSONRequestBody, reqEditors ...RequestEditorFn) (*CreateUISettingResponse, error)
|
||||
|
||||
// DeleteUISettingWithResponse request
|
||||
DeleteUISettingWithResponse(ctx context.Context, id UISettingID, reqEditors ...RequestEditorFn) (*DeleteUISettingResponse, error)
|
||||
|
||||
// GetUISettingWithResponse request
|
||||
GetUISettingWithResponse(ctx context.Context, id UISettingID, reqEditors ...RequestEditorFn) (*GetUISettingResponse, error)
|
||||
|
||||
// UpdateUISettingWithBodyWithResponse request with any body
|
||||
UpdateUISettingWithBodyWithResponse(ctx context.Context, id UISettingID, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*UpdateUISettingResponse, error)
|
||||
|
||||
UpdateUISettingWithResponse(ctx context.Context, id UISettingID, body UpdateUISettingJSONRequestBody, reqEditors ...RequestEditorFn) (*UpdateUISettingResponse, error)
|
||||
}
|
||||
|
||||
type ListEulaVersionsResponse struct {
|
||||
@@ -6572,6 +6995,138 @@ func (r LogoutResponse) StatusCode() int {
|
||||
return 0
|
||||
}
|
||||
|
||||
type ListUISettingsResponse struct {
|
||||
Body []byte
|
||||
HTTPResponse *http.Response
|
||||
JSON200 *UISettingListResponse
|
||||
JSON400 *InvalidRequest
|
||||
JSON401 *Unauthorized
|
||||
JSON429 *TooManyRequests
|
||||
JSON500 *InternalError
|
||||
}
|
||||
|
||||
// Status returns HTTPResponse.Status
|
||||
func (r ListUISettingsResponse) Status() string {
|
||||
if r.HTTPResponse != nil {
|
||||
return r.HTTPResponse.Status
|
||||
}
|
||||
return http.StatusText(0)
|
||||
}
|
||||
|
||||
// StatusCode returns HTTPResponse.StatusCode
|
||||
func (r ListUISettingsResponse) StatusCode() int {
|
||||
if r.HTTPResponse != nil {
|
||||
return r.HTTPResponse.StatusCode
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type CreateUISettingResponse struct {
|
||||
Body []byte
|
||||
HTTPResponse *http.Response
|
||||
JSON201 *UISetting
|
||||
JSON400 *InvalidRequest
|
||||
JSON401 *Unauthorized
|
||||
JSON429 *TooManyRequests
|
||||
JSON500 *InternalError
|
||||
}
|
||||
|
||||
// Status returns HTTPResponse.Status
|
||||
func (r CreateUISettingResponse) Status() string {
|
||||
if r.HTTPResponse != nil {
|
||||
return r.HTTPResponse.Status
|
||||
}
|
||||
return http.StatusText(0)
|
||||
}
|
||||
|
||||
// StatusCode returns HTTPResponse.StatusCode
|
||||
func (r CreateUISettingResponse) StatusCode() int {
|
||||
if r.HTTPResponse != nil {
|
||||
return r.HTTPResponse.StatusCode
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type DeleteUISettingResponse struct {
|
||||
Body []byte
|
||||
HTTPResponse *http.Response
|
||||
JSON400 *InvalidRequest
|
||||
JSON401 *Unauthorized
|
||||
JSON404 *NotFound
|
||||
JSON429 *TooManyRequests
|
||||
JSON500 *InternalError
|
||||
}
|
||||
|
||||
// Status returns HTTPResponse.Status
|
||||
func (r DeleteUISettingResponse) Status() string {
|
||||
if r.HTTPResponse != nil {
|
||||
return r.HTTPResponse.Status
|
||||
}
|
||||
return http.StatusText(0)
|
||||
}
|
||||
|
||||
// StatusCode returns HTTPResponse.StatusCode
|
||||
func (r DeleteUISettingResponse) StatusCode() int {
|
||||
if r.HTTPResponse != nil {
|
||||
return r.HTTPResponse.StatusCode
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type GetUISettingResponse struct {
|
||||
Body []byte
|
||||
HTTPResponse *http.Response
|
||||
JSON200 *UISetting
|
||||
JSON400 *InvalidRequest
|
||||
JSON401 *Unauthorized
|
||||
JSON404 *NotFound
|
||||
JSON429 *TooManyRequests
|
||||
JSON500 *InternalError
|
||||
}
|
||||
|
||||
// Status returns HTTPResponse.Status
|
||||
func (r GetUISettingResponse) Status() string {
|
||||
if r.HTTPResponse != nil {
|
||||
return r.HTTPResponse.Status
|
||||
}
|
||||
return http.StatusText(0)
|
||||
}
|
||||
|
||||
// StatusCode returns HTTPResponse.StatusCode
|
||||
func (r GetUISettingResponse) StatusCode() int {
|
||||
if r.HTTPResponse != nil {
|
||||
return r.HTTPResponse.StatusCode
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type UpdateUISettingResponse struct {
|
||||
Body []byte
|
||||
HTTPResponse *http.Response
|
||||
JSON200 *UISetting
|
||||
JSON400 *InvalidRequest
|
||||
JSON401 *Unauthorized
|
||||
JSON404 *NotFound
|
||||
JSON429 *TooManyRequests
|
||||
JSON500 *InternalError
|
||||
}
|
||||
|
||||
// Status returns HTTPResponse.Status
|
||||
func (r UpdateUISettingResponse) Status() string {
|
||||
if r.HTTPResponse != nil {
|
||||
return r.HTTPResponse.Status
|
||||
}
|
||||
return http.StatusText(0)
|
||||
}
|
||||
|
||||
// StatusCode returns HTTPResponse.StatusCode
|
||||
func (r UpdateUISettingResponse) StatusCode() int {
|
||||
if r.HTTPResponse != nil {
|
||||
return r.HTTPResponse.StatusCode
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ListEulaVersionsWithResponse request returning *ListEulaVersionsResponse
|
||||
func (c *ClientWithResponses) ListEulaVersionsWithResponse(ctx context.Context, params *ListEulaVersionsParams, reqEditors ...RequestEditorFn) (*ListEulaVersionsResponse, error) {
|
||||
rsp, err := c.ListEulaVersions(ctx, params, reqEditors...)
|
||||
@@ -7145,6 +7700,67 @@ func (c *ClientWithResponses) LogoutWithResponse(ctx context.Context, reqEditors
|
||||
return ParseLogoutResponse(rsp)
|
||||
}
|
||||
|
||||
// ListUISettingsWithResponse request returning *ListUISettingsResponse
|
||||
func (c *ClientWithResponses) ListUISettingsWithResponse(ctx context.Context, params *ListUISettingsParams, reqEditors ...RequestEditorFn) (*ListUISettingsResponse, error) {
|
||||
rsp, err := c.ListUISettings(ctx, params, reqEditors...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ParseListUISettingsResponse(rsp)
|
||||
}
|
||||
|
||||
// CreateUISettingWithBodyWithResponse request with arbitrary body returning *CreateUISettingResponse
|
||||
func (c *ClientWithResponses) CreateUISettingWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateUISettingResponse, error) {
|
||||
rsp, err := c.CreateUISettingWithBody(ctx, contentType, body, reqEditors...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ParseCreateUISettingResponse(rsp)
|
||||
}
|
||||
|
||||
func (c *ClientWithResponses) CreateUISettingWithResponse(ctx context.Context, body CreateUISettingJSONRequestBody, reqEditors ...RequestEditorFn) (*CreateUISettingResponse, error) {
|
||||
rsp, err := c.CreateUISetting(ctx, body, reqEditors...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ParseCreateUISettingResponse(rsp)
|
||||
}
|
||||
|
||||
// DeleteUISettingWithResponse request returning *DeleteUISettingResponse
|
||||
func (c *ClientWithResponses) DeleteUISettingWithResponse(ctx context.Context, id UISettingID, reqEditors ...RequestEditorFn) (*DeleteUISettingResponse, error) {
|
||||
rsp, err := c.DeleteUISetting(ctx, id, reqEditors...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ParseDeleteUISettingResponse(rsp)
|
||||
}
|
||||
|
||||
// GetUISettingWithResponse request returning *GetUISettingResponse
|
||||
func (c *ClientWithResponses) GetUISettingWithResponse(ctx context.Context, id UISettingID, reqEditors ...RequestEditorFn) (*GetUISettingResponse, error) {
|
||||
rsp, err := c.GetUISetting(ctx, id, reqEditors...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ParseGetUISettingResponse(rsp)
|
||||
}
|
||||
|
||||
// UpdateUISettingWithBodyWithResponse request with arbitrary body returning *UpdateUISettingResponse
|
||||
func (c *ClientWithResponses) UpdateUISettingWithBodyWithResponse(ctx context.Context, id UISettingID, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*UpdateUISettingResponse, error) {
|
||||
rsp, err := c.UpdateUISettingWithBody(ctx, id, contentType, body, reqEditors...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ParseUpdateUISettingResponse(rsp)
|
||||
}
|
||||
|
||||
func (c *ClientWithResponses) UpdateUISettingWithResponse(ctx context.Context, id UISettingID, body UpdateUISettingJSONRequestBody, reqEditors ...RequestEditorFn) (*UpdateUISettingResponse, error) {
|
||||
rsp, err := c.UpdateUISetting(ctx, id, body, reqEditors...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ParseUpdateUISettingResponse(rsp)
|
||||
}
|
||||
|
||||
// ParseListEulaVersionsResponse parses an HTTP response from a ListEulaVersionsWithResponse call
|
||||
func ParseListEulaVersionsResponse(rsp *http.Response) (*ListEulaVersionsResponse, error) {
|
||||
bodyBytes, err := io.ReadAll(rsp.Body)
|
||||
@@ -10308,3 +10924,287 @@ func ParseLogoutResponse(rsp *http.Response) (*LogoutResponse, error) {
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// ParseListUISettingsResponse parses an HTTP response from a ListUISettingsWithResponse call
|
||||
func ParseListUISettingsResponse(rsp *http.Response) (*ListUISettingsResponse, error) {
|
||||
bodyBytes, err := io.ReadAll(rsp.Body)
|
||||
defer func() { _ = rsp.Body.Close() }()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response := &ListUISettingsResponse{
|
||||
Body: bodyBytes,
|
||||
HTTPResponse: rsp,
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200:
|
||||
var dest UISettingListResponse
|
||||
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response.JSON200 = &dest
|
||||
|
||||
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400:
|
||||
var dest InvalidRequest
|
||||
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response.JSON400 = &dest
|
||||
|
||||
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401:
|
||||
var dest Unauthorized
|
||||
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response.JSON401 = &dest
|
||||
|
||||
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 429:
|
||||
var dest TooManyRequests
|
||||
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response.JSON429 = &dest
|
||||
|
||||
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500:
|
||||
var dest InternalError
|
||||
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response.JSON500 = &dest
|
||||
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// ParseCreateUISettingResponse parses an HTTP response from a CreateUISettingWithResponse call
|
||||
func ParseCreateUISettingResponse(rsp *http.Response) (*CreateUISettingResponse, error) {
|
||||
bodyBytes, err := io.ReadAll(rsp.Body)
|
||||
defer func() { _ = rsp.Body.Close() }()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response := &CreateUISettingResponse{
|
||||
Body: bodyBytes,
|
||||
HTTPResponse: rsp,
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 201:
|
||||
var dest UISetting
|
||||
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response.JSON201 = &dest
|
||||
|
||||
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400:
|
||||
var dest InvalidRequest
|
||||
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response.JSON400 = &dest
|
||||
|
||||
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401:
|
||||
var dest Unauthorized
|
||||
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response.JSON401 = &dest
|
||||
|
||||
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 429:
|
||||
var dest TooManyRequests
|
||||
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response.JSON429 = &dest
|
||||
|
||||
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500:
|
||||
var dest InternalError
|
||||
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response.JSON500 = &dest
|
||||
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// ParseDeleteUISettingResponse parses an HTTP response from a DeleteUISettingWithResponse call
|
||||
func ParseDeleteUISettingResponse(rsp *http.Response) (*DeleteUISettingResponse, error) {
|
||||
bodyBytes, err := io.ReadAll(rsp.Body)
|
||||
defer func() { _ = rsp.Body.Close() }()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response := &DeleteUISettingResponse{
|
||||
Body: bodyBytes,
|
||||
HTTPResponse: rsp,
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400:
|
||||
var dest InvalidRequest
|
||||
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response.JSON400 = &dest
|
||||
|
||||
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401:
|
||||
var dest Unauthorized
|
||||
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response.JSON401 = &dest
|
||||
|
||||
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404:
|
||||
var dest NotFound
|
||||
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response.JSON404 = &dest
|
||||
|
||||
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 429:
|
||||
var dest TooManyRequests
|
||||
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response.JSON429 = &dest
|
||||
|
||||
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500:
|
||||
var dest InternalError
|
||||
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response.JSON500 = &dest
|
||||
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// ParseGetUISettingResponse parses an HTTP response from a GetUISettingWithResponse call
|
||||
func ParseGetUISettingResponse(rsp *http.Response) (*GetUISettingResponse, error) {
|
||||
bodyBytes, err := io.ReadAll(rsp.Body)
|
||||
defer func() { _ = rsp.Body.Close() }()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response := &GetUISettingResponse{
|
||||
Body: bodyBytes,
|
||||
HTTPResponse: rsp,
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200:
|
||||
var dest UISetting
|
||||
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response.JSON200 = &dest
|
||||
|
||||
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400:
|
||||
var dest InvalidRequest
|
||||
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response.JSON400 = &dest
|
||||
|
||||
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401:
|
||||
var dest Unauthorized
|
||||
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response.JSON401 = &dest
|
||||
|
||||
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404:
|
||||
var dest NotFound
|
||||
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response.JSON404 = &dest
|
||||
|
||||
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 429:
|
||||
var dest TooManyRequests
|
||||
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response.JSON429 = &dest
|
||||
|
||||
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500:
|
||||
var dest InternalError
|
||||
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response.JSON500 = &dest
|
||||
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// ParseUpdateUISettingResponse parses an HTTP response from a UpdateUISettingWithResponse call
|
||||
func ParseUpdateUISettingResponse(rsp *http.Response) (*UpdateUISettingResponse, error) {
|
||||
bodyBytes, err := io.ReadAll(rsp.Body)
|
||||
defer func() { _ = rsp.Body.Close() }()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response := &UpdateUISettingResponse{
|
||||
Body: bodyBytes,
|
||||
HTTPResponse: rsp,
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200:
|
||||
var dest UISetting
|
||||
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response.JSON200 = &dest
|
||||
|
||||
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400:
|
||||
var dest InvalidRequest
|
||||
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response.JSON400 = &dest
|
||||
|
||||
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401:
|
||||
var dest Unauthorized
|
||||
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response.JSON401 = &dest
|
||||
|
||||
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404:
|
||||
var dest NotFound
|
||||
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response.JSON404 = &dest
|
||||
|
||||
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 429:
|
||||
var dest TooManyRequests
|
||||
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response.JSON429 = &dest
|
||||
|
||||
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500:
|
||||
var dest InternalError
|
||||
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response.JSON500 = &dest
|
||||
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ vars:
|
||||
# Files excluded from coverage: generated files, auth middleware, test infrastructure, metrics setup
|
||||
# Cognito-dependent code (eulaAdminHandlers, service_cognito, envcheck, envid_register) require real Cognito which localstack doesn't support
|
||||
# yamllint disable-line rule:line-length
|
||||
EXCLUDED_FILES: ".gen.go|.sql.go|internal/serviceconfig/observability/prometheus/generator/main.go|internal/cognitoauth/middleware.go|internal/cognitoauth/token.go|internal/cognitoauth/auth.go|internal/cognitoauth/handler.go|internal/cognitoauth/models.go|internal/cognitoauth/test_middleware.go|internal/cognitoauth/envcheck.go|api/queryAPI/authHandlers.go|api/queryAPI/homehandler.go|api/queryAPI/controllers.go|api/queryAPI/test_helpers.go|api/queryAPI/eulaAdminHandlers.go|internal/eula/service_cognito.go|internal/serviceconfig/common.go|internal/test/queryAPI/service.go|api/queryAPI/adminHandlers.go|internal/usermanagement/audit.go|internal/usermanagement/cognito.go|internal/usermanagement/permitio.go|internal/usermanagement/types.go|internal/usermanagement/envid_register.go|internal/test/container.go|internal/test/ecosystem.go|internal/test/objectstore.go|internal/test/runner.go|internal/test/aws.go|internal/test/api.go|internal/test/db.go|internal/test/network.go|internal/test/queue.go|internal/test/helpers.go|internal/test/assertions.go|internal/server/runner/|internal/serviceconfig/observability/config.go|internal/serviceconfig/observability/prometheus/common.go|internal/serviceconfig/aws/config.go|internal/serviceconfig/objectstore/config.go|api/docCleanRunner/|internal/document/clean/|internal/document/types/"
|
||||
EXCLUDED_FILES: ".gen.go|.sql.go|internal/serviceconfig/observability/prometheus/generator/main.go|internal/cognitoauth/middleware.go|internal/cognitoauth/token.go|internal/cognitoauth/auth.go|internal/cognitoauth/handler.go|internal/cognitoauth/models.go|internal/cognitoauth/test_middleware.go|internal/cognitoauth/envcheck.go|api/queryAPI/authHandlers.go|api/queryAPI/homehandler.go|api/queryAPI/controllers.go|api/queryAPI/test_helpers.go|api/queryAPI/eulaAdminHandlers.go|internal/eula/service_cognito.go|internal/serviceconfig/common.go|internal/test/queryAPI/service.go|api/queryAPI/adminHandlers.go|internal/usermanagement/audit.go|internal/usermanagement/cognito.go|internal/usermanagement/permitio.go|internal/usermanagement/types.go|internal/usermanagement/envid_register.go|internal/test/container.go|internal/test/ecosystem.go|internal/test/objectstore.go|internal/test/runner.go|internal/test/aws.go|internal/test/api.go|internal/test/db.go|internal/test/network.go|internal/test/queue.go|internal/test/helpers.go|internal/test/assertions.go|internal/server/runner/|internal/serviceconfig/observability/config.go|internal/serviceconfig/observability/prometheus/common.go|internal/serviceconfig/aws/config.go|internal/serviceconfig/objectstore/config.go|api/docCleanRunner/|internal/document/clean/|internal/document/types/|internal/database/repository/models.go"
|
||||
# yamllint disable-line rule:line-length
|
||||
TESTS: "./internal/database/repository ./test/queryAPI ./test/... ./internal/serviceconfig/queue ./internal/server/... ./..."
|
||||
|
||||
|
||||
@@ -1680,6 +1680,9 @@ paths:
|
||||
summary: List users
|
||||
description: >-
|
||||
Lists users from AWS Cognito with pagination, filtering, and sorting support.
|
||||
Note: The roles field is not populated in this response. To retrieve roles for
|
||||
a specific user, call GET /admin/users/{email} which returns the full user
|
||||
details including Permit.io roles.
|
||||
security:
|
||||
- jwtAuth: []
|
||||
parameters:
|
||||
@@ -2942,8 +2945,65 @@ components:
|
||||
nullable: true
|
||||
description: When the batch upload completed processing
|
||||
|
||||
BatchDocumentOutcome:
|
||||
type: object
|
||||
description: Per-file outcome from batch extraction and pipeline processing
|
||||
required: [filename, outcome]
|
||||
properties:
|
||||
filename:
|
||||
type: string
|
||||
maxLength: 4096
|
||||
description: Original filename from the ZIP archive
|
||||
outcome:
|
||||
type: string
|
||||
enum:
|
||||
[
|
||||
submitted,
|
||||
duplicate,
|
||||
failed_open,
|
||||
failed_read,
|
||||
failed_s3_upload,
|
||||
failed_upload,
|
||||
invalid_type,
|
||||
init_complete,
|
||||
init_duplicate,
|
||||
sync_complete,
|
||||
sync_skipped,
|
||||
clean_passed,
|
||||
clean_failed,
|
||||
]
|
||||
description: Current processing status
|
||||
error_detail:
|
||||
type: string
|
||||
maxLength: 2048
|
||||
nullable: true
|
||||
description: Human-readable error message for failure outcomes
|
||||
document_id:
|
||||
type: string
|
||||
format: uuid
|
||||
maxLength: 36
|
||||
nullable: true
|
||||
description: Assigned document ID (null for files that never became documents)
|
||||
clean_fail_reason:
|
||||
type: string
|
||||
maxLength: 2048
|
||||
nullable: true
|
||||
description: >
|
||||
Specific clean validation failure reason when outcome is clean_failed.
|
||||
Null otherwise.
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
maxLength: 50
|
||||
description: When the file was first extracted from the ZIP
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
maxLength: 50
|
||||
description: When the outcome was last updated by a pipeline stage
|
||||
|
||||
BatchUploadDetails:
|
||||
description: Detailed information about a batch upload including failed filenames
|
||||
description: Detailed information about a batch upload including failed filenames and per-file outcomes
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/BatchUploadSummary"
|
||||
- type: object
|
||||
@@ -2957,6 +3017,12 @@ components:
|
||||
pattern: "^.+$"
|
||||
description: List of filenames that failed processing
|
||||
example: ["doc3.pdf", "doc17.pdf"]
|
||||
document_outcomes:
|
||||
type: array
|
||||
maxItems: 10000
|
||||
items:
|
||||
$ref: "#/components/schemas/BatchDocumentOutcome"
|
||||
description: Per-file outcome tracking for all files in the batch
|
||||
|
||||
BatchUploadList:
|
||||
type: object
|
||||
@@ -3653,7 +3719,9 @@ components:
|
||||
type: string
|
||||
maxLength: 100
|
||||
pattern: "^.+$"
|
||||
description: Roles assigned in Permit.io
|
||||
description: >-
|
||||
Roles assigned in Permit.io. Only populated by GET /admin/users/{email}.
|
||||
Not populated by GET /admin/users (list endpoint) for performance reasons.
|
||||
example: ["admin", "viewer"]
|
||||
created_at:
|
||||
type: string
|
||||
|
||||
Reference in New Issue
Block a user