monorepo/cloud/maplefile-backend/pkg/transaction/saga_test.go

516 lines
12 KiB
Go

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)
}
}