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