package service import ( "strings" "testing" "time" "browser.local/platform/domain" "browser.local/platform/repo" ) func TestSCUMWorkflowDispatchesReadStepsWithBoundedConcurrencyAndIdempotency(t *testing.T) { svc, session, instance := newSCUMWorkflowFixture(t, true) workflow, err := svc.CreateSCUMWorkflowForSession(session, instance.ID, domain.SCUMWorkflowInstance{TemplateKey: "scum.world-refresh", IdempotencyKey: "world-refresh-1", Input: map[string]any{"scope": "world"}}) if err != nil || workflow.Status != domain.SCUMWorkflowQueued { t.Fatalf("create world workflow=%+v err=%v", workflow, err) } duplicate, err := svc.CreateSCUMWorkflowForSession(session, instance.ID, domain.SCUMWorkflowInstance{TemplateKey: "scum.world-refresh", IdempotencyKey: "world-refresh-1"}) if err != nil || duplicate.ID != workflow.ID { t.Fatalf("expected idempotent workflow create: duplicate=%+v err=%v", duplicate, err) } dispatched, err := svc.DispatchNextSCUMWorkflowSteps(instance.ID, 3) if err != nil || len(dispatched) != 3 { t.Fatalf("expected three bounded read steps dispatched: steps=%+v err=%v", dispatched, err) } for _, step := range dispatched { if step.MutatesState || step.Status != domain.SCUMWorkflowStepRunning || step.Attempt != 1 { t.Fatalf("unexpected read step dispatch: %+v", step) } } } func TestSCUMWorkflowSerializesMutatingStepsPerServer(t *testing.T) { svc, session, instance := newSCUMWorkflowFixture(t, true) first, err := svc.CreateSCUMWorkflowForSession(session, instance.ID, domain.SCUMWorkflowInstance{TemplateKey: "scum.gift-delivery", IdempotencyKey: "gift-1"}) if err != nil { t.Fatalf("create first gift workflow: %v", err) } if _, err := svc.CreateSCUMWorkflowForSession(session, instance.ID, domain.SCUMWorkflowInstance{TemplateKey: "scum.gift-delivery", IdempotencyKey: "gift-2"}); err != nil { t.Fatalf("create second gift workflow: %v", err) } steps, err := svc.DispatchNextSCUMWorkflowSteps(instance.ID, 1) if err != nil || len(steps) != 1 || steps[0].StepKey != "eligibility-check" { t.Fatalf("expected first eligibility step: steps=%+v err=%v", steps, err) } if _, err := svc.CompleteSCUMWorkflowStep(steps[0].ID, domain.SCUMWorkflowStepConfirmed, domain.SCUMOperationConfirmation{Status: "confirmed"}); err != nil { t.Fatalf("complete eligibility: %v", err) } steps, err = svc.DispatchNextSCUMWorkflowSteps(instance.ID, 1) if err != nil || len(steps) != 1 || steps[0].StepKey != "deliver-reward" || !steps[0].MutatesState { t.Fatalf("expected first mutating reward step: steps=%+v err=%v", steps, err) } if steps[0].WorkflowID != first.ID { t.Fatalf("expected first workflow to keep the mutation slot: step=%+v first=%+v", steps[0], first) } blockedByActiveMutation, err := svc.DispatchNextSCUMWorkflowSteps(instance.ID, 1) if err != nil { t.Fatalf("dispatch while mutation active: %v", err) } for _, step := range blockedByActiveMutation { if step.MutatesState { t.Fatalf("second state-changing step should wait for first terminal state: steps=%+v", blockedByActiveMutation) } } } func TestSCUMWorkflowBlocksWhenRunUnavailable(t *testing.T) { svc, session, instance := newSCUMWorkflowFixture(t, false) workflow, err := svc.CreateSCUMWorkflowForSession(session, instance.ID, domain.SCUMWorkflowInstance{TemplateKey: "scum.player-refresh", IdempotencyKey: "player-refresh-blocked"}) if err != nil { t.Fatalf("create player refresh workflow: %v", err) } steps, err := svc.DispatchNextSCUMWorkflowSteps(instance.ID, 1) if err != nil || len(steps) != 1 || steps[0].Status != domain.SCUMWorkflowStepBlocked { t.Fatalf("expected blocked run step: steps=%+v err=%v", steps, err) } updated, err := svc.store.SCUMWorkflowInstances().Get(workflow.ID) if err != nil || updated.Status != domain.SCUMWorkflowBlocked || strings.Contains(updated.SafeSummary.Message, "/") || strings.Contains(strings.ToLower(updated.SafeSummary.Message), "token") { t.Fatalf("workflow blocker should be safe: workflow=%+v err=%v", updated, err) } } func TestSCUMWorkflowRetryRequiresConfirmationAfterUnknownMutation(t *testing.T) { svc, session, instance := newSCUMWorkflowFixture(t, true) if _, err := svc.CreateSCUMWorkflowForSession(session, instance.ID, domain.SCUMWorkflowInstance{TemplateKey: "scum.gift-delivery", IdempotencyKey: "gift-unknown"}); err != nil { t.Fatalf("create gift workflow: %v", err) } steps, err := svc.DispatchNextSCUMWorkflowSteps(instance.ID, 1) if err != nil || len(steps) != 1 { t.Fatalf("dispatch eligibility: steps=%+v err=%v", steps, err) } if _, err := svc.CompleteSCUMWorkflowStep(steps[0].ID, domain.SCUMWorkflowStepConfirmed, domain.SCUMOperationConfirmation{Status: "confirmed"}); err != nil { t.Fatalf("complete eligibility: %v", err) } steps, err = svc.DispatchNextSCUMWorkflowSteps(instance.ID, 1) if err != nil || len(steps) != 1 || !steps[0].MutatesState { t.Fatalf("dispatch mutating reward: steps=%+v err=%v", steps, err) } if _, err := svc.CompleteSCUMWorkflowStep(steps[0].ID, domain.SCUMWorkflowStepUnknown, domain.SCUMOperationConfirmation{Status: "unknown"}); err != nil { t.Fatalf("complete unknown mutation: %v", err) } retry, err := svc.RetrySCUMWorkflowStep(steps[0].ID) if err != nil || retry.Status != domain.SCUMWorkflowStepUnknown || !strings.Contains(retry.SafeSummary.Title, "确认") { t.Fatalf("unknown mutating retry should require confirmation: step=%+v err=%v", retry, err) } } func newSCUMWorkflowFixture(t *testing.T, runAvailable bool) (*CoreService, string, domain.ServerInstance) { t.Helper() svc := newCoreService(repo.NewMemoryStore(), func() time.Time { return fixedTime }) capabilities := []string{domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunLogsTransfer, domain.JobCapabilityRemoteRunProtectedSQL, domain.JobCapabilityRemoteRunRCONCommand} 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: capabilities, DeclaredPermissions: []string{"server.game-client.read", "server.game-client.command", "server.game-client.maintenance"}, Permissions: domain.PluginPermissions{Jobs: true, RemoteAccess: true}, RemoteAccess: domain.GamePluginRemoteAccess{Methods: []string{"run"}, RunCapabilities: capabilities, DatabaseEngines: []string{"sqlite"}, RCON: true, LogTransfer: true}, LifecycleActions: domain.PluginLifecycleActions{Start: "actions/start.json"}, RuntimeProfiles: domain.GamePluginRuntimeProfiles{TransportProfiles: []domain.RuntimeTransportProfile{{Key: "scum-database", Kind: "sqlite", TargetKey: "scum-database", Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunProtectedSQL}}, {Key: "scum-management", Kind: "rcon", TargetKey: "scum-management", Capabilities: []string{domain.JobCapabilityRemoteRunRCONCommand}}}}}) if err != nil { t.Fatalf("create workflow plugin: %v", err) } endpoint, err := svc.CreateRunEndpoint(domain.RunEndpoint{ID: "run-local", DisplayName: "Local Run", Version: "0.1.0", Platform: "windows", Architecture: "amd64", Status: domain.RunEndpointStatusOnline, Capabilities: capabilities, Capacity: domain.RunCapacity{MaxJobs: 4}, LastHeartbeatAt: fixedTime}) if err != nil { t.Fatalf("create workflow endpoint: %v", err) } session := createServiceUserAndLogin(t, svc, domain.User{ID: "workflow-owner", DisplayName: "Workflow Owner", Email: "workflow-owner@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"}) instance, err := svc.CreateServerInstanceForSession(session, domain.ServerInstance{ID: "server-workflow", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "Workflow Server", State: domain.ServerInstanceStateRunning}) if err != nil { t.Fatalf("create workflow server: %v", err) } if !runAvailable { endpoint.Status = domain.RunEndpointStatusOffline if err := svc.store.RunEndpoints().Update(endpoint); err != nil { t.Fatalf("mark workflow endpoint offline: %v", err) } } return svc, session, instance }