- 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>
298 lines
7.6 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|