package transaction import ( "context" "errors" "testing" "go.uber.org/zap" "go.uber.org/zap/zaptest" ) // TestNewSaga verifies that NewSaga creates a properly initialized Saga instance func TestNewSaga(t *testing.T) { logger := zaptest.NewLogger(t) saga := NewSaga("test-saga", logger) if saga == nil { t.Fatal("NewSaga returned nil") } if saga.name != "test-saga" { t.Errorf("expected name 'test-saga', got '%s'", saga.name) } if saga.compensators == nil { t.Error("compensators slice is nil") } if len(saga.compensators) != 0 { t.Errorf("expected 0 compensators, got %d", len(saga.compensators)) } if saga.logger == nil { t.Error("logger is nil") } } // TestAddCompensation_Single verifies that adding a single compensation works func TestAddCompensation_Single(t *testing.T) { logger := zaptest.NewLogger(t) saga := NewSaga("test-saga", logger) executed := false compensator := func(ctx context.Context) error { executed = true return nil } saga.AddCompensation(compensator) if len(saga.compensators) != 1 { t.Errorf("expected 1 compensator, got %d", len(saga.compensators)) } // Verify compensator can be called ctx := context.Background() if err := saga.compensators[0](ctx); err != nil { t.Errorf("compensator returned error: %v", err) } if !executed { t.Error("compensator was not executed") } } // TestAddCompensation_Multiple verifies that adding multiple compensations works func TestAddCompensation_Multiple(t *testing.T) { logger := zaptest.NewLogger(t) saga := NewSaga("test-saga", logger) // Add three compensations for i := 0; i < 3; i++ { saga.AddCompensation(func(ctx context.Context) error { return nil }) } if len(saga.compensators) != 3 { t.Errorf("expected 3 compensators, got %d", len(saga.compensators)) } } // TestRollback_EmptyCompensations verifies that rollback with no compensations is safe func TestRollback_EmptyCompensations(t *testing.T) { logger := zaptest.NewLogger(t) saga := NewSaga("test-saga", logger) ctx := context.Background() // Should not panic or error saga.Rollback(ctx) } // TestRollback_SingleCompensation_Success verifies successful rollback with one compensation func TestRollback_SingleCompensation_Success(t *testing.T) { logger := zaptest.NewLogger(t) saga := NewSaga("test-saga", logger) executed := false saga.AddCompensation(func(ctx context.Context) error { executed = true return nil }) ctx := context.Background() saga.Rollback(ctx) if !executed { t.Error("compensation was not executed during rollback") } } // TestRollback_SingleCompensation_Failure verifies rollback continues on compensation failure func TestRollback_SingleCompensation_Failure(t *testing.T) { logger := zaptest.NewLogger(t) saga := NewSaga("test-saga", logger) expectedError := errors.New("compensation failed") executed := false saga.AddCompensation(func(ctx context.Context) error { executed = true return expectedError }) ctx := context.Background() saga.Rollback(ctx) if !executed { t.Error("compensation was not executed during rollback") } // Rollback should not panic or return error, just log it } // TestRollback_LIFO_ExecutionOrder verifies compensations execute in reverse order func TestRollback_LIFO_ExecutionOrder(t *testing.T) { logger := zaptest.NewLogger(t) saga := NewSaga("test-saga", logger) var executionOrder []int // Add three compensations for i := 1; i <= 3; i++ { index := i // Capture value in local variable saga.AddCompensation(func(ctx context.Context) error { executionOrder = append(executionOrder, index) return nil }) } ctx := context.Background() saga.Rollback(ctx) // Verify LIFO order: should be [3, 2, 1] expected := []int{3, 2, 1} if len(executionOrder) != len(expected) { t.Fatalf("expected %d executions, got %d", len(expected), len(executionOrder)) } for i, v := range executionOrder { if v != expected[i] { t.Errorf("execution order[%d]: expected %d, got %d", i, expected[i], v) } } } // TestRollback_MultipleCompensations_AllSuccess verifies all compensations execute func TestRollback_MultipleCompensations_AllSuccess(t *testing.T) { logger := zaptest.NewLogger(t) saga := NewSaga("test-saga", logger) executedCount := 0 // Add 5 compensations for i := 0; i < 5; i++ { saga.AddCompensation(func(ctx context.Context) error { executedCount++ return nil }) } ctx := context.Background() saga.Rollback(ctx) if executedCount != 5 { t.Errorf("expected 5 compensations executed, got %d", executedCount) } } // TestRollback_MultipleCompensations_PartialFailure verifies best-effort behavior func TestRollback_MultipleCompensations_PartialFailure(t *testing.T) { logger := zaptest.NewLogger(t) saga := NewSaga("test-saga", logger) executedCount := 0 // Add 5 compensations, second one fails for i := 0; i < 5; i++ { index := i saga.AddCompensation(func(ctx context.Context) error { executedCount++ if index == 1 { return errors.New("compensation 2 failed") } return nil }) } ctx := context.Background() saga.Rollback(ctx) // All 5 should be attempted despite failure if executedCount != 5 { t.Errorf("expected 5 compensations attempted, got %d", executedCount) } } // TestRollback_ContextCancellation verifies context is passed to compensations func TestRollback_ContextCancellation(t *testing.T) { logger := zaptest.NewLogger(t) saga := NewSaga("test-saga", logger) receivedContext := false saga.AddCompensation(func(ctx context.Context) error { if ctx != nil { receivedContext = true } return nil }) ctx := context.Background() saga.Rollback(ctx) if !receivedContext { t.Error("compensation did not receive context") } } // TestRollback_CancelledContext verifies compensations see cancelled context func TestRollback_CancelledContext(t *testing.T) { logger := zaptest.NewLogger(t) saga := NewSaga("test-saga", logger) ctxCancelled := false saga.AddCompensation(func(ctx context.Context) error { select { case <-ctx.Done(): ctxCancelled = true default: // Context not cancelled } return nil }) ctx, cancel := context.WithCancel(context.Background()) cancel() // Cancel context before rollback saga.Rollback(ctx) if !ctxCancelled { t.Error("compensation did not receive cancelled context") } } // TestMustRollback verifies MustRollback behaves like Rollback func TestMustRollback(t *testing.T) { logger := zaptest.NewLogger(t) saga := NewSaga("test-saga", logger) executed := false saga.AddCompensation(func(ctx context.Context) error { executed = true return nil }) ctx := context.Background() saga.MustRollback(ctx) if !executed { t.Error("MustRollback did not execute compensation") } } // TestMustRollback_DoesNotPanic verifies MustRollback doesn't panic on failure func TestMustRollback_DoesNotPanic(t *testing.T) { logger := zaptest.NewLogger(t) saga := NewSaga("test-saga", logger) saga.AddCompensation(func(ctx context.Context) error { return errors.New("compensation failed") }) ctx := context.Background() // Should not panic defer func() { if r := recover(); r != nil { t.Errorf("MustRollback panicked: %v", r) } }() saga.MustRollback(ctx) } // TestRollback_VariableCaptureWarning demonstrates the closure gotcha func TestRollback_VariableCaptureWarning(t *testing.T) { logger := zaptest.NewLogger(t) // Simulate loop with variable capture issue var executedIDs []int // WRONG: Capturing loop variable directly type Resource struct { ID int } resources := []Resource{{ID: 1}, {ID: 2}, {ID: 3}} sagaWrong := NewSaga("wrong-capture", logger) for _, resource := range resources { // This is WRONG but we're demonstrating the problem sagaWrong.AddCompensation(func(ctx context.Context) error { // Will always use the last value of 'resource' executedIDs = append(executedIDs, resource.ID) return nil }) } ctx := context.Background() sagaWrong.Rollback(ctx) // All compensations will use resource.ID = 3 (the last value) for _, id := range executedIDs { if id != 3 { t.Logf("Warning: captured variable changed (this is expected in wrong usage)") } } // CORRECT: Capture value in local variable executedIDsCorrect := []int{} sagaCorrect := NewSaga("correct-capture", logger) for _, resource := range resources { resourceID := resource.ID // Capture value sagaCorrect.AddCompensation(func(ctx context.Context) error { executedIDsCorrect = append(executedIDsCorrect, resourceID) return nil }) } sagaCorrect.Rollback(ctx) // Should execute in reverse: [3, 2, 1] expected := []int{3, 2, 1} if len(executedIDsCorrect) != len(expected) { t.Fatalf("expected %d executions, got %d", len(expected), len(executedIDsCorrect)) } for i, id := range executedIDsCorrect { if id != expected[i] { t.Errorf("execution[%d]: expected %d, got %d", i, expected[i], id) } } } // TestRollback_Idempotency verifies compensations can be called multiple times func TestRollback_Idempotency(t *testing.T) { logger := zaptest.NewLogger(t) saga := NewSaga("test-saga", logger) executionCount := 0 saga.AddCompensation(func(ctx context.Context) error { executionCount++ // Idempotent: safe to call multiple times return nil }) ctx := context.Background() // Call rollback twice saga.Rollback(ctx) saga.Rollback(ctx) // Should execute twice (once per rollback call) if executionCount != 2 { t.Errorf("expected 2 executions, got %d", executionCount) } } // TestRollback_RealWorldScenario simulates a registration flow func TestRollback_RealWorldScenario(t *testing.T) { logger := zaptest.NewLogger(t) saga := NewSaga("user-registration", logger) ctx := context.Background() // Track what got created and deleted tenantCreated := false tenantDeleted := false userCreated := false userDeleted := false // Step 1: Create tenant tenantID := "tenant-123" tenantCreated = true saga.AddCompensation(func(ctx context.Context) error { tenantDeleted = true return nil }) // Step 2: Create user userID := "user-456" userCreated = true saga.AddCompensation(func(ctx context.Context) error { userDeleted = true return nil }) // Step 3: Something fails (e.g., email sending) emailErr := errors.New("email service unavailable") if emailErr != nil { // Rollback should delete user then tenant (LIFO) saga.Rollback(ctx) } // Verify cleanup happened if !tenantCreated { t.Error("tenant was not created") } if !userCreated { t.Error("user was not created") } if !userDeleted { t.Error("user was not deleted during rollback") } if !tenantDeleted { t.Error("tenant was not deleted during rollback") } // Verify IDs are still accessible (not used in this test but good practice) _ = tenantID _ = userID } // TestRollback_NoCompensationsRegistered_NoOp verifies rollback is safe when nothing registered func TestRollback_NoCompensationsRegistered_NoOp(t *testing.T) { logger := zaptest.NewLogger(t) saga := NewSaga("test-saga", logger) ctx := context.Background() // Should be a no-op, no panic saga.Rollback(ctx) saga.MustRollback(ctx) } // BenchmarkSaga_AddCompensation benchmarks compensation registration func BenchmarkSaga_AddCompensation(b *testing.B) { logger := zap.NewNop() saga := NewSaga("benchmark-saga", logger) compensator := func(ctx context.Context) error { return nil } b.ResetTimer() for i := 0; i < b.N; i++ { saga.AddCompensation(compensator) } } // BenchmarkSaga_Rollback benchmarks rollback execution func BenchmarkSaga_Rollback(b *testing.B) { logger := zap.NewNop() ctx := context.Background() // Prepare saga with 10 compensations compensator := func(ctx context.Context) error { return nil } b.ResetTimer() for i := 0; i < b.N; i++ { saga := NewSaga("benchmark-saga", logger) for j := 0; j < 10; j++ { saga.AddCompensation(compensator) } saga.Rollback(ctx) } } // BenchmarkSaga_RollbackWithFailures benchmarks rollback with compensation failures func BenchmarkSaga_RollbackWithFailures(b *testing.B) { logger := zap.NewNop() ctx := context.Background() successCompensator := func(ctx context.Context) error { return nil } failureCompensator := func(ctx context.Context) error { return errors.New("compensation failed") } b.ResetTimer() for i := 0; i < b.N; i++ { saga := NewSaga("benchmark-saga", logger) for j := 0; j < 5; j++ { saga.AddCompensation(successCompensator) saga.AddCompensation(failureCompensator) } saga.Rollback(ctx) } }