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:
MrFastwind
2026-07-23 20:14:56 +02:00
parent 914e0fbe48
commit d55200886b
15 changed files with 1765 additions and 67 deletions
@@ -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)
}
}
}
+125
View File
@@ -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()")
}
}