Files
browser/platform/service/scum_query_ingest_test.go
T
npc0-hue 300390dc4d Fill the SCUM user and vehicle tables from the plugin-declared database read path
The SCUM 用户管理 list stayed empty because the typed scum_user and
scum_vehicle tables had no producer: the run ingest endpoint is signed-run
only, and the previously registered plugin templates asked for a Run
database capability the endpoint never advertises.

The plugin now declares bounded, read-only SQLite projections for players
and vehicles (sql/scum-db-v57/*.sql with query schemas), and the platform
dispatches those templates as durable remote.run.db.sqlite.query jobs on
run heartbeat and page open, then projects the returned rows into the typed
SCUM tables through the same shared ingest path used by signed run facts.
2026-09-16 18:01:33 +08:00

228 lines
12 KiB
Go

package service
import (
"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)
}
}