package transaction import ( "context" "encoding/json" "errors" "path/filepath" "sync" "testing" "time" ) func TestStoreCreateIsIdempotentAndAllowsOnlyOneActiveTransaction(t *testing.T) { t.Parallel() ctx := context.Background() store := openTestStore(t) request := CreateRequest{ ID: "transaction-1", IdempotencyKey: "request-1", Source: "test", Service: "backend", Request: json.RawMessage(`{"service":"backend"}`), } created, isNew, err := store.CreateTransaction(ctx, request) if err != nil { t.Fatalf("create transaction: %v", err) } if !isNew || created.State != StateCreated { t.Fatalf("unexpected created transaction: %+v new=%v", created, isNew) } retried, isNew, err := store.CreateTransaction(ctx, request) if err != nil { t.Fatalf("retry transaction: %v", err) } if isNew || retried.ID != created.ID { t.Fatalf("idempotent retry created another transaction: %+v", retried) } _, _, err = store.CreateTransaction(ctx, CreateRequest{ ID: "transaction-2", IdempotencyKey: "request-2", Source: "test", Service: "frontend", }) var activeErr *ActiveTransactionError if !errors.As(err, &activeErr) || activeErr.TransactionID != created.ID { t.Fatalf("expected active transaction error, got %v", err) } if _, err := store.Transition(ctx, created.ID, StateFailed, "test terminal state"); err != nil { t.Fatalf("finish first transaction: %v", err) } second, isNew, err := store.CreateTransaction(ctx, CreateRequest{ ID: "transaction-2", IdempotencyKey: "request-2", Source: "test", Service: "frontend", }) if err != nil || !isNew || second.ID != "transaction-2" { t.Fatalf("create transaction after terminal state: record=%+v new=%v err=%v", second, isNew, err) } } func TestStoreTransitionStepAndEventPersistence(t *testing.T) { t.Parallel() ctx := context.Background() databasePath := filepath.Join(t.TempDir(), "transaction.db") store, err := OpenStore(ctx, databasePath) if err != nil { t.Fatalf("open store: %v", err) } fixedTime := time.Date(2026, time.August, 15, 10, 0, 0, 0, time.UTC) store.now = func() time.Time { return fixedTime } record, _, err := store.CreateTransaction(ctx, CreateRequest{ ID: "transaction-persisted", IdempotencyKey: "request-persisted", Source: "test", Service: "all", }) if err != nil { t.Fatalf("create transaction: %v", err) } if _, err := store.Transition(ctx, record.ID, StateValidating, "validation started"); err != nil { t.Fatalf("transition transaction: %v", err) } if _, err := store.Transition(ctx, record.ID, StatePrepared, "validation completed"); err != nil { t.Fatalf("transition transaction: %v", err) } step, isNew, err := store.RecordStepIntent(ctx, record.ID, StepIntent{ Key: "prepare-files", Name: "prepare immutable files", Intent: json.RawMessage(`{"sha256":"abc"}`), }) if err != nil || !isNew || step.Status != StepIntentRecorded { t.Fatalf("record step intent: step=%+v new=%v err=%v", step, isNew, err) } if err := store.Close(); err != nil { t.Fatalf("close store: %v", err) } reopened, err := OpenStore(ctx, databasePath) if err != nil { t.Fatalf("reopen store: %v", err) } t.Cleanup(func() { _ = reopened.Close() }) persisted, err := reopened.Transaction(ctx, record.ID) if err != nil { t.Fatalf("read persisted transaction: %v", err) } if persisted.State != StatePrepared || persisted.Version != 3 { t.Fatalf("unexpected persisted transaction: %+v", persisted) } pending, err := reopened.PendingSteps(ctx, record.ID) if err != nil { t.Fatalf("read pending steps: %v", err) } if len(pending) != 1 || pending[0].Key != step.Key { t.Fatalf("unexpected pending steps: %+v", pending) } completed, err := reopened.CompleteStep(ctx, record.ID, step.Key, StepSucceeded, json.RawMessage(`{"installed":true}`), "") if err != nil || completed.Status != StepSucceeded { t.Fatalf("complete step: step=%+v err=%v", completed, err) } events, err := reopened.EventsAfter(ctx, record.ID, 0, 100) if err != nil { t.Fatalf("read events: %v", err) } if len(events) != 5 { t.Fatalf("unexpected event count: got %d events=%+v", len(events), events) } for index := 1; index < len(events); index++ { if events[index].Sequence <= events[index-1].Sequence { t.Fatalf("events are not ordered: %+v", events) } } } func TestStoreRejectsInvalidTransitionAndConflictingStepIntent(t *testing.T) { t.Parallel() ctx := context.Background() store := openTestStore(t) record, _, err := store.CreateTransaction(ctx, CreateRequest{ ID: "transaction-conflict", IdempotencyKey: "request-conflict", Source: "test", Service: "backend", }) if err != nil { t.Fatalf("create transaction: %v", err) } _, err = store.Transition(ctx, record.ID, StatePrepared, "skip validation") var transitionErr *TransitionError if !errors.As(err, &transitionErr) { t.Fatalf("expected transition error, got %v", err) } intent := StepIntent{Key: "same-key", Name: "first", Intent: json.RawMessage(`{"value":1}`)} if _, _, err := store.RecordStepIntent(ctx, record.ID, intent); err != nil { t.Fatalf("record first step intent: %v", err) } _, _, err = store.RecordStepIntent(ctx, record.ID, StepIntent{ Key: intent.Key, Name: "different", Intent: intent.Intent, }) if !errors.Is(err, ErrStepConflict) { t.Fatalf("expected step conflict, got %v", err) } } func TestStoreSerializesConcurrentCreates(t *testing.T) { t.Parallel() ctx := context.Background() store := openTestStore(t) const workers = 12 var wait sync.WaitGroup wait.Add(workers) results := make(chan error, workers) for index := 0; index < workers; index++ { go func(index int) { defer wait.Done() _, _, err := store.CreateTransaction(ctx, CreateRequest{ IdempotencyKey: "concurrent-" + string(rune('A'+index)), Source: "test", Service: "backend", }) results <- err }(index) } wait.Wait() close(results) var created, rejected int for err := range results { switch { case err == nil: created++ case errors.Is(err, ErrActiveExists): rejected++ default: t.Fatalf("unexpected create error: %v", err) } } if created != 1 || rejected != workers-1 { t.Fatalf("unexpected concurrent result: created=%d rejected=%d", created, rejected) } } func openTestStore(t *testing.T) *Store { t.Helper() store, err := OpenStore(context.Background(), filepath.Join(t.TempDir(), "transaction.db")) if err != nil { t.Fatalf("open test store: %v", err) } t.Cleanup(func() { if err := store.Close(); err != nil { t.Errorf("close test store: %v", err) } }) return store }