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
+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{}