package services import ( "os" "path/filepath" "strings" "testing" "time" "arma3-web-server/internal/models" ) func TestSplitArgs(t *testing.T) { tests := []struct { name string raw string want []string }{ {"empty", "", nil}, {"single", "-server", []string{"-server"}}, {"multiple", "-server -port=2302 -world=empty", []string{"-server", "-port=2302", "-world=empty"}}, {"extra spaces", " -server -port=2302 ", []string{"-server", "-port=2302"}}, {"tabs", "-server\t-port=2302", []string{"-server", "-port=2302"}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := splitArgs(tt.raw) if len(got) != len(tt.want) { t.Fatalf("splitArgs(%q) = %v, want %v", tt.raw, got, tt.want) } for i := range got { if got[i] != tt.want[i] { t.Errorf("splitArgs(%q)[%d] = %q, want %q", tt.raw, i, got[i], tt.want[i]) } } }) } } func TestHasArgPrefix(t *testing.T) { args := []string{"-config=foo.cfg", "-mod=@ace", "-port=2302"} if !hasArgPrefix(args, "-config=") { t.Error("should find -config=") } if !hasArgPrefix(args, "-mod=") { t.Error("should find -mod=") } if hasArgPrefix(args, "-profiles=") { t.Error("should not find -profiles=") } if hasArgPrefix(nil, "-config=") { t.Error("nil args should return false") } } func TestBuildAutoArgs_ConfigAndPort(t *testing.T) { dir := t.TempDir() cfgDir := filepath.Join(dir, "cfg") profilesDir := filepath.Join(dir, "profiles") modsDir := filepath.Join(dir, "mods") sm := NewSettingsManager(dir) mm := NewModlistManager(filepath.Join(dir, "modlists")) cm := NewConfigManager(cfgDir) streamer := NewLogStreamer() pm := NewProcessManager(dir, modsDir, cfgDir, profilesDir, sm, mm, cm, streamer) s := &models.ServerSettings{ ActiveConfig: "server_config", IPPort: "0.0.0.0:2302", } args := pm.buildAutoArgs(s) // Should contain -config=/server_config.cfg foundConfig := false for _, a := range args { if strings.HasPrefix(a, "-config=") && strings.HasSuffix(a, "server_config.cfg") { foundConfig = true } } if !foundConfig { t.Errorf("expected -config=...server_config.cfg in args, got %v", args) } // Should contain -port=2302 foundPort := false for _, a := range args { if a == "-port=2302" { foundPort = true } } if !foundPort { t.Errorf("expected -port=2302 in args, got %v", args) } // Should contain -profiles= foundProfiles := false for _, a := range args { if strings.HasPrefix(a, "-profiles=") { foundProfiles = true } } if !foundProfiles { t.Errorf("expected -profiles=... in args, got %v", args) } } func TestBuildAutoArgs_NoConfigNoPort(t *testing.T) { dir := t.TempDir() cfgDir := filepath.Join(dir, "cfg") profilesDir := filepath.Join(dir, "profiles") modsDir := filepath.Join(dir, "mods") sm := NewSettingsManager(dir) mm := NewModlistManager(filepath.Join(dir, "modlists")) cm := NewConfigManager(cfgDir) streamer := NewLogStreamer() pm := NewProcessManager(dir, modsDir, cfgDir, profilesDir, sm, mm, cm, streamer) s := &models.ServerSettings{} args := pm.buildAutoArgs(s) // Should NOT contain -config= or -port= for _, a := range args { if strings.HasPrefix(a, "-config=") { t.Errorf("unexpected -config= in args: %v", a) } if strings.HasPrefix(a, "-port=") { t.Errorf("unexpected -port= in args: %v", a) } } } func TestBuildAutoArgs_PortAlreadyInUserArgs(t *testing.T) { dir := t.TempDir() cfgDir := filepath.Join(dir, "cfg") profilesDir := filepath.Join(dir, "profiles") modsDir := filepath.Join(dir, "mods") sm := NewSettingsManager(dir) mm := NewModlistManager(filepath.Join(dir, "modlists")) cm := NewConfigManager(cfgDir) streamer := NewLogStreamer() pm := NewProcessManager(dir, modsDir, cfgDir, profilesDir, sm, mm, cm, streamer) s := &models.ServerSettings{ IPPort: "0.0.0.0:2302", } args := pm.buildAutoArgs(s) // -port= should only appear once (from auto args) portCount := 0 for _, a := range args { if strings.HasPrefix(a, "-port=") { portCount++ } } if portCount != 1 { t.Errorf("expected exactly 1 -port= arg, got %d in %v", portCount, args) } } func TestBuildArgs_CombinesParametersAndAutoArgs(t *testing.T) { dir := t.TempDir() cfgDir := filepath.Join(dir, "cfg") profilesDir := filepath.Join(dir, "profiles") modsDir := filepath.Join(dir, "mods") sm := NewSettingsManager(dir) mm := NewModlistManager(filepath.Join(dir, "modlists")) cm := NewConfigManager(cfgDir) streamer := NewLogStreamer() pm := NewProcessManager(dir, modsDir, cfgDir, profilesDir, sm, mm, cm, streamer) s := &models.ServerSettings{ ServerParameters: "-server -world=empty -noPause", ActiveConfig: "main", IPPort: "0.0.0.0:2400", } args := pm.buildArgs(s) // Should start with user params if args[0] != "-server" || args[1] != "-world=empty" || args[2] != "-noPause" { t.Errorf("user params missing from front of args: %v", args) } // Should contain auto-generated args foundConfig := false foundPort := false for _, a := range args { if strings.Contains(a, "main.cfg") { foundConfig = true } if a == "-port=2400" { foundPort = true } } if !foundConfig { t.Errorf("expected config arg in %v", args) } if !foundPort { t.Errorf("expected port arg in %v", args) } } func TestResolveModPath_ByModName(t *testing.T) { dir := t.TempDir() modsDir := filepath.Join(dir, "mods") modDir := filepath.Join(modsDir, "@CBA_A3") os.MkdirAll(modDir, 0755) sm := NewSettingsManager(dir) mm := NewModlistManager(filepath.Join(dir, "modlists")) cm := NewConfigManager(filepath.Join(dir, "cfg")) streamer := NewLogStreamer() pm := NewProcessManager(dir, modsDir, filepath.Join(dir, "cfg"), filepath.Join(dir, "profiles"), sm, mm, cm, streamer) // Existing mod dir → resolved path got := pm.resolveModPath(models.ModEntry{Name: "CBA_A3"}) if got != modDir { t.Errorf("resolveModPath(CBA_A3) = %q, want %q", got, modDir) } // Non-existing mod dir → fallback to expected path got = pm.resolveModPath(models.ModEntry{Name: "NonExistent"}) expected := filepath.Join(modsDir, "@NonExistent") if got != expected { t.Errorf("resolveModPath(NonExistent) = %q, want %q", got, expected) } } func TestResolveModPath_ByWorkshopID(t *testing.T) { dir := t.TempDir() workshopDir := filepath.Join(dir, "steamapps", "workshop", "content", "107410", "456789") os.MkdirAll(workshopDir, 0755) modsDir := filepath.Join(dir, "mods") sm := NewSettingsManager(dir) mm := NewModlistManager(filepath.Join(dir, "modlists")) cm := NewConfigManager(filepath.Join(dir, "cfg")) streamer := NewLogStreamer() pm := NewProcessManager(dir, modsDir, filepath.Join(dir, "cfg"), filepath.Join(dir, "profiles"), sm, mm, cm, streamer) got := pm.resolveModPath(models.ModEntry{ID: "456789"}) if got != workshopDir { t.Errorf("resolveModPath(ID=456789) = %q, want %q", got, workshopDir) } } func TestResolveModPath_EmptyEntry(t *testing.T) { dir := t.TempDir() modsDir := filepath.Join(dir, "mods") sm := NewSettingsManager(dir) mm := NewModlistManager(filepath.Join(dir, "modlists")) cm := NewConfigManager(filepath.Join(dir, "cfg")) streamer := NewLogStreamer() pm := NewProcessManager(dir, modsDir, filepath.Join(dir, "cfg"), filepath.Join(dir, "profiles"), sm, mm, cm, streamer) got := pm.resolveModPath(models.ModEntry{}) if got != "" { t.Errorf("resolveModPath(empty) = %q, want empty", got) } } func TestBuildModPath_MultipleMods(t *testing.T) { dir := t.TempDir() modsDir := filepath.Join(dir, "mods") os.MkdirAll(filepath.Join(modsDir, "@CBA_A3"), 0755) os.MkdirAll(filepath.Join(modsDir, "@ACE3"), 0755) sm := NewSettingsManager(dir) mm := NewModlistManager(filepath.Join(dir, "modlists")) cm := NewConfigManager(filepath.Join(dir, "cfg")) streamer := NewLogStreamer() pm := NewProcessManager(dir, modsDir, filepath.Join(dir, "cfg"), filepath.Join(dir, "profiles"), sm, mm, cm, streamer) ml, _ := mm.Create("Test List") mm.Update(ml.ID, "Test List", []models.ModEntry{ {Name: "CBA_A3", Enabled: true}, {Name: "ACE3", Enabled: true}, {Name: "DisabledMod", Enabled: false}, }) got := pm.buildModPath(ml.ID) if !strings.HasPrefix(got, "-mod=") { t.Errorf("expected -mod= prefix, got %q", got) } if !strings.Contains(got, "CBA_A3") || !strings.Contains(got, "ACE3") { t.Errorf("expected CBA_A3 and ACE3 in mod path, got %q", got) } // Disabled mod should not appear if strings.Contains(got, "DisabledMod") { t.Errorf("DisabledMod should not be in mod path: %q", got) } // Separator should be ; if !strings.Contains(got, ";") { t.Errorf("expected ; separator in mod path, got %q", got) } } func TestBuildModPath_EmptyModlist(t *testing.T) { dir := t.TempDir() modsDir := filepath.Join(dir, "mods") sm := NewSettingsManager(dir) mm := NewModlistManager(filepath.Join(dir, "modlists")) cm := NewConfigManager(filepath.Join(dir, "cfg")) streamer := NewLogStreamer() pm := NewProcessManager(dir, modsDir, filepath.Join(dir, "cfg"), filepath.Join(dir, "profiles"), sm, mm, cm, streamer) got := pm.buildModPath("nonexistent-id") if got != "" { t.Errorf("buildModPath(nonexistent) = %q, want empty", got) } } func TestWriteUserconfigFiles(t *testing.T) { dir := t.TempDir() modsDir := filepath.Join(dir, "mods") sm := NewSettingsManager(dir) mm := NewModlistManager(filepath.Join(dir, "modlists")) cm := NewConfigManager(filepath.Join(dir, "cfg")) streamer := NewLogStreamer() pm := NewProcessManager(dir, modsDir, filepath.Join(dir, "cfg"), filepath.Join(dir, "profiles"), sm, mm, cm, streamer) s := &models.ServerSettings{ CBASettings: "force = 1", AILevelPresets: "preset1", DifficultyPresets: "difficulty_normal", } if err := pm.WriteUserconfigFiles(s); err != nil { t.Fatalf("WriteUserconfigFiles() error = %v", err) } userconfigDir := filepath.Join(dir, "userconfig") tests := []struct { filename string content string }{ {"cba_settings.sqf", "force = 1"}, {"CfgAILevelPresets.sqf", "preset1"}, {"CfgDifficultyPresets.sqf", "difficulty_normal"}, } for _, tt := range tests { path := filepath.Join(userconfigDir, tt.filename) data, err := os.ReadFile(path) if err != nil { t.Errorf("ReadFile(%s) error = %v", tt.filename, err) continue } if string(data) != tt.content { t.Errorf("%s content = %q, want %q", tt.filename, string(data), tt.content) } } } func TestWriteUserconfigFiles_EmptyContent(t *testing.T) { dir := t.TempDir() modsDir := filepath.Join(dir, "mods") sm := NewSettingsManager(dir) mm := NewModlistManager(filepath.Join(dir, "modlists")) cm := NewConfigManager(filepath.Join(dir, "cfg")) streamer := NewLogStreamer() pm := NewProcessManager(dir, modsDir, filepath.Join(dir, "cfg"), filepath.Join(dir, "profiles"), sm, mm, cm, streamer) s := &models.ServerSettings{} if err := pm.WriteUserconfigFiles(s); err != nil { t.Fatalf("WriteUserconfigFiles() error = %v", err) } // Files should still be created (even if empty) for _, name := range []string{"cba_settings.sqf", "CfgAILevelPresets.sqf", "CfgDifficultyPresets.sqf"} { path := filepath.Join(dir, "userconfig", name) if _, err := os.Stat(path); os.IsNotExist(err) { t.Errorf("expected %s to exist", name) } } } func TestStartAndStopWithStub(t *testing.T) { dir := t.TempDir() serverfileDir := filepath.Join(dir, "server") modsDir := filepath.Join(dir, "mods") cfgDir := filepath.Join(dir, "cfg") profilesDir := filepath.Join(dir, "profiles") os.MkdirAll(serverfileDir, 0755) // Copy stub into serverfileDir so exec finds it stubSrc, _ := os.ReadFile("testdata/arma3server_x64") os.WriteFile(filepath.Join(serverfileDir, "arma3server_x64"), stubSrc, 0755) sm := NewSettingsManager(dir) mm := NewModlistManager(filepath.Join(dir, "modlists")) cm := NewConfigManager(cfgDir) streamer := NewLogStreamer() pm := NewProcessManager(serverfileDir, modsDir, cfgDir, profilesDir, sm, mm, cm, streamer) s, _ := sm.Load() s.ServerParameters = "-server -world=empty" sm.Save(s) if err := pm.Start(); err != nil { t.Fatalf("Start() error = %v", err) } if !pm.IsRunning() { t.Fatal("IsRunning() should be true after Start()") } // Second Start should fail (already running) if err := pm.Start(); err == nil { t.Error("second Start() should fail") } if err := pm.Stop(); err != nil { t.Fatalf("Stop() error = %v", err) } // Give goroutine time to update state time.Sleep(100 * time.Millisecond) if pm.IsRunning() { t.Error("IsRunning() should be false after Stop()") } // Second Stop should fail if err := pm.Stop(); err == nil { t.Error("second Stop() should fail") } } func TestStartWithPercentCommand(t *testing.T) { dir := t.TempDir() serverfileDir := filepath.Join(dir, "server") modsDir := filepath.Join(dir, "mods") cfgDir := filepath.Join(dir, "cfg") profilesDir := filepath.Join(dir, "profiles") os.MkdirAll(serverfileDir, 0755) // Copy stub stubSrc, _ := os.ReadFile("testdata/arma3server_x64") stubPath := filepath.Join(serverfileDir, "arma3server_x64") os.WriteFile(stubPath, stubSrc, 0755) sm := NewSettingsManager(dir) mm := NewModlistManager(filepath.Join(dir, "modlists")) cm := NewConfigManager(cfgDir) streamer := NewLogStreamer() pm := NewProcessManager(serverfileDir, modsDir, cfgDir, profilesDir, sm, mm, cm, streamer) s, _ := sm.Load() s.ServerParameters = "%command% -world=empty" sm.Save(s) if err := pm.Start(); err != nil { t.Fatalf("Start() with %%command%% error = %v", err) } if !pm.IsRunning() { t.Fatal("IsRunning() should be true") } pm.Stop() time.Sleep(100 * time.Millisecond) } func TestStartWithEmptyCommand(t *testing.T) { dir := t.TempDir() serverfileDir := filepath.Join(dir, "server") os.MkdirAll(serverfileDir, 0755) sm := NewSettingsManager(dir) mm := NewModlistManager(filepath.Join(dir, "modlists")) cm := NewConfigManager(filepath.Join(dir, "cfg")) streamer := NewLogStreamer() pm := NewProcessManager(serverfileDir, filepath.Join(dir, "mods"), filepath.Join(dir, "cfg"), filepath.Join(dir, "profiles"), sm, mm, cm, streamer) s, _ := sm.Load() s.ServerParameters = "" sm.Save(s) // Empty params without %command% should still work (starts with just the binary) // But if we use %command% with nothing after it, it should fail s.ServerParameters = "%command%" sm.Save(s) err := pm.Start() if err == nil { t.Error("Start() with percent-command-percent only (no other args) should fail") pm.Stop() } } func TestStartWithServerBinaryEnv(t *testing.T) { dir := t.TempDir() serverfileDir := filepath.Join(dir, "server") os.MkdirAll(serverfileDir, 0755) stubSrc, _ := os.ReadFile("testdata/arma3server_x64") customBin := "my-custom-server" os.WriteFile(filepath.Join(serverfileDir, customBin), stubSrc, 0755) sm := NewSettingsManager(dir) mm := NewModlistManager(filepath.Join(dir, "modlists")) cm := NewConfigManager(filepath.Join(dir, "cfg")) streamer := NewLogStreamer() pm := NewProcessManager(serverfileDir, filepath.Join(dir, "mods"), filepath.Join(dir, "cfg"), filepath.Join(dir, "profiles"), sm, mm, cm, streamer) s, _ := sm.Load() s.ServerParameters = "-server -world=empty" sm.Save(s) t.Setenv("SERVER_BINARY", customBin) if err := pm.Start(); err != nil { t.Fatalf("Start() with SERVER_BINARY env error = %v", err) } if !pm.IsRunning() { t.Fatal("IsRunning() should be true") } pm.Stop() time.Sleep(100 * time.Millisecond) } func TestStartWithServerParamsEnv(t *testing.T) { dir := t.TempDir() serverfileDir := filepath.Join(dir, "server") os.MkdirAll(serverfileDir, 0755) stubSrc, _ := os.ReadFile("testdata/arma3server_x64") os.WriteFile(filepath.Join(serverfileDir, "arma3server_x64"), stubSrc, 0755) sm := NewSettingsManager(dir) mm := NewModlistManager(filepath.Join(dir, "modlists")) cm := NewConfigManager(filepath.Join(dir, "cfg")) streamer := NewLogStreamer() pm := NewProcessManager(serverfileDir, filepath.Join(dir, "mods"), filepath.Join(dir, "cfg"), filepath.Join(dir, "profiles"), sm, mm, cm, streamer) s, _ := sm.Load() s.ServerParameters = "-server -world=empty" sm.Save(s) t.Setenv("SERVER_PARAMS", "%command% -custom-param") if err := pm.Start(); err != nil { t.Fatalf("Start() with SERVER_PARAMS env error = %v", err) } if !pm.IsRunning() { t.Fatal("IsRunning() should be true") } pm.Stop() time.Sleep(100 * time.Millisecond) } func TestServerParamsEnvOverridesSettings(t *testing.T) { dir := t.TempDir() serverfileDir := filepath.Join(dir, "server") os.MkdirAll(serverfileDir, 0755) stubSrc, _ := os.ReadFile("testdata/arma3server_x64") os.WriteFile(filepath.Join(serverfileDir, "arma3server_x64"), stubSrc, 0755) sm := NewSettingsManager(dir) mm := NewModlistManager(filepath.Join(dir, "modlists")) cm := NewConfigManager(filepath.Join(dir, "cfg")) streamer := NewLogStreamer() pm := NewProcessManager(serverfileDir, filepath.Join(dir, "mods"), filepath.Join(dir, "cfg"), filepath.Join(dir, "profiles"), sm, mm, cm, streamer) s, _ := sm.Load() s.ServerParameters = "-server -world=empty" sm.Save(s) // Without SERVER_PARAMS override, Start() would use settings value (no %command%). // With override, it uses %command% substitution. t.Setenv("SERVER_PARAMS", "%command% -test-override") if err := pm.Start(); err != nil { t.Fatalf("Start() error = %v (SERVER_PARAMS override not applied?)", err) } pm.Stop() time.Sleep(100 * time.Millisecond) }