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>
This commit is contained in:
parent
7dbb4cab1a
commit
1978d3d421
8 changed files with 1315 additions and 40 deletions
|
|
@ -43,32 +43,32 @@ Requirements for Phase 1 (Development Focus: Live Tournament Management). Each m
|
||||||
|
|
||||||
### Blind Structure
|
### Blind Structure
|
||||||
|
|
||||||
- [ ] **BLIND-01**: Unlimited configurable levels (round or break, game type, SB/BB, ante, duration, chip-up, notes)
|
- [x] **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
|
- [x] **BLIND-02**: Big Blind Ante support alongside standard ante
|
||||||
- [ ] **BLIND-03**: Mixed game rotation support (HORSE, 8-Game round definitions)
|
- [x] **BLIND-03**: Mixed game rotation support (HORSE, 8-Game round definitions)
|
||||||
- [ ] **BLIND-04**: Save/load reusable blind structure templates
|
- [x] **BLIND-04**: Save/load reusable blind structure templates
|
||||||
- [ ] **BLIND-05**: Built-in templates (Turbo ~2hr, Standard ~3-4hr, Deep Stack ~5-6hr, WSOP-style)
|
- [x] **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-06**: Structure wizard (inputs: player count, starting chips, duration, denominations → suggested structure)
|
||||||
|
|
||||||
### Chip Management
|
### Chip Management
|
||||||
|
|
||||||
- [ ] **CHIP-01**: Define denominations with colors (hex) and values
|
- [x] **CHIP-01**: Define denominations with colors (hex) and values
|
||||||
- [ ] **CHIP-02**: Chip-up tracking per break with visual indicator on displays
|
- [x] **CHIP-02**: Chip-up tracking per break with visual indicator on displays
|
||||||
- [ ] **CHIP-03**: Total chips in play calculation
|
- [x] **CHIP-03**: Total chips in play calculation
|
||||||
- [ ] **CHIP-04**: Average stack display
|
- [x] **CHIP-04**: Average stack display
|
||||||
|
|
||||||
### Financial Engine
|
### Financial Engine
|
||||||
|
|
||||||
- [ ] **FIN-01**: Buy-in configuration (amount, starting chips, per-player rake, fixed rake, house contribution, bounty cost, points)
|
- [x] **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-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-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-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)
|
- [x] **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-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-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-08**: Prize pool auto-calculation from all financial inputs
|
||||||
- [ ] **FIN-09**: Guaranteed pot support (house covers shortfall)
|
- [ ] **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-11**: Chop/deal support (ICM calculator, chip-chop, even-chop, custom)
|
||||||
- [ ] **FIN-12**: End-of-season withholding (reserve rake portion for season prizes)
|
- [ ] **FIN-12**: End-of-season withholding (reserve rake portion for season prizes)
|
||||||
- [ ] **FIN-13**: Every financial action generates a receipt with full transaction log
|
- [ ] **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-07 | Phase 1 | Complete |
|
||||||
| CLOCK-08 | Phase 1 | Complete |
|
| CLOCK-08 | Phase 1 | Complete |
|
||||||
| CLOCK-09 | Phase 1 | Complete |
|
| CLOCK-09 | Phase 1 | Complete |
|
||||||
| BLIND-01 | Phase 1 | Pending |
|
| BLIND-01 | Phase 1 | Complete |
|
||||||
| BLIND-02 | Phase 1 | Pending |
|
| BLIND-02 | Phase 1 | Complete |
|
||||||
| BLIND-03 | Phase 1 | Pending |
|
| BLIND-03 | Phase 1 | Complete |
|
||||||
| BLIND-04 | Phase 1 | Pending |
|
| BLIND-04 | Phase 1 | Complete |
|
||||||
| BLIND-05 | Phase 1 | Pending |
|
| BLIND-05 | Phase 1 | Complete |
|
||||||
| BLIND-06 | Phase 1 | Pending |
|
| BLIND-06 | Phase 1 | Complete |
|
||||||
| CHIP-01 | Phase 1 | Pending |
|
| CHIP-01 | Phase 1 | Complete |
|
||||||
| CHIP-02 | Phase 1 | Pending |
|
| CHIP-02 | Phase 1 | Complete |
|
||||||
| CHIP-03 | Phase 1 | Pending |
|
| CHIP-03 | Phase 1 | Complete |
|
||||||
| CHIP-04 | Phase 1 | Pending |
|
| CHIP-04 | Phase 1 | Complete |
|
||||||
| MULTI-01 | Phase 1 | Pending |
|
| MULTI-01 | Phase 1 | Pending |
|
||||||
| MULTI-02 | Phase 1 | Pending |
|
| MULTI-02 | Phase 1 | Pending |
|
||||||
| FIN-01 | Phase 1 | Pending |
|
| FIN-01 | Phase 1 | Complete |
|
||||||
| FIN-02 | Phase 1 | Pending |
|
| FIN-02 | Phase 1 | Complete |
|
||||||
| FIN-03 | Phase 1 | Pending |
|
| FIN-03 | Phase 1 | Pending |
|
||||||
| FIN-04 | Phase 1 | Pending |
|
| FIN-04 | Phase 1 | Pending |
|
||||||
| FIN-05 | Phase 1 | Pending |
|
| FIN-05 | Phase 1 | Complete |
|
||||||
| FIN-06 | Phase 1 | Pending |
|
| FIN-06 | Phase 1 | Complete |
|
||||||
| FIN-07 | Phase 1 | Pending |
|
| FIN-07 | Phase 1 | Pending |
|
||||||
| FIN-08 | Phase 1 | Pending |
|
| FIN-08 | Phase 1 | Pending |
|
||||||
| FIN-09 | Phase 1 | Pending |
|
| FIN-09 | Phase 1 | Pending |
|
||||||
| FIN-10 | Phase 1 | Pending |
|
| FIN-10 | Phase 1 | Complete |
|
||||||
| FIN-11 | Phase 1 | Pending |
|
| FIN-11 | Phase 1 | Pending |
|
||||||
| FIN-12 | Phase 1 | Pending |
|
| FIN-12 | Phase 1 | Pending |
|
||||||
| FIN-13 | Phase 1 | Pending |
|
| FIN-13 | Phase 1 | Pending |
|
||||||
|
|
|
||||||
|
|
@ -141,7 +141,7 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7
|
||||||
|
|
||||||
| Phase | Plans Complete | Status | Completed |
|
| 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 | - |
|
| 2. Display Views + Player PWA | 0/TBD | Not started | - |
|
||||||
| 3. Core Sync + Platform Identity | 0/TBD | Not started | - |
|
| 3. Core Sync + Platform Identity | 0/TBD | Not started | - |
|
||||||
| 4. Digital Signage + Events Engine | 0/TBD | Not started | - |
|
| 4. Digital Signage + Events Engine | 0/TBD | Not started | - |
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ progress:
|
||||||
total_phases: 1
|
total_phases: 1
|
||||||
completed_phases: 0
|
completed_phases: 0
|
||||||
total_plans: 14
|
total_plans: 14
|
||||||
completed_plans: 4
|
completed_plans: 5
|
||||||
---
|
---
|
||||||
|
|
||||||
# Project State
|
# Project State
|
||||||
|
|
@ -23,27 +23,27 @@ See: .planning/PROJECT.md (updated 2026-02-28)
|
||||||
## Current Position
|
## Current Position
|
||||||
|
|
||||||
Phase: 1 of 7 (Tournament Engine)
|
Phase: 1 of 7 (Tournament Engine)
|
||||||
Plan: 5 of 14 in current phase
|
Plan: 6 of 14 in current phase
|
||||||
Status: Executing Phase 1
|
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
|
## Performance Metrics
|
||||||
|
|
||||||
**Velocity:**
|
**Velocity:**
|
||||||
- Total plans completed: 4
|
- Total plans completed: 5
|
||||||
- Average duration: 10min
|
- Average duration: 10min
|
||||||
- Total execution time: 0.63 hours
|
- Total execution time: 0.80 hours
|
||||||
|
|
||||||
**By Phase:**
|
**By Phase:**
|
||||||
|
|
||||||
| Phase | Plans | Total | Avg/Plan |
|
| Phase | Plans | Total | Avg/Plan |
|
||||||
|-------|-------|-------|----------|
|
|-------|-------|-------|----------|
|
||||||
| 01-tournament-engine | 4 | 38min | 10min |
|
| 01-tournament-engine | 5 | 48min | 10min |
|
||||||
|
|
||||||
**Recent Trend:**
|
**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
|
- Trend: accelerating
|
||||||
|
|
||||||
*Updated after each plan completion*
|
*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]: Crash recovery always restores clock as paused (operator must explicitly resume)
|
||||||
- [01-04]: Overtime mode defaults to repeat (last level repeats indefinitely)
|
- [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-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
|
### Pending Todos
|
||||||
|
|
||||||
|
|
@ -90,5 +94,5 @@ None yet.
|
||||||
## Session Continuity
|
## Session Continuity
|
||||||
|
|
||||||
Last session: 2026-03-01
|
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
|
Resume file: None
|
||||||
|
|
|
||||||
140
.planning/phases/01-tournament-engine/01-05-SUMMARY.md
Normal file
140
.planning/phases/01-tournament-engine/01-05-SUMMARY.md
Normal file
|
|
@ -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*
|
||||||
|
|
@ -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
|
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])
|
||||||
|
}
|
||||||
|
|
|
||||||
349
internal/audit/trail_test.go
Normal file
349
internal/audit/trail_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1 +1,126 @@
|
||||||
package audit
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
298
internal/audit/undo_test.go
Normal file
298
internal/audit/undo_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue