Merged in feature/batch-status (pull request #215)
Implement and test the batch status feature * working
This commit is contained in:
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"queryorchestration/internal/database/repository"
|
||||
"queryorchestration/internal/document/batch/outcome"
|
||||
documentclean "queryorchestration/internal/document/clean"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -11,30 +13,60 @@ import (
|
||||
|
||||
const Name = "docCleanRunner"
|
||||
|
||||
// Services holds the dependencies for the docCleanRunner.
|
||||
//
|
||||
// Fields:
|
||||
// - Clean: service for cleaning/validating documents
|
||||
// - Outcome: reporter for writing batch pipeline outcomes
|
||||
type Services struct {
|
||||
Clean *documentclean.Service
|
||||
Clean *documentclean.Service
|
||||
Outcome *outcome.Reporter
|
||||
}
|
||||
|
||||
// Runner processes document clean messages from the SQS queue.
|
||||
type Runner struct {
|
||||
svc *Services
|
||||
}
|
||||
|
||||
// New creates a new docCleanRunner.
|
||||
//
|
||||
// Parameters:
|
||||
// - svc: the runner's service dependencies
|
||||
//
|
||||
// Returns:
|
||||
// - Runner: the runner instance
|
||||
func New(svc *Services) Runner {
|
||||
return Runner{
|
||||
svc: svc,
|
||||
}
|
||||
}
|
||||
|
||||
// Body is the SQS message body for docCleanRunner.
|
||||
type Body struct {
|
||||
DocumentID uuid.UUID `json:"id" validate:"required,uuid"`
|
||||
}
|
||||
|
||||
// Process handles a single document clean message. Returns true to acknowledge
|
||||
// the message (success or validation failure), or false to requeue (infrastructure error).
|
||||
// Validation failures are acknowledged (not requeued) to prevent infinite retry loops
|
||||
// for permanently-invalid documents.
|
||||
func (s Runner) Process(ctx context.Context, body Body) bool {
|
||||
err := s.svc.Clean.Clean(ctx, body.DocumentID)
|
||||
result, err := s.svc.Clean.Clean(ctx, body.DocumentID)
|
||||
if err != nil {
|
||||
slog.Error("unable to process", "error", err)
|
||||
return false
|
||||
return false // infrastructure error -> requeue
|
||||
}
|
||||
|
||||
return true
|
||||
outcomeName := repository.BatchOutcomeStatusCleanPassed
|
||||
var errorDetail *string
|
||||
if !result.Passed {
|
||||
outcomeName = repository.BatchOutcomeStatusCleanFailed
|
||||
errorDetail = result.FailReason
|
||||
}
|
||||
if updateErr := s.svc.Outcome.Update(ctx, body.DocumentID, outcomeName, errorDetail); updateErr != nil {
|
||||
slog.Error("failed to update batch outcome",
|
||||
"document_id", body.DocumentID, "outcome", outcomeName, "error", updateErr)
|
||||
}
|
||||
|
||||
return true // validation pass or fail -> acknowledge (don't requeue bad PDFs)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
// Package doccleanrunner_test contains integration tests for the docCleanRunner.
|
||||
// Tests use real database and S3 via testcontainers (no mocks).
|
||||
package doccleanrunner_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
doccleanrunner "queryorchestration/api/docCleanRunner"
|
||||
"queryorchestration/internal/database/repository"
|
||||
"queryorchestration/internal/document/batch/outcome"
|
||||
documentclean "queryorchestration/internal/document/clean"
|
||||
"queryorchestration/internal/server/runner"
|
||||
"queryorchestration/internal/serviceconfig/objectstore"
|
||||
"queryorchestration/internal/serviceconfig/queue"
|
||||
"queryorchestration/internal/test"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type DocCleanConfig struct {
|
||||
runner.BaseConfig[doccleanrunner.Body]
|
||||
queue.QueueConfig
|
||||
objectstore.ObjectStoreConfig
|
||||
}
|
||||
|
||||
// TestDocCleanRunner_ReportsCleanPassed tests that for a document that has
|
||||
// already been cleaned (HasDocumentCleanEntry returns true), the runner
|
||||
// reports clean_passed.
|
||||
func TestDocCleanRunner_ReportsCleanPassed(t *testing.T) {
|
||||
cfg := &DocCleanConfig{}
|
||||
test.CreateDB(t, cfg)
|
||||
ctx := t.Context()
|
||||
|
||||
clientID := "clean_passed_" + uuid.New().String()[:8]
|
||||
err := cfg.GetDBQueries().CreateClient(ctx, &repository.CreateClientParams{
|
||||
Clientid: clientID,
|
||||
Name: "Test Clean Passed",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create batch
|
||||
batchID, err := cfg.GetDBQueries().CreateBatchUpload(ctx, &repository.CreateBatchUploadParams{
|
||||
ClientID: clientID,
|
||||
OriginalFilename: "test.zip",
|
||||
TotalDocuments: 1,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create document
|
||||
docID, err := cfg.GetDBQueries().CreateDocument(ctx, &repository.CreateDocumentParams{
|
||||
Clientid: clientID,
|
||||
Hash: "hash_clean_pass_" + uuid.New().String()[:8],
|
||||
BatchID: &batchID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a successful clean entry so HasDocumentCleanEntry returns true.
|
||||
// This takes the shortcut path in Clean() -> returns {Passed: true}
|
||||
bucket := "bucket"
|
||||
key := "key"
|
||||
hash := "hash"
|
||||
cleanID, err := cfg.GetDBQueries().AddDocumentClean(ctx, &repository.AddDocumentCleanParams{
|
||||
Documentid: docID,
|
||||
Bucket: &bucket,
|
||||
Key: &key,
|
||||
Hash: &hash,
|
||||
Mimetype: repository.NullCleanmimetype{
|
||||
Cleanmimetype: repository.CleanmimetypeApplicationPdf,
|
||||
Valid: true,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
err = cfg.GetDBQueries().AddDocumentCleanEntry(ctx, &repository.AddDocumentCleanEntryParams{
|
||||
Cleanid: cleanID,
|
||||
Version: 1,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Insert sync_complete outcome (the state before clean)
|
||||
err = cfg.GetDBQueries().InsertBatchDocumentOutcome(ctx, &repository.InsertBatchDocumentOutcomeParams{
|
||||
BatchID: batchID,
|
||||
Filename: "invoice.pdf",
|
||||
Column3: repository.BatchOutcomeStatusSyncComplete,
|
||||
DocumentID: &docID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
cleanRunner := doccleanrunner.New(&doccleanrunner.Services{
|
||||
Clean: documentclean.New(cfg),
|
||||
Outcome: outcome.New(cfg.GetDBQueries()),
|
||||
})
|
||||
|
||||
assert.True(t, cleanRunner.Process(ctx, doccleanrunner.Body{DocumentID: docID}))
|
||||
|
||||
// Verify outcome updated to clean_passed
|
||||
rows, err := cfg.GetDBQueries().ListBatchDocumentOutcomes(ctx, batchID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, rows, 1)
|
||||
assert.Equal(t, repository.BatchOutcomeStatusCleanPassed, rows[0].Outcome)
|
||||
}
|
||||
|
||||
// TestDocCleanRunner_ReportsCleanFailed tests that a document failing clean
|
||||
// validation gets the clean_failed outcome.
|
||||
func TestDocCleanRunner_ReportsCleanFailed(t *testing.T) {
|
||||
cfg := &DocCleanConfig{}
|
||||
test.CreateDB(t, cfg)
|
||||
test.CreateAWSResources(t, cfg)
|
||||
ctx := t.Context()
|
||||
|
||||
clientID := "clean_failed_" + uuid.New().String()[:8]
|
||||
err := cfg.GetDBQueries().CreateClient(ctx, &repository.CreateClientParams{
|
||||
Clientid: clientID,
|
||||
Name: "Test Clean Failed",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create batch
|
||||
batchID, err := cfg.GetDBQueries().CreateBatchUpload(ctx, &repository.CreateBatchUploadParams{
|
||||
ClientID: clientID,
|
||||
OriginalFilename: "test.zip",
|
||||
TotalDocuments: 1,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Upload a non-PDF file to S3 (plain text content that will fail mime type check)
|
||||
fileContent := []byte("This is NOT a PDF file. It has no PDF signature.")
|
||||
location := objectstore.BucketKey{
|
||||
Location: objectstore.Import,
|
||||
ClientID: clientID,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
EntityID: uuid.New(),
|
||||
}
|
||||
|
||||
_, err = cfg.GetStoreClient().PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: aws.String(cfg.GetBucket()),
|
||||
Key: aws.String(location.String()),
|
||||
Body: bytes.NewReader(fileContent),
|
||||
ContentType: aws.String("text/plain"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get the ETag so we can use it as the hash for IfMatch
|
||||
headResp, err := cfg.GetStoreClient().HeadObject(ctx, &s3.HeadObjectInput{
|
||||
Bucket: aws.String(cfg.GetBucket()),
|
||||
Key: aws.String(location.String()),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
etag := *headResp.ETag
|
||||
|
||||
// Create document with hash matching the ETag
|
||||
docID, err := cfg.GetDBQueries().CreateDocument(ctx, &repository.CreateDocumentParams{
|
||||
Clientid: clientID,
|
||||
Hash: etag,
|
||||
BatchID: &batchID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Add document entry pointing to the S3 file
|
||||
err = cfg.GetDBQueries().AddDocumentEntry(ctx, &repository.AddDocumentEntryParams{
|
||||
Documentid: docID,
|
||||
Bucket: cfg.GetBucket(),
|
||||
Key: location.String(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Insert sync_complete outcome
|
||||
err = cfg.GetDBQueries().InsertBatchDocumentOutcome(ctx, &repository.InsertBatchDocumentOutcomeParams{
|
||||
BatchID: batchID,
|
||||
Filename: "bad_file.pdf",
|
||||
Column3: repository.BatchOutcomeStatusSyncComplete,
|
||||
DocumentID: &docID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
cleanRunner := doccleanrunner.New(&doccleanrunner.Services{
|
||||
Clean: documentclean.New(cfg),
|
||||
Outcome: outcome.New(cfg.GetDBQueries()),
|
||||
})
|
||||
|
||||
// The runner should return true (acknowledge), even for validation failures
|
||||
result := cleanRunner.Process(ctx, doccleanrunner.Body{DocumentID: docID})
|
||||
assert.True(t, result, "validation failures should be acknowledged (not requeued)")
|
||||
|
||||
// Verify outcome updated to clean_failed
|
||||
rows, err := cfg.GetDBQueries().ListBatchDocumentOutcomes(ctx, batchID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, rows, 1)
|
||||
assert.Equal(t, repository.BatchOutcomeStatusCleanFailed, rows[0].Outcome)
|
||||
}
|
||||
|
||||
// TestDocCleanRunner_RequeuesOnInfraError tests that an infrastructure error
|
||||
// (e.g., document not found in DB) causes the runner to return false (requeue).
|
||||
func TestDocCleanRunner_RequeuesOnInfraError(t *testing.T) {
|
||||
cfg := &DocCleanConfig{}
|
||||
test.CreateDB(t, cfg)
|
||||
ctx := t.Context()
|
||||
|
||||
cleanRunner := doccleanrunner.New(&doccleanrunner.Services{
|
||||
Clean: documentclean.New(cfg),
|
||||
Outcome: outcome.New(cfg.GetDBQueries()),
|
||||
})
|
||||
|
||||
// Process a non-existent document -- should fail with DB error
|
||||
result := cleanRunner.Process(ctx, doccleanrunner.Body{DocumentID: uuid.New()})
|
||||
assert.False(t, result, "infrastructure error should return false for requeue")
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"log/slog"
|
||||
|
||||
"queryorchestration/internal/database/repository"
|
||||
"queryorchestration/internal/document/batch/outcome"
|
||||
documentinit "queryorchestration/internal/document/init"
|
||||
"queryorchestration/internal/serviceconfig/objectstore"
|
||||
|
||||
@@ -15,27 +16,45 @@ import (
|
||||
|
||||
const Name = "docInitRunner"
|
||||
|
||||
// Services holds the dependencies for the docInitRunner.
|
||||
//
|
||||
// Fields:
|
||||
// - Document: service for creating/deduplicating documents
|
||||
// - Queries: SQLC-generated database queries
|
||||
// - Outcome: reporter for writing batch pipeline outcomes
|
||||
type Services struct {
|
||||
Document *documentinit.Service
|
||||
Queries *repository.Queries
|
||||
Outcome *outcome.Reporter
|
||||
}
|
||||
|
||||
// Runner processes document init messages from the SQS queue.
|
||||
type Runner struct {
|
||||
svc *Services
|
||||
}
|
||||
|
||||
// New creates a new docInitRunner.
|
||||
//
|
||||
// Parameters:
|
||||
// - svc: the runner's service dependencies
|
||||
//
|
||||
// Returns:
|
||||
// - Runner: the runner instance
|
||||
func New(svc *Services) Runner {
|
||||
return Runner{
|
||||
svc: svc,
|
||||
}
|
||||
}
|
||||
|
||||
// Body is the SQS message body for docInitRunner.
|
||||
type Body struct {
|
||||
Bucket string `json:"bucket" validate:"required"`
|
||||
Key string `json:"key" validate:"required"`
|
||||
Hash string `json:"hash" validate:"required"`
|
||||
}
|
||||
|
||||
// Process handles a single document init message. Returns true to acknowledge
|
||||
// the message (success), or false to requeue (infrastructure error).
|
||||
func (s Runner) Process(ctx context.Context, body Body) bool {
|
||||
key, err := objectstore.ParseBucketKey(body.Key)
|
||||
if err != nil {
|
||||
@@ -61,7 +80,7 @@ func (s Runner) Process(ctx context.Context, body Body) bool {
|
||||
folderID = upload.FolderID
|
||||
}
|
||||
|
||||
id, err := s.svc.Document.Create(ctx, &documentinit.Create{
|
||||
result, err := s.svc.Document.Create(ctx, &documentinit.Create{
|
||||
Key: key,
|
||||
Bucket: body.Bucket,
|
||||
Hash: body.Hash,
|
||||
@@ -72,7 +91,23 @@ func (s Runner) Process(ctx context.Context, body Body) bool {
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
slog.Debug("created document", "id", id, "filename", filename, "folder_id", folderID)
|
||||
slog.Debug("created document", "id", result.ID, "filename", filename, "folder_id", folderID)
|
||||
|
||||
// Report outcome for batch documents
|
||||
outcomeName := repository.BatchOutcomeStatusInitComplete
|
||||
if result.IsDuplicate {
|
||||
outcomeName = repository.BatchOutcomeStatusInitDuplicate
|
||||
}
|
||||
var resolveFilename string
|
||||
if result.Filename != nil {
|
||||
resolveFilename = *result.Filename
|
||||
}
|
||||
if resolveErr := s.svc.Outcome.Resolve(ctx, result.BatchID, resolveFilename,
|
||||
result.ID, outcomeName, nil); resolveErr != nil {
|
||||
slog.Error("failed to resolve batch outcome",
|
||||
"document_id", result.ID, "outcome", outcomeName, "error", resolveErr)
|
||||
// Non-fatal: document was created successfully
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
docinitrunner "queryorchestration/api/docInitRunner"
|
||||
"queryorchestration/internal/database/repository"
|
||||
"queryorchestration/internal/document/batch/outcome"
|
||||
documentinit "queryorchestration/internal/document/init"
|
||||
"queryorchestration/internal/server/runner"
|
||||
"queryorchestration/internal/serviceconfig/objectstore"
|
||||
@@ -51,6 +52,7 @@ func TestDocInitRunner(t *testing.T) {
|
||||
runnerInstance := docinitrunner.New(&docinitrunner.Services{
|
||||
Document: documentinit.New(cfg),
|
||||
Queries: cfg.GetDBQueries(),
|
||||
Outcome: outcome.New(cfg.GetDBQueries()),
|
||||
})
|
||||
|
||||
err := cfg.GetDBQueries().CreateClient(t.Context(), &repository.CreateClientParams{
|
||||
@@ -101,3 +103,242 @@ func TestDocInitRunner(t *testing.T) {
|
||||
|
||||
test.AssertMessageBody(t, cfg, cfg.GetDocumentSyncURL(), regexp.MustCompile(`{"id":".+"}`))
|
||||
}
|
||||
|
||||
// setupDocInitTest creates shared infrastructure (DB, S3, SQS) and returns
|
||||
// the config, runner, and a cleanup-style helper.
|
||||
func setupDocInitTest(t *testing.T) *DocInitConfig {
|
||||
t.Helper()
|
||||
cfg := &DocInitConfig{}
|
||||
test.CreateDB(t, cfg)
|
||||
acfg := test.CreateAWSContainer(t, cfg)
|
||||
|
||||
test.SetQueueClient(t, t.Context(), cfg, acfg.ExternalEndpoint)
|
||||
cfg.DocumentSyncURL = test.CreateQueue(t, cfg, test.DocSyncRunnerName)
|
||||
|
||||
test.SetStoreClient(t, t.Context(), cfg, acfg.ExternalEndpoint)
|
||||
test.CreateBucket(t, cfg)
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
// uploadTestFileToS3 uploads a test file and returns the BucketKey.
|
||||
// If batchID is non-nil, it is embedded in the key so ParseBucketKey can extract it.
|
||||
func uploadTestFileToS3(t *testing.T, cfg *DocInitConfig, clientID string, batchID *uuid.UUID) objectstore.BucketKey {
|
||||
t.Helper()
|
||||
location := objectstore.BucketKey{
|
||||
Location: objectstore.Import,
|
||||
ClientID: clientID,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
EntityID: uuid.New(),
|
||||
BatchID: batchID,
|
||||
}
|
||||
|
||||
testContent := []byte("test doc content " + uuid.New().String())
|
||||
_, err := cfg.GetStoreClient().PutObject(t.Context(), &s3.PutObjectInput{
|
||||
Bucket: aws.String(cfg.GetBucket()),
|
||||
Key: aws.String(location.String()),
|
||||
Body: bytes.NewReader(testContent),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
return location
|
||||
}
|
||||
|
||||
// TestDocInitRunner_ReportsInitComplete processes a batch document and verifies
|
||||
// the outcome row is updated to init_complete with the assigned document_id.
|
||||
func TestDocInitRunner_ReportsInitComplete(t *testing.T) {
|
||||
cfg := setupDocInitTest(t)
|
||||
ctx := t.Context()
|
||||
|
||||
clientID := "batch_init_complete_" + uuid.New().String()[:8]
|
||||
err := cfg.GetDBQueries().CreateClient(ctx, &repository.CreateClientParams{
|
||||
Clientid: clientID,
|
||||
Name: "Batch Init Complete",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create batch
|
||||
batchID, err := cfg.GetDBQueries().CreateBatchUpload(ctx, &repository.CreateBatchUploadParams{
|
||||
ClientID: clientID,
|
||||
OriginalFilename: "test.zip",
|
||||
TotalDocuments: 1,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
filename := "invoice.pdf"
|
||||
// Insert submitted outcome
|
||||
err = cfg.GetDBQueries().InsertBatchDocumentOutcome(ctx, &repository.InsertBatchDocumentOutcomeParams{
|
||||
BatchID: batchID,
|
||||
Filename: filename,
|
||||
Column3: repository.BatchOutcomeStatusSubmitted,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Upload test file and create upload record with batch ID embedded in key
|
||||
location := uploadTestFileToS3(t, cfg, clientID, &batchID)
|
||||
|
||||
uploadID := uuid.New()
|
||||
err = cfg.GetDBQueries().AddDocumentUpload(ctx, &repository.AddDocumentUploadParams{
|
||||
ID: uploadID,
|
||||
Clientid: clientID,
|
||||
Bucket: cfg.GetBucket(),
|
||||
Key: location.String(),
|
||||
Part: 1,
|
||||
Createdat: pgtype.Timestamp{
|
||||
Valid: true,
|
||||
Time: time.Now().UTC(),
|
||||
},
|
||||
Filename: &filename,
|
||||
BatchID: &batchID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
runnerInstance := docinitrunner.New(&docinitrunner.Services{
|
||||
Document: documentinit.New(cfg),
|
||||
Queries: cfg.GetDBQueries(),
|
||||
Outcome: outcome.New(cfg.GetDBQueries()),
|
||||
})
|
||||
|
||||
doc := docinitrunner.Body{
|
||||
Bucket: cfg.GetBucket(),
|
||||
Key: location.String(),
|
||||
Hash: "unique_hash_init_complete_" + uuid.New().String()[:8],
|
||||
}
|
||||
|
||||
assert.True(t, runnerInstance.Process(ctx, doc))
|
||||
|
||||
// Verify outcome was updated to init_complete with document_id set
|
||||
rows, err := cfg.GetDBQueries().ListBatchDocumentOutcomes(ctx, batchID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, rows, 1)
|
||||
assert.Equal(t, repository.BatchOutcomeStatusInitComplete, rows[0].Outcome)
|
||||
assert.NotNil(t, rows[0].DocumentID, "document_id should be set after init")
|
||||
}
|
||||
|
||||
// TestDocInitRunner_ReportsInitDuplicate processes a batch document whose hash
|
||||
// matches an existing document and verifies the outcome is init_duplicate.
|
||||
func TestDocInitRunner_ReportsInitDuplicate(t *testing.T) {
|
||||
cfg := setupDocInitTest(t)
|
||||
ctx := t.Context()
|
||||
|
||||
clientID := "batch_init_dup_" + uuid.New().String()[:8]
|
||||
err := cfg.GetDBQueries().CreateClient(ctx, &repository.CreateClientParams{
|
||||
Clientid: clientID,
|
||||
Name: "Batch Init Duplicate",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create an existing document with a known hash
|
||||
existingHash := "existing_hash_" + uuid.New().String()[:8]
|
||||
_, err = cfg.GetDBQueries().CreateDocument(ctx, &repository.CreateDocumentParams{
|
||||
Clientid: clientID,
|
||||
Hash: existingHash,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create batch
|
||||
batchID, err := cfg.GetDBQueries().CreateBatchUpload(ctx, &repository.CreateBatchUploadParams{
|
||||
ClientID: clientID,
|
||||
OriginalFilename: "test.zip",
|
||||
TotalDocuments: 1,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
filename := "duplicate.pdf"
|
||||
// Insert submitted outcome
|
||||
err = cfg.GetDBQueries().InsertBatchDocumentOutcome(ctx, &repository.InsertBatchDocumentOutcomeParams{
|
||||
BatchID: batchID,
|
||||
Filename: filename,
|
||||
Column3: repository.BatchOutcomeStatusSubmitted,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Upload test file and create upload record with batch ID in key
|
||||
location := uploadTestFileToS3(t, cfg, clientID, &batchID)
|
||||
|
||||
uploadID := uuid.New()
|
||||
err = cfg.GetDBQueries().AddDocumentUpload(ctx, &repository.AddDocumentUploadParams{
|
||||
ID: uploadID,
|
||||
Clientid: clientID,
|
||||
Bucket: cfg.GetBucket(),
|
||||
Key: location.String(),
|
||||
Part: 1,
|
||||
Createdat: pgtype.Timestamp{
|
||||
Valid: true,
|
||||
Time: time.Now().UTC(),
|
||||
},
|
||||
Filename: &filename,
|
||||
BatchID: &batchID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
runnerInstance := docinitrunner.New(&docinitrunner.Services{
|
||||
Document: documentinit.New(cfg),
|
||||
Queries: cfg.GetDBQueries(),
|
||||
Outcome: outcome.New(cfg.GetDBQueries()),
|
||||
})
|
||||
|
||||
// Use the same hash as the existing document to trigger duplicate
|
||||
doc := docinitrunner.Body{
|
||||
Bucket: cfg.GetBucket(),
|
||||
Key: location.String(),
|
||||
Hash: existingHash,
|
||||
}
|
||||
|
||||
assert.True(t, runnerInstance.Process(ctx, doc))
|
||||
|
||||
// Verify outcome was updated to init_duplicate
|
||||
rows, err := cfg.GetDBQueries().ListBatchDocumentOutcomes(ctx, batchID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, rows, 1)
|
||||
assert.Equal(t, repository.BatchOutcomeStatusInitDuplicate, rows[0].Outcome)
|
||||
assert.NotNil(t, rows[0].DocumentID, "document_id should be set for duplicate")
|
||||
}
|
||||
|
||||
// TestDocInitRunner_NoOpForNonBatchDocument verifies that processing a non-batch
|
||||
// document does not cause outcome errors (the Resolve call is a no-op for nil batchID).
|
||||
func TestDocInitRunner_NoOpForNonBatchDocument(t *testing.T) {
|
||||
cfg := setupDocInitTest(t)
|
||||
ctx := t.Context()
|
||||
|
||||
clientID := "nonbatch_init_" + uuid.New().String()[:8]
|
||||
err := cfg.GetDBQueries().CreateClient(ctx, &repository.CreateClientParams{
|
||||
Clientid: clientID,
|
||||
Name: "NonBatch Init",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
location := uploadTestFileToS3(t, cfg, clientID, nil)
|
||||
|
||||
uploadID := uuid.New()
|
||||
fname := "regular.pdf"
|
||||
err = cfg.GetDBQueries().AddDocumentUpload(ctx, &repository.AddDocumentUploadParams{
|
||||
ID: uploadID,
|
||||
Clientid: clientID,
|
||||
Bucket: cfg.GetBucket(),
|
||||
Key: location.String(),
|
||||
Part: 1,
|
||||
Createdat: pgtype.Timestamp{
|
||||
Valid: true,
|
||||
Time: time.Now().UTC(),
|
||||
},
|
||||
Filename: &fname,
|
||||
BatchID: nil, // Non-batch
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
runnerInstance := docinitrunner.New(&docinitrunner.Services{
|
||||
Document: documentinit.New(cfg),
|
||||
Queries: cfg.GetDBQueries(),
|
||||
Outcome: outcome.New(cfg.GetDBQueries()),
|
||||
})
|
||||
|
||||
doc := docinitrunner.Body{
|
||||
Bucket: cfg.GetBucket(),
|
||||
Key: location.String(),
|
||||
Hash: "nonbatch_hash_" + uuid.New().String()[:8],
|
||||
}
|
||||
|
||||
// Should process successfully without any outcome errors
|
||||
assert.True(t, runnerInstance.Process(ctx, doc))
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"queryorchestration/internal/database/repository"
|
||||
"queryorchestration/internal/document/batch/outcome"
|
||||
documentsync "queryorchestration/internal/document/sync"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -11,30 +13,56 @@ import (
|
||||
|
||||
const Name = "docSyncRunner"
|
||||
|
||||
// Services holds the dependencies for the docSyncRunner.
|
||||
//
|
||||
// Fields:
|
||||
// - Document: service for syncing documents
|
||||
// - Outcome: reporter for writing batch pipeline outcomes
|
||||
type Services struct {
|
||||
Document *documentsync.Service
|
||||
Outcome *outcome.Reporter
|
||||
}
|
||||
|
||||
// Runner processes document sync messages from the SQS queue.
|
||||
type Runner struct {
|
||||
svc *Services
|
||||
}
|
||||
|
||||
// New creates a new docSyncRunner.
|
||||
//
|
||||
// Parameters:
|
||||
// - svc: the runner's service dependencies
|
||||
//
|
||||
// Returns:
|
||||
// - Runner: the runner instance
|
||||
func New(svc *Services) Runner {
|
||||
return Runner{
|
||||
svc: svc,
|
||||
}
|
||||
}
|
||||
|
||||
// Body is the SQS message body for docSyncRunner.
|
||||
type Body struct {
|
||||
DocumentID uuid.UUID `json:"id" validate:"required,uuid"`
|
||||
}
|
||||
|
||||
// Process handles a single document sync message. Returns true to acknowledge
|
||||
// the message (success), or false to requeue (infrastructure error).
|
||||
func (s Runner) Process(ctx context.Context, body Body) bool {
|
||||
err := s.svc.Document.Sync(ctx, body.DocumentID)
|
||||
result, err := s.svc.Document.Sync(ctx, body.DocumentID)
|
||||
if err != nil {
|
||||
slog.Error("unable to process", "error", err)
|
||||
return false
|
||||
}
|
||||
|
||||
outcomeName := repository.BatchOutcomeStatusSyncComplete
|
||||
if result.Skipped {
|
||||
outcomeName = repository.BatchOutcomeStatusSyncSkipped
|
||||
}
|
||||
if updateErr := s.svc.Outcome.Update(ctx, body.DocumentID, outcomeName, nil); updateErr != nil {
|
||||
slog.Error("failed to update batch outcome",
|
||||
"document_id", body.DocumentID, "outcome", outcomeName, "error", updateErr)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"queryorchestration/internal/client"
|
||||
"queryorchestration/internal/database/repository"
|
||||
"queryorchestration/internal/document"
|
||||
"queryorchestration/internal/document/batch/outcome"
|
||||
documentsync "queryorchestration/internal/document/sync"
|
||||
"queryorchestration/internal/server/runner"
|
||||
"queryorchestration/internal/serviceconfig/objectstore"
|
||||
@@ -42,6 +43,7 @@ func TestDocSyncRunner(t *testing.T) {
|
||||
Document: document.New(cfg),
|
||||
Client: client.New(cfg),
|
||||
}),
|
||||
Outcome: outcome.New(cfg.GetDBQueries()),
|
||||
})
|
||||
|
||||
err := cfg.GetDBQueries().CreateClient(t.Context(), &repository.CreateClientParams{
|
||||
@@ -85,3 +87,131 @@ func TestDocSyncRunner(t *testing.T) {
|
||||
|
||||
test.AssertMessageBody(t, cfg, cfg.GetDocumentCleanURL(), regexp.MustCompile(fmt.Sprintf("{\"id\":\"%s\"}", docId)))
|
||||
}
|
||||
|
||||
// setupDocSyncTest creates shared infrastructure and returns the config.
|
||||
func setupDocSyncTest(t *testing.T) *DocSyncConfig {
|
||||
t.Helper()
|
||||
cfg := &DocSyncConfig{}
|
||||
test.CreateDB(t, cfg)
|
||||
acfg := test.CreateAWSContainer(t, cfg)
|
||||
|
||||
test.SetQueueClient(t, t.Context(), cfg, acfg.ExternalEndpoint)
|
||||
cfg.DocumentCleanURL = test.CreateQueue(t, cfg, test.DocCleanRunnerName)
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
// setupBatchSyncTest creates a client, batch, document, outcome row, and
|
||||
// returns all IDs needed for a sync runner test.
|
||||
func setupBatchSyncTest(t *testing.T, cfg *DocSyncConfig, canSync bool) (uuid.UUID, uuid.UUID) {
|
||||
t.Helper()
|
||||
ctx := t.Context()
|
||||
|
||||
clientID := fmt.Sprintf("sync_%s_%s", fmt.Sprintf("%v", canSync), uuid.New().String()[:8])
|
||||
|
||||
// Create client and set sync flag
|
||||
err := cfg.GetDBQueries().CreateClient(ctx, &repository.CreateClientParams{
|
||||
Clientid: clientID,
|
||||
Name: "Test " + clientID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
err = cfg.GetDBQueries().AddClientCanSync(ctx, &repository.AddClientCanSyncParams{
|
||||
Clientid: clientID,
|
||||
Cansync: canSync,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create batch (client must exist first due to FK)
|
||||
batchID, err := cfg.GetDBQueries().CreateBatchUpload(ctx, &repository.CreateBatchUploadParams{
|
||||
ClientID: clientID,
|
||||
OriginalFilename: "test.zip",
|
||||
TotalDocuments: 1,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create document linked to batch
|
||||
docID, err := cfg.GetDBQueries().CreateDocument(ctx, &repository.CreateDocumentParams{
|
||||
Clientid: clientID,
|
||||
Hash: "hash_" + uuid.New().String()[:8],
|
||||
BatchID: &batchID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
part := uint16(1)
|
||||
filetype := "pdf"
|
||||
key := objectstore.BucketKey{
|
||||
CreatedAt: time.Now().UTC(),
|
||||
ClientID: clientID,
|
||||
EntityID: uuid.New(),
|
||||
Location: objectstore.Import,
|
||||
Part: &part,
|
||||
FileType: &filetype,
|
||||
}
|
||||
err = cfg.GetDBQueries().AddDocumentEntry(ctx, &repository.AddDocumentEntryParams{
|
||||
Documentid: docID,
|
||||
Bucket: "bucket",
|
||||
Key: key.String(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Insert init_complete outcome (the state before sync)
|
||||
err = cfg.GetDBQueries().InsertBatchDocumentOutcome(ctx, &repository.InsertBatchDocumentOutcomeParams{
|
||||
BatchID: batchID,
|
||||
Filename: "test.pdf",
|
||||
Column3: repository.BatchOutcomeStatusInitComplete,
|
||||
DocumentID: &docID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
return batchID, docID
|
||||
}
|
||||
|
||||
// TestDocSyncRunner_ReportsSyncComplete verifies that processing a batch document
|
||||
// with can_sync=true updates the outcome to sync_complete.
|
||||
func TestDocSyncRunner_ReportsSyncComplete(t *testing.T) {
|
||||
cfg := setupDocSyncTest(t)
|
||||
ctx := t.Context()
|
||||
|
||||
batchID, docID := setupBatchSyncTest(t, cfg, true)
|
||||
|
||||
syncRunner := docsyncrunner.New(&docsyncrunner.Services{
|
||||
Document: documentsync.New(cfg, &documentsync.Services{
|
||||
Document: document.New(cfg),
|
||||
Client: client.New(cfg),
|
||||
}),
|
||||
Outcome: outcome.New(cfg.GetDBQueries()),
|
||||
})
|
||||
|
||||
assert.True(t, syncRunner.Process(ctx, docsyncrunner.Body{DocumentID: docID}))
|
||||
|
||||
// Verify outcome updated to sync_complete
|
||||
rows, err := cfg.GetDBQueries().ListBatchDocumentOutcomes(ctx, batchID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, rows, 1)
|
||||
assert.Equal(t, repository.BatchOutcomeStatusSyncComplete, rows[0].Outcome)
|
||||
}
|
||||
|
||||
// TestDocSyncRunner_ReportsSyncSkipped verifies that processing a batch document
|
||||
// with can_sync=false updates the outcome to sync_skipped.
|
||||
func TestDocSyncRunner_ReportsSyncSkipped(t *testing.T) {
|
||||
cfg := setupDocSyncTest(t)
|
||||
ctx := t.Context()
|
||||
|
||||
batchID, docID := setupBatchSyncTest(t, cfg, false)
|
||||
|
||||
syncRunner := docsyncrunner.New(&docsyncrunner.Services{
|
||||
Document: documentsync.New(cfg, &documentsync.Services{
|
||||
Document: document.New(cfg),
|
||||
Client: client.New(cfg),
|
||||
}),
|
||||
Outcome: outcome.New(cfg.GetDBQueries()),
|
||||
})
|
||||
|
||||
assert.True(t, syncRunner.Process(ctx, docsyncrunner.Body{DocumentID: docID}))
|
||||
|
||||
// Verify outcome updated to sync_skipped
|
||||
rows, err := cfg.GetDBQueries().ListBatchDocumentOutcomes(ctx, batchID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, rows, 1)
|
||||
assert.Equal(t, repository.BatchOutcomeStatusSyncSkipped, rows[0].Outcome)
|
||||
}
|
||||
|
||||
+501
-437
File diff suppressed because it is too large
Load Diff
+44
-16
@@ -254,13 +254,13 @@ func (s *Controllers) UploadDocumentBatch(ctx echo.Context, clientId ClientID) e
|
||||
return ctx.JSON(http.StatusAccepted, response)
|
||||
}
|
||||
|
||||
// GetDocumentBatch returns batch upload status
|
||||
// GetDocumentBatch returns batch upload status including per-file document outcomes.
|
||||
func (s *Controllers) GetDocumentBatch(ctx echo.Context, clientId ClientID, batchId BatchID) error {
|
||||
// BatchID is already a UUID type
|
||||
batchUUID := uuid.UUID(batchId)
|
||||
|
||||
// Get batch details
|
||||
batch, err := s.svc.DocumentBatch.Get(ctx.Request().Context(), clientId, batchUUID)
|
||||
// Get batch details (includes document outcomes)
|
||||
batchDetails, err := s.svc.DocumentBatch.Get(ctx.Request().Context(), clientId, batchUUID)
|
||||
if err != nil {
|
||||
slog.Error("unable to get batch", "error", err, "batchId", batchId)
|
||||
return echo.NewHTTPError(http.StatusNotFound, "batch not found")
|
||||
@@ -268,22 +268,50 @@ func (s *Controllers) GetDocumentBatch(ctx echo.Context, clientId ClientID, batc
|
||||
|
||||
// Convert to API response format
|
||||
details := BatchUploadDetails{
|
||||
BatchId: BatchID(batch.ID),
|
||||
ClientId: ClientID(batch.ClientID),
|
||||
OriginalFilename: batch.OriginalFilename,
|
||||
Status: BatchStatus(batch.Status),
|
||||
TotalDocuments: batch.TotalDocuments,
|
||||
ProcessedDocuments: batch.ProcessedDocuments,
|
||||
FailedDocuments: batch.FailedDocuments,
|
||||
InvalidTypeDocuments: batch.InvalidTypeDocuments,
|
||||
ProgressPercent: batch.ProgressPercent,
|
||||
FailedFilenames: &batch.FailedFilenames,
|
||||
CreatedAt: batch.CreatedAt,
|
||||
BatchId: BatchID(batchDetails.ID),
|
||||
ClientId: ClientID(batchDetails.ClientID),
|
||||
OriginalFilename: batchDetails.OriginalFilename,
|
||||
Status: BatchStatus(batchDetails.Status),
|
||||
TotalDocuments: batchDetails.TotalDocuments,
|
||||
ProcessedDocuments: batchDetails.ProcessedDocuments,
|
||||
FailedDocuments: batchDetails.FailedDocuments,
|
||||
InvalidTypeDocuments: batchDetails.InvalidTypeDocuments,
|
||||
ProgressPercent: batchDetails.ProgressPercent,
|
||||
FailedFilenames: &batchDetails.FailedFilenames,
|
||||
CreatedAt: batchDetails.CreatedAt,
|
||||
}
|
||||
|
||||
// Handle nullable completed at
|
||||
if batch.CompletedAt != nil {
|
||||
details.CompletedAt = nullable.NewNullableWithValue(*batch.CompletedAt)
|
||||
if batchDetails.CompletedAt != nil {
|
||||
details.CompletedAt = nullable.NewNullableWithValue(*batchDetails.CompletedAt)
|
||||
}
|
||||
|
||||
// Map document outcomes to API response format
|
||||
if len(batchDetails.DocumentOutcomes) > 0 {
|
||||
outcomes := make([]BatchDocumentOutcome, len(batchDetails.DocumentOutcomes))
|
||||
for i, o := range batchDetails.DocumentOutcomes {
|
||||
outcome := BatchDocumentOutcome{
|
||||
Filename: o.Filename,
|
||||
Outcome: BatchDocumentOutcomeOutcome(o.Outcome),
|
||||
CreatedAt: &o.CreatedAt,
|
||||
UpdatedAt: &o.UpdatedAt,
|
||||
}
|
||||
|
||||
if o.ErrorDetail != nil {
|
||||
outcome.ErrorDetail = nullable.NewNullableWithValue(*o.ErrorDetail)
|
||||
}
|
||||
|
||||
if o.DocumentID != nil {
|
||||
outcome.DocumentId = nullable.NewNullableWithValue(openapi_types.UUID(*o.DocumentID))
|
||||
}
|
||||
|
||||
if o.CleanFail != nil {
|
||||
outcome.CleanFailReason = nullable.NewNullableWithValue(*o.CleanFail)
|
||||
}
|
||||
|
||||
outcomes[i] = outcome
|
||||
}
|
||||
details.DocumentOutcomes = &outcomes
|
||||
}
|
||||
|
||||
return ctx.JSON(http.StatusOK, details)
|
||||
|
||||
@@ -418,6 +418,94 @@ func TestGetDocumentBatch(t *testing.T) {
|
||||
assert.Equal(t, "test.zip", response.OriginalFilename)
|
||||
}
|
||||
|
||||
// TestGetDocumentBatch_IncludesOutcomes verifies that GetDocumentBatch returns
|
||||
// the document_outcomes array populated with per-file outcome rows.
|
||||
func TestGetDocumentBatch_IncludesOutcomes(t *testing.T) {
|
||||
cfg := &ControllerConfig{}
|
||||
test.CreateDB(t, cfg)
|
||||
initializeTestConfig(t, cfg)
|
||||
|
||||
svc := createControllerServices(cfg)
|
||||
cons := queryapi.NewControllers(svc, cfg)
|
||||
|
||||
clientID := fmt.Sprintf("outcome_client_%s", uuid.New().String()[:8])
|
||||
test.CreateTestClient(t, cfg, clientID, "Test Outcomes Client")
|
||||
|
||||
// Create batch
|
||||
batchID, err := cfg.GetDBQueries().CreateBatchUpload(t.Context(), &repository.CreateBatchUploadParams{
|
||||
ClientID: clientID,
|
||||
OriginalFilename: "outcomes.zip",
|
||||
TotalDocuments: 3,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a document so we can reference it in outcome rows
|
||||
docID, err := cfg.GetDBQueries().CreateDocument(t.Context(), &repository.CreateDocumentParams{
|
||||
Clientid: clientID,
|
||||
Hash: "outcome_hash_" + uuid.New().String()[:8],
|
||||
BatchID: &batchID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Insert outcomes with different statuses
|
||||
err = cfg.GetDBQueries().InsertBatchDocumentOutcome(t.Context(), &repository.InsertBatchDocumentOutcomeParams{
|
||||
BatchID: batchID,
|
||||
Filename: "invoice.pdf",
|
||||
Column3: repository.BatchOutcomeStatusCleanPassed,
|
||||
DocumentID: &docID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
invalidErr := "not a PDF"
|
||||
err = cfg.GetDBQueries().InsertBatchDocumentOutcome(t.Context(), &repository.InsertBatchDocumentOutcomeParams{
|
||||
BatchID: batchID,
|
||||
Filename: "spreadsheet.xlsx",
|
||||
Column3: repository.BatchOutcomeStatusInvalidType,
|
||||
ErrorDetail: &invalidErr,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = cfg.GetDBQueries().InsertBatchDocumentOutcome(t.Context(), &repository.InsertBatchDocumentOutcomeParams{
|
||||
BatchID: batchID,
|
||||
Filename: "receipt.pdf",
|
||||
Column3: repository.BatchOutcomeStatusSubmitted,
|
||||
DocumentID: &docID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Call GetDocumentBatch
|
||||
ctx, rec := createContext(t)
|
||||
err = cons.GetDocumentBatch(ctx, clientID, queryapi.BatchID(batchID))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var response queryapi.BatchUploadDetails
|
||||
err = json.Unmarshal(rec.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify document_outcomes array is populated
|
||||
require.NotNil(t, response.DocumentOutcomes, "document_outcomes should be present")
|
||||
require.Len(t, *response.DocumentOutcomes, 3, "should have 3 outcome entries")
|
||||
|
||||
// Build a map by filename for easier assertion
|
||||
outcomeMap := make(map[string]queryapi.BatchDocumentOutcome)
|
||||
for _, o := range *response.DocumentOutcomes {
|
||||
outcomeMap[o.Filename] = o
|
||||
}
|
||||
|
||||
// Verify each outcome
|
||||
invoiceOutcome := outcomeMap["invoice.pdf"]
|
||||
assert.Equal(t, queryapi.BatchDocumentOutcomeOutcome("clean_passed"), invoiceOutcome.Outcome)
|
||||
assert.True(t, invoiceOutcome.DocumentId.IsSpecified(), "clean_passed outcome should have document_id")
|
||||
|
||||
xlsxOutcome := outcomeMap["spreadsheet.xlsx"]
|
||||
assert.Equal(t, queryapi.BatchDocumentOutcomeOutcome("invalid_type"), xlsxOutcome.Outcome)
|
||||
assert.True(t, xlsxOutcome.ErrorDetail.IsSpecified(), "invalid_type outcome should have error_detail")
|
||||
|
||||
receiptOutcome := outcomeMap["receipt.pdf"]
|
||||
assert.Equal(t, queryapi.BatchDocumentOutcomeOutcome("submitted"), receiptOutcome.Outcome)
|
||||
}
|
||||
|
||||
func TestUploadDocumentBatch_Part1_Success(t *testing.T) {
|
||||
// Setup: Create testcontainers (database + localstack)
|
||||
cfg := &ControllerConfig{}
|
||||
|
||||
Reference in New Issue
Block a user