package playtime import ( "context" "database/sql" _ "embed" "os" "strings" "testing" "time" "github.com/go-sql-driver/mysql" ) //go:embed testdata/schema.sql var schema string // TestPlaytimeDB runs every query against a real MySQL/MariaDB holding PlaytimeHistory's schema // (testdata/schema.sql). It drops and recreates the pt_ tables, so only point it at a scratch // database: // // SAW_TEST_PT_DSN='root:test@tcp(127.0.0.1:33071)/playtime_history' go test ./internal/playtime -run DB func TestPlaytimeDB(t *testing.T) { dsn := os.Getenv("SAW_TEST_PT_DSN") if dsn == "" { t.Skip("SAW_TEST_PT_DSN not set") } cfg, err := mysql.ParseDSN(dsn) if err != nil { t.Fatal(err) } cfg.ParseTime, cfg.Loc = true, time.UTC db, err := sql.Open("mysql", cfg.FormatDSN()) if err != nil { t.Fatal(err) } defer db.Close() exec := func(q string, args ...any) { t.Helper() if _, err := db.Exec(q, args...); err != nil { t.Fatalf("%s: %v", q, err) } } exec("DROP VIEW IF EXISTS pt_segments_hourly") for _, table := range tables { exec("DROP TABLE IF EXISTS " + table) } exec("DROP TABLE IF EXISTS pt_servers") ctx := context.Background() st := New(db, 1) if ok, _, err := st.State(ctx); err != nil || ok { t.Fatalf("State without tables: %v, %v", ok, err) } for stmt := range strings.SplitSeq(schema, ";\n") { if code := strings.TrimSpace(stripComments(stmt)); code != "" { exec(code) } } if ok, data, err := st.State(ctx); err != nil || !ok || data { t.Fatalf("State with empty tables: %v, %v, %v", ok, data, err) } // Times are whole seconds at a few minutes past the hour, so segments cross hour boundaries. now := time.Now().UTC().Truncate(time.Hour).Add(-50 * time.Minute) ago := func(d time.Duration) time.Time { return now.Add(-d) } const a, b = uint64(76561198000001001), uint64(76561198000001002) exec(`INSERT INTO pt_players (steamid64, first_seen, last_seen, sessions_count, total_seconds, t_seconds, ct_seconds, spec_seconds, alive_seconds, dead_seconds, last_nickname, last_country, last_server_id) VALUES (?, ?, ?, 1, 5400, 3600, 1800, 1800, 4000, 1400, 'Alpha', 'GB', 1), (?, ?, ?, 2, 7200, 7200, 0, 0, 7200, 0, 'Bravo', 'DE', 1)`, a, ago(3*time.Hour), ago(time.Hour), b, ago(10*24*time.Hour), ago(10*24*time.Hour-2*time.Hour)) exec(`INSERT INTO pt_nicknames (steamid64, nickname, first_seen, last_seen, times_seen) VALUES (?, 'Alpha', ?, ?, 3), (?, 'alpha_old', ?, ?, 1)`, a, ago(3*time.Hour), ago(time.Hour), a, ago(5*time.Hour), ago(4*time.Hour)) session := func(key string, id uint64, start time.Time, end *time.Time, kind int, reason string, kills, deaths int) { var dur any if end != nil { dur = int(end.Sub(start).Seconds()) } hb := start if end != nil { hb = *end } exec(`INSERT INTO pt_sessions (session_key, steamid64, server_id, nickname, started_at, ended_at, heartbeat_at, duration_seconds, end_kind, connect_map, disconnect_map, disconnect_reason_name, players_online, max_players, country_iso, country_name, city, kills, deaths, rounds_played, damage, ping_avg, plugin_version) VALUES (?, ?, 1, 'x', ?, ?, ?, ?, ?, 'de_mirage', 'de_mirage', NULLIF(?, ''), 2, 10, 'GB', 'United Kingdom', 'London', ?, ?, 10, 900, 20, '0.1.0')`, key, id, start, end, hb, dur, kind, reason, kills, deaths) } segment := func(key, sess string, id uint64, team int, start time.Time, end *time.Time) { var secs any if end != nil { secs = int(end.Sub(start).Seconds()) } alive := any(nil) if team >= TeamT { alive = 1 } exec(`INSERT INTO pt_segments (segment_key, session_key, steamid64, server_id, map, team, alive, started_at, ended_at, heartbeat_at, seconds) VALUES (?, ?, ?, 1, 'de_mirage', ?, ?, ?, ?, ?, ?)`, key, sess, id, team, alive, start, end, start, secs) } at := func(d time.Duration) *time.Time { x := ago(d); return &x } session("a1", a, ago(3*time.Hour), at(time.Hour), EndDisconnect, "NETWORK_DISCONNECT_DISCONNECT_BY_USER", 10, 5) segment("a1-1", "a1", a, TeamT, ago(3*time.Hour), at(2*time.Hour)) segment("a1-2", "a1", a, TeamCT, ago(2*time.Hour), at(90*time.Minute)) segment("a1-3", "a1", a, TeamSpectator, ago(90*time.Minute), at(time.Hour)) session("b1", b, ago(10*24*time.Hour), at(10*24*time.Hour-2*time.Hour), EndMapChange, "", 3, 3) segment("b1-1", "b1", b, TeamT, ago(10*24*time.Hour), at(10*24*time.Hour-2*time.Hour)) session("b2", b, ago(30*time.Minute), nil, EndOpen, "", 0, 0) segment("b2-1", "b2", b, TeamCT, ago(30*time.Minute), nil) // Another server's session, which a store for server 1 never counts. exec(`INSERT INTO pt_sessions (session_key, steamid64, server_id, nickname, started_at, heartbeat_at, end_kind, connect_map, players_online, max_players, plugin_version) VALUES ('other', ?, 2, 'x', ?, ?, 1, 'de_dust2', 1, 10, '0.1.0')`, a, ago(time.Hour), ago(time.Hour)) exec(`INSERT INTO pt_online_snapshots (server_id, taken_at, players, bots, max_players, map) VALUES (1, ?, 5, 0, 10, 'de_mirage'), (1, ?, 9, 0, 10, 'de_mirage'), (2, ?, 20, 0, 10, 'de_dust2')`, ago(24*time.Hour), ago(48*time.Hour), ago(24*time.Hour)) // Events: Alpha plants twice (once 10 days ago), defuses once and knifes a player and a bot; // Bravo gets a Zeus kill and a headshot. // Kills say who died (nil for a bot) and the killer's session. event := func(id uint64, sess string, kind int, at time.Time, weapon any, hs any, victim any, bot any) { t.Helper() exec(`INSERT INTO pt_events (event_key, server_id, session_key, steamid64, kind, at, map, team, weapon, headshot, victim_steamid64, victim_bot) VALUES (REPLACE(UUID(), '-', ''), 1, NULLIF(?, ''), ?, ?, ?, 'de_mirage', 2, ?, ?, ?, ?)`, sess, id, kind, at, weapon, hs, victim, bot) } event(a, "", EventPlanted, ago(10*24*time.Hour), nil, nil, nil, nil) event(a, "a1", EventPlanted, ago(2*time.Hour), nil, nil, nil, nil) event(a, "a1", EventDefused, ago(2*time.Hour), nil, nil, nil, nil) event(a, "a1", EventKill, ago(2*time.Hour), "knife_karambit", 0, b, 0) event(a, "a1", EventKill, ago(2*time.Hour), "knife", 1, nil, 1) // a bot: doesn't count, not even as a headshot event(b, "b2", EventKill, ago(20*time.Minute), "taser", 0, a, 0) event(b, "b2", EventKill, ago(20*time.Minute), "ak47", 1, a, 0) if ok, data, err := st.State(ctx); err != nil || !ok || !data || !st.HasEvents() { t.Fatalf("State with data: %v, %v, %v (events %v)", ok, data, err, st.HasEvents()) } // The hour split must add up to exactly the segments' lengths. buckets, err := st.hours(ctx, ago(30*24*time.Hour), a) if err != nil { t.Fatal(err) } var total int64 for _, bk := range buckets { total += bk.Seconds } if total != 5400 || len(buckets) < 2 { t.Fatalf("Alpha's buckets add up to %d s over %d hours, want 5400 s over 2+", total, len(buckets)) } // Clamped to the start of the window: only the 30 min since. if buckets, err = st.hours(ctx, ago(150*time.Minute), a); err != nil { t.Fatal(err) } total = 0 for _, bk := range buckets { total += bk.Seconds } if total != 3600 { t.Fatalf("since 2.5 h ago: %d s, want 3600 (30 min of T, 30 of CT)", total) } london, _ := time.LoadLocation("Europe/London") o, err := st.Overview(ctx, now, london) if err != nil { t.Fatal(err) } s := o.Summary if s.Players != 2 || s.PlayersPrev != 1 || s.Hours != 1.5 || s.HoursPrev != 2 || s.Peak != 9 || s.NewPlayers != 1 { t.Fatalf("summary %+v", s) } var byHour float64 for _, h := range o.ByHour { byHour += h } // Recording began with Bravo's session 10 days ago, so 3.5 h spread over those 11 days. if o.ByHourDays != 11 || o.HeatDays != 11 { t.Fatalf("averaged over %d days (heatmap %d), want 11", o.ByHourDays, o.HeatDays) } if want := 3.5 / 11; byHour < want-0.05 || byHour > want+0.05 { t.Fatalf("byHour adds up to %.3f, want about %.3f", byHour, want) } // Alpha on one day, Bravo on two days ten days apart. players := 0 for _, d := range o.Days { players += d.Players } if len(o.Days) != 30 || players != 3 { t.Fatalf("%d players over %d days", players, len(o.Days)) } if len(o.Maps) != 1 || o.Maps[0].Map != "de_mirage" || o.Maps[0].Hours != 3.5 || o.Maps[0].Players != 2 { t.Fatalf("maps %+v", o.Maps) } week := ago(7 * 24 * time.Hour) leaders, err := st.Leaders(ctx, &week, 10) if err != nil { t.Fatal(err) } if len(leaders) != 1 || leaders[0].Name != "Alpha" || leaders[0].T != 1 || leaders[0].CT != 0.5 || leaders[0].Kills != 1 || leaders[0].Deaths != 2 { t.Fatalf("leaders this week %+v", leaders) } if leaders, err = st.Leaders(ctx, nil, 10); err != nil { t.Fatal(err) } if len(leaders) != 2 || leaders[0].Name != "Bravo" || leaders[0].Kills != 2 || leaders[0].Deaths != 1 { t.Fatalf("leaders all time %+v", leaders) } p, err := st.Player(ctx, a, now, london) if err != nil || p == nil { t.Fatalf("Player: %v, %v", p, err) } var days float64 for _, d := range p.Days { days += d.Hours } if p.T != 1 || p.CT != 0.5 || p.Spec != 0.5 || p.Stats == nil || *p.Stats != (Stats{Kills: 1, Deaths: 2}) || days != 1.5 || len(p.Maps) != 1 { t.Fatalf("player %+v, %.1f h in days", p, days) } if p, err := st.Player(ctx, 76561198000009999, now, london); err != nil || p != nil { t.Fatalf("unknown player: %v, %v", p, err) } ps, err := st.PlayerStaff(ctx, a) if err != nil { t.Fatal(err) } // Alpha's knife kill was in a1; Bravo killed Alpha twice, but after a1 ended. if r := ps.Recent; len(r) != 1 || r[0].Kills == nil || *r[0].Kills != 1 || r[0].Deaths == nil || *r[0].Deaths != 0 { t.Fatalf("recent session kills and deaths %+v", ps.Recent) } if len(ps.Names) != 2 || ps.Names[0].Name != "Alpha" || len(ps.Recent) != 1 || ps.Recent[0].Seconds != 7200 || ps.Recent[0].Reason != "NETWORK_DISCONNECT_DISCONNECT_BY_USER" || ps.Country != "GB" || ps.City != "London" { t.Fatalf("staff %+v", ps) } on, err := st.Online(ctx, []uint64{a, b}) if err != nil { t.Fatal(err) } if len(on) != 1 || on["76561198000001002"].Team == nil || *on["76561198000001002"].Team != TeamCT { t.Fatalf("online %+v", on) } act, err := st.Activity(ctx, []uint64{a, b}, week) if err != nil { t.Fatal(err) } if x := act["76561198000001001"]; x.Hours != 1.5 || x.Spec != 0.5 || x.LastSeen == nil { t.Fatalf("activity %+v", act) } if x := act["76561198000001002"]; x.Hours != 0 || x.LastSeen == nil { t.Fatalf("Bravo's activity %+v", x) } rec, err := st.Records(ctx, &week, 5) if err != nil { t.Fatal(err) } one := func(name string, got []Record, who string, n int) { t.Helper() if len(got) != 1 || got[0].Name != who || got[0].Count != n { t.Fatalf("%s: %+v, want %s with %d", name, got, who, n) } } one("plants", rec.Plants, "Alpha", 1) one("defuses", rec.Defuses, "Alpha", 1) one("knife kills", rec.KnifeKills, "Alpha", 1) one("zeus kills", rec.ZeusKills, "Bravo", 1) one("headshots", rec.Headshots, "Bravo", 1) if rec.Since == nil || !rec.Since.Equal(ago(10*24*time.Hour)) { t.Fatalf("records since %v", rec.Since) } if rec, err = st.Records(ctx, nil, 5); err != nil { t.Fatal(err) } one("plants ever", rec.Plants, "Alpha", 2) if e := p.Events; e == nil || e.Plants != 2 || e.Defuses != 1 || e.KnifeKills != 1 || e.ZeusKills != 0 || e.Headshots != 0 { t.Fatalf("Alpha's events %+v", e) } reasons, err := st.Reasons(ctx, ago(30*24*time.Hour)) if err != nil { t.Fatal(err) } if len(reasons) != 1 || reasons[0].Count != 1 || reasons[0].AvgSeconds != 7200 { t.Fatalf("reasons %+v", reasons) } countries, err := st.Countries(ctx) if err != nil { t.Fatal(err) } if len(countries) != 1 || countries[0].ISO != "GB" || countries[0].Players != 2 { t.Fatalf("countries %+v", countries) } ends, err := st.EndKinds(ctx, ago(30*24*time.Hour)) if err != nil { t.Fatal(err) } if ends[EndDisconnect] != 1 || ends[EndMapChange] != 1 || ends[EndOpen] != 1 { t.Fatalf("end kinds %+v", ends) } } func stripComments(stmt string) string { var out []string for l := range strings.SplitSeq(stmt, "\n") { if !strings.HasPrefix(strings.TrimSpace(l), "--") { out = append(out, l) } } return strings.Join(out, "\n") }