Files
browser/plugins/examples/scum-server-plugin/companion/storage_test.go
T

239 lines
9.7 KiB
Go

package companion
import (
"context"
"database/sql"
"database/sql/driver"
"errors"
"strings"
"sync"
"testing"
"time"
)
const recordingSQLDriverName = "scum_companion_recording_sql"
var (
recordingSQLDriverOnce sync.Once
activeSQLRecorder *sqlRecorder
)
type sqlRecorder struct {
mu sync.Mutex
dsn string
statements []string
args [][]driver.NamedValue
commits int
}
func (recorder *sqlRecorder) append(query string, args []driver.NamedValue) {
recorder.mu.Lock()
defer recorder.mu.Unlock()
recorder.statements = append(recorder.statements, query)
recorder.args = append(recorder.args, append([]driver.NamedValue(nil), args...))
}
func (recorder *sqlRecorder) committed() {
recorder.mu.Lock()
defer recorder.mu.Unlock()
recorder.commits++
}
type recordingSQLDriver struct{}
type recordingSQLConn struct{ recorder *sqlRecorder }
type recordingSQLTx struct{ recorder *sqlRecorder }
func (recordingSQLDriver) Open(dsn string) (driver.Conn, error) {
if activeSQLRecorder == nil {
return nil, errors.New("recording SQL recorder is not configured")
}
activeSQLRecorder.dsn = dsn
return &recordingSQLConn{recorder: activeSQLRecorder}, nil
}
func (conn *recordingSQLConn) Prepare(string) (driver.Stmt, error) {
return nil, errors.New("prepare is not supported")
}
func (conn *recordingSQLConn) Close() error { return nil }
func (conn *recordingSQLConn) Begin() (driver.Tx, error) {
return &recordingSQLTx{recorder: conn.recorder}, nil
}
func (conn *recordingSQLConn) BeginTx(context.Context, driver.TxOptions) (driver.Tx, error) {
return &recordingSQLTx{recorder: conn.recorder}, nil
}
func (conn *recordingSQLConn) ExecContext(_ context.Context, query string, args []driver.NamedValue) (driver.Result, error) {
conn.recorder.append(query, args)
return driver.RowsAffected(1), nil
}
func (tx *recordingSQLTx) Commit() error { tx.recorder.committed(); return nil }
func (tx *recordingSQLTx) Rollback() error { return nil }
func TestSCUMSQLStoreWritesTrajectorySamplesWithoutCoordinateConversion(t *testing.T) {
db, recorder := newRecordingSQLDB(t, "direct-db")
store, err := NewSCUMSQLStore(db)
if err != nil {
t.Fatalf("create SCUM SQL store: %v", err)
}
if err := store.EnsureSchema(context.Background()); err != nil {
t.Fatalf("ensure schema: %v", err)
}
z := 7.75
sampledAt := time.Date(2026, 8, 28, 7, 30, 0, 123456000, time.UTC)
written, err := store.StoreTrajectorySamples(context.Background(), []TrajectorySample{{
ServerInstanceID: "server-1", SubjectType: "player", SubjectID: "76561198000000001", SteamID: "76561198000000001", UserProfileID: "profile-1", GamePlayerID: "player-1",
WorldX: 123.5, WorldY: -456.25, WorldZ: &z, ObservedAt: sampledAt.Add(-time.Second), SampledAt: sampledAt, Source: "plugin.sql.scum.positions",
}})
if err != nil || written != 1 {
t.Fatalf("store trajectory sample: written=%d err=%v", written, err)
}
if recorder.commits != 1 {
t.Fatalf("trajectory write did not commit once: %d", recorder.commits)
}
insertIndex := findStatement(recorder.statements, "INSERT INTO scum_trajectories")
if insertIndex < 0 {
t.Fatalf("missing trajectory insert statement: %v", recorder.statements)
}
insert := recorder.statements[insertIndex]
if !strings.Contains(insert, "world_x") || !strings.Contains(insert, "world_y") || !strings.Contains(insert, "world_z") || strings.Contains(insert, "map_x") || strings.Contains(insert, "pixel") {
t.Fatalf("trajectory SQL does not use raw world coordinate columns: %s", insert)
}
args := recorder.args[insertIndex]
if args[13].Value != 123.5 || args[14].Value != -456.25 || args[15].Value != 7.75 {
t.Fatalf("coordinates were changed before storage: %+v", args[13:16])
}
}
func TestSCUMSQLStoreWritesConsoleAndSemanticEventsToPluginTables(t *testing.T) {
db, recorder := newRecordingSQLDB(t, "console-db")
store, err := NewSCUMSQLStore(db)
if err != nil {
t.Fatalf("create SCUM SQL store: %v", err)
}
stamp := time.Date(2026, 8, 31, 3, 0, 0, 0, time.UTC)
records := []ConsoleRecord{{ServerID: "server-1", Stream: "stdout", Sequence: 9, OccurredAt: stamp, Text: "SCUM LOGIN 76561198000000001 10.0.0.1"}}
written, err := store.StoreConsoleRecords(context.Background(), records)
if err != nil || written != 1 {
t.Fatalf("store console records: written=%d err=%v", written, err)
}
batch := ParseConsoleRecords("server-1", records, "correlation-secret")
if len(batch.Events) != 1 || batch.Events[0].NetworkCorrelation == "" {
t.Fatalf("expected one correlated semantic event: %#v", batch)
}
semanticWritten, err := store.StoreSemanticEventBatch(context.Background(), batch)
if err != nil || semanticWritten != 1 {
t.Fatalf("store semantic events: written=%d err=%v", semanticWritten, err)
}
if recorder.commits != 2 {
t.Fatalf("console and semantic writes did not commit once each: %d", recorder.commits)
}
consoleIndex := findStatement(recorder.statements, "INSERT INTO scum_console_logs")
if consoleIndex < 0 {
t.Fatalf("missing console insert statement: %v", recorder.statements)
}
consoleInsert := recorder.statements[consoleIndex]
if !strings.Contains(consoleInsert, "line_text") || strings.Contains(consoleInsert, "platform_logs") {
t.Fatalf("console SQL must write the SCUM plugin table only: %s", consoleInsert)
}
consoleArgs := recorder.args[consoleIndex]
if consoleArgs[1].Value != "server-1" || consoleArgs[2].Value != "stdout" || !driverNumberEquals(consoleArgs[3].Value, 9) || consoleArgs[5].Value != records[0].Text {
t.Fatalf("unexpected console insert args: %+v", consoleArgs)
}
semanticIndex := findStatement(recorder.statements, "INSERT INTO scum_semantic_events")
if semanticIndex < 0 {
t.Fatalf("missing semantic event insert statement: %v", recorder.statements)
}
semanticInsert := recorder.statements[semanticIndex]
if strings.Contains(semanticInsert, "platform_logs") || strings.Contains(semanticInsert, "run_logs") {
t.Fatalf("semantic SQL must write the SCUM plugin table only: %s", semanticInsert)
}
semanticArgs := recorder.args[semanticIndex]
if semanticArgs[1].Value != "server-1" || !driverNumberEquals(semanticArgs[2].Value, 9) || semanticArgs[3].Value != "scum.login" || semanticArgs[4].Value != "76561198000000001" {
t.Fatalf("unexpected semantic insert args: %+v", semanticArgs)
}
correlation, ok := semanticArgs[7].Value.(string)
if !ok || correlation == "10.0.0.1" || len(correlation) != 64 {
t.Fatalf("semantic event stored raw or missing network correlation: %+v", semanticArgs[7])
}
}
func TestTrajectorySamplesFromSCUMRowsKeepWorldCoordinates(t *testing.T) {
sampledAt := time.Date(2026, 8, 28, 8, 0, 0, 0, time.UTC)
positionSamples, err := TrajectorySamplesFromPositionRows("server-1", []map[string]any{
{"subjectType": "player", "subjectId": "76561198000000001", "userProfileId": "profile-1", "gamePlayerId": "player-1", "x": 10.25, "y": -20.5, "z": 3.75, "observedAt": "2026-08-28T07:59:00Z"},
{"subjectType": "base", "subjectId": "base-1", "x": 1, "y": 2, "z": 0},
}, sampledAt)
if err != nil || len(positionSamples) != 1 {
t.Fatalf("position samples=%+v err=%v", positionSamples, err)
}
if positionSamples[0].WorldX != 10.25 || positionSamples[0].WorldY != -20.5 || positionSamples[0].WorldZ == nil || *positionSamples[0].WorldZ != 3.75 || positionSamples[0].Source != "plugin.sql.scum.positions" {
t.Fatalf("position coordinates were not preserved: %+v", positionSamples[0])
}
vehicleSamples, err := TrajectorySamplesFromVehicleRows("server-1", []map[string]any{{"vehicleId": "vehicle-1", "entityId": "entity-1", "className": "BPC_Laika_C", "label": "Laika", "x": "400.5", "y": 200, "z": 0, "lastAccessTime": "2026-08-28T07:58:00Z"}}, sampledAt)
if err != nil || len(vehicleSamples) != 1 {
t.Fatalf("vehicle samples=%+v err=%v", vehicleSamples, err)
}
if vehicleSamples[0].SubjectType != "vehicle" || vehicleSamples[0].WorldX != 400.5 || vehicleSamples[0].WorldY != 200 || vehicleSamples[0].ClassName != "BPC_Laika_C" || vehicleSamples[0].Source != "plugin.sql.scum.vehicles" {
t.Fatalf("vehicle coordinates were not preserved: %+v", vehicleSamples[0])
}
}
func TestOpenSCUMSQLStoreFromEnvUsesSharedPlatformDSNName(t *testing.T) {
recorder := &sqlRecorder{}
activeSQLRecorder = recorder
previousOpenSQL := openSQL
t.Cleanup(func() { openSQL = previousOpenSQL })
openSQL = func(driverName, dsn string) (*sql.DB, error) {
if driverName != "mysql" {
t.Fatalf("unexpected SQL driver: %s", driverName)
}
return sql.Open(recordingSQLDriverName, dsn)
}
t.Setenv(PlatformMySQLDSNEnvironment, "platform:platform@tcp(127.0.0.1:3306)/platform?parseTime=true")
store, err := OpenSCUMSQLStoreFromEnv(PlatformMySQLDSNEnvironment)
if err != nil {
t.Fatalf("open store from shared env: %v", err)
}
if err := store.EnsureSchema(context.Background()); err != nil {
t.Fatalf("ensure schema from shared env: %v", err)
}
store.db.Close()
if recorder.dsn != "platform:platform@tcp(127.0.0.1:3306)/platform?parseTime=true" {
t.Fatalf("store did not use the shared platform DSN environment: %q", recorder.dsn)
}
}
func newRecordingSQLDB(t *testing.T, dsn string) (*sql.DB, *sqlRecorder) {
t.Helper()
recordingSQLDriverOnce.Do(func() { sql.Register(recordingSQLDriverName, recordingSQLDriver{}) })
recorder := &sqlRecorder{}
activeSQLRecorder = recorder
db, err := sql.Open(recordingSQLDriverName, dsn)
if err != nil {
t.Fatalf("open recording SQL db: %v", err)
}
return db, recorder
}
func findStatement(statements []string, prefix string) int {
for index, statement := range statements {
if strings.Contains(statement, prefix) {
return index
}
}
return -1
}
func driverNumberEquals(value any, want int64) bool {
switch typed := value.(type) {
case int:
return int64(typed) == want
case int64:
return typed == want
case uint64:
return typed == uint64(want)
default:
return false
}
}