239 lines
9.7 KiB
Go
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
|
|
}
|
|
}
|