Files
gogobee/internal/db/proddb_integration_test.go
prosolis 9e1a1f606c Adv 2.0 D&D layer: Phases 1-8 + Phase 9 SP1+SP2
Combines all uncommitted D&D work into one checkpoint:

Phases 1-8 (per gogobee_dnd_session_summary.md):
- Race/class/stats system, !setup flow, !sheet, !respec
- d20-vs-AC combat with race/class passives, auto-migration
- XP/level-up, skill checks, NPC refund hooks
- Equipment slot mapping, rarity, !rest short/long
- Pre-arm active abilities, resource pools
- Bestiary, !roll/!stats/!level, onboarding DM
- Audit fixes A-I, flavor wiring under internal/flavor/
- Phase 8 weapon dice + armor AC tables (gogobee_equipment_appendix)

Phase 9 SP1 — spell system foundations:
- 79 SpellDefinitions (76 in-scope + 3 reaction-deferred)
- dnd_known_spells, dnd_spell_slots tables
- pending_cast / concentration_* columns on dnd_character
- Slot tables for Mage/Cleric/Ranger, DC + attack-bonus math
- Long-rest refreshes slots; respec wipes spell state
- Auto-grant default known list on !setup confirm and auto-migration

Phase 9 SP2 — !cast / !spells / !prepare commands:
- Out-of-combat HEAL and UTILITY resolve immediately
- Damage/control/buff queue as pending_cast for next combat
- Audit-style save-then-debit, slot refund on save failure
- !cast --drop, concentration supersession, prep-cap enforcement
- Reaction spells refused with Phase 11 note

SP3 (combat-time resolution of pending_cast) and SP4 (full prep/learn
tests) are next. Build clean, full test suite green.

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

190 lines
5.2 KiB
Go

package db
import (
"database/sql"
"io"
"os"
"path/filepath"
"testing"
_ "modernc.org/sqlite"
)
// TestProdDBMigrations runs the full migration suite against a copy of the
// real prod database to verify Phase 1 + Phase 2 schema changes apply cleanly.
//
// Gated on the prod-db file actually existing — skips silently in CI.
// Mutations write only to the tempdir copy; the real prod file is untouched.
func TestProdDBMigrations(t *testing.T) {
src := "/home/reala-misaki/git/gogobee/data/gogobee.db"
if _, err := os.Stat(src); err != nil {
t.Skip("prod db not present at " + src)
}
dir := t.TempDir()
dst := filepath.Join(dir, "gogobee.db")
copyFile(t, src, dst)
// Snapshot row counts BEFORE migration via a raw connection.
preCounts := tableCounts(t, dst, []string{
"adventure_characters",
"euro_balances",
"user_archetypes",
"adventure_equipment",
"adventure_inventory",
"adventure_treasures",
})
t.Logf("pre-migration counts: %+v", preCounts)
// Reset global state (other tests may have run Init already).
Close()
// Run migrations via the production code path.
if err := Init(dir); err != nil {
t.Fatalf("Init failed: %v", err)
}
t.Cleanup(Close)
d := Get()
// 1. New D&D tables must exist.
for _, table := range []string{
"dnd_character", "dnd_abilities", "dnd_resources",
"dnd_combat_state", "adventure_characters_pre_dnd",
} {
var n int
if err := d.QueryRow(
`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?`,
table,
).Scan(&n); err != nil {
t.Fatalf("query %s: %v", table, err)
}
if n != 1 {
t.Errorf("table %s missing after migrations", table)
}
}
// 2. Existing-data row counts unchanged.
postCounts := tableCounts(t, dst, []string{
"adventure_characters",
"euro_balances",
"user_archetypes",
"adventure_equipment",
"adventure_inventory",
"adventure_treasures",
})
for k, pre := range preCounts {
if post := postCounts[k]; post != pre {
t.Errorf("row count drift in %s: pre=%d post=%d", k, pre, post)
}
}
t.Logf("post-migration counts: %+v", postCounts)
// 3. Pre-DnD snapshot populated for every adventure character.
var advCount, snapCount int
d.QueryRow(`SELECT COUNT(*) FROM adventure_characters`).Scan(&advCount)
d.QueryRow(`SELECT COUNT(*) FROM adventure_characters_pre_dnd`).Scan(&snapCount)
if snapCount != advCount {
t.Errorf("snapshot rows %d != adventure_characters %d", snapCount, advCount)
}
t.Logf("snapshot populated: %d rows", snapCount)
// 4. Snapshot JSON parses and contains key fields. SQLite's single-conn
// pool means we must drain the rows before issuing follow-up queries.
rows, err := d.Query(`SELECT user_id, snapshot_json FROM adventure_characters_pre_dnd`)
if err != nil {
t.Fatalf("query snapshot: %v", err)
}
type snapPair struct{ uid, j string }
var snaps []snapPair
for rows.Next() {
var uid, j string
if err := rows.Scan(&uid, &j); err != nil {
rows.Close()
t.Fatal(err)
}
snaps = append(snaps, snapPair{uid, j})
}
rows.Close()
for _, s := range snaps {
var checkCL int
if err := d.QueryRow(`SELECT json_extract(?, '$.combat_level')`, s.j).Scan(&checkCL); err != nil {
t.Errorf("snapshot for %s: bad json: %v", s.uid, err)
continue
}
var realCL int
d.QueryRow(`SELECT combat_level FROM adventure_characters WHERE user_id=?`, s.uid).Scan(&realCL)
if checkCL != realCL {
t.Errorf("snapshot %s: combat_level mismatch snap=%d real=%d", s.uid, checkCL, realCL)
}
}
// 5. New columns on adventure_equipment / adventure_inventory exist and default empty.
rows2, err := d.Query(`SELECT dnd_rarity, dnd_stat_bonus_json, dnd_attuned FROM adventure_equipment LIMIT 5`)
if err != nil {
t.Errorf("new equipment columns query failed: %v", err)
} else {
rows2.Close()
}
rows3, err := d.Query(`SELECT dnd_rarity, dnd_stat_bonus_json FROM adventure_inventory LIMIT 5`)
if err != nil {
t.Errorf("new inventory columns query failed: %v", err)
} else {
rows3.Close()
}
// 6. auto_migrated column on dnd_character exists.
if _, err := d.Exec(`SELECT auto_migrated FROM dnd_character LIMIT 0`); err != nil {
t.Errorf("auto_migrated column missing: %v", err)
}
// 7. Idempotent: running Init again must not error or change row counts.
Close()
if err := Init(dir); err != nil {
t.Fatalf("second Init failed: %v", err)
}
postCounts2 := tableCounts(t, dst, []string{"adventure_characters_pre_dnd"})
if postCounts2["adventure_characters_pre_dnd"] != snapCount {
t.Errorf("snapshot row count drifted on second Init: %d → %d",
snapCount, postCounts2["adventure_characters_pre_dnd"])
}
}
func copyFile(t *testing.T, src, dst string) {
t.Helper()
in, err := os.Open(src)
if err != nil {
t.Fatal(err)
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
t.Fatal(err)
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
t.Fatal(err)
}
}
func tableCounts(t *testing.T, dbPath string, tables []string) map[string]int {
t.Helper()
d, err := sql.Open("sqlite", dbPath)
if err != nil {
t.Fatal(err)
}
defer d.Close()
out := map[string]int{}
for _, tbl := range tables {
var n int
err := d.QueryRow("SELECT COUNT(*) FROM " + tbl).Scan(&n)
if err != nil {
out[tbl] = -1
continue
}
out[tbl] = n
}
return out
}