package sqs import ( "context" "errors" "io" "log/slog" "testing" "time" awsv2 "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/sqs" sqstypes "github.com/aws/aws-sdk-go-v2/service/sqs/types" "github.com/aws/smithy-go" "wucher/internal/config" "wucher/internal/queue" "wucher/internal/resilience" ) type mockAPI struct { sendInput *sqs.SendMessageInput receiveInput *sqs.ReceiveMessageInput deleteBatchInput *sqs.DeleteMessageBatchInput changeVisibilityInput *sqs.ChangeMessageVisibilityInput getAttributesInput *sqs.GetQueueAttributesInput sendErr error receiveErr error deleteBatchErr error changeVisibilityErr error getAttributesErr error sendCalls int receiveCalls int } func (m *mockAPI) SendMessage(_ context.Context, params *sqs.SendMessageInput, _ ...func(*sqs.Options)) (*sqs.SendMessageOutput, error) { m.sendCalls++ m.sendInput = params if m.sendErr != nil { return nil, m.sendErr } return &sqs.SendMessageOutput{}, nil } func (m *mockAPI) ReceiveMessage(_ context.Context, params *sqs.ReceiveMessageInput, _ ...func(*sqs.Options)) (*sqs.ReceiveMessageOutput, error) { m.receiveCalls++ m.receiveInput = params if m.receiveErr != nil { return nil, m.receiveErr } return &sqs.ReceiveMessageOutput{ Messages: []sqstypes.Message{{ MessageId: awsv2.String("msg-1"), ReceiptHandle: awsv2.String("receipt-1"), Body: awsv2.String(`{"version":"v1","message_id":"msg-1","kind":"email.dispatch","occurred_at":"2026-03-19T00:00:00Z","payload":{"to":"user@example.com","subject":"Hello","body":"World"}}`), MessageAttributes: map[string]sqstypes.MessageAttributeValue{ "correlation_id": {DataType: awsv2.String("String"), StringValue: awsv2.String("req-1")}, }, Attributes: map[string]string{ string(sqstypes.MessageSystemAttributeNameApproximateReceiveCount): "3", string(sqstypes.MessageSystemAttributeNameSentTimestamp): "1710806400000", }, }}, }, nil } func (m *mockAPI) DeleteMessageBatch(_ context.Context, params *sqs.DeleteMessageBatchInput, _ ...func(*sqs.Options)) (*sqs.DeleteMessageBatchOutput, error) { m.deleteBatchInput = params if m.deleteBatchErr != nil { return nil, m.deleteBatchErr } return &sqs.DeleteMessageBatchOutput{}, nil } func (m *mockAPI) ChangeMessageVisibility(_ context.Context, params *sqs.ChangeMessageVisibilityInput, _ ...func(*sqs.Options)) (*sqs.ChangeMessageVisibilityOutput, error) { m.changeVisibilityInput = params if m.changeVisibilityErr != nil { return nil, m.changeVisibilityErr } return &sqs.ChangeMessageVisibilityOutput{}, nil } func (m *mockAPI) GetQueueAttributes(_ context.Context, params *sqs.GetQueueAttributesInput, _ ...func(*sqs.Options)) (*sqs.GetQueueAttributesOutput, error) { m.getAttributesInput = params if m.getAttributesErr != nil { return nil, m.getAttributesErr } return &sqs.GetQueueAttributesOutput{ Attributes: map[string]string{ string(sqstypes.QueueAttributeNameApproximateNumberOfMessages): "5", string(sqstypes.QueueAttributeNameApproximateNumberOfMessagesNotVisible): "2", }, }, nil } func TestEmailQueuePublishReceiveAckRetryDeadLetter(t *testing.T) { api := &mockAPI{} cfg := config.SQSConfig{ Region: "ap-southeast-1", QueueURL: "https://sqs.ap-southeast-1.amazonaws.com/123/test", QueueType: queue.QueueTypeFIFO, MessageGroupID: "email", VisibilityTimeout: 30 * time.Second, WaitTimeSeconds: 20, } q, err := NewEmailQueue(api, cfg) if err != nil { t.Fatalf("new queue: %v", err) } err = q.Publish(context.Background(), queue.OutboundMessage{ ID: "msg-1", Body: []byte(`{"hello":"world"}`), Attributes: map[string]string{"correlation_id": "req-1"}, MessageGroupID: "email", DeduplicationID: "dedup-1", }) if err != nil { t.Fatalf("publish: %v", err) } if api.sendInput == nil || awsv2.ToString(api.sendInput.MessageGroupId) != "email" { t.Fatalf("expected fifo publish settings") } deliveries, err := q.Receive(context.Background(), 5) if err != nil { t.Fatalf("receive: %v", err) } if len(deliveries) != 1 || deliveries[0].ReceiveCount != 3 { t.Fatalf("unexpected deliveries: %+v", deliveries) } if err := q.Ack(context.Background(), deliveries); err != nil { t.Fatalf("ack: %v", err) } if api.deleteBatchInput == nil || len(api.deleteBatchInput.Entries) != 1 { t.Fatalf("expected batch delete") } if err := q.Retry(context.Background(), deliveries[0], 45*time.Second); err != nil { t.Fatalf("retry: %v", err) } if api.changeVisibilityInput == nil || api.changeVisibilityInput.VisibilityTimeout != 45 { t.Fatalf("expected retry visibility timeout 45") } if err := q.DeadLetter(context.Background(), deliveries[0], "permanent failure", nil); err != nil { t.Fatalf("dead-letter: %v", err) } if got := awsv2.ToString(api.sendInput.MessageBody); got != `{"hello":"world"}` { t.Fatalf("expected dead-letter path to leave message managed by SQS redrive, got send body %q", got) } depth, err := q.ApproximateDepth(context.Background()) if err != nil { t.Fatalf("depth: %v", err) } if depth != 7 { t.Fatalf("expected depth 7, got %d", depth) } } func TestValidateConfigRejectsInsecureEndpoint(t *testing.T) { err := validateConfig(config.SQSConfig{ Region: "ap-southeast-1", QueueURL: "https://sqs.ap-southeast-1.amazonaws.com/123/test", Endpoint: "http://localhost:4566", }) if err == nil { t.Fatalf("expected insecure endpoint validation error") } } func TestResilientAPI_CircuitBreaker(t *testing.T) { newExecutor := func() *resilience.Executor { return resilience.NewExecutor("sqs", config.CircuitBreakerPolicy{ Enabled: true, Timeout: time.Second, MinRequests: 2, FailureRatio: 0.5, ConsecutiveFailures: 2, }, slog.New(slog.NewJSONHandler(io.Discard, nil)), resilience.ClassifySQSError) } t.Run("client faults are excluded", func(t *testing.T) { api := &mockAPI{ sendErr: sqsAPIError{code: "AccessDenied", fault: smithy.FaultClient}, } wrapped := NewResilientAPI(api, newExecutor()) for range 3 { _, err := wrapped.SendMessage(context.Background(), &sqs.SendMessageInput{}) if err == nil { t.Fatalf("expected access denied error") } var openErr *resilience.OpenError if errors.As(err, &openErr) { t.Fatalf("expected excluded error to keep breaker closed, got %v", err) } } if api.sendCalls != 3 { t.Fatalf("expected all excluded calls to reach API, got %d", api.sendCalls) } }) t.Run("server faults open the breaker", func(t *testing.T) { api := &mockAPI{ receiveErr: sqsAPIError{code: "InternalError", fault: smithy.FaultServer}, } wrapped := NewResilientAPI(api, newExecutor()) for range 2 { if _, err := wrapped.ReceiveMessage(context.Background(), &sqs.ReceiveMessageInput{}); err == nil { t.Fatalf("expected receive error") } } _, err := wrapped.ReceiveMessage(context.Background(), &sqs.ReceiveMessageInput{}) var openErr *resilience.OpenError if !errors.As(err, &openErr) { t.Fatalf("expected circuit open error, got %v", err) } if api.receiveCalls != 2 { t.Fatalf("expected breaker to fail fast after opening, got %d receive calls", api.receiveCalls) } }) } type sqsAPIError struct { code string fault smithy.ErrorFault } func (e sqsAPIError) Error() string { return e.code } func (e sqsAPIError) ErrorCode() string { return e.code } func (e sqsAPIError) ErrorMessage() string { return e.code } func (e sqsAPIError) ErrorFault() smithy.ErrorFault { return e.fault }