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", "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":"","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].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) } }