From a1455679fc2bcdf6b64de65a0de881021a7a0e47 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 28 Sep 2026 06:37:01 +0200 Subject: [PATCH 1/2] fix(server): report SQS partial batch failures instead of failing the batch handleLambdaSQSEvent looped over every record, collected the failed message IDs, then returned a single error for the whole batch. Lambda's SQS integration treats any non-nil handler error as "the entire batch failed" and redelivers every record, including ones HandleSQSMessage already processed successfully. On redelivery those already-succeeded records typically fail a second time (their status already advanced past what the CAS-guarded handlers expect), which redelivers the whole batch again and burns the redrive budget on records that were never actually broken, until the one genuinely poison message drags the rest of the batch to the DLQ with it -- surfacing phantom failures for purchases that actually completed, the worst possible signal during a money-path incident. Return events.SQSEventResponse{BatchItemFailures: [...]} naming only the records that actually failed, with a nil error, so SQS (per the Lambda SQS partial-batch-response contract) redelivers only those records. Note: this repo's IaC provisions no SQS queue and no Lambda event source mapping at all -- confirmed via a full-repo search for aws_sqs_queue, AWS::SQS::Queue, EventSourceMapping, and batch_size, all with zero hits across terraform/, cloudformation/, iac/, and arm/. This matches the originating audit's own caveat (docs/audits/ codebase-audit-2026-09-02.md:6025), which called the scenario "PLAUSIBLE" rather than confirmed for exactly this reason. The `function_response_types = ["ReportBatchItemFailures"]` setting the issue asks for cannot be added to a resource that does not exist in this repo; provisioning that queue (batch size, DLQ, redrive policy, IAM grants) is a separate infrastructure decision, not a one-line addition, and is called out in the PR body rather than folded into this focused code fix. Regression test: internal/server/lambda_test.go adds TestHandleLambdaSQSEvent_PartialBatchFailure (batch of 3, record 2 fails, asserts BatchItemFailures contains only msg-2's own messageId and the returned error is nil) and TestHandleLambdaSQSEvent_AllSucceedReturnsEmptyFailures. Confirmed both fail pre-fix: stashing lambda.go alone reproduces the old aggregate-error return and the old map[string]string success shape. Closes #108 Co-Authored-By: claude-flow --- internal/server/lambda.go | 26 ++++++++--- internal/server/lambda_test.go | 81 ++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 7 deletions(-) diff --git a/internal/server/lambda.go b/internal/server/lambda.go index 348d349b1..419d9c24f 100644 --- a/internal/server/lambda.go +++ b/internal/server/lambda.go @@ -223,6 +223,22 @@ func isTextContentType(ct string) bool { } // handleLambdaSQSEvent processes SQS messages (for async purchase processing). +// +// Returns a partial batch response (events.SQSEventResponse) naming only the +// records that actually failed, with a nil error, instead of a single error +// for the whole batch (issue #108). Lambda's SQS integration treats ANY +// non-nil handler error as "the entire batch failed" and redelivers every +// record, including ones HandleSQSMessage already processed successfully. +// On redelivery those already-succeeded records typically fail a second +// time (status already advanced past what the CAS-guarded handlers expect), +// which redelivers the whole batch again and burns the redrive budget on +// records that were never actually broken, until the one genuinely poison +// message drags the rest of the batch to the DLQ with it. +// +// This response shape is only honored by AWS when the SQS event-source +// mapping sets function_response_types = ["ReportBatchItemFailures"] +// (Terraform); without it, AWS silently ignores the response and redelivers +// the whole batch on any failure regardless of what this function returns. func (app *Application) handleLambdaSQSEvent(ctx context.Context, rawEvent json.RawMessage) (any, error) { var sqsEvent events.SQSEvent if err := json.Unmarshal(rawEvent, &sqsEvent); err != nil { @@ -230,21 +246,17 @@ func (app *Application) handleLambdaSQSEvent(ctx context.Context, rawEvent json. return nil, err } - var failures []string + var failures []events.SQSBatchItemFailure for _rvc := range sqsEvent.Records { record := sqsEvent.Records[_rvc] log.Printf("Processing SQS message: %s", record.MessageId) if err := app.HandleSQSMessage(ctx, record.Body); err != nil { log.Printf("Failed to process message %s: %v", record.MessageId, err) - failures = append(failures, record.MessageId) + failures = append(failures, events.SQSBatchItemFailure{ItemIdentifier: record.MessageId}) } } - if len(failures) > 0 { - return nil, fmt.Errorf("failed to process %d SQS message(s): %v", len(failures), failures) - } - - return map[string]string{"status": "processed"}, nil + return events.SQSEventResponse{BatchItemFailures: failures}, nil } // handleLambdaScheduledEvent processes scheduled/cron events. diff --git a/internal/server/lambda_test.go b/internal/server/lambda_test.go index f9d1fa002..b29f14df9 100644 --- a/internal/server/lambda_test.go +++ b/internal/server/lambda_test.go @@ -3,12 +3,14 @@ package server import ( "context" "encoding/json" + "fmt" "strings" "testing" "github.com/LeanerCloud/cloud-commitments-platform/internal/api" "github.com/LeanerCloud/cloud-commitments-platform/internal/scheduler" "github.com/LeanerCloud/cloud-commitments-platform/internal/testutil" + "github.com/aws/aws-lambda-go/events" ) func TestDetectLambdaEventType(t *testing.T) { @@ -212,6 +214,85 @@ func TestHandleLambdaSQSEvent(t *testing.T) { } } +// TestHandleLambdaSQSEvent_PartialBatchFailure pins issue #108: a batch +// where only some records fail must leave the succeeded records ACKed +// rather than redelivering the whole batch. Pre-fix, handleLambdaSQSEvent +// collected every failed message ID and returned a single aggregate error +// for the batch; Lambda's SQS integration treats ANY non-nil handler error +// as "the entire batch failed", so records 1 and 3 (which HandleSQSMessage +// already processed successfully) would be redelivered right alongside the +// genuinely failed record 2 -- and would typically fail a second time since +// their status already advanced past what the CAS-guarded handlers expect, +// repeating until the whole batch (not just the poison message) hits the +// DLQ. +// +// Asserted on the returned events.SQSEventResponse.BatchItemFailures set, +// per the issue's own fix direction, rather than on the error value: the +// function must return a nil error alongside the partial-failure response, +// since a non-nil error here is exactly the all-or-nothing behavior being +// removed. +func TestHandleLambdaSQSEvent_PartialBatchFailure(t *testing.T) { + ctx := testutil.TestContext(t) + + mockPurchase := &testutil.MockPurchaseManager{ + ProcessMessageFunc: func(_ context.Context, body string) error { + if strings.Contains(body, "msg-2") { + return fmt.Errorf("execution not found: msg-2") + } + return nil + }, + } + app := &Application{Purchase: mockPurchase} + + rawEvent := `{ + "Records": [ + {"messageId": "msg-1", "eventSource": "aws:sqs", "body": "{\"execution_id\": \"msg-1\"}"}, + {"messageId": "msg-2", "eventSource": "aws:sqs", "body": "{\"execution_id\": \"msg-2\"}"}, + {"messageId": "msg-3", "eventSource": "aws:sqs", "body": "{\"execution_id\": \"msg-3\"}"} + ] + }` + + result, err := app.handleLambdaSQSEvent(ctx, json.RawMessage(rawEvent)) + testutil.AssertNoError(t, err) + + resp, ok := result.(events.SQSEventResponse) + if !ok { + t.Fatalf("expected events.SQSEventResponse, got %T", result) + } + if len(resp.BatchItemFailures) != 1 { + t.Fatalf("expected exactly 1 batch item failure, got %d: %+v", len(resp.BatchItemFailures), resp.BatchItemFailures) + } + if resp.BatchItemFailures[0].ItemIdentifier != "msg-2" { + t.Fatalf("expected the failed record's own messageId (msg-2) in BatchItemFailures, got %q", + resp.BatchItemFailures[0].ItemIdentifier) + } +} + +// TestHandleLambdaSQSEvent_AllSucceedReturnsEmptyFailures documents the +// all-succeed shape: a nil error and an explicitly empty (not omitted) +// BatchItemFailures list, matching the SQS partial-batch-response contract. +func TestHandleLambdaSQSEvent_AllSucceedReturnsEmptyFailures(t *testing.T) { + ctx := testutil.TestContext(t) + + mockPurchase := &testutil.MockPurchaseManager{ + ProcessMessageFunc: func(_ context.Context, _ string) error { return nil }, + } + app := &Application{Purchase: mockPurchase} + + rawEvent := `{"Records": [{"messageId": "msg-1", "eventSource": "aws:sqs", "body": "{}"}]}` + + result, err := app.handleLambdaSQSEvent(ctx, json.RawMessage(rawEvent)) + testutil.AssertNoError(t, err) + + resp, ok := result.(events.SQSEventResponse) + if !ok { + t.Fatalf("expected events.SQSEventResponse, got %T", result) + } + if len(resp.BatchItemFailures) != 0 { + t.Fatalf("expected no batch item failures, got %+v", resp.BatchItemFailures) + } +} + // testOwnerToken is a fixed, well-formed collection owner token. It has to be // a real UUID because ParseScheduledEvent rejects malformed non-empty tokens // at the boundary rather than letting them reach the UUID-typed column. From 1f972b0261c14cba61accb17db9a51b4280c136f Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 28 Sep 2026 10:50:31 +0200 Subject: [PATCH 2/2] fix(server): fail closed on SQS partial batch response without the flag Returning events.SQSEventResponse{BatchItemFailures} with a nil error unconditionally was itself unsafe: this repo's SQS event source mapping (which does not exist yet, see issue #399) has no function_response_types = ["ReportBatchItemFailures"], so AWS would ignore the response body entirely and treat the nil error as the WHOLE BATCH succeeding, deleting every message including the ones that just failed. That is worse than the pre-#108 behavior, which at least redelivered a genuine failure until it hit the DLQ. Add SQS_REPORT_BATCH_ITEM_FAILURES (bool, parsed at startup via loadReportSQSBatchItemFailures, error on an unparseable value rather than a silently-ignored typo). Default false keeps the old all-or-nothing aggregate-error behavior until an operator confirms the Terraform side is live and flips the flag. Regardless of the flag, a batch where every record failed always returns an error -- there is nothing to partially succeed on. Also: initialize failures as an empty slice rather than nil, so a below-the-flag empty response marshals as [] rather than null. Tests: split the partial-failure case into _FlagOn (partial response, nil error) and _FlagOff (old aggregate error) variants, added _AllFailed_ReturnsErrorRegardlessOfFlag, and TestLoadReportSQSBatchItemFailures covering unset/true/false/garbage. Filed #399 to either provision the SQS queue and event source mapping (with the setting this flag expects) or remove the dead async-SQS purchase-execution path entirely. --- internal/server/app.go | 13 +++ internal/server/lambda.go | 91 ++++++++++++++--- internal/server/lambda_test.go | 172 +++++++++++++++++++++++++++------ 3 files changed, 235 insertions(+), 41 deletions(-) diff --git a/internal/server/app.go b/internal/server/app.go index 1d33d8df4..7025ea88e 100644 --- a/internal/server/app.go +++ b/internal/server/app.go @@ -118,6 +118,13 @@ type ApplicationConfig struct { DefaultTerm int EnableDashboard bool IsLambda bool + // ReportSQSBatchItemFailures gates whether handleLambdaSQSEvent returns + // the partial events.SQSEventResponse{BatchItemFailures} shape (true) or + // the old whole-batch aggregate error (false, default). See + // loadReportSQSBatchItemFailures for why this must stay false until the + // SQS event source mapping's function_response_types actually includes + // "ReportBatchItemFailures" (issue #108 follow-up). + ReportSQSBatchItemFailures bool } // ExternalDeps holds pre-built external dependencies that require infrastructure. @@ -534,6 +541,12 @@ func NewApplication(ctx context.Context, version string) (*Application, error) { return nil, fmt.Errorf("invalid analytics configuration: %w", err) } + reportSQSBatchItemFailures, err := loadReportSQSBatchItemFailures() + if err != nil { + return nil, fmt.Errorf("invalid SQS configuration: %w", err) + } + cfg.ReportSQSBatchItemFailures = reportSQSBatchItemFailures + log.Printf("CUDly Server initializing, version: %s", cfg.Version) // Initialize configuration store (PostgreSQL). The store connects lazily on diff --git a/internal/server/lambda.go b/internal/server/lambda.go index 419d9c24f..98c22c807 100644 --- a/internal/server/lambda.go +++ b/internal/server/lambda.go @@ -6,6 +6,8 @@ import ( "encoding/json" "fmt" "log" + "os" + "strconv" "strings" "github.com/aws/aws-lambda-go/events" @@ -224,21 +226,37 @@ func isTextContentType(ct string) bool { // handleLambdaSQSEvent processes SQS messages (for async purchase processing). // -// Returns a partial batch response (events.SQSEventResponse) naming only the -// records that actually failed, with a nil error, instead of a single error -// for the whole batch (issue #108). Lambda's SQS integration treats ANY -// non-nil handler error as "the entire batch failed" and redelivers every -// record, including ones HandleSQSMessage already processed successfully. -// On redelivery those already-succeeded records typically fail a second -// time (status already advanced past what the CAS-guarded handlers expect), +// When app.appConfig.ReportSQSBatchItemFailures is true, returns a partial +// batch response (events.SQSEventResponse) naming only the records that +// actually failed, with a nil error, instead of a single error for the +// whole batch (issue #108). Lambda's SQS integration treats ANY non-nil +// handler error as "the entire batch failed" and redelivers every record, +// including ones HandleSQSMessage already processed successfully. On +// redelivery those already-succeeded records typically fail a second time +// (status already advanced past what the CAS-guarded handlers expect), // which redelivers the whole batch again and burns the redrive budget on // records that were never actually broken, until the one genuinely poison // message drags the rest of the batch to the DLQ with it. // -// This response shape is only honored by AWS when the SQS event-source -// mapping sets function_response_types = ["ReportBatchItemFailures"] -// (Terraform); without it, AWS silently ignores the response and redelivers -// the whole batch on any failure regardless of what this function returns. +// The partial-response shape is only honored by AWS when the SQS +// event-source mapping sets function_response_types = +// ["ReportBatchItemFailures"] (Terraform; see loadReportSQSBatchItemFailures +// for why no such resource currently exists in this repo). Without that +// setting live, AWS ignores the response body entirely and treats a nil +// error as the WHOLE BATCH having succeeded -- deleting every message from +// the queue, including the ones that just failed. That is a strictly worse +// outcome than the old all-or-nothing behavior (which at least redelivered +// and eventually DLQ'd a genuine failure): a transient error would now +// silently drop an approval, and a poison message would vanish instead of +// landing in the DLQ for investigation. ReportSQSBatchItemFailures defaults +// to false specifically to keep the old, safe, all-or-nothing behavior +// until an operator has confirmed the event source mapping is actually +// configured to honor partial responses and flips +// SQS_REPORT_BATCH_ITEM_FAILURES=true. +// +// Regardless of the flag, a batch where every record failed always returns +// an error: there is no reason to ack (or partially ack) a batch that +// accomplished nothing, flag or no flag. func (app *Application) handleLambdaSQSEvent(ctx context.Context, rawEvent json.RawMessage) (any, error) { var sqsEvent events.SQSEvent if err := json.Unmarshal(rawEvent, &sqsEvent); err != nil { @@ -246,7 +264,7 @@ func (app *Application) handleLambdaSQSEvent(ctx context.Context, rawEvent json. return nil, err } - var failures []events.SQSBatchItemFailure + failures := []events.SQSBatchItemFailure{} for _rvc := range sqsEvent.Records { record := sqsEvent.Records[_rvc] log.Printf("Processing SQS message: %s", record.MessageId) @@ -256,9 +274,58 @@ func (app *Application) handleLambdaSQSEvent(ctx context.Context, rawEvent json. } } + if len(failures) == 0 { + return events.SQSEventResponse{BatchItemFailures: failures}, nil + } + + if len(failures) == len(sqsEvent.Records) { + return nil, fmt.Errorf("failed to process all %d SQS message(s) in batch: %v", len(failures), sqsBatchItemFailureIDs(failures)) + } + + if !app.appConfig.ReportSQSBatchItemFailures { + // Fail closed: see the function doc comment above for why acking + // (even partially) without the Terraform side confirmed live would + // silently drop failed messages instead of redelivering them. + return nil, fmt.Errorf("failed to process %d SQS message(s): %v", len(failures), sqsBatchItemFailureIDs(failures)) + } + return events.SQSEventResponse{BatchItemFailures: failures}, nil } +// sqsBatchItemFailureIDs extracts the message IDs from a batch item failure +// list for the aggregate-error log/error text. +func sqsBatchItemFailureIDs(failures []events.SQSBatchItemFailure) []string { + ids := make([]string, len(failures)) + for i, f := range failures { + ids[i] = f.ItemIdentifier + } + return ids +} + +// loadReportSQSBatchItemFailures parses SQS_REPORT_BATCH_ITEM_FAILURES at +// startup (called once from NewApplication). Unset means false: keep the +// old all-or-nothing batch behavior, which is safe (if noisier) until an +// operator confirms the SQS event source mapping's function_response_types +// actually includes "ReportBatchItemFailures" -- a Terraform resource that +// does not exist anywhere in this repo today (issue #108 follow-up: no +// aws_sqs_queue / AWS::SQS::Queue / EventSourceMapping / batch_size found +// across terraform/, cloudformation/, iac/, or arm/). A set but +// unparseable value is a startup error rather than a silently-ignored +// typo: flipping this flag is a deploy-time contract with that Terraform +// setting, and getting it wrong risks silently dropping failed purchase +// messages instead of redelivering them (see handleLambdaSQSEvent). +func loadReportSQSBatchItemFailures() (bool, error) { + raw := os.Getenv("SQS_REPORT_BATCH_ITEM_FAILURES") + if raw == "" { + return false, nil + } + val, err := strconv.ParseBool(raw) + if err != nil { + return false, fmt.Errorf("invalid SQS_REPORT_BATCH_ITEM_FAILURES %q: must be a boolean: %w", raw, err) + } + return val, nil +} + // handleLambdaScheduledEvent processes scheduled/cron events. func (app *Application) handleLambdaScheduledEvent(ctx context.Context, rawEvent json.RawMessage) (any, error) { taskType, params, err := ParseScheduledEvent(rawEvent) diff --git a/internal/server/lambda_test.go b/internal/server/lambda_test.go index b29f14df9..da2824fd8 100644 --- a/internal/server/lambda_test.go +++ b/internal/server/lambda_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "os" "strings" "testing" @@ -214,45 +215,54 @@ func TestHandleLambdaSQSEvent(t *testing.T) { } } -// TestHandleLambdaSQSEvent_PartialBatchFailure pins issue #108: a batch -// where only some records fail must leave the succeeded records ACKed -// rather than redelivering the whole batch. Pre-fix, handleLambdaSQSEvent -// collected every failed message ID and returned a single aggregate error -// for the batch; Lambda's SQS integration treats ANY non-nil handler error -// as "the entire batch failed", so records 1 and 3 (which HandleSQSMessage -// already processed successfully) would be redelivered right alongside the -// genuinely failed record 2 -- and would typically fail a second time since -// their status already advanced past what the CAS-guarded handlers expect, -// repeating until the whole batch (not just the poison message) hits the -// DLQ. +// partialFailureRawEvent is a 3-record batch where only "msg-2" fails, +// shared by the flag-on and flag-off partial-failure tests below. +const partialFailureRawEvent = `{ + "Records": [ + {"messageId": "msg-1", "eventSource": "aws:sqs", "body": "{\"execution_id\": \"msg-1\"}"}, + {"messageId": "msg-2", "eventSource": "aws:sqs", "body": "{\"execution_id\": \"msg-2\"}"}, + {"messageId": "msg-3", "eventSource": "aws:sqs", "body": "{\"execution_id\": \"msg-3\"}"} + ] +}` + +func failOnMsg2Purchase() *testutil.MockPurchaseManager { + return &testutil.MockPurchaseManager{ + ProcessMessageFunc: func(_ context.Context, body string) error { + if strings.Contains(body, "msg-2") { + return fmt.Errorf("execution not found: msg-2") + } + return nil + }, + } +} + +// TestHandleLambdaSQSEvent_PartialBatchFailure_FlagOn pins issue #108's +// fix, gated behind ReportSQSBatchItemFailures=true: a batch where only +// some records fail must leave the succeeded records ACKed rather than +// redelivering the whole batch. Pre-fix, handleLambdaSQSEvent collected +// every failed message ID and returned a single aggregate error for the +// batch; Lambda's SQS integration treats ANY non-nil handler error as "the +// entire batch failed", so records 1 and 3 (which HandleSQSMessage already +// processed successfully) would be redelivered right alongside the +// genuinely failed record 2 -- and would typically fail a second time +// since their status already advanced past what the CAS-guarded handlers +// expect, repeating until the whole batch (not just the poison message) +// hits the DLQ. // // Asserted on the returned events.SQSEventResponse.BatchItemFailures set, // per the issue's own fix direction, rather than on the error value: the // function must return a nil error alongside the partial-failure response, // since a non-nil error here is exactly the all-or-nothing behavior being // removed. -func TestHandleLambdaSQSEvent_PartialBatchFailure(t *testing.T) { +func TestHandleLambdaSQSEvent_PartialBatchFailure_FlagOn(t *testing.T) { ctx := testutil.TestContext(t) - mockPurchase := &testutil.MockPurchaseManager{ - ProcessMessageFunc: func(_ context.Context, body string) error { - if strings.Contains(body, "msg-2") { - return fmt.Errorf("execution not found: msg-2") - } - return nil - }, + app := &Application{ + Purchase: failOnMsg2Purchase(), + appConfig: ApplicationConfig{ReportSQSBatchItemFailures: true}, } - app := &Application{Purchase: mockPurchase} - - rawEvent := `{ - "Records": [ - {"messageId": "msg-1", "eventSource": "aws:sqs", "body": "{\"execution_id\": \"msg-1\"}"}, - {"messageId": "msg-2", "eventSource": "aws:sqs", "body": "{\"execution_id\": \"msg-2\"}"}, - {"messageId": "msg-3", "eventSource": "aws:sqs", "body": "{\"execution_id\": \"msg-3\"}"} - ] - }` - result, err := app.handleLambdaSQSEvent(ctx, json.RawMessage(rawEvent)) + result, err := app.handleLambdaSQSEvent(ctx, json.RawMessage(partialFailureRawEvent)) testutil.AssertNoError(t, err) resp, ok := result.(events.SQSEventResponse) @@ -268,9 +278,113 @@ func TestHandleLambdaSQSEvent_PartialBatchFailure(t *testing.T) { } } +// TestHandleLambdaSQSEvent_PartialBatchFailure_FlagOff pins the review +// finding on PR #382: without the SQS event source mapping's +// function_response_types actually including "ReportBatchItemFailures", +// AWS ignores the events.SQSEventResponse body entirely, so returning a +// nil error there means the WHOLE BATCH is acked and deleted -- silently +// dropping the genuinely failed record instead of redelivering it. With +// ReportSQSBatchItemFailures left at its default (false), a partial +// failure must still return the old aggregate error so the whole batch is +// redelivered (safe, if noisier) until an operator confirms the Terraform +// side is live and flips the flag. +func TestHandleLambdaSQSEvent_PartialBatchFailure_FlagOff(t *testing.T) { + ctx := testutil.TestContext(t) + + // Zero-value Application.appConfig: ReportSQSBatchItemFailures defaults + // to false, matching a real, unconfigured deployment. + app := &Application{Purchase: failOnMsg2Purchase()} + + result, err := app.handleLambdaSQSEvent(ctx, json.RawMessage(partialFailureRawEvent)) + testutil.AssertError(t, err) + if result != nil { + t.Fatalf("expected a nil result alongside the aggregate error, got %+v", result) + } + if !strings.Contains(err.Error(), "msg-2") { + t.Fatalf("expected the aggregate error to name the failed message, got: %v", err) + } +} + +// TestHandleLambdaSQSEvent_AllFailed_ReturnsErrorRegardlessOfFlag pins the +// review finding that a batch where every record failed must never be +// acked, flag or no flag -- there is nothing to partially succeed on, so +// returning the partial-success shape (or a nil error) would ack a batch +// that accomplished nothing. +func TestHandleLambdaSQSEvent_AllFailed_ReturnsErrorRegardlessOfFlag(t *testing.T) { + for _, flagValue := range []bool{true, false} { + t.Run(fmt.Sprintf("flag=%v", flagValue), func(t *testing.T) { + ctx := testutil.TestContext(t) + + app := &Application{ + Purchase: &testutil.MockPurchaseManager{ + ProcessMessageFunc: func(_ context.Context, _ string) error { + return fmt.Errorf("execution not found") + }, + }, + appConfig: ApplicationConfig{ReportSQSBatchItemFailures: flagValue}, + } + + rawEvent := `{"Records": [ + {"messageId": "msg-1", "eventSource": "aws:sqs", "body": "{}"}, + {"messageId": "msg-2", "eventSource": "aws:sqs", "body": "{}"} + ]}` + + result, err := app.handleLambdaSQSEvent(ctx, json.RawMessage(rawEvent)) + testutil.AssertError(t, err) + if result != nil { + t.Fatalf("expected a nil result alongside the all-failed error, got %+v", result) + } + }) + } +} + +// TestLoadReportSQSBatchItemFailures pins the "error on garbage" contract +// for SQS_REPORT_BATCH_ITEM_FAILURES: unset defaults to false (fail +// closed), a recognized boolean parses, and anything else is a startup +// error rather than a silently-ignored typo. +func TestLoadReportSQSBatchItemFailures(t *testing.T) { + t.Cleanup(func() { os.Unsetenv("SQS_REPORT_BATCH_ITEM_FAILURES") }) + + tests := []struct { + name string + envValue string + unset bool + wantVal bool + wantError bool + }{ + {name: "unset defaults to false", unset: true, wantVal: false}, + {name: "true", envValue: "true", wantVal: true}, + {name: "false", envValue: "false", wantVal: false}, + {name: "garbage errors", envValue: "yes-please", wantError: true}, + {name: "empty string treated as unset", envValue: "", wantVal: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.unset { + os.Unsetenv("SQS_REPORT_BATCH_ITEM_FAILURES") + } else { + os.Setenv("SQS_REPORT_BATCH_ITEM_FAILURES", tt.envValue) + } + + got, err := loadReportSQSBatchItemFailures() + if tt.wantError { + testutil.AssertError(t, err) + return + } + testutil.AssertNoError(t, err) + if got != tt.wantVal { + t.Fatalf("expected %v, got %v", tt.wantVal, got) + } + }) + } +} + // TestHandleLambdaSQSEvent_AllSucceedReturnsEmptyFailures documents the // all-succeed shape: a nil error and an explicitly empty (not omitted) // BatchItemFailures list, matching the SQS partial-batch-response contract. +// This holds regardless of ReportSQSBatchItemFailures, since an empty +// failure list is equivalent to plain success either way. func TestHandleLambdaSQSEvent_AllSucceedReturnsEmptyFailures(t *testing.T) { ctx := testutil.TestContext(t)