simpleadmin-web/internal/playtime/playtime_db_test.go
Astra 7004945d49 Playtime records from pt_events; sticky top bar, page scroll locked under the drawer
Records: the most bomb plants, defuses, knife, Zeus and headshot kills for
the last 7 days, 30 days or all time, and each player's counts, when
PlaytimeHistory 0.2.0's pt_events is there. The public site's top bar now
sticks the whole way down, Playtime comes after Server, and only the
player drawer scrolls while it's open.
2026-10-04 21:37:42 +01:00

303 lines
11 KiB
Go

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.
event := func(id uint64, kind int, at time.Time, weapon any, hs any, bot any) {
t.Helper()
exec(`INSERT INTO pt_events (event_key, server_id, steamid64, kind, at, map, team, weapon, headshot, victim_bot)
VALUES (REPLACE(UUID(), '-', ''), 1, ?, ?, ?, 'de_mirage', 2, ?, ?, ?)`, id, kind, at, weapon, hs, bot)
}
event(a, EventPlanted, ago(2*time.Hour), nil, nil, nil)
event(a, EventPlanted, ago(10*24*time.Hour), nil, nil, nil)
event(a, EventDefused, ago(2*time.Hour), nil, nil, nil)
event(a, EventKill, ago(2*time.Hour), "knife_karambit", 0, 0)
event(a, EventKill, ago(2*time.Hour), "knife", 1, 1) // a bot: doesn't count, not even as a headshot
event(b, EventKill, ago(20*time.Minute), "taser", 0, 0)
event(b, EventKill, ago(20*time.Minute), "ak47", 1, 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
}
if byHour < 3.3/30 || byHour > 3.7/30 { // 3.5 h spread over 30 days, rounded per hour
t.Fatalf("byHour adds up to %.3f, want about %.3f", byHour, 3.5/30)
}
// 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 != 10 || leaders[0].Deaths != 5 {
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 != 3 {
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.Kills != 10 || p.Stats.Rounds != 10 || 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, now)
if err != nil {
t.Fatal(err)
}
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" || ps.Ping == nil || *ps.Ping != 20 {
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")
}