package audit import ( "context" "encoding/json" "testing" ) func TestUndoCreatesReversalEntry(t *testing.T) { trail, _ := setupTestTrail(t) ctx := context.Background() undo := NewUndoEngine(trail) tournamentID := "550e8400-e29b-41d4-a716-446655440020" original, err := trail.Record(ctx, AuditEntry{ TournamentID: &tournamentID, OperatorID: "op-1", Action: ActionPlayerBust, TargetType: "player", TargetID: "player-1", PreviousState: json.RawMessage(`{"status":"active","chips":5000}`), NewState: json.RawMessage(`{"status":"busted","chips":0}`), }) if err != nil { t.Fatalf("record original: %v", err) } // Undo the bust undoEntry, err := undo.Undo(ctx, original.ID) if err != nil { t.Fatalf("undo: %v", err) } // Verify undo entry has reversed action if undoEntry.Action != "undo."+ActionPlayerBust { t.Fatalf("expected action undo.%s, got %s", ActionPlayerBust, undoEntry.Action) } // Verify state is reversed if string(undoEntry.PreviousState) != `{"status":"busted","chips":0}` { t.Fatalf("expected undo previous_state to be original new_state, got %s", undoEntry.PreviousState) } if string(undoEntry.NewState) != `{"status":"active","chips":5000}` { t.Fatalf("expected undo new_state to be original previous_state, got %s", undoEntry.NewState) } // Verify metadata contains undone entry ID var meta map[string]interface{} if err := json.Unmarshal(undoEntry.Metadata, &meta); err != nil { t.Fatalf("unmarshal undo metadata: %v", err) } if meta["undone_entry_id"] != original.ID { t.Fatalf("expected undone_entry_id %s, got %v", original.ID, meta["undone_entry_id"]) } // Verify original is now marked as undone updatedOriginal, err := trail.GetEntry(ctx, original.ID) if err != nil { t.Fatalf("get updated original: %v", err) } if updatedOriginal.UndoneBy == nil { t.Fatal("expected original to be marked as undone") } if *updatedOriginal.UndoneBy != undoEntry.ID { t.Fatalf("expected undone_by %s, got %s", undoEntry.ID, *updatedOriginal.UndoneBy) } } func TestDoubleUndoReturnsError(t *testing.T) { trail, _ := setupTestTrail(t) ctx := context.Background() undo := NewUndoEngine(trail) original, err := trail.Record(ctx, AuditEntry{ OperatorID: "op-1", Action: ActionPlayerBuyin, TargetType: "player", TargetID: "player-1", PreviousState: json.RawMessage(`{"chips":0}`), NewState: json.RawMessage(`{"chips":10000}`), }) if err != nil { t.Fatalf("record original: %v", err) } // First undo should succeed _, err = undo.Undo(ctx, original.ID) if err != nil { t.Fatalf("first undo: %v", err) } // Second undo should fail _, err = undo.Undo(ctx, original.ID) if err != ErrAlreadyUndone { t.Fatalf("expected ErrAlreadyUndone, got %v", err) } } func TestUndoNonUndoableActionReturnsError(t *testing.T) { trail, _ := setupTestTrail(t) ctx := context.Background() undo := NewUndoEngine(trail) // Clock actions are not undoable original, err := trail.Record(ctx, AuditEntry{ OperatorID: "op-1", Action: ActionClockStart, TargetType: "tournament", TargetID: "t-1", }) if err != nil { t.Fatalf("record original: %v", err) } _, err = undo.Undo(ctx, original.ID) if err != ErrNotUndoable { t.Fatalf("expected ErrNotUndoable, got %v", err) } } func TestCanUndo(t *testing.T) { trail, _ := setupTestTrail(t) ctx := context.Background() undo := NewUndoEngine(trail) // Undoable action undoableEntry, err := trail.Record(ctx, AuditEntry{ OperatorID: "op-1", Action: ActionPlayerBust, TargetType: "player", TargetID: "player-1", }) if err != nil { t.Fatalf("record undoable: %v", err) } canUndo, err := undo.CanUndo(ctx, undoableEntry.ID) if err != nil { t.Fatalf("can undo: %v", err) } if !canUndo { t.Fatal("expected CanUndo to return true for undoable action") } // Non-undoable action nonUndoableEntry, err := trail.Record(ctx, AuditEntry{ OperatorID: "op-1", Action: ActionClockPause, TargetType: "tournament", TargetID: "t-1", }) if err != nil { t.Fatalf("record non-undoable: %v", err) } canUndo, err = undo.CanUndo(ctx, nonUndoableEntry.ID) if err != nil { t.Fatalf("can undo: %v", err) } if canUndo { t.Fatal("expected CanUndo to return false for non-undoable action") } // Already undone _, err = undo.Undo(ctx, undoableEntry.ID) if err != nil { t.Fatalf("undo: %v", err) } canUndo, err = undo.CanUndo(ctx, undoableEntry.ID) if err != nil { t.Fatalf("can undo after undo: %v", err) } if canUndo { t.Fatal("expected CanUndo to return false for already-undone entry") } } func TestUndoNonexistentEntryReturnsError(t *testing.T) { trail, _ := setupTestTrail(t) ctx := context.Background() undo := NewUndoEngine(trail) _, err := undo.Undo(ctx, "nonexistent-id") if err == nil { t.Fatal("expected error for nonexistent entry") } } func TestUndoPreservesTargetInfo(t *testing.T) { trail, _ := setupTestTrail(t) ctx := context.Background() undo := NewUndoEngine(trail) tournamentID := "550e8400-e29b-41d4-a716-446655440021" original, err := trail.Record(ctx, AuditEntry{ TournamentID: &tournamentID, OperatorID: "op-1", Action: ActionFinancialPayout, TargetType: "transaction", TargetID: "tx-123", PreviousState: json.RawMessage(`{"paid":false}`), NewState: json.RawMessage(`{"paid":true,"amount":50000}`), }) if err != nil { t.Fatalf("record original: %v", err) } undoEntry, err := undo.Undo(ctx, original.ID) if err != nil { t.Fatalf("undo: %v", err) } // Verify target info is preserved if undoEntry.TargetType != "transaction" { t.Fatalf("expected target_type transaction, got %s", undoEntry.TargetType) } if undoEntry.TargetID != "tx-123" { t.Fatalf("expected target_id tx-123, got %s", undoEntry.TargetID) } if undoEntry.TournamentID == nil || *undoEntry.TournamentID != tournamentID { t.Fatalf("expected tournament_id %s, got %v", tournamentID, undoEntry.TournamentID) } } func TestUndoAllUndoableActions(t *testing.T) { trail, _ := setupTestTrail(t) ctx := context.Background() undo := NewUndoEngine(trail) // Test that all declared undoable actions can actually be undone for action := range undoableActions { entry, err := trail.Record(ctx, AuditEntry{ OperatorID: "op-1", Action: action, TargetType: "test", TargetID: "test-1", PreviousState: json.RawMessage(`{"before":true}`), NewState: json.RawMessage(`{"after":true}`), }) if err != nil { t.Fatalf("record %s: %v", action, err) } undoEntry, err := undo.Undo(ctx, entry.ID) if err != nil { t.Fatalf("undo %s: %v", action, err) } expectedAction := "undo." + action if undoEntry.Action != expectedAction { t.Fatalf("expected undo action %s, got %s", expectedAction, undoEntry.Action) } } } func TestNonUndoableActions(t *testing.T) { trail, _ := setupTestTrail(t) ctx := context.Background() undo := NewUndoEngine(trail) nonUndoable := []string{ ActionClockStart, ActionClockPause, ActionClockResume, ActionClockAdvance, ActionClockRewind, ActionClockJump, ActionTournamentStart, ActionTournamentEnd, ActionTournamentCancel, ActionTournamentCreate, ActionOperatorLogin, ActionOperatorLogout, ActionTemplateCreate, ActionTemplateUpdate, ActionTemplateDelete, ActionSeatAssign, ActionSeatBreakTable, } for _, action := range nonUndoable { entry, err := trail.Record(ctx, AuditEntry{ OperatorID: "op-1", Action: action, TargetType: "test", TargetID: "test-1", }) if err != nil { t.Fatalf("record %s: %v", action, err) } _, err = undo.Undo(ctx, entry.ID) if err != ErrNotUndoable { t.Fatalf("expected ErrNotUndoable for %s, got %v", action, err) } } }