Files
gogobee/internal/plugin/audit_fixes_test.go
prosolis 8afd9494fe Adv 2.0 Phase L2 step 6: arena render reads → DnDCharacter
renderArenaStreakEntry now takes *DnDCharacter and reads c.Level
instead of AdventureCharacter.CombatLevel. renderArenaStatus drops
its unused *AdventureCharacter arg. Callers in handleArenaMenu /
handleArenaStatus updated; menu path uses ensureCharForDnDCmd to
fetch (or auto-migrate) the sheet. Tests switched accordingly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00

326 lines
10 KiB
Go

package plugin
import (
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
// ── #1: Credit/Debit reject non-positive amounts ───────────────────────────
// These can't call the real Debit/Credit (they need a DB), so we test the
// guard logic by verifying the internal credit() helper is never reached
// with bad amounts. We test the normalizeExpression path instead, and rely
// on the exported method signatures for the guard.
// TestNormalizeExpression_NoMutation ensures normalizeExpression doesn't
// corrupt valid expressions (regression guard for calc changes).
func TestNormalizeExpression_NoMutation_Audit(t *testing.T) {
// Already-numeric expressions should pass through unmangled.
got := normalizeExpression("100 + 200")
if got != "100 + 200" {
t.Errorf("normalizeExpression mangled numeric expression: %q", got)
}
}
// ── #6: safeGo recovers panics ─────────────────────────────────────────────
func TestSafeGo_RecoversPanic(t *testing.T) {
var recovered atomic.Bool
done := make(chan struct{})
// We can't directly observe the slog.Error from safeGo, but we can
// verify the goroutine doesn't kill the test process.
safeGo("test-panic", func() {
defer func() { close(done) }()
recovered.Store(true)
panic("intentional test panic")
})
select {
case <-done:
// The goroutine ran (recovered.Store happened before panic).
if !recovered.Load() {
t.Error("safeGo function body did not execute")
}
case <-time.After(2 * time.Second):
t.Fatal("safeGo goroutine did not complete within timeout")
}
}
func TestSafeGo_NormalExecution(t *testing.T) {
var result atomic.Int64
done := make(chan struct{})
safeGo("test-normal", func() {
result.Store(42)
close(done)
})
select {
case <-done:
if result.Load() != 42 {
t.Errorf("safeGo result = %d, want 42", result.Load())
}
case <-time.After(2 * time.Second):
t.Fatal("safeGo goroutine did not complete within timeout")
}
}
func TestSafeGo_NilPanic(t *testing.T) {
done := make(chan struct{})
safeGo("test-nil-panic", func() {
defer func() { close(done) }()
panic(nil)
})
select {
case <-done:
// didn't crash — success
case <-time.After(2 * time.Second):
t.Fatal("safeGo did not recover from nil panic")
}
}
// ── #9: Arena bail channel ownership transfer ──────────────────────────────
// Verify that the LoadAndDelete pattern prevents double-close panics.
func TestArenaBailChannel_OwnershipTransfer(t *testing.T) {
var m sync.Map
// Simulate: countdown stores channel
bailCh := make(chan struct{})
m.Store("user1", bailCh)
// Simulate: bail handler claims it via LoadAndDelete
ch, ok := m.LoadAndDelete("user1")
if !ok {
t.Fatal("LoadAndDelete should succeed first time")
}
close(ch.(chan struct{}))
// Simulate: countdown timer expires, tries LoadAndDelete — should fail
_, ok = m.LoadAndDelete("user1")
if ok {
t.Error("second LoadAndDelete should fail — bail handler already claimed it")
}
}
func TestArenaBailChannel_TimerExpires_BailLate(t *testing.T) {
var m sync.Map
bailCh := make(chan struct{})
m.Store("user1", bailCh)
// Timer expires — countdown claims it
_, ok := m.LoadAndDelete("user1")
if !ok {
t.Fatal("countdown should claim channel")
}
// Bail comes late — should find nothing
_, ok = m.LoadAndDelete("user1")
if ok {
t.Error("late bail should find nothing — countdown already claimed")
}
}
// ── #13: Index DDL is valid SQL ────────────────────────────────────────────
// Verify the index creation statements parse (they'll be tested at schema
// init time, but this catches obvious syntax errors in the string).
func TestIndexDDL_ContainsNewIndexes(t *testing.T) {
// Verify the arena status render works for both states that the new
// idx_arena_runs_user(user_id, status) index covers.
run := &ArenaRun{
Tier: 2,
Round: 3,
Status: "active",
Earnings: 500,
TierEarnings: 100,
}
status := renderArenaStatus(run)
if !strings.Contains(status, "Tier: 2") {
t.Error("renderArenaStatus should show tier")
}
if !strings.Contains(status, "fight") {
t.Error("renderArenaStatus should show fight command for active runs")
}
// Awaiting state
run.Status = "awaiting"
status = renderArenaStatus(run)
if !strings.Contains(status, "bail") {
t.Error("renderArenaStatus should show bail command for awaiting runs")
}
}
// ── #15: JSON unmarshal error handling ──────────────────────────────────────
// We can't test lottery_db directly (needs DB), but we can verify the
// countMatches and formatLotteryNumbers handle edge cases that would
// surface from corrupt JSON.
func TestCountMatches_EmptySlices(t *testing.T) {
if got := countMatches([]int{}, []int{1, 2, 3}); got != 0 {
t.Errorf("countMatches(empty, winning) = %d, want 0", got)
}
if got := countMatches([]int{1, 2, 3}, []int{}); got != 0 {
t.Errorf("countMatches(ticket, empty) = %d, want 0", got)
}
if got := countMatches([]int{}, []int{}); got != 0 {
t.Errorf("countMatches(empty, empty) = %d, want 0", got)
}
}
func TestFormatLotteryNumbers_Empty(t *testing.T) {
got := formatLotteryNumbers([]int{})
if got != "" {
t.Errorf("formatLotteryNumbers(empty) = %q, want empty", got)
}
}
func TestFormatLotteryNumbers_Single(t *testing.T) {
got := formatLotteryNumbers([]int{7})
if got != "7" {
t.Errorf("formatLotteryNumbers([7]) = %q, want %q", got, "7")
}
}
// ── #16: Error messages don't leak internals ───────────────────────────────
func TestRenderArenaDeath_NoInternalLeak(t *testing.T) {
tier := &ArenaTier{Number: 3, Name: "The Gauntlet"}
monster := &ArenaMonster{Name: "Shadow Beast"}
msg := renderArenaDeath(tier, 2, monster, 1500, "The beast was faster.")
// Should contain user-facing info
if !strings.Contains(msg, "DEAD") {
t.Error("death message should say DEAD")
}
if !strings.Contains(msg, "€1,500") && !strings.Contains(msg, "1500") {
// Check for either comma-formatted or raw
if !strings.Contains(msg, "Forfeited") {
t.Error("death message should mention forfeited earnings")
}
}
if !strings.Contains(msg, "midnight UTC") {
t.Error("death message should mention respawn time")
}
// Should NOT contain stack traces or internal error patterns
for _, bad := range []string{"panic", "goroutine", "runtime.", ".go:", "err="} {
if strings.Contains(msg, bad) {
t.Errorf("death message should not contain internal detail %q", bad)
}
}
}
func TestRenderArenaStatus_NoInternalLeak(t *testing.T) {
run := &ArenaRun{
Tier: 1,
Round: 2,
Status: "active",
Earnings: 0,
TierEarnings: 50,
}
msg := renderArenaStatus(run)
for _, bad := range []string{"panic", "goroutine", "runtime.", "err="} {
if strings.Contains(msg, bad) {
t.Errorf("status message should not contain internal detail %q", bad)
}
}
}
// ── #20/#21: Error messages have actionable guidance ────────────────────────
func TestRenderArenaAlreadyInRun_HasGuidance(t *testing.T) {
// Active state
run := &ArenaRun{Tier: 2, Round: 3, Status: "active"}
msg := renderArenaAlreadyInRun(run)
if !strings.Contains(msg, "!arena fight") {
t.Error("already-in-run (active) should tell user to !arena fight")
}
// Awaiting state
run.Status = "awaiting"
run.Earnings = 500
msg = renderArenaAlreadyInRun(run)
if !strings.Contains(msg, "!bail") {
t.Error("already-in-run (awaiting) should tell user to !bail")
}
}
func TestArenaHelpText_HasAllCommands(t *testing.T) {
required := []string{"!arena", "!arena fight", "!bail", "!arena status", "!arena stats", "!arena leaderboard", "!arena help"}
for _, cmd := range required {
if !strings.Contains(arenaHelpText, cmd) {
t.Errorf("arena help text missing command %q", cmd)
}
}
}
// ── #17: Integer overflow edge cases ───────────────────────────────────────
func TestArenaRoundReward_LargeTier(t *testing.T) {
tier := &ArenaTier{BasePayout: 1000, SkillMultiplier: 2.0}
reward := arenaRoundReward(tier, 4, 100)
expected := int64(1000*4) + int64(float64(100)*2.0)
if reward != expected {
t.Errorf("arenaRoundReward = %d, want %d", reward, expected)
}
if reward < 0 {
t.Error("arenaRoundReward should not overflow to negative")
}
}
func TestFormatNumber_MaxInt(t *testing.T) {
// Large number shouldn't panic
got := formatNumber(999999999)
if !strings.Contains(got, "999") {
t.Errorf("formatNumber(999999999) = %q, doesn't look right", got)
}
}
func TestFormatNumberInt64_MaxValues(t *testing.T) {
got := formatNumberInt64(9_223_372_036_854_775_807) // max int64
if got == "" {
t.Error("formatNumberInt64(maxint64) should not return empty")
}
if !strings.Contains(got, ",") {
t.Errorf("formatNumberInt64(maxint64) = %q, expected commas", got)
}
}
// ── Regression: normalizeExpression edge cases ─────────────────────────────
func TestNormalizeExpression_NegativeInput(t *testing.T) {
got := normalizeExpression("-5 + 3")
if !strings.Contains(got, "-5") || !strings.Contains(got, "+ 3") {
t.Errorf("normalizeExpression(\"-5 + 3\") = %q, unexpected", got)
}
}
func TestNormalizeExpression_EmptyInput(t *testing.T) {
got := normalizeExpression("")
if got != "" {
t.Errorf("normalizeExpression(\"\") = %q, want empty", got)
}
}
func TestFormatCalcResult_NegativeFloat(t *testing.T) {
got := formatCalcResult(-12345.67)
if !strings.Contains(got, "-") {
t.Errorf("formatCalcResult(-12345.67) = %q, should be negative", got)
}
}
func TestFormatCalcResult_LargeWholeFloat(t *testing.T) {
got := formatCalcResult(1000000.0)
if got != "1,000,000" {
t.Errorf("formatCalcResult(1000000.0) = %q, want %q", got, "1,000,000")
}
}