From 1978d3d42110b18c1821374bc0a3f3b4d6300cb8 Mon Sep 17 00:00:00 2001 From: Mikkel Georgsen Date: Sun, 1 Mar 2026 04:02:11 +0100 Subject: [PATCH] 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 --- .planning/REQUIREMENTS.md | 60 +-- .planning/ROADMAP.md | 2 +- .planning/STATE.md | 22 +- .../01-tournament-engine/01-05-SUMMARY.md | 140 +++++++ internal/audit/trail.go | 359 ++++++++++++++++++ internal/audit/trail_test.go | 349 +++++++++++++++++ internal/audit/undo.go | 125 ++++++ internal/audit/undo_test.go | 298 +++++++++++++++ 8 files changed, 1315 insertions(+), 40 deletions(-) create mode 100644 .planning/phases/01-tournament-engine/01-05-SUMMARY.md create mode 100644 internal/audit/trail_test.go create mode 100644 internal/audit/undo_test.go diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index e6d1a52..b4fb5a5 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -43,32 +43,32 @@ Requirements for Phase 1 (Development Focus: Live Tournament Management). Each m ### Blind Structure -- [ ] **BLIND-01**: Unlimited configurable levels (round or break, game type, SB/BB, ante, duration, chip-up, notes) -- [ ] **BLIND-02**: Big Blind Ante support alongside standard ante -- [ ] **BLIND-03**: Mixed game rotation support (HORSE, 8-Game round definitions) -- [ ] **BLIND-04**: Save/load reusable blind structure templates -- [ ] **BLIND-05**: Built-in templates (Turbo ~2hr, Standard ~3-4hr, Deep Stack ~5-6hr, WSOP-style) -- [ ] **BLIND-06**: Structure wizard (inputs: player count, starting chips, duration, denominations → suggested structure) +- [x] **BLIND-01**: Unlimited configurable levels (round or break, game type, SB/BB, ante, duration, chip-up, notes) +- [x] **BLIND-02**: Big Blind Ante support alongside standard ante +- [x] **BLIND-03**: Mixed game rotation support (HORSE, 8-Game round definitions) +- [x] **BLIND-04**: Save/load reusable blind structure templates +- [x] **BLIND-05**: Built-in templates (Turbo ~2hr, Standard ~3-4hr, Deep Stack ~5-6hr, WSOP-style) +- [x] **BLIND-06**: Structure wizard (inputs: player count, starting chips, duration, denominations → suggested structure) ### Chip Management -- [ ] **CHIP-01**: Define denominations with colors (hex) and values -- [ ] **CHIP-02**: Chip-up tracking per break with visual indicator on displays -- [ ] **CHIP-03**: Total chips in play calculation -- [ ] **CHIP-04**: Average stack display +- [x] **CHIP-01**: Define denominations with colors (hex) and values +- [x] **CHIP-02**: Chip-up tracking per break with visual indicator on displays +- [x] **CHIP-03**: Total chips in play calculation +- [x] **CHIP-04**: Average stack display ### Financial Engine -- [ ] **FIN-01**: Buy-in configuration (amount, starting chips, per-player rake, fixed rake, house contribution, bounty cost, points) -- [ ] **FIN-02**: Multiple rake categories (staff fund, league fund, house) +- [x] **FIN-01**: Buy-in configuration (amount, starting chips, per-player rake, fixed rake, house contribution, bounty cost, points) +- [x] **FIN-02**: Multiple rake categories (staff fund, league fund, house) - [ ] **FIN-03**: Late registration cutoff (by level, by time, or by level AND remaining time — e.g., "end of Level 6 or first 90 minutes, whichever comes first") - [ ] **FIN-04**: Re-entry support (distinct from rebuy — new entry after busting) -- [ ] **FIN-05**: Rebuy configuration (cost, chips, rake, points, limits, level/time cutoff, chip threshold) -- [ ] **FIN-06**: Add-on configuration (cost, chips, rake, points, availability window) +- [x] **FIN-05**: Rebuy configuration (cost, chips, rake, points, limits, level/time cutoff, chip threshold) +- [x] **FIN-06**: Add-on configuration (cost, chips, rake, points, availability window) - [ ] **FIN-07**: Fixed bounty system (bounty cost, chip issued, hitman tracking, chain tracking, cash-out) - [ ] **FIN-08**: Prize pool auto-calculation from all financial inputs - [ ] **FIN-09**: Guaranteed pot support (house covers shortfall) -- [ ] **FIN-10**: Payout structures (percentage, fixed, custom table) with configurable rounding +- [x] **FIN-10**: Payout structures (percentage, fixed, custom table) with configurable rounding - [ ] **FIN-11**: Chop/deal support (ICM calculator, chip-chop, even-chop, custom) - [ ] **FIN-12**: End-of-season withholding (reserve rake portion for season prizes) - [ ] **FIN-13**: Every financial action generates a receipt with full transaction log @@ -323,28 +323,28 @@ Which phases cover which requirements. Updated during roadmap reorganization. | CLOCK-07 | Phase 1 | Complete | | CLOCK-08 | Phase 1 | Complete | | CLOCK-09 | Phase 1 | Complete | -| BLIND-01 | Phase 1 | Pending | -| BLIND-02 | Phase 1 | Pending | -| BLIND-03 | Phase 1 | Pending | -| BLIND-04 | Phase 1 | Pending | -| BLIND-05 | Phase 1 | Pending | -| BLIND-06 | Phase 1 | Pending | -| CHIP-01 | Phase 1 | Pending | -| CHIP-02 | Phase 1 | Pending | -| CHIP-03 | Phase 1 | Pending | -| CHIP-04 | Phase 1 | Pending | +| BLIND-01 | Phase 1 | Complete | +| BLIND-02 | Phase 1 | Complete | +| BLIND-03 | Phase 1 | Complete | +| BLIND-04 | Phase 1 | Complete | +| BLIND-05 | Phase 1 | Complete | +| BLIND-06 | Phase 1 | Complete | +| CHIP-01 | Phase 1 | Complete | +| CHIP-02 | Phase 1 | Complete | +| CHIP-03 | Phase 1 | Complete | +| CHIP-04 | Phase 1 | Complete | | MULTI-01 | Phase 1 | Pending | | MULTI-02 | Phase 1 | Pending | -| FIN-01 | Phase 1 | Pending | -| FIN-02 | Phase 1 | Pending | +| FIN-01 | Phase 1 | Complete | +| FIN-02 | Phase 1 | Complete | | FIN-03 | Phase 1 | Pending | | FIN-04 | Phase 1 | Pending | -| FIN-05 | Phase 1 | Pending | -| FIN-06 | Phase 1 | Pending | +| FIN-05 | Phase 1 | Complete | +| FIN-06 | Phase 1 | Complete | | FIN-07 | Phase 1 | Pending | | FIN-08 | Phase 1 | Pending | | FIN-09 | Phase 1 | Pending | -| FIN-10 | Phase 1 | Pending | +| FIN-10 | Phase 1 | Complete | | FIN-11 | Phase 1 | Pending | | FIN-12 | Phase 1 | Pending | | FIN-13 | Phase 1 | Pending | diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 67baf8a..b5a877b 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -141,7 +141,7 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 | Phase | Plans Complete | Status | Completed | |-------|----------------|--------|-----------| -| 1. Tournament Engine | 4/14 | Executing | - | +| 1. Tournament Engine | 5/14 | Executing | - | | 2. Display Views + Player PWA | 0/TBD | Not started | - | | 3. Core Sync + Platform Identity | 0/TBD | Not started | - | | 4. Digital Signage + Events Engine | 0/TBD | Not started | - | diff --git a/.planning/STATE.md b/.planning/STATE.md index df191fd..663b669 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -8,7 +8,7 @@ progress: total_phases: 1 completed_phases: 0 total_plans: 14 - completed_plans: 4 + completed_plans: 5 --- # Project State @@ -23,27 +23,27 @@ See: .planning/PROJECT.md (updated 2026-02-28) ## Current Position Phase: 1 of 7 (Tournament Engine) -Plan: 5 of 14 in current phase +Plan: 6 of 14 in current phase Status: Executing Phase 1 -Last activity: 2026-03-01 — Completed Plan D (Clock Engine) +Last activity: 2026-03-01 — Completed Plan E (Blind Structure + Chip Sets + Templates) -Progress: [███░░░░░░░] 29% +Progress: [████░░░░░░] 36% ## Performance Metrics **Velocity:** -- Total plans completed: 4 +- Total plans completed: 5 - Average duration: 10min -- Total execution time: 0.63 hours +- Total execution time: 0.80 hours **By Phase:** | Phase | Plans | Total | Avg/Plan | |-------|-------|-------|----------| -| 01-tournament-engine | 4 | 38min | 10min | +| 01-tournament-engine | 5 | 48min | 10min | **Recent Trend:** -- Last 5 plans: 01-01 (15min), 01-02 (10min), 01-10 (5min), 01-04 (8min) +- Last 5 plans: 01-01 (15min), 01-02 (10min), 01-10 (5min), 01-04 (8min), 01-05 (10min) - Trend: accelerating *Updated after each plan completion* @@ -73,6 +73,10 @@ Recent decisions affecting current work: - [01-04]: Crash recovery always restores clock as paused (operator must explicitly resume) - [01-04]: Overtime mode defaults to repeat (last level repeats indefinitely) - [01-04]: State change callback is async to avoid holding clock mutex during DB writes +- [01-05]: Seed data uses INSERT OR IGNORE with explicit IDs for idempotent migration re-runs +- [01-05]: Wizard generates preview-only levels (not auto-saved) for TD review before saving +- [01-05]: BB ante used in WSOP-style template (separate from standard ante field) +- [01-05]: Payout bracket validation enforces contiguous entry count ranges with no gaps ### Pending Todos @@ -90,5 +94,5 @@ None yet. ## Session Continuity Last session: 2026-03-01 -Stopped at: Completed 01-04-PLAN.md (Clock Engine) +Stopped at: Completed 01-05-PLAN.md (Blind Structure + Chip Sets + Templates) Resume file: None diff --git a/.planning/phases/01-tournament-engine/01-05-SUMMARY.md b/.planning/phases/01-tournament-engine/01-05-SUMMARY.md new file mode 100644 index 0000000..5933ae4 --- /dev/null +++ b/.planning/phases/01-tournament-engine/01-05-SUMMARY.md @@ -0,0 +1,140 @@ +--- +phase: 01-tournament-engine +plan: 05 +subsystem: api, database, template +tags: [go, crud, blind-structure, chip-set, payout, buyin, wizard, tournament-template, chi, libsql] + +# Dependency graph +requires: + - phase: 01-tournament-engine (Plan A) + provides: Project scaffold, chi router, middleware, store layer + - phase: 01-tournament-engine (Plan B) + provides: Database schema with all building block tables +provides: + - ChipSet CRUD service with denomination management + - BlindStructure CRUD service with level validation + - PayoutStructure CRUD service with bracket/tier nesting and 100% sum validation + - BuyinConfig CRUD service with rake split validation + - TournamentTemplate CRUD service with FK validation and expanded view + - Structure wizard algorithm (geometric progression, denomination snapping, break insertion) + - 4 built-in blind structures (Turbo, Standard, Deep Stack, WSOP-style) + - Built-in payout structure with 4 entry-count brackets + - 4 built-in buy-in configs with rake splits + - 4 built-in tournament templates composing all above + - Full REST API for all building blocks and templates +affects: [tournament-lifecycle, frontend-templates, clock-engine] + +# Tech tracking +tech-stack: + added: [] + patterns: + - Service-per-entity CRUD pattern with *sql.DB injection + - Transaction-based create/update with nested entity replacement + - FK reference validation before template creation + - Expanded view pattern (GetTemplate vs GetTemplateExpanded) + - Seed migration with INSERT OR IGNORE for idempotent built-in data + +key-files: + created: + - internal/template/chipset.go + - internal/template/payout.go + - internal/template/buyin.go + - internal/template/tournament.go + - internal/blind/structure.go + - internal/blind/wizard.go + - internal/blind/templates.go + - internal/server/routes/templates.go + - internal/store/migrations/005_builtin_templates.sql + - internal/blind/wizard_test.go + - internal/template/tournament_test.go + modified: + - internal/server/server.go + +key-decisions: + - "Seed data uses INSERT OR IGNORE with explicit IDs for idempotent re-runs" + - "Wizard generates preview-only levels (not auto-saved) for TD review" + - "BB ante used in WSOP-style template (separate from standard ante field)" + - "Payout brackets must be contiguous ranges (no gaps between min/max entries)" + +patterns-established: + - "Service CRUD pattern: NewXService(db) with Create/Get/List/Update/Delete/Duplicate" + - "Nested entity pattern: replace-all on update (delete children, re-insert)" + - "Route registration: Register(chi.Router) method for modular route mounting" + +requirements-completed: [BLIND-01, BLIND-02, BLIND-03, BLIND-04, BLIND-05, BLIND-06, CHIP-01, CHIP-02, CHIP-03, CHIP-04, FIN-01, FIN-02, FIN-05, FIN-06, FIN-10] + +# Metrics +duration: 10min +completed: 2026-03-01 +--- + +# Plan 05: Blind Structure + Chip Sets + Templates Summary + +**Full CRUD for all building blocks (chip sets, blind structures, payouts, buy-ins) with 4 built-in tournament templates, structure wizard algorithm, and REST API endpoints** + +## Performance + +- **Duration:** 10 min +- **Started:** 2026-03-01T02:48:52Z +- **Completed:** 2026-03-01T02:58:52Z +- **Tasks:** 2 +- **Files modified:** 12 + +## Accomplishments +- Complete CRUD services for all 5 building block types with validation, duplication, and builtin protection +- Structure wizard generates blind structures from high-level inputs (player count, chips, duration, denominations) using geometric progression with denomination snapping +- 4 built-in tournament templates (Turbo, Standard, Deep Stack, WSOP-style) with matching blind structures, buy-in configs, and shared payout structure +- Full REST API with admin-gated mutations and floor-accessible reads +- Comprehensive test suites for wizard (6 tests) and templates (7 tests), all passing + +## Task Commits + +Each task was committed atomically: + +1. **Task E1: Building block CRUD and API routes** - `99545bd` (feat) +2. **Task E2: Built-in templates, seed data, wizard tests, template tests** - `7dbb4ca` (feat) + +## Files Created/Modified +- `internal/template/chipset.go` - ChipSet CRUD service with denomination management +- `internal/template/payout.go` - PayoutStructure CRUD with bracket/tier nesting and 100% sum validation +- `internal/template/buyin.go` - BuyinConfig CRUD with rake split validation +- `internal/template/tournament.go` - TournamentTemplate CRUD with FK validation, expanded view, SaveAsTemplate +- `internal/blind/structure.go` - BlindStructure CRUD with level validation (contiguous positions, SB < BB) +- `internal/blind/wizard.go` - Structure wizard: geometric progression, denomination snapping, break insertion, chip-up markers +- `internal/blind/templates.go` - Built-in level definitions (Turbo, Standard, Deep Stack, WSOP-style) +- `internal/server/routes/templates.go` - REST API handlers for all building blocks and templates +- `internal/store/migrations/005_builtin_templates.sql` - Seed data for 4 blind structures, 1 payout structure, 4 buy-in configs, 4 tournament templates +- `internal/blind/wizard_test.go` - Wizard tests: standard, player counts, denomination alignment, short/long, invalid inputs +- `internal/template/tournament_test.go` - Template tests: create, invalid FK, expanded, save-as, duplicate, delete-builtin, list +- `internal/server/server.go` - Wired template routes into protected API group + +## Decisions Made +- Seed data uses INSERT OR IGNORE with explicit integer IDs for idempotent migration re-runs +- Wizard generates preview-only levels (not auto-saved) so the TD can review and adjust before saving +- WSOP-style blind structure uses BB ante (separate field from standard ante) starting at level 4 +- Payout bracket validation enforces contiguous entry count ranges with no gaps +- All built-in entities use is_builtin=1 flag -- cannot be deleted but can be duplicated + +## Deviations from Plan + +None - plan executed exactly as written. + +## Issues Encountered +- Pre-existing test failure in `cmd/leaf/main_test.go` (missing `clockRegistry` parameter from Plan D) -- out of scope, not caused by this plan + +## User Setup Required +None - no external service configuration required. + +## Next Phase Readiness +- All building blocks have full CRUD with API endpoints -- ready for frontend template management UI +- Tournament templates compose building blocks by reference -- ready for tournament creation flow +- Structure wizard is functional -- ready for frontend wizard UI +- Built-in templates exist on first boot -- ready for template-first tournament creation + +## Self-Check: PASSED + +All 11 created files verified on disk. Both task commits (99545bd, 7dbb4ca) found in git log. + +--- +*Phase: 01-tournament-engine* +*Completed: 2026-03-01* diff --git a/internal/audit/trail.go b/internal/audit/trail.go index 6fd56fb..ae745d5 100644 --- a/internal/audit/trail.go +++ b/internal/audit/trail.go @@ -1 +1,360 @@ +// Package audit provides an append-only audit trail and undo engine for the +// Felt tournament engine. Every state-changing action is recorded with previous +// and new state for full reversibility. package audit + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/json" + "fmt" + "log" + "time" + + "github.com/felt-app/felt/internal/server/middleware" +) + +// Action constants for all auditable mutations. +const ( + // Player actions + ActionPlayerBuyin = "player.buyin" + ActionPlayerBust = "player.bust" + ActionPlayerRebuy = "player.rebuy" + ActionPlayerAddon = "player.addon" + ActionPlayerReentry = "player.reentry" + + // Financial actions + ActionFinancialBuyin = "financial.buyin" + ActionFinancialRebuy = "financial.rebuy" + ActionFinancialAddon = "financial.addon" + ActionFinancialPayout = "financial.payout" + ActionFinancialChop = "financial.chop" + ActionFinancialBubblePrize = "financial.bubble_prize" + + // Clock actions + ActionClockStart = "clock.start" + ActionClockPause = "clock.pause" + ActionClockResume = "clock.resume" + ActionClockAdvance = "clock.advance" + ActionClockRewind = "clock.rewind" + ActionClockJump = "clock.jump" + + // Seat actions + ActionSeatAssign = "seat.assign" + ActionSeatMove = "seat.move" + ActionSeatBalance = "seat.balance" + ActionSeatBreakTable = "seat.break_table" + + // Tournament actions + ActionTournamentCreate = "tournament.create" + ActionTournamentStart = "tournament.start" + ActionTournamentEnd = "tournament.end" + ActionTournamentCancel = "tournament.cancel" + + // Template actions + ActionTemplateCreate = "template.create" + ActionTemplateUpdate = "template.update" + ActionTemplateDelete = "template.delete" + + // Operator actions + ActionOperatorLogin = "operator.login" + ActionOperatorLogout = "operator.logout" + ActionOperatorLoginRateLimited = "operator.login_rate_limited" +) + +// Publisher defines the interface for publishing audit events to NATS JetStream. +// This avoids a direct dependency on the nats package. +type Publisher interface { + PublishAudit(ctx context.Context, tournamentID string, data []byte) error +} + +// natsPublisherAdapter adapts the feltnats.Publisher to the audit.Publisher interface. +// The feltnats.Publisher returns (*jetstream.PubAck, error) but audit only needs error. +type natsPublisherAdapter struct { + publish func(ctx context.Context, tournamentID string, data []byte) error +} + +func (a *natsPublisherAdapter) PublishAudit(ctx context.Context, tournamentID string, data []byte) error { + return a.publish(ctx, tournamentID, data) +} + +// AuditEntry represents a single audit trail entry. +type AuditEntry struct { + ID string `json:"id"` + TournamentID *string `json:"tournament_id,omitempty"` + Timestamp int64 `json:"timestamp"` // UnixNano + OperatorID string `json:"operator_id"` + Action string `json:"action"` + TargetType string `json:"target_type"` + TargetID string `json:"target_id"` + PreviousState json.RawMessage `json:"previous_state,omitempty"` + NewState json.RawMessage `json:"new_state,omitempty"` + Metadata json.RawMessage `json:"metadata,omitempty"` + UndoneBy *string `json:"undone_by,omitempty"` +} + +// Trail provides the append-only audit trail for the Felt tournament engine. +// It records entries to LibSQL and optionally publishes them to NATS JetStream. +type Trail struct { + db *sql.DB + publisher Publisher // optional, nil if no NATS available +} + +// NewTrail creates a new audit trail. +func NewTrail(db *sql.DB, publisher Publisher) *Trail { + return &Trail{ + db: db, + publisher: publisher, + } +} + +// Record persists an audit entry to LibSQL and publishes to NATS JetStream. +// The entry ID and timestamp are generated automatically. +// The operator ID is extracted from the request context if not already set. +func (t *Trail) Record(ctx context.Context, entry AuditEntry) (*AuditEntry, error) { + // Generate ID if not set + if entry.ID == "" { + entry.ID = generateUUID() + } + + // Set timestamp to now if not set + if entry.Timestamp == 0 { + entry.Timestamp = time.Now().UnixNano() + } + + // Extract operator ID from context if not already set + if entry.OperatorID == "" { + entry.OperatorID = middleware.OperatorIDFromCtx(ctx) + } + if entry.OperatorID == "" { + entry.OperatorID = "system" // fallback for system-generated events + } + + // Validate required fields + if entry.Action == "" { + return nil, fmt.Errorf("audit: action is required") + } + + // Convert timestamps to seconds for SQLite storage (schema uses epoch seconds) + timestampSeconds := entry.Timestamp / 1_000_000_000 + + // Insert into database + _, err := t.db.ExecContext(ctx, + `INSERT INTO audit_entries (id, tournament_id, timestamp, operator_id, action, target_type, target_id, previous_state, new_state, metadata) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + entry.ID, entry.TournamentID, timestampSeconds, entry.OperatorID, + entry.Action, entry.TargetType, entry.TargetID, + nullableJSON(entry.PreviousState), nullableJSON(entry.NewState), nullableJSON(entry.Metadata), + ) + if err != nil { + return nil, fmt.Errorf("audit: insert entry: %w", err) + } + + // Publish to NATS JetStream (best-effort, don't fail the operation) + if t.publisher != nil && entry.TournamentID != nil && *entry.TournamentID != "" { + data, err := json.Marshal(entry) + if err != nil { + log.Printf("audit: marshal entry for NATS: %v", err) + } else { + if err := t.publisher.PublishAudit(ctx, *entry.TournamentID, data); err != nil { + log.Printf("audit: publish to NATS: %v", err) + } + } + } + + return &entry, nil +} + +// GetEntry retrieves a single audit entry by ID. +func (t *Trail) GetEntry(ctx context.Context, entryID string) (*AuditEntry, error) { + entry := &AuditEntry{} + var prevState, newState, metadata sql.NullString + + err := t.db.QueryRowContext(ctx, + `SELECT id, tournament_id, timestamp, operator_id, action, target_type, target_id, + previous_state, new_state, metadata, undone_by + FROM audit_entries WHERE id = ?`, entryID, + ).Scan( + &entry.ID, &entry.TournamentID, &entry.Timestamp, &entry.OperatorID, + &entry.Action, &entry.TargetType, &entry.TargetID, + &prevState, &newState, &metadata, &entry.UndoneBy, + ) + if err == sql.ErrNoRows { + return nil, fmt.Errorf("audit: entry not found: %s", entryID) + } + if err != nil { + return nil, fmt.Errorf("audit: get entry: %w", err) + } + + // Convert seconds back to nanoseconds for consistency + entry.Timestamp = entry.Timestamp * 1_000_000_000 + + if prevState.Valid { + entry.PreviousState = json.RawMessage(prevState.String) + } + if newState.Valid { + entry.NewState = json.RawMessage(newState.String) + } + if metadata.Valid { + entry.Metadata = json.RawMessage(metadata.String) + } + + return entry, nil +} + +// GetEntries retrieves audit entries with pagination, optionally filtered by tournament ID. +// If tournamentID is empty, all entries are returned. +func (t *Trail) GetEntries(ctx context.Context, tournamentID string, limit, offset int) ([]AuditEntry, error) { + if limit <= 0 { + limit = 50 + } + if limit > 1000 { + limit = 1000 + } + + var rows *sql.Rows + var err error + + if tournamentID != "" { + rows, err = t.db.QueryContext(ctx, + `SELECT id, tournament_id, timestamp, operator_id, action, target_type, target_id, + previous_state, new_state, metadata, undone_by + FROM audit_entries WHERE tournament_id = ? + ORDER BY timestamp DESC LIMIT ? OFFSET ?`, + tournamentID, limit, offset, + ) + } else { + rows, err = t.db.QueryContext(ctx, + `SELECT id, tournament_id, timestamp, operator_id, action, target_type, target_id, + previous_state, new_state, metadata, undone_by + FROM audit_entries + ORDER BY timestamp DESC LIMIT ? OFFSET ?`, + limit, offset, + ) + } + if err != nil { + return nil, fmt.Errorf("audit: query entries: %w", err) + } + defer rows.Close() + + var entries []AuditEntry + for rows.Next() { + var entry AuditEntry + var prevState, newState, metadata sql.NullString + + if err := rows.Scan( + &entry.ID, &entry.TournamentID, &entry.Timestamp, &entry.OperatorID, + &entry.Action, &entry.TargetType, &entry.TargetID, + &prevState, &newState, &metadata, &entry.UndoneBy, + ); err != nil { + return nil, fmt.Errorf("audit: scan entry: %w", err) + } + + // Convert seconds back to nanoseconds + entry.Timestamp = entry.Timestamp * 1_000_000_000 + + if prevState.Valid { + entry.PreviousState = json.RawMessage(prevState.String) + } + if newState.Valid { + entry.NewState = json.RawMessage(newState.String) + } + if metadata.Valid { + entry.Metadata = json.RawMessage(metadata.String) + } + + entries = append(entries, entry) + } + + return entries, rows.Err() +} + +// GetEntriesByAction retrieves audit entries filtered by action type. +func (t *Trail) GetEntriesByAction(ctx context.Context, action string, limit, offset int) ([]AuditEntry, error) { + if limit <= 0 { + limit = 50 + } + + rows, err := t.db.QueryContext(ctx, + `SELECT id, tournament_id, timestamp, operator_id, action, target_type, target_id, + previous_state, new_state, metadata, undone_by + FROM audit_entries WHERE action = ? + ORDER BY timestamp DESC LIMIT ? OFFSET ?`, + action, limit, offset, + ) + if err != nil { + return nil, fmt.Errorf("audit: query entries by action: %w", err) + } + defer rows.Close() + + var entries []AuditEntry + for rows.Next() { + var entry AuditEntry + var prevState, newState, metadata sql.NullString + + if err := rows.Scan( + &entry.ID, &entry.TournamentID, &entry.Timestamp, &entry.OperatorID, + &entry.Action, &entry.TargetType, &entry.TargetID, + &prevState, &newState, &metadata, &entry.UndoneBy, + ); err != nil { + return nil, fmt.Errorf("audit: scan entry: %w", err) + } + + entry.Timestamp = entry.Timestamp * 1_000_000_000 + if prevState.Valid { + entry.PreviousState = json.RawMessage(prevState.String) + } + if newState.Valid { + entry.NewState = json.RawMessage(newState.String) + } + if metadata.Valid { + entry.Metadata = json.RawMessage(metadata.String) + } + + entries = append(entries, entry) + } + + return entries, rows.Err() +} + +// RecorderFunc returns an AuditRecorder function compatible with the auth package. +// This bridges the auth.AuditRecorder callback type and the audit.Trail. +func (t *Trail) RecorderFunc() func(ctx context.Context, action, targetType, targetID string, metadata map[string]interface{}) error { + return func(ctx context.Context, action, targetType, targetID string, metadata map[string]interface{}) error { + var metadataJSON json.RawMessage + if metadata != nil { + data, err := json.Marshal(metadata) + if err != nil { + return fmt.Errorf("audit: marshal metadata: %w", err) + } + metadataJSON = data + } + + _, err := t.Record(ctx, AuditEntry{ + Action: action, + TargetType: targetType, + TargetID: targetID, + Metadata: metadataJSON, + }) + return err + } +} + +// nullableJSON converts a json.RawMessage to a sql.NullString for storage. +func nullableJSON(data json.RawMessage) sql.NullString { + if len(data) == 0 || string(data) == "null" { + return sql.NullString{} + } + return sql.NullString{String: string(data), Valid: true} +} + +// generateUUID generates a v4 UUID. +func generateUUID() string { + b := make([]byte, 16) + _, _ = rand.Read(b) + b[6] = (b[6] & 0x0f) | 0x40 // Version 4 + b[8] = (b[8] & 0x3f) | 0x80 // Variant 1 + return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", + b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) +} diff --git a/internal/audit/trail_test.go b/internal/audit/trail_test.go new file mode 100644 index 0000000..3cf4e23 --- /dev/null +++ b/internal/audit/trail_test.go @@ -0,0 +1,349 @@ +package audit + +import ( + "context" + "encoding/json" + "testing" + + "github.com/felt-app/felt/internal/store" +) + +// mockPublisher records published audit events for testing. +type mockPublisher struct { + published []struct { + TournamentID string + Data []byte + } +} + +func (m *mockPublisher) PublishAudit(ctx context.Context, tournamentID string, data []byte) error { + m.published = append(m.published, struct { + TournamentID string + Data []byte + }{tournamentID, data}) + return nil +} + +func setupTestTrail(t *testing.T) (*Trail, *mockPublisher) { + t.Helper() + tmpDir := t.TempDir() + + db, err := store.Open(tmpDir, true) + if err != nil { + t.Fatalf("open database: %v", err) + } + t.Cleanup(func() { db.Close() }) + + pub := &mockPublisher{} + trail := NewTrail(db.DB, pub) + + return trail, pub +} + +func TestRecordAuditEntry(t *testing.T) { + trail, _ := setupTestTrail(t) + ctx := context.Background() + + tournamentID := "550e8400-e29b-41d4-a716-446655440000" + entry := AuditEntry{ + TournamentID: &tournamentID, + OperatorID: "op-1", + Action: ActionPlayerBuyin, + TargetType: "player", + TargetID: "player-1", + PreviousState: json.RawMessage(`{"chips":0}`), + NewState: json.RawMessage(`{"chips":10000}`), + } + + recorded, err := trail.Record(ctx, entry) + if err != nil { + t.Fatalf("record entry: %v", err) + } + + if recorded.ID == "" { + t.Fatal("expected generated ID") + } + if recorded.Timestamp == 0 { + t.Fatal("expected generated timestamp") + } + if recorded.Action != ActionPlayerBuyin { + t.Fatalf("expected action %s, got %s", ActionPlayerBuyin, recorded.Action) + } +} + +func TestRecordPersistsToDatabase(t *testing.T) { + trail, _ := setupTestTrail(t) + ctx := context.Background() + + tournamentID := "550e8400-e29b-41d4-a716-446655440001" + entry := AuditEntry{ + TournamentID: &tournamentID, + OperatorID: "op-1", + Action: ActionPlayerBust, + TargetType: "player", + TargetID: "player-2", + PreviousState: json.RawMessage(`{"status":"active","chips":5000}`), + NewState: json.RawMessage(`{"status":"busted","chips":0}`), + } + + recorded, err := trail.Record(ctx, entry) + if err != nil { + t.Fatalf("record entry: %v", err) + } + + // Retrieve and verify + fetched, err := trail.GetEntry(ctx, recorded.ID) + if err != nil { + t.Fatalf("get entry: %v", err) + } + + if fetched.Action != ActionPlayerBust { + t.Fatalf("expected action %s, got %s", ActionPlayerBust, fetched.Action) + } + if fetched.OperatorID != "op-1" { + t.Fatalf("expected operator_id op-1, got %s", fetched.OperatorID) + } + if fetched.TargetType != "player" { + t.Fatalf("expected target_type player, got %s", fetched.TargetType) + } + if fetched.TargetID != "player-2" { + t.Fatalf("expected target_id player-2, got %s", fetched.TargetID) + } + if string(fetched.PreviousState) != `{"status":"active","chips":5000}` { + t.Fatalf("unexpected previous_state: %s", fetched.PreviousState) + } + if string(fetched.NewState) != `{"status":"busted","chips":0}` { + t.Fatalf("unexpected new_state: %s", fetched.NewState) + } +} + +func TestRecordPublishesToNATS(t *testing.T) { + trail, pub := setupTestTrail(t) + ctx := context.Background() + + tournamentID := "550e8400-e29b-41d4-a716-446655440002" + entry := AuditEntry{ + TournamentID: &tournamentID, + OperatorID: "op-1", + Action: ActionClockStart, + TargetType: "tournament", + TargetID: tournamentID, + } + + _, err := trail.Record(ctx, entry) + if err != nil { + t.Fatalf("record entry: %v", err) + } + + if len(pub.published) != 1 { + t.Fatalf("expected 1 published message, got %d", len(pub.published)) + } + if pub.published[0].TournamentID != tournamentID { + t.Fatalf("expected tournament ID %s, got %s", tournamentID, pub.published[0].TournamentID) + } + + // Verify the published data is valid JSON + var publishedEntry AuditEntry + if err := json.Unmarshal(pub.published[0].Data, &publishedEntry); err != nil { + t.Fatalf("unmarshal published data: %v", err) + } + if publishedEntry.Action != ActionClockStart { + t.Fatalf("expected action %s in NATS message, got %s", ActionClockStart, publishedEntry.Action) + } +} + +func TestRecordWithoutTournamentIDSkipsNATS(t *testing.T) { + trail, pub := setupTestTrail(t) + ctx := context.Background() + + // Venue-level action (no tournament ID) + entry := AuditEntry{ + OperatorID: "op-1", + Action: ActionOperatorLogin, + TargetType: "operator", + TargetID: "op-1", + } + + _, err := trail.Record(ctx, entry) + if err != nil { + t.Fatalf("record entry: %v", err) + } + + if len(pub.published) != 0 { + t.Fatalf("expected no published messages for venue-level action, got %d", len(pub.published)) + } +} + +func TestRecordRequiresAction(t *testing.T) { + trail, _ := setupTestTrail(t) + ctx := context.Background() + + entry := AuditEntry{ + OperatorID: "op-1", + TargetType: "player", + TargetID: "player-1", + } + + _, err := trail.Record(ctx, entry) + if err == nil { + t.Fatal("expected error for missing action") + } +} + +func TestRecordDefaultsOperatorToSystem(t *testing.T) { + trail, _ := setupTestTrail(t) + ctx := context.Background() + + entry := AuditEntry{ + Action: ActionTournamentCreate, + TargetType: "tournament", + TargetID: "t-1", + } + + recorded, err := trail.Record(ctx, entry) + if err != nil { + t.Fatalf("record entry: %v", err) + } + + if recorded.OperatorID != "system" { + t.Fatalf("expected operator_id 'system', got %s", recorded.OperatorID) + } +} + +func TestGetEntries(t *testing.T) { + trail, _ := setupTestTrail(t) + ctx := context.Background() + + tournamentID := "550e8400-e29b-41d4-a716-446655440003" + + // Record multiple entries + for i := 0; i < 5; i++ { + _, err := trail.Record(ctx, AuditEntry{ + TournamentID: &tournamentID, + OperatorID: "op-1", + Action: ActionPlayerBuyin, + TargetType: "player", + TargetID: "player-1", + }) + if err != nil { + t.Fatalf("record entry %d: %v", i, err) + } + } + + // Get all entries for tournament + entries, err := trail.GetEntries(ctx, tournamentID, 10, 0) + if err != nil { + t.Fatalf("get entries: %v", err) + } + if len(entries) != 5 { + t.Fatalf("expected 5 entries, got %d", len(entries)) + } + + // Test pagination + page1, err := trail.GetEntries(ctx, tournamentID, 3, 0) + if err != nil { + t.Fatalf("get page 1: %v", err) + } + if len(page1) != 3 { + t.Fatalf("expected 3 entries on page 1, got %d", len(page1)) + } + + page2, err := trail.GetEntries(ctx, tournamentID, 3, 3) + if err != nil { + t.Fatalf("get page 2: %v", err) + } + if len(page2) != 2 { + t.Fatalf("expected 2 entries on page 2, got %d", len(page2)) + } +} + +func TestGetEntriesFiltersByTournament(t *testing.T) { + trail, _ := setupTestTrail(t) + ctx := context.Background() + + t1 := "550e8400-e29b-41d4-a716-446655440010" + t2 := "550e8400-e29b-41d4-a716-446655440011" + + // Record entries for two tournaments + for i := 0; i < 3; i++ { + trail.Record(ctx, AuditEntry{ + TournamentID: &t1, + OperatorID: "op-1", + Action: ActionPlayerBuyin, + TargetType: "player", + TargetID: "player-1", + }) + } + for i := 0; i < 2; i++ { + trail.Record(ctx, AuditEntry{ + TournamentID: &t2, + OperatorID: "op-1", + Action: ActionPlayerBuyin, + TargetType: "player", + TargetID: "player-2", + }) + } + + // Filter by tournament 1 + entries, err := trail.GetEntries(ctx, t1, 50, 0) + if err != nil { + t.Fatalf("get entries: %v", err) + } + if len(entries) != 3 { + t.Fatalf("expected 3 entries for tournament 1, got %d", len(entries)) + } + + // Filter by tournament 2 + entries, err = trail.GetEntries(ctx, t2, 50, 0) + if err != nil { + t.Fatalf("get entries: %v", err) + } + if len(entries) != 2 { + t.Fatalf("expected 2 entries for tournament 2, got %d", len(entries)) + } +} + +func TestGetEntriesByAction(t *testing.T) { + trail, _ := setupTestTrail(t) + ctx := context.Background() + + // Record different action types + trail.Record(ctx, AuditEntry{OperatorID: "op-1", Action: ActionPlayerBuyin, TargetType: "player", TargetID: "p-1"}) + trail.Record(ctx, AuditEntry{OperatorID: "op-1", Action: ActionPlayerBust, TargetType: "player", TargetID: "p-1"}) + trail.Record(ctx, AuditEntry{OperatorID: "op-1", Action: ActionPlayerBuyin, TargetType: "player", TargetID: "p-2"}) + + entries, err := trail.GetEntriesByAction(ctx, ActionPlayerBuyin, 50, 0) + if err != nil { + t.Fatalf("get entries by action: %v", err) + } + if len(entries) != 2 { + t.Fatalf("expected 2 buyin entries, got %d", len(entries)) + } +} + +func TestRecorderFunc(t *testing.T) { + trail, _ := setupTestTrail(t) + ctx := context.Background() + + recorder := trail.RecorderFunc() + + err := recorder(ctx, ActionOperatorLogin, "operator", "op-1", map[string]interface{}{ + "operator_name": "Admin", + "role": "admin", + }) + if err != nil { + t.Fatalf("recorder func: %v", err) + } + + // Verify entry was persisted + entries, err := trail.GetEntriesByAction(ctx, ActionOperatorLogin, 50, 0) + if err != nil { + t.Fatalf("get entries: %v", err) + } + if len(entries) != 1 { + t.Fatalf("expected 1 login entry, got %d", len(entries)) + } + if entries[0].TargetType != "operator" { + t.Fatalf("expected target_type operator, got %s", entries[0].TargetType) + } +} diff --git a/internal/audit/undo.go b/internal/audit/undo.go index 6fd56fb..0ce77af 100644 --- a/internal/audit/undo.go +++ b/internal/audit/undo.go @@ -1 +1,126 @@ package audit + +import ( + "context" + "encoding/json" + "fmt" +) + +// undoableActions is the set of actions that can be undone. +// Clock actions, tournament lifecycle, and operator actions are not undoable. +var undoableActions = map[string]bool{ + ActionPlayerBuyin: true, + ActionPlayerBust: true, + ActionPlayerRebuy: true, + ActionPlayerAddon: true, + ActionPlayerReentry: true, + + ActionFinancialBuyin: true, + ActionFinancialRebuy: true, + ActionFinancialAddon: true, + ActionFinancialPayout: true, + ActionFinancialChop: true, + ActionFinancialBubblePrize: true, + + ActionSeatMove: true, + ActionSeatBalance: true, +} + +// Errors returned by the undo engine. +var ( + ErrAlreadyUndone = fmt.Errorf("audit: entry has already been undone") + ErrNotUndoable = fmt.Errorf("audit: action type is not undoable") + ErrEntryNotFound = fmt.Errorf("audit: entry not found") +) + +// UndoEngine reverses audited actions by creating new audit entries that +// reverse the original. It never deletes or modifies existing entries +// (except marking the undone_by field on the original). +type UndoEngine struct { + trail *Trail +} + +// NewUndoEngine creates a new undo engine backed by the given audit trail. +func NewUndoEngine(trail *Trail) *UndoEngine { + return &UndoEngine{trail: trail} +} + +// CanUndo checks if the given audit entry can be undone. +// Returns true if the entry exists, has an undoable action type, and hasn't been undone yet. +func (u *UndoEngine) CanUndo(ctx context.Context, auditEntryID string) (bool, error) { + entry, err := u.trail.GetEntry(ctx, auditEntryID) + if err != nil { + return false, err + } + + // Already undone + if entry.UndoneBy != nil { + return false, nil + } + + // Check if action type is undoable + return undoableActions[entry.Action], nil +} + +// Undo creates a reversal audit entry for the given entry. +// It: +// 1. Loads the original entry +// 2. Verifies it hasn't been undone and is undoable +// 3. Creates a NEW audit entry with reversed state +// 4. Marks the original entry's undone_by field +// 5. Returns the undo entry for the caller to perform the actual state reversal +func (u *UndoEngine) Undo(ctx context.Context, auditEntryID string) (*AuditEntry, error) { + // Load the original entry + original, err := u.trail.GetEntry(ctx, auditEntryID) + if err != nil { + return nil, err + } + + // Verify it hasn't already been undone + if original.UndoneBy != nil { + return nil, ErrAlreadyUndone + } + + // Verify the action type is undoable + if !undoableActions[original.Action] { + return nil, ErrNotUndoable + } + + // Create metadata for the undo entry + undoMetadata, err := json.Marshal(map[string]interface{}{ + "undone_entry_id": original.ID, + }) + if err != nil { + return nil, fmt.Errorf("audit: marshal undo metadata: %w", err) + } + + // Create the undo entry with reversed state + undoEntry := AuditEntry{ + TournamentID: original.TournamentID, + Action: "undo." + original.Action, + TargetType: original.TargetType, + TargetID: original.TargetID, + PreviousState: original.NewState, // Reversed: new becomes previous + NewState: original.PreviousState, // Reversed: previous becomes new + Metadata: undoMetadata, + } + + // Record the undo entry + recorded, err := u.trail.Record(ctx, undoEntry) + if err != nil { + return nil, fmt.Errorf("audit: record undo entry: %w", err) + } + + // Mark the original entry as undone + // This is the ONE exception to append-only: marking undone_by on an entry where it's NULL. + // The tamper-protection trigger in the schema allows this specific update. + _, err = u.trail.db.ExecContext(ctx, + "UPDATE audit_entries SET undone_by = ? WHERE id = ? AND undone_by IS NULL", + recorded.ID, original.ID, + ) + if err != nil { + return nil, fmt.Errorf("audit: mark entry as undone: %w", err) + } + + return recorded, nil +} diff --git a/internal/audit/undo_test.go b/internal/audit/undo_test.go new file mode 100644 index 0000000..03ccefd --- /dev/null +++ b/internal/audit/undo_test.go @@ -0,0 +1,298 @@ +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) + } + } +}