v0.3.0: fix HTTP client leak, add tests and CI pipeline
Reuse a single long-poll HTTP client instead of creating one per Events() call (~every 30s). Make TLS skip-verify configurable via syncthing_insecure_tls. Log previously swallowed config errors. Add unit tests for all monitor trackers, config, and state logic. Add CI workflow (vet, golangci-lint, govulncheck, go test -race). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
28
internal/monitor/conflicts_test.go
Normal file
28
internal/monitor/conflicts_test.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package monitor
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestConflictTracker_IncrementAndCount(t *testing.T) {
|
||||
ct := NewConflictTracker()
|
||||
if ct.Count() != 0 {
|
||||
t.Fatalf("initial count should be 0, got %d", ct.Count())
|
||||
}
|
||||
|
||||
ct.Increment()
|
||||
ct.Increment()
|
||||
ct.Increment()
|
||||
|
||||
if ct.Count() != 3 {
|
||||
t.Errorf("expected 3, got %d", ct.Count())
|
||||
}
|
||||
}
|
||||
|
||||
func TestConflictTracker_SetCount(t *testing.T) {
|
||||
ct := NewConflictTracker()
|
||||
ct.Increment()
|
||||
ct.SetCount(42)
|
||||
|
||||
if ct.Count() != 42 {
|
||||
t.Errorf("expected 42 after SetCount, got %d", ct.Count())
|
||||
}
|
||||
}
|
||||
67
internal/monitor/folders_test.go
Normal file
67
internal/monitor/folders_test.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package monitor
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
st "git.davoryn.de/calic/syncwarden/internal/syncthing"
|
||||
)
|
||||
|
||||
func TestFolderTracker_UpdateFromConfig(t *testing.T) {
|
||||
ft := NewFolderTracker()
|
||||
ft.UpdateFromConfig([]st.FolderConfig{
|
||||
{ID: "docs", Label: "Documents", Path: "/home/user/docs"},
|
||||
{ID: "photos", Label: "Photos", Path: "/home/user/photos"},
|
||||
})
|
||||
|
||||
folders := ft.Folders()
|
||||
if len(folders) != 2 {
|
||||
t.Fatalf("expected 2 folders, got %d", len(folders))
|
||||
}
|
||||
if folders[0].Label != "Documents" {
|
||||
t.Errorf("expected label 'Documents', got %q", folders[0].Label)
|
||||
}
|
||||
if folders[0].State != "unknown" {
|
||||
t.Errorf("initial state should be 'unknown', got %q", folders[0].State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFolderTracker_EmptyLabelFallback(t *testing.T) {
|
||||
ft := NewFolderTracker()
|
||||
ft.UpdateFromConfig([]st.FolderConfig{
|
||||
{ID: "my-folder", Label: "", Path: "/data"},
|
||||
})
|
||||
|
||||
folders := ft.Folders()
|
||||
if folders[0].Label != "my-folder" {
|
||||
t.Errorf("empty label should fall back to ID, got %q", folders[0].Label)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFolderTracker_UpdateStatus(t *testing.T) {
|
||||
ft := NewFolderTracker()
|
||||
ft.UpdateFromConfig([]st.FolderConfig{
|
||||
{ID: "docs", Label: "Docs", Path: "/docs"},
|
||||
})
|
||||
|
||||
ft.UpdateStatus("docs", "syncing")
|
||||
|
||||
folders := ft.Folders()
|
||||
if folders[0].State != "syncing" {
|
||||
t.Errorf("expected 'syncing', got %q", folders[0].State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFolderTracker_UpdateStatusNonexistent(t *testing.T) {
|
||||
ft := NewFolderTracker()
|
||||
ft.UpdateFromConfig([]st.FolderConfig{
|
||||
{ID: "docs", Label: "Docs", Path: "/docs"},
|
||||
})
|
||||
|
||||
// Should not panic
|
||||
ft.UpdateStatus("nonexistent", "idle")
|
||||
|
||||
folders := ft.Folders()
|
||||
if folders[0].State != "unknown" {
|
||||
t.Errorf("existing folder should be unchanged, got %q", folders[0].State)
|
||||
}
|
||||
}
|
||||
@@ -82,7 +82,9 @@ func (m *Monitor) Stop() {
|
||||
m.mu.Lock()
|
||||
m.cfg.LastEventID = m.events.LastEventID()
|
||||
m.mu.Unlock()
|
||||
_ = config.Save(m.cfg)
|
||||
if err := config.Save(m.cfg); err != nil {
|
||||
log.Printf("config save error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Monitor) pollLoop() {
|
||||
|
||||
44
internal/monitor/recent_test.go
Normal file
44
internal/monitor/recent_test.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package monitor
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRecentTracker_AddOrder(t *testing.T) {
|
||||
rt := NewRecentTracker()
|
||||
rt.Add("a.txt", "docs")
|
||||
rt.Add("b.txt", "docs")
|
||||
|
||||
files := rt.Files()
|
||||
if len(files) != 2 {
|
||||
t.Fatalf("expected 2 files, got %d", len(files))
|
||||
}
|
||||
if files[0].Name != "b.txt" {
|
||||
t.Errorf("most recent should be first, got %s", files[0].Name)
|
||||
}
|
||||
if files[1].Name != "a.txt" {
|
||||
t.Errorf("oldest should be last, got %s", files[1].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecentTracker_RingBufferOverflow(t *testing.T) {
|
||||
rt := NewRecentTracker()
|
||||
for i := 0; i < 15; i++ {
|
||||
rt.Add("file", "f")
|
||||
}
|
||||
files := rt.Files()
|
||||
if len(files) != maxRecentFiles {
|
||||
t.Errorf("expected %d files after overflow, got %d", maxRecentFiles, len(files))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecentTracker_FilesCopy(t *testing.T) {
|
||||
rt := NewRecentTracker()
|
||||
rt.Add("a.txt", "docs")
|
||||
|
||||
files := rt.Files()
|
||||
files[0].Name = "mutated"
|
||||
|
||||
original := rt.Files()
|
||||
if original[0].Name != "a.txt" {
|
||||
t.Error("Files() should return a copy, but internal state was mutated")
|
||||
}
|
||||
}
|
||||
60
internal/monitor/speed_test.go
Normal file
60
internal/monitor/speed_test.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package monitor
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSpeedTracker_FirstUpdateBaseline(t *testing.T) {
|
||||
s := NewSpeedTracker()
|
||||
s.Update(1000, 500)
|
||||
|
||||
down, up := s.Rates()
|
||||
if down != 0 || up != 0 {
|
||||
t.Errorf("first update should be baseline (0,0), got (%f,%f)", down, up)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpeedTracker_RateCalculation(t *testing.T) {
|
||||
s := NewSpeedTracker()
|
||||
|
||||
// Seed baseline
|
||||
s.mu.Lock()
|
||||
s.lastIn = 0
|
||||
s.lastOut = 0
|
||||
s.lastTime = time.Now().Add(-1 * time.Second)
|
||||
s.mu.Unlock()
|
||||
|
||||
s.Update(1000, 500)
|
||||
|
||||
down, up := s.Rates()
|
||||
// Allow some tolerance for timing
|
||||
if down < 900 || down > 1100 {
|
||||
t.Errorf("expected ~1000 B/s down, got %f", down)
|
||||
}
|
||||
if up < 400 || up > 600 {
|
||||
t.Errorf("expected ~500 B/s up, got %f", up)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpeedTracker_NegativeDeltaClamped(t *testing.T) {
|
||||
s := NewSpeedTracker()
|
||||
|
||||
// Seed with high values
|
||||
s.mu.Lock()
|
||||
s.lastIn = 5000
|
||||
s.lastOut = 3000
|
||||
s.lastTime = time.Now().Add(-1 * time.Second)
|
||||
s.mu.Unlock()
|
||||
|
||||
// Update with lower values (counter reset)
|
||||
s.Update(100, 50)
|
||||
|
||||
down, up := s.Rates()
|
||||
if down < 0 {
|
||||
t.Errorf("negative download rate not clamped: %f", down)
|
||||
}
|
||||
if up < 0 {
|
||||
t.Errorf("negative upload rate not clamped: %f", up)
|
||||
}
|
||||
}
|
||||
68
internal/monitor/state_test.go
Normal file
68
internal/monitor/state_test.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package monitor
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.davoryn.de/calic/syncwarden/internal/icons"
|
||||
)
|
||||
|
||||
func TestStateFromFolders_Empty(t *testing.T) {
|
||||
got := stateFromFolders(nil, false)
|
||||
if got != icons.StateIdle {
|
||||
t.Errorf("empty folders should be idle, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStateFromFolders_Idle(t *testing.T) {
|
||||
folders := []FolderInfo{
|
||||
{ID: "a", State: "idle"},
|
||||
{ID: "b", State: "idle"},
|
||||
}
|
||||
got := stateFromFolders(folders, false)
|
||||
if got != icons.StateIdle {
|
||||
t.Errorf("all idle should be StateIdle, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStateFromFolders_Syncing(t *testing.T) {
|
||||
folders := []FolderInfo{
|
||||
{ID: "a", State: "idle"},
|
||||
{ID: "b", State: "syncing"},
|
||||
}
|
||||
got := stateFromFolders(folders, false)
|
||||
if got != icons.StateSyncing {
|
||||
t.Errorf("syncing folder should produce StateSyncing, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStateFromFolders_ScanningIsSyncing(t *testing.T) {
|
||||
folders := []FolderInfo{
|
||||
{ID: "a", State: "scanning"},
|
||||
}
|
||||
got := stateFromFolders(folders, false)
|
||||
if got != icons.StateSyncing {
|
||||
t.Errorf("scanning should map to StateSyncing, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStateFromFolders_ErrorOverSyncing(t *testing.T) {
|
||||
folders := []FolderInfo{
|
||||
{ID: "a", State: "syncing"},
|
||||
{ID: "b", State: "error"},
|
||||
}
|
||||
got := stateFromFolders(folders, false)
|
||||
if got != icons.StateError {
|
||||
t.Errorf("error should take priority over syncing, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStateFromFolders_PausedOverAll(t *testing.T) {
|
||||
folders := []FolderInfo{
|
||||
{ID: "a", State: "error"},
|
||||
{ID: "b", State: "syncing"},
|
||||
}
|
||||
got := stateFromFolders(folders, true)
|
||||
if got != icons.StatePaused {
|
||||
t.Errorf("paused should override all states, got %d", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user