felt/internal/audit/undo_test.go
Mikkel Georgsen 1978d3d421 docs(01-05): complete Blind Structure + Chip Sets + Templates plan
- SUMMARY.md with self-check passed
- STATE.md updated: plan 6 of 14, 36% progress, 4 decisions added
- ROADMAP.md updated: 5/14 plans complete
- REQUIREMENTS.md: 15 requirements marked complete (BLIND-01-06, CHIP-01-04, FIN-01,02,05,06,10)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 04:02:11 +01:00

298 lines
7.6 KiB
Go

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