287 lines
14 KiB
Go
287 lines
14 KiB
Go
package service
|
|
|
|
import (
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
|
|
"browser.local/platform/domain"
|
|
"browser.local/platform/repo"
|
|
)
|
|
|
|
func newSCUMQueryIngestFixture(t *testing.T) (*CoreService, string, domain.ServerInstance, *time.Time) {
|
|
t.Helper()
|
|
clock := fixedTime
|
|
svc := newCoreService(repo.NewMemoryStore(), func() time.Time { return clock })
|
|
capability := domain.JobCapabilityRemoteRunDBSQLiteQuery
|
|
plugin, err := svc.CreateGamePlugin(domain.GamePlugin{
|
|
ID: "server.scum",
|
|
Name: "SCUM",
|
|
Version: "1.0.0",
|
|
ServerType: "scum",
|
|
ManifestRef: "artifact://manifests/server.scum/1.0.0",
|
|
CreateFormSchemaRef: "artifact://schemas/server.scum/create-form/1.0.0",
|
|
RequiredRunCapabilities: []string{capability},
|
|
DeclaredPermissions: []string{"server.game-client.read"},
|
|
Permissions: domain.PluginPermissions{Jobs: true, RemoteAccess: true},
|
|
RemoteAccess: domain.GamePluginRemoteAccess{Methods: []string{"run"}, RunCapabilities: []string{capability}, DatabaseEngines: []string{"sqlite"}},
|
|
RuntimeProfiles: domain.GamePluginRuntimeProfiles{
|
|
LifecycleProfiles: []domain.RuntimeLifecycleProfile{{Key: "run-local", Mode: "local-process", Capabilities: []string{capability}, Platforms: []string{"windows"}}},
|
|
TransportProfiles: []domain.RuntimeTransportProfile{{Key: "scum-database", Kind: "sqlite", TargetKey: "scum-database", Capabilities: []string{capability}}},
|
|
DataTargets: []domain.RuntimeDataTarget{{Key: "scum-database", Kind: "sqlite.snapshot", TransportKey: "scum-database", SourceRootKey: "server-root", SourcePath: "SCUM/Saved/SaveFiles/SCUM.db", WorkspaceKey: "databases/scum/SCUM.db", RefreshPolicy: "on-demand-snapshot", MaxBytes: 1 << 30, Platforms: []string{"windows"}}},
|
|
},
|
|
GameClientBridge: domain.GameClientBridgeManifest{Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 100}, QueryTemplates: []domain.GameClientBridgeQueryTemplateDeclaration{{
|
|
Key: "scum.database.players", Title: "Read SCUM players", Permission: "server.game-client.read", Engine: "sqlite",
|
|
TransportKey: "scum-database", TargetKey: "scum-database", ParameterSchemaRef: "schemas/queries/players.parameters.schema.json",
|
|
ResultSchemaRef: "schemas/queries/players.result.schema.json", SQLRef: "sql/sum-db-v57/players.sql",
|
|
PollIntervalSeconds: 60, MaxRows: 500, TimeoutSeconds: 30,
|
|
Projections: []domain.GameClientBridgeQueryProjectionDeclaration{{
|
|
Collection: scumProjectionUsers, RowPath: "rows", UpsertKeys: []string{"steamId"},
|
|
FieldMappings: map[string]string{"steamId": "steamId", "displayName": "displayName", "loginIp": "lastLoginIp", "online": "online", "login": "freshLogin", "loginObservedAt": "lastLoginTime", "bankBalance": "normalBalance", "goldBars": "goldBalance", "x": "x", "y": "y", "z": "z"},
|
|
}},
|
|
}, {
|
|
Key: "scum.database.vehicles", Title: "Read SCUM vehicles", Permission: "server.game-client.read", Engine: "sqlite",
|
|
TransportKey: "scum-database", TargetKey: "scum-database", ParameterSchemaRef: "schemas/queries/vehicles.parameters.schema.json",
|
|
ResultSchemaRef: "schemas/queries/vehicles.result.schema.json", SQLRef: "sql/scum-db-v57/vehicles.sql",
|
|
PollIntervalSeconds: 120, MaxRows: 500, TimeoutSeconds: 30,
|
|
Projections: []domain.GameClientBridgeQueryProjectionDeclaration{{
|
|
Collection: scumProjectionVehicles, RowPath: "rows", UpsertKeys: []string{"gameVehicleId"},
|
|
FieldMappings: map[string]string{"gameVehicleId": "gameVehicleId", "vehicleClass": "vehicleClass", "displayName": "displayName", "functional": "functional", "exists": "existsInGame", "x": "x", "y": "y", "z": "z"},
|
|
}},
|
|
}}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create SCUM plugin: %v", err)
|
|
}
|
|
endpoint, err := svc.CreateRunEndpoint(domain.RunEndpoint{ID: "run-local", DisplayName: "Local Run", Version: "0.1.0", Platform: "windows", Architecture: "amd64", Capabilities: []string{capability}, Capacity: domain.RunCapacity{MaxJobs: 4}})
|
|
if err != nil {
|
|
t.Fatalf("create run endpoint: %v", err)
|
|
}
|
|
session := createServiceUserAndLogin(t, svc, domain.User{ID: "user-scum-owner", DisplayName: "SCUM Owner", Email: "scum-owner@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"})
|
|
instance, err := svc.CreateServerInstanceForSession(session, domain.ServerInstance{ID: "server-scum-query", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "SCUM Query Server", State: domain.ServerInstanceStateRunning})
|
|
if err != nil {
|
|
t.Fatalf("create server instance: %v", err)
|
|
}
|
|
binding, err := svc.buildRuntimeBinding(instance, plugin, domain.RuntimeBindingUpdate{ProfileKey: "run-local", Bindings: map[string]string{}}, true)
|
|
if err != nil {
|
|
t.Fatalf("build runtime binding: %v", err)
|
|
}
|
|
if err := svc.store.RuntimeBindings().Create(binding); err != nil {
|
|
t.Fatalf("store runtime binding: %v", err)
|
|
}
|
|
hello := validRunControlHello()
|
|
hello.CapabilityReport.Capabilities = []string{capability}
|
|
hello.CapabilityReport.Fingerprint = "cap-scum-query"
|
|
if _, err := svc.RegisterRunHello(hello); err != nil {
|
|
t.Fatalf("register run hello: %v", err)
|
|
}
|
|
return svc, session, instance, &clock
|
|
}
|
|
|
|
func refreshSCUMQueryRunHeartbeat(t *testing.T, svc *CoreService, stamp time.Time) {
|
|
t.Helper()
|
|
endpoint, err := svc.store.RunEndpoints().Get("run-local")
|
|
if err != nil {
|
|
t.Fatalf("get run endpoint: %v", err)
|
|
}
|
|
endpoint.LastHeartbeatAt = stamp
|
|
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
|
|
t.Fatalf("refresh run endpoint: %v", err)
|
|
}
|
|
}
|
|
|
|
func countSCUMQueryJobs(t *testing.T, svc *CoreService, instanceID string) []domain.Job {
|
|
t.Helper()
|
|
jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instanceID})
|
|
if err != nil {
|
|
t.Fatalf("list jobs: %v", err)
|
|
}
|
|
return jobs
|
|
}
|
|
|
|
func TestSCUMQueryTemplatesDispatchWithinPollInterval(t *testing.T) {
|
|
svc, _, instance, clock := newSCUMQueryIngestFixture(t)
|
|
aligned := time.Date(2026, 9, 16, 8, 0, 0, 0, time.UTC)
|
|
*clock = aligned
|
|
refreshSCUMQueryRunHeartbeat(t, svc, aligned)
|
|
|
|
if err := svc.ReconcileSCUMQueryTemplates(instance.ID); err != nil {
|
|
t.Fatalf("reconcile query templates: %v", err)
|
|
}
|
|
jobs := countSCUMQueryJobs(t, svc, instance.ID)
|
|
if len(jobs) != 2 {
|
|
t.Fatalf("expected one job per declared template, got %d", len(jobs))
|
|
}
|
|
for _, job := range jobs {
|
|
if job.Capability != domain.JobCapabilityRemoteRunDBSQLiteQuery || job.TargetKey != "scum-database" {
|
|
t.Fatalf("unexpected dispatch job: %+v", job)
|
|
}
|
|
if job.ExecutionInput.WorkspaceScope != "run-local" || job.ExecutionInput.RemoteAdapterKind != string(domain.RemoteAdapterDatabase) {
|
|
t.Fatalf("unexpected dispatch scope: %+v", job.ExecutionInput)
|
|
}
|
|
if job.ExecutionInput.Inputs["sqlRef"] == "" || job.ExecutionInput.Inputs["templateKey"] == "" || job.ExecutionInput.Inputs["maxRows"] != "500" {
|
|
t.Fatalf("unexpected dispatch inputs: %+v", job.ExecutionInput.Inputs)
|
|
}
|
|
}
|
|
|
|
if err := svc.ReconcileSCUMQueryTemplates(instance.ID); err != nil {
|
|
t.Fatalf("repeat reconcile: %v", err)
|
|
}
|
|
if repeated := countSCUMQueryJobs(t, svc, instance.ID); len(repeated) != 2 {
|
|
t.Fatalf("expected dispatch de-duplication inside one poll interval, got %d jobs", len(repeated))
|
|
}
|
|
|
|
*clock = aligned.Add(61 * time.Second)
|
|
refreshSCUMQueryRunHeartbeat(t, svc, *clock)
|
|
if err := svc.ReconcileSCUMQueryTemplates(instance.ID); err != nil {
|
|
t.Fatalf("reconcile after player poll interval: %v", err)
|
|
}
|
|
if afterPlayers := countSCUMQueryJobs(t, svc, instance.ID); len(afterPlayers) != 3 {
|
|
t.Fatalf("expected only the players template to be due after 61s, got %d jobs", len(afterPlayers))
|
|
}
|
|
|
|
*clock = aligned.Add(301 * time.Second)
|
|
refreshSCUMQueryRunHeartbeat(t, svc, *clock)
|
|
if err := svc.ReconcileSCUMQueryTemplates(instance.ID); err != nil {
|
|
t.Fatalf("reconcile after both poll intervals: %v", err)
|
|
}
|
|
if afterBoth := countSCUMQueryJobs(t, svc, instance.ID); len(afterBoth) != 5 {
|
|
t.Fatalf("expected both templates to be due after 301s, got %d jobs", len(afterBoth))
|
|
}
|
|
|
|
*clock = aligned.Add(4000 * time.Second)
|
|
if err := svc.ReconcileSCUMQueryTemplates(instance.ID); err != nil {
|
|
t.Fatalf("reconcile with stale heartbeat: %v", err)
|
|
}
|
|
if staleJobs := countSCUMQueryJobs(t, svc, instance.ID); len(staleJobs) != 5 {
|
|
t.Fatalf("expected no dispatch while the run heartbeat is stale, got %d jobs", len(staleJobs))
|
|
}
|
|
}
|
|
|
|
func TestSCUMQueryProjectionFillsTypedTables(t *testing.T) {
|
|
svc, session, instance, _ := newSCUMQueryIngestFixture(t)
|
|
if err := svc.ReconcileSCUMQueryTemplates(instance.ID); err != nil {
|
|
t.Fatalf("reconcile query templates: %v", err)
|
|
}
|
|
jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instance.ID})
|
|
if err != nil {
|
|
t.Fatalf("list jobs: %v", err)
|
|
}
|
|
playerJob, vehicleJob := jobs[0], jobs[1]
|
|
if playerJob.ExecutionInput.Inputs["templateKey"] != "scum.database.players" {
|
|
playerJob, vehicleJob = vehicleJob, playerJob
|
|
}
|
|
|
|
playerJob.State = domain.JobStateSucceeded
|
|
playerJob.ExecutionResult = domain.JobExecutionResult{Kind: "sqlite.query", Content: `{"rows":[{"steamId":"76561199510658111","displayName":"love_fitting","lastLoginIp":"218.29.163.205","squadId":"38","online":1,"freshLogin":0,"normalBalance":100000,"goldBalance":7,"lastLoginTime":"2026-09-16T08:00:00.000Z","lastSaveTime":"2026-09-16T08:05:00Z","x":182046.75,"y":573985.875,"z":100962.578}]}`}
|
|
if err := svc.projectRemoteAdapterJobResult(playerJob, fixedTime); err != nil {
|
|
t.Fatalf("project player rows: %v", err)
|
|
}
|
|
vehicleJob.State = domain.JobStateSucceeded
|
|
vehicleJob.ExecutionResult = domain.JobExecutionResult{Kind: "sqlite.query", Content: `{"rows":[{"gameVehicleId":"5923426","vehicleClass":"MountainBike_ES","displayName":"","functional":1,"existsInGame":1,"x":-406400.84375,"y":558707.4375,"z":83294.6171875,"observedAt":"2026-09-16T08:05:00Z"}]}`}
|
|
if err := svc.projectRemoteAdapterJobResult(vehicleJob, fixedTime); err != nil {
|
|
t.Fatalf("project vehicle rows: %v", err)
|
|
}
|
|
|
|
users, err := svc.ListSCUMUsersForSession(session, domain.SCUMUserFilter{ServerInstanceID: instance.ID})
|
|
if err != nil || len(users) != 1 {
|
|
t.Fatalf("list users: users=%+v err=%v", users, err)
|
|
}
|
|
user := users[0]
|
|
if user.SteamID != "76561199510658111" || user.DisplayName != "love_fitting" || !user.Online || user.X == nil || *user.X != 182046.75 {
|
|
t.Fatalf("unexpected projected user: %+v", user)
|
|
}
|
|
if user.BankBalance == nil || *user.BankBalance != 100000 || user.GoldBars == nil || *user.GoldBars != 7 || user.SquadID != 38 {
|
|
t.Fatalf("unexpected projected economy: %+v", user)
|
|
}
|
|
if user.LastLoginAt.IsZero() || !user.LastLoginAt.Equal(time.Date(2026, 9, 16, 8, 0, 0, 0, time.UTC)) {
|
|
t.Fatalf("expected last login from database evidence, got %s", user.LastLoginAt)
|
|
}
|
|
vehicles, err := svc.ListSCUMVehiclesForSession(session, domain.SCUMVehicleFilter{ServerInstanceID: instance.ID})
|
|
if err != nil || len(vehicles) != 1 || vehicles[0].VehicleClass != "MountainBike_ES" || vehicles[0].Functional == nil || !*vehicles[0].Functional || vehicles[0].X == nil || *vehicles[0].X != -406400.84375 {
|
|
t.Fatalf("unexpected projected vehicle: vehicles=%+v err=%v", vehicles, err)
|
|
}
|
|
userTracks, err := svc.ListSCUMUserTrajectoriesForSession(session, domain.SCUMUserTrajectoryFilter{ServerInstanceID: instance.ID})
|
|
if err != nil || len(userTracks) != 1 {
|
|
t.Fatalf("expected one projected user trajectory, rows=%+v err=%v", userTracks, err)
|
|
}
|
|
vehicleTracks, err := svc.ListSCUMVehicleTrajectoriesForSession(session, domain.SCUMVehicleTrajectoryFilter{ServerInstanceID: instance.ID})
|
|
if err != nil || len(vehicleTracks) != 1 {
|
|
t.Fatalf("expected one projected vehicle trajectory, rows=%+v err=%v", vehicleTracks, err)
|
|
}
|
|
}
|
|
|
|
func TestSCUMQueryProjectionIgnoresUndeclaredTemplateResults(t *testing.T) {
|
|
svc, session, instance, _ := newSCUMQueryIngestFixture(t)
|
|
job := domain.Job{
|
|
ID: "job-unrelated", ServerInstanceID: instance.ID, Capability: domain.JobCapabilityRemoteRunDBSQLiteQuery, State: domain.JobStateSucceeded,
|
|
ExecutionInput: domain.JobExecutionInput{Inputs: map[string]string{"templateKey": "scum.database.unknown"}},
|
|
ExecutionResult: domain.JobExecutionResult{Kind: "sqlite.query", Content: `{"rows":[{"steamId":"76561199510658111"}]}`},
|
|
}
|
|
if err := svc.projectRemoteAdapterJobResult(job, fixedTime); err != nil {
|
|
t.Fatalf("project undeclared template: %v", err)
|
|
}
|
|
users, err := svc.ListSCUMUsersForSession(session, domain.SCUMUserFilter{ServerInstanceID: instance.ID})
|
|
if err != nil || len(users) != 0 {
|
|
t.Fatalf("expected no projected users for an undeclared template, users=%+v err=%v", users, err)
|
|
}
|
|
}
|
|
|
|
func TestSCUMQueryJobRetentionKeepsBoundedHistory(t *testing.T) {
|
|
svc, _, instance, clock := newSCUMQueryIngestFixture(t)
|
|
aligned := time.Date(2026, 9, 16, 8, 0, 0, 0, time.UTC)
|
|
*clock = aligned
|
|
refreshSCUMQueryRunHeartbeat(t, svc, aligned)
|
|
|
|
playersJobs := func() []domain.Job {
|
|
jobs := countSCUMQueryJobs(t, svc, instance.ID)
|
|
players := make([]domain.Job, 0, len(jobs))
|
|
for _, job := range jobs {
|
|
if job.ExecutionInput.Inputs["templateKey"] == "scum.database.players" {
|
|
players = append(players, job)
|
|
}
|
|
}
|
|
return players
|
|
}
|
|
|
|
var firstJob domain.Job
|
|
for cycle := 0; cycle < 4; cycle++ {
|
|
if cycle > 0 {
|
|
*clock = aligned.Add(time.Duration(cycle) * 61 * time.Second)
|
|
refreshSCUMQueryRunHeartbeat(t, svc, *clock)
|
|
}
|
|
if err := svc.ReconcileSCUMQueryTemplates(instance.ID); err != nil {
|
|
t.Fatalf("reconcile cycle %d: %v", cycle, err)
|
|
}
|
|
for _, job := range playersJobs() {
|
|
if isTerminalJobState(job.State) {
|
|
continue
|
|
}
|
|
if cycle == 0 {
|
|
firstJob = job
|
|
}
|
|
job.State = domain.JobStateSucceeded
|
|
job.TerminalAt = *clock
|
|
if err := svc.store.Jobs().Update(job); err != nil {
|
|
t.Fatalf("complete query job: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
retained := playersJobs()
|
|
if len(retained) != scumQueryJobRetention {
|
|
t.Fatalf("expected %d retained player query jobs, got %d", scumQueryJobRetention, len(retained))
|
|
}
|
|
for _, job := range retained {
|
|
if !isTerminalJobState(job.State) {
|
|
t.Fatalf("retention must not remove in-flight jobs: %+v", job)
|
|
}
|
|
}
|
|
if _, err := svc.store.Jobs().Get(firstJob.ID); !errors.Is(err, repo.ErrNotFound) {
|
|
t.Fatalf("expected the oldest query job to be retired, err=%v", err)
|
|
}
|
|
if _, err := svc.store.LogStreams().Get(jobLogStreamID(firstJob.ID, "stdout")); !errors.Is(err, repo.ErrNotFound) {
|
|
t.Fatalf("expected retired query job log streams to be removed, err=%v", err)
|
|
}
|
|
}
|