133 lines
8.0 KiB
Go
133 lines
8.0 KiB
Go
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
|
|
}
|