docs: rewrite PLAN.md and update CODEBASE.md, README.md
- Full rewrite of PLAN.md to reflect current architecture (27 REST + 3 WS endpoints, embedded frontend, automation, scheduler, health check, GoReleaser CI) - Added health.go, mods.go, scheduler.go, robfig/cron dep to CODEBASE.md - Added Gitea Actions CI/CD section to CODEBASE.md - Added conventional commits to code style section - Added missing API routes and embed/ to README.md
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConfigManager_ListEmpty(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cm := NewConfigManager(dir)
|
||||
|
||||
configs, err := cm.List()
|
||||
if err != nil {
|
||||
t.Fatalf("List() error = %v", err)
|
||||
}
|
||||
if len(configs) != 0 {
|
||||
t.Errorf("List() returned %d configs, want 0", len(configs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigManager_CreateAndGet(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cm := NewConfigManager(dir)
|
||||
|
||||
if err := cm.Create("server", "host = 0.0.0.0"); err != nil {
|
||||
t.Fatalf("Create() error = %v", err)
|
||||
}
|
||||
|
||||
content, err := cm.Get("server")
|
||||
if err != nil {
|
||||
t.Fatalf("Get() error = %v", err)
|
||||
}
|
||||
if content != "host = 0.0.0.0" {
|
||||
t.Errorf("Get() = %q, want %q", content, "host = 0.0.0.0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigManager_CreateDuplicate(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cm := NewConfigManager(dir)
|
||||
|
||||
cm.Create("server", "content")
|
||||
err := cm.Create("server", "content2")
|
||||
if err == nil {
|
||||
t.Error("Create() should fail for existing config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigManager_Update(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cm := NewConfigManager(dir)
|
||||
|
||||
cm.Create("server", "old")
|
||||
if err := cm.Update("server", "new"); err != nil {
|
||||
t.Fatalf("Update() error = %v", err)
|
||||
}
|
||||
|
||||
content, _ := cm.Get("server")
|
||||
if content != "new" {
|
||||
t.Errorf("Get() after Update() = %q, want %q", content, "new")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigManager_Delete(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cm := NewConfigManager(dir)
|
||||
|
||||
cm.Create("server", "content")
|
||||
if err := cm.Delete("server"); err != nil {
|
||||
t.Fatalf("Delete() error = %v", err)
|
||||
}
|
||||
|
||||
_, err := cm.Get("server")
|
||||
if err == nil {
|
||||
t.Error("Get() should fail after Delete()")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigManager_Duplicate(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cm := NewConfigManager(dir)
|
||||
|
||||
cm.Create("server", "host = 0.0.0.0")
|
||||
if err := cm.Duplicate("server", "backup"); err != nil {
|
||||
t.Fatalf("Duplicate() error = %v", err)
|
||||
}
|
||||
|
||||
content, _ := cm.Get("backup")
|
||||
if content != "host = 0.0.0.0" {
|
||||
t.Errorf("Get(backup) = %q, want %q", content, "host = 0.0.0.0")
|
||||
}
|
||||
|
||||
configs, _ := cm.List()
|
||||
if len(configs) != 2 {
|
||||
t.Errorf("List() returned %d configs, want 2", len(configs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigManager_ListWithMultipleFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cm := NewConfigManager(dir)
|
||||
|
||||
cm.Create("server", "s")
|
||||
cm.Create("difficulty", "d")
|
||||
os.WriteFile(filepath.Join(dir, "notacfg.txt"), []byte("txt"), 0644)
|
||||
|
||||
configs, err := cm.List()
|
||||
if err != nil {
|
||||
t.Fatalf("List() error = %v", err)
|
||||
}
|
||||
if len(configs) != 2 {
|
||||
t.Errorf("List() returned %d configs, want 2 (should exclude non-.cfg)", len(configs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigManager_GetNotFound(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cm := NewConfigManager(dir)
|
||||
|
||||
_, err := cm.Get("nonexistent")
|
||||
if err == nil {
|
||||
t.Error("Get() should fail for nonexistent config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigManager_CreateWithPathTraversal(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cm := NewConfigManager(dir)
|
||||
|
||||
// Attempt to create with path traversal
|
||||
err := cm.Create("../../etc/passwd", "content")
|
||||
if err != nil {
|
||||
t.Fatalf("Create() error = %v", err)
|
||||
}
|
||||
|
||||
// Verify it was sanitized to just "passwd.cfg" in the cfgDir
|
||||
path := filepath.Join(dir, "passwd.cfg")
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
t.Error("File should be created with sanitized name in cfgDir")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeConfigName(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"server", "server"},
|
||||
{"../etc/passwd", "passwd"},
|
||||
{"../../secret.cfg", "secret"},
|
||||
{"server.cfg", "server"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := sanitizeConfigName(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("sanitizeConfigName(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLogStreamer_SubscribeUnsubscribe(t *testing.T) {
|
||||
ls := NewLogStreamer()
|
||||
|
||||
ch := ls.Subscribe("server", "client1")
|
||||
if ch == nil {
|
||||
t.Fatal("Subscribe() returned nil channel")
|
||||
}
|
||||
|
||||
ls.Unsubscribe("server", "client1")
|
||||
|
||||
// Channel should be closed after unsubscribe
|
||||
select {
|
||||
case _, ok := <-ch:
|
||||
if ok {
|
||||
t.Error("channel should be closed after unsubscribe")
|
||||
}
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Error("channel not closed within timeout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogStreamer_Broadcast(t *testing.T) {
|
||||
ls := NewLogStreamer()
|
||||
|
||||
ch1 := ls.Subscribe("server", "client1")
|
||||
ch2 := ls.Subscribe("server", "client2")
|
||||
|
||||
ls.Broadcast("server", "hello")
|
||||
|
||||
for _, ch := range []chan string{ch1, ch2} {
|
||||
select {
|
||||
case line := <-ch:
|
||||
if line != "hello" {
|
||||
t.Errorf("received %q, want %q", line, "hello")
|
||||
}
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Error("timeout waiting for broadcast")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogStreamer_BroadcastNonBlocking(t *testing.T) {
|
||||
ls := NewLogStreamer()
|
||||
|
||||
ch := ls.Subscribe("server", "client1")
|
||||
|
||||
// Fill the channel (capacity 256)
|
||||
for i := 0; i < 256; i++ {
|
||||
ls.Broadcast("server", "line")
|
||||
}
|
||||
|
||||
// This should not block even though channel is full
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
ls.Broadcast("server", "dropped")
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
// Good, broadcast didn't block
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Error("broadcast blocked on full channel")
|
||||
}
|
||||
|
||||
// Drain all queued messages
|
||||
for i := 0; i < 256; i++ {
|
||||
<-ch
|
||||
}
|
||||
|
||||
// Now the next broadcast should succeed
|
||||
ls.Broadcast("server", "after")
|
||||
select {
|
||||
case line := <-ch:
|
||||
if line != "after" {
|
||||
t.Errorf("received %q, want %q", line, "after")
|
||||
}
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Error("timeout waiting for broadcast")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogStreamer_Stream(t *testing.T) {
|
||||
ls := NewLogStreamer()
|
||||
|
||||
ch := ls.Subscribe("server", "client1")
|
||||
|
||||
reader := strings.NewReader("line1\nline2\nline3\n")
|
||||
go ls.Stream("server", reader, "DONE")
|
||||
|
||||
lines := []string{}
|
||||
for i := 0; i < 4; i++ { // 3 lines + DONE close message
|
||||
select {
|
||||
case line := <-ch:
|
||||
lines = append(lines, line)
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Fatalf("timeout waiting for line %d", i+1)
|
||||
}
|
||||
}
|
||||
|
||||
if lines[0] != "line1" || lines[1] != "line2" || lines[2] != "line3" || lines[3] != "DONE" {
|
||||
t.Errorf("lines = %v, want [line1 line2 line3 DONE]", lines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogStreamer_UnsubscribeCleansUpEmptyServer(t *testing.T) {
|
||||
ls := NewLogStreamer()
|
||||
|
||||
ls.Subscribe("server", "client1")
|
||||
ls.Unsubscribe("server", "client1")
|
||||
|
||||
// After unsubscribing the last client, the server entry should be cleaned up
|
||||
ls.mu.RLock()
|
||||
_, exists := ls.subs["server"]
|
||||
ls.mu.RUnlock()
|
||||
|
||||
if exists {
|
||||
t.Error("empty server entry should be cleaned up after last unsubscribe")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogStreamer_MultipleServers(t *testing.T) {
|
||||
ls := NewLogStreamer()
|
||||
|
||||
chServer := ls.Subscribe("server", "client1")
|
||||
chSteamcmd := ls.Subscribe("steamcmd", "client1")
|
||||
|
||||
ls.Broadcast("server", "server msg")
|
||||
ls.Broadcast("steamcmd", "steamcmd msg")
|
||||
|
||||
select {
|
||||
case line := <-chServer:
|
||||
if line != "server msg" {
|
||||
t.Errorf("server channel received %q, want %q", line, "server msg")
|
||||
}
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Error("timeout on server channel")
|
||||
}
|
||||
|
||||
// SteamCMD channel should not have received the server message
|
||||
select {
|
||||
case line := <-chSteamcmd:
|
||||
if line != "steamcmd msg" {
|
||||
t.Errorf("steamcmd channel received %q, want %q", line, "steamcmd msg")
|
||||
}
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Error("timeout on steamcmd channel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogStreamer_ConcurrentSubscribeUnsubscribe(t *testing.T) {
|
||||
ls := NewLogStreamer()
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := 0; i < 100; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
clientID := "client"
|
||||
ch := ls.Subscribe("server", clientID)
|
||||
_ = ch
|
||||
ls.Unsubscribe("server", clientID)
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"arma3-web-server/internal/models"
|
||||
)
|
||||
|
||||
func TestModlistManager_CreateAndGet(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mm := NewModlistManager(dir)
|
||||
|
||||
ml, err := mm.Create("My Modlist")
|
||||
if err != nil {
|
||||
t.Fatalf("Create() error = %v", err)
|
||||
}
|
||||
if ml.Name != "My Modlist" {
|
||||
t.Errorf("Name = %q, want %q", ml.Name, "My Modlist")
|
||||
}
|
||||
if ml.ID == "" {
|
||||
t.Error("ID should not be empty")
|
||||
}
|
||||
if len(ml.Mods) != 0 {
|
||||
t.Errorf("Mods should be empty, got %d", len(ml.Mods))
|
||||
}
|
||||
|
||||
got, err := mm.Get(ml.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Get() error = %v", err)
|
||||
}
|
||||
if got.Name != "My Modlist" {
|
||||
t.Errorf("Get().Name = %q, want %q", got.Name, "My Modlist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModlistManager_List(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mm := NewModlistManager(dir)
|
||||
|
||||
mm.Create("List A")
|
||||
mm.Create("List B")
|
||||
|
||||
items, err := mm.List()
|
||||
if err != nil {
|
||||
t.Fatalf("List() error = %v", err)
|
||||
}
|
||||
if len(items) != 2 {
|
||||
t.Errorf("List() returned %d items, want 2", len(items))
|
||||
}
|
||||
|
||||
names := map[string]bool{}
|
||||
for _, item := range items {
|
||||
names[item.Name] = true
|
||||
}
|
||||
if !names["List A"] || !names["List B"] {
|
||||
t.Errorf("List() missing expected names: %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModlistManager_Update(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mm := NewModlistManager(dir)
|
||||
|
||||
ml, _ := mm.Create("Original")
|
||||
mods := []models.ModEntry{
|
||||
{ID: "123", Name: "CBA_A3", Enabled: true},
|
||||
{ID: "456", Name: "ACE", Enabled: false},
|
||||
}
|
||||
|
||||
updated, err := mm.Update(ml.ID, "Updated", mods)
|
||||
if err != nil {
|
||||
t.Fatalf("Update() error = %v", err)
|
||||
}
|
||||
if updated.Name != "Updated" {
|
||||
t.Errorf("Name = %q, want %q", updated.Name, "Updated")
|
||||
}
|
||||
if len(updated.Mods) != 2 {
|
||||
t.Errorf("Mods = %d, want 2", len(updated.Mods))
|
||||
}
|
||||
|
||||
got, _ := mm.Get(ml.ID)
|
||||
if got.Name != "Updated" {
|
||||
t.Errorf("Get().Name = %q, want %q", got.Name, "Updated")
|
||||
}
|
||||
if got.Mods[0].ID != "123" {
|
||||
t.Errorf("Get().Mods[0].ID = %q, want %q", got.Mods[0].ID, "123")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModlistManager_Delete(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mm := NewModlistManager(dir)
|
||||
|
||||
ml, _ := mm.Create("To Delete")
|
||||
if err := mm.Delete(ml.ID); err != nil {
|
||||
t.Fatalf("Delete() error = %v", err)
|
||||
}
|
||||
|
||||
_, err := mm.Get(ml.ID)
|
||||
if err == nil {
|
||||
t.Error("Get() should fail after Delete()")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModlistManager_Duplicate(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mm := NewModlistManager(dir)
|
||||
|
||||
orig, _ := mm.Create("Original")
|
||||
mm.Update(orig.ID, "Original", []models.ModEntry{
|
||||
{ID: "123", Name: "CBA_A3", Enabled: true},
|
||||
})
|
||||
|
||||
dup, err := mm.Duplicate(orig.ID, "Copy")
|
||||
if err != nil {
|
||||
t.Fatalf("Duplicate() error = %v", err)
|
||||
}
|
||||
if dup.Name != "Copy" {
|
||||
t.Errorf("Name = %q, want %q", dup.Name, "Copy")
|
||||
}
|
||||
if dup.ID == orig.ID {
|
||||
t.Error("Duplicate should have different ID")
|
||||
}
|
||||
if len(dup.Mods) != 1 {
|
||||
t.Errorf("Mods = %d, want 1", len(dup.Mods))
|
||||
}
|
||||
}
|
||||
|
||||
func TestModlistManager_GetNotFound(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mm := NewModlistManager(dir)
|
||||
|
||||
_, err := mm.Get("nonexistent-uuid")
|
||||
if err == nil {
|
||||
t.Error("Get() should fail for nonexistent modlist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModlistManager_DeleteNotFound(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mm := NewModlistManager(dir)
|
||||
|
||||
err := mm.Delete("nonexistent-uuid")
|
||||
if err == nil {
|
||||
t.Error("Delete() should fail for nonexistent modlist")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"arma3-web-server/internal/models"
|
||||
)
|
||||
|
||||
func TestParseModlistHTML_SingleMod(t *testing.T) {
|
||||
html := `<html><body><table>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">CBA_A3</td>
|
||||
<td><span class="from-steam">Steam</span></td>
|
||||
<td><a href="https://steamcommunity.com/sharedfiles/filedetails/?id=450814997">link</a></td>
|
||||
</tr>
|
||||
</table></body></html>`
|
||||
|
||||
_, mods, err := ParseModlistHTML(strings.NewReader(html))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseModlistHTML() error = %v", err)
|
||||
}
|
||||
if len(mods) != 1 {
|
||||
t.Fatalf("got %d mods, want 1", len(mods))
|
||||
}
|
||||
if mods[0].ID != "450814997" {
|
||||
t.Errorf("mods[0].ID = %q, want %q", mods[0].ID, "450814997")
|
||||
}
|
||||
if mods[0].Name != "CBA_A3" {
|
||||
t.Errorf("mods[0].Name = %q, want %q", mods[0].Name, "CBA_A3")
|
||||
}
|
||||
if !mods[0].Enabled {
|
||||
t.Error("mods[0].Enabled should be true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseModlistHTML_MultipleMods(t *testing.T) {
|
||||
html := `<html><body><table>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">CBA_A3</td>
|
||||
<td><span>Steam</span></td>
|
||||
<td><a href="https://steamcommunity.com/sharedfiles/filedetails/?id=450814997">link</a></td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">ACE3</td>
|
||||
<td><span>Steam</span></td>
|
||||
<td><a href="https://steamcommunity.com/sharedfiles/filedetails/?id=463939057">link</a></td>
|
||||
</tr>
|
||||
</table></body></html>`
|
||||
|
||||
_, mods, err := ParseModlistHTML(strings.NewReader(html))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseModlistHTML() error = %v", err)
|
||||
}
|
||||
if len(mods) != 2 {
|
||||
t.Fatalf("got %d mods, want 2", len(mods))
|
||||
}
|
||||
if mods[0].Name != "CBA_A3" || mods[1].Name != "ACE3" {
|
||||
t.Errorf("mods = [%q, %q], want [CBA_A3, ACE3]", mods[0].Name, mods[1].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseModlistHTML_Deduplication(t *testing.T) {
|
||||
html := `<html><body><table>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">CBA_A3</td>
|
||||
<td><span>Steam</span></td>
|
||||
<td><a href="https://steamcommunity.com/sharedfiles/filedetails/?id=450814997">link</a></td>
|
||||
</tr>
|
||||
<tr data-type="ModContainer">
|
||||
<td data-type="DisplayName">CBA_A3</td>
|
||||
<td><span>Steam</span></td>
|
||||
<td><a href="https://steamcommunity.com/sharedfiles/filedetails/?id=450814997">link</a></td>
|
||||
</tr>
|
||||
</table></body></html>`
|
||||
|
||||
_, mods, _ := ParseModlistHTML(strings.NewReader(html))
|
||||
if len(mods) != 1 {
|
||||
t.Errorf("got %d mods, want 1 (deduplication)", len(mods))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseModlistHTML_EmptyTable(t *testing.T) {
|
||||
html := `<html><body><table></table></body></html>`
|
||||
_, mods, err := ParseModlistHTML(strings.NewReader(html))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseModlistHTML() error = %v", err)
|
||||
}
|
||||
if len(mods) != 0 {
|
||||
t.Errorf("got %d mods, want 0", len(mods))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseModlistHTML_InvalidHTML(t *testing.T) {
|
||||
// Go's html parser is lenient and returns empty results for garbage input
|
||||
_, mods, err := ParseModlistHTML(strings.NewReader("not html at all <>"))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseModlistHTML() should not error on lenient parser, got: %v", err)
|
||||
}
|
||||
if len(mods) != 0 {
|
||||
t.Errorf("ParseModlistHTML() returned %d mods for garbage input, want 0", len(mods))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderModlistHTML(t *testing.T) {
|
||||
mods := []models.ModEntry{
|
||||
{ID: "450814997", Name: "CBA_A3", Enabled: true},
|
||||
{ID: "463939057", Name: "ACE3", Enabled: true},
|
||||
}
|
||||
|
||||
output, err := RenderModlistHTML("Test", mods)
|
||||
if err != nil {
|
||||
t.Fatalf("RenderModlistHTML() error = %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(output, "450814997") {
|
||||
t.Error("output should contain mod ID 450814997")
|
||||
}
|
||||
if !strings.Contains(output, "CBA_A3") {
|
||||
t.Error("output should contain mod name CBA_A3")
|
||||
}
|
||||
if !strings.Contains(output, "463939057") {
|
||||
t.Error("output should contain mod ID 463939057")
|
||||
}
|
||||
if !strings.Contains(output, "<?xml") {
|
||||
t.Error("output should start with XML declaration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderModlistHTML_SkipsEmptyIDs(t *testing.T) {
|
||||
mods := []models.ModEntry{
|
||||
{ID: "", Name: "No ID", Enabled: true},
|
||||
{ID: "450814997", Name: "CBA_A3", Enabled: true},
|
||||
}
|
||||
|
||||
output, err := RenderModlistHTML("Test", mods)
|
||||
if err != nil {
|
||||
t.Fatalf("RenderModlistHTML() error = %v", err)
|
||||
}
|
||||
if strings.Contains(output, "No ID") {
|
||||
t.Error("output should not contain mod with empty ID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileNameToModlistName(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"my_modlist.html", "my modlist"},
|
||||
{"LibUkraine Final (3).html", "LibUkraine Final (3)"},
|
||||
{"test-file-name.html", "test file name"},
|
||||
{"simple.html", "simple"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := FileNameToModlistName(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("FileNameToModlistName(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanModName(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"CBA_A3", "CBA_A3"},
|
||||
{"mod/name", "mod_name"},
|
||||
{"mod\\name", "mod_name"},
|
||||
{" spaces ", "spaces"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := cleanModName(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("cleanModName(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSettingsManager_LoadDefaults(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sm := NewSettingsManager(dir)
|
||||
|
||||
s, err := sm.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if s.IPPort != "0.0.0.0:2302" {
|
||||
t.Errorf("IPPort = %q, want %q", s.IPPort, "0.0.0.0:2302")
|
||||
}
|
||||
if s.ServerParameters != "-server -world=empty -loadMissionToMemory -noPause" {
|
||||
t.Errorf("ServerParameters = %q", s.ServerParameters)
|
||||
}
|
||||
if s.SteamBranch != "stable" {
|
||||
t.Errorf("SteamBranch = %q, want %q", s.SteamBranch, "stable")
|
||||
}
|
||||
if s.SteamUser != "anonymous" {
|
||||
t.Errorf("SteamUser = %q, want %q", s.SteamUser, "anonymous")
|
||||
}
|
||||
if s.Platform != "linux" {
|
||||
t.Errorf("Platform = %q, want %q", s.Platform, "linux")
|
||||
}
|
||||
if s.AutoUpdateOnStartup {
|
||||
t.Error("AutoUpdateOnStartup should be false by default")
|
||||
}
|
||||
if s.WasRunning {
|
||||
t.Error("WasRunning should be false by default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsManager_SaveLoadRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sm := NewSettingsManager(dir)
|
||||
|
||||
s, err := sm.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
|
||||
s.IPPort = "192.168.1.100:2400"
|
||||
s.SteamUser = "myuser"
|
||||
s.Platform = "windows"
|
||||
s.CBASettings = "some_cba_content"
|
||||
s.AutoUpdateOnStartup = true
|
||||
s.WasRunning = true
|
||||
s.ScheduledUpdate = "0 4 * * *"
|
||||
|
||||
if err := sm.Save(s); err != nil {
|
||||
t.Fatalf("Save() error = %v", err)
|
||||
}
|
||||
|
||||
// Verify file exists
|
||||
if _, err := os.Stat(filepath.Join(dir, "settings.json")); os.IsNotExist(err) {
|
||||
t.Fatal("settings.json not created")
|
||||
}
|
||||
|
||||
// Load in a new manager to verify persistence
|
||||
sm2 := NewSettingsManager(dir)
|
||||
s2, err := sm2.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if s2.IPPort != "192.168.1.100:2400" {
|
||||
t.Errorf("IPPort = %q, want %q", s2.IPPort, "192.168.1.100:2400")
|
||||
}
|
||||
if s2.SteamUser != "myuser" {
|
||||
t.Errorf("SteamUser = %q, want %q", s2.SteamUser, "myuser")
|
||||
}
|
||||
if s2.Platform != "windows" {
|
||||
t.Errorf("Platform = %q, want %q", s2.Platform, "windows")
|
||||
}
|
||||
if s2.CBASettings != "some_cba_content" {
|
||||
t.Errorf("CBASettings = %q, want %q", s2.CBASettings, "some_cba_content")
|
||||
}
|
||||
if !s2.AutoUpdateOnStartup {
|
||||
t.Error("AutoUpdateOnStartup should be true")
|
||||
}
|
||||
if !s2.WasRunning {
|
||||
t.Error("WasRunning should be true")
|
||||
}
|
||||
if s2.ScheduledUpdate != "0 4 * * *" {
|
||||
t.Errorf("ScheduledUpdate = %q, want %q", s2.ScheduledUpdate, "0 4 * * *")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsManager_LoadMissingFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sm := NewSettingsManager(dir)
|
||||
|
||||
s, err := sm.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v, want nil", err)
|
||||
}
|
||||
if s == nil {
|
||||
t.Fatal("Load() returned nil settings")
|
||||
}
|
||||
if s.IPPort != "0.0.0.0:2302" {
|
||||
t.Errorf("default IPPort = %q, want %q", s.IPPort, "0.0.0.0:2302")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsManager_SaveUpdatesTimestamp(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sm := NewSettingsManager(dir)
|
||||
|
||||
s, _ := sm.Load()
|
||||
s.SteamUser = "test"
|
||||
if err := sm.Save(s); err != nil {
|
||||
t.Fatalf("Save() error = %v", err)
|
||||
}
|
||||
|
||||
s2, _ := sm.Load()
|
||||
if s2.UpdatedAt.IsZero() {
|
||||
t.Error("UpdatedAt should be set after Save()")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user