Merged in feature/batch-status (pull request #215)

Implement and test the batch status feature

* working
This commit is contained in:
Jay Brown
2026-03-12 18:53:42 +00:00
parent 8a4029058b
commit aafe7d5b5f
39 changed files with 4252 additions and 516 deletions
+1
View File
@@ -6,6 +6,7 @@ ignore: |
out/
node_modules/
requirements/node_modules/
.serena/
rules:
line-length:
max: 150
+36 -4
View File
@@ -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)
}
+212
View File
@@ -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")
}
+37 -2
View File
@@ -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
}
+241
View File
@@ -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))
}
+29 -1
View File
@@ -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
}
+130
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+44 -16
View File
@@ -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)
+88
View File
@@ -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{}
+3 -1
View File
@@ -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()),
})
}
+2
View File
@@ -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()),
})
}
+2
View File
@@ -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()),
})
}
+77 -5
View File
@@ -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)
+41
View File
@@ -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.
+2 -1
View File
@@ -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);
+55 -3
View File
@@ -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;
+186 -2
View File
@@ -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,
+85 -1
View File
@@ -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)
}
+143 -8
View File
@@ -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)
+192
View File
@@ -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)
}
+37 -13
View File
@@ -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 {
+25 -5
View File
@@ -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
}
+110 -1
View File
@@ -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")
}
+34 -5
View File
@@ -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 {
+130
View File
@@ -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")
}
+112
View File
@@ -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")
}
+26 -6
View File
@@ -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
}
+22
View File
@@ -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
}
+217
View File
@@ -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
View File
@@ -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
View File
@@ -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/... ./..."
+70 -2
View File
@@ -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