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
@@ -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() {