package static import ( "encoding/json" "strconv" "strings" "sync" "testing" "unicode/utf8" "github.com/alexei/tinyforge/internal/workload/plugin" ) func TestIdShort_TruncatesLongID(t *testing.T) { got := idShort(plugin.Workload{ID: "abcd1234-5678-1234-abcd-deadbeef0000"}) if got != "abcd1234" { t.Fatalf("idShort = %q, want %q", got, "abcd1234") } } func TestIdShort_ShortIDPassesThrough(t *testing.T) { // IDs shorter than 8 chars must not panic on slicing. got := idShort(plugin.Workload{ID: "abc"}) if got != "abc" { t.Fatalf("idShort = %q, want %q", got, "abc") } } func TestIdShort_ExactlyEightChars(t *testing.T) { got := idShort(plugin.Workload{ID: "12345678"}) if got != "12345678" { t.Fatalf("idShort = %q, want %q", got, "12345678") } } func TestContainerNameFor_Shape(t *testing.T) { w := plugin.Workload{ID: "abcd1234-rest", Name: "mysite"} got := containerNameFor(w) if got != "dw-site-mysite-abcd1234" { t.Fatalf("containerNameFor = %q, want %q", got, "dw-site-mysite-abcd1234") } } func TestImageTagFor_Shape(t *testing.T) { w := plugin.Workload{ID: "abcd1234-rest", Name: "mysite"} got := imageTagFor(w) if got != "dw-site-mysite-abcd1234:latest" { t.Fatalf("imageTagFor = %q, want %q", got, "dw-site-mysite-abcd1234:latest") } } func TestSiteVolumeKey_Shape(t *testing.T) { w := plugin.Workload{ID: "abcd1234-rest", Name: "mysite"} got := siteVolumeKey(w) if got != "mysite-abcd1234" { t.Fatalf("siteVolumeKey = %q, want %q", got, "mysite-abcd1234") } } func TestNaming_TwoWorkloadsSameNameGetDifferentResources(t *testing.T) { // Workload names are not UNIQUE in the schema; the ID short suffix // is the only thing keeping two same-named workloads from clobbering // each other's container / image / volume. a := plugin.Workload{ID: "aaaaaaaa-rest", Name: "site"} b := plugin.Workload{ID: "bbbbbbbb-rest", Name: "site"} if containerNameFor(a) == containerNameFor(b) { t.Errorf("container names collide for same-named workloads: %q", containerNameFor(a)) } if imageTagFor(a) == imageTagFor(b) { t.Errorf("image tags collide for same-named workloads: %q", imageTagFor(a)) } if siteVolumeKey(a) == siteVolumeKey(b) { t.Errorf("volume keys collide for same-named workloads: %q", siteVolumeKey(a)) } } func TestSanitizeError_Empty(t *testing.T) { if got := sanitizeError("", "tok"); got != "" { t.Fatalf("sanitizeError(\"\") = %q, want \"\"", got) } } func TestSanitizeError_CollapsesWhitespace(t *testing.T) { got := sanitizeError("line1\nline2\rline3\tline4", "") if strings.ContainsAny(got, "\n\r\t") { t.Fatalf("sanitizeError did not collapse whitespace: %q", got) } if got != "line1 line2 line3 line4" { t.Fatalf("sanitizeError = %q, want %q", got, "line1 line2 line3 line4") } } func TestSanitizeError_RedactsAccessToken(t *testing.T) { tok := "ghp_supersecrettoken" msg := "401 from gitea using token=" + tok + " ok" got := sanitizeError(msg, tok) if strings.Contains(got, tok) { t.Fatalf("sanitizeError leaked token: %q", got) } if !strings.Contains(got, "[REDACTED]") { t.Fatalf("sanitizeError missing [REDACTED] marker: %q", got) } } func TestSanitizeError_EmptyTokenIsNoOp(t *testing.T) { // An empty token must not redact arbitrary substrings (a naive // ReplaceAll with "" splits the string by every byte boundary). msg := "auth failed" got := sanitizeError(msg, "") if got != msg { t.Fatalf("sanitizeError(msg, \"\") = %q, want %q", got, msg) } } func TestSanitizeError_TruncatesLongInput(t *testing.T) { // 240-byte cap from the implementation. long := strings.Repeat("a", 1000) got := sanitizeError(long, "") if !strings.HasSuffix(got, "…") { t.Fatalf("sanitizeError did not append ellipsis: ...%s", got[max(0, len(got)-20):]) } // 240 bytes of "a" plus the three-byte ellipsis rune. if len(got) != 240+len("…") { t.Fatalf("sanitizeError length = %d, want %d", len(got), 240+len("…")) } } func TestSanitizeError_MultibyteRuneAtCutoff(t *testing.T) { // The truncation slices at byte 240 — if a multi-byte rune straddles // that boundary the output ends in a broken rune sequence. The // implementation is byte-sliced today so this is more a "guard the // expected behavior" test: the function must still produce valid // UTF-8 on input that wasn't already broken, OR ship a known // fix-needed test if the implementation changes. "é" is 2 bytes // (C3 A9); 1000 of them = 2000 bytes well past the cap. long := strings.Repeat("é", 1000) got := sanitizeError(long, "") if !strings.HasSuffix(got, "…") { t.Fatalf("sanitizeError did not append ellipsis on multi-byte input: %q", got) } // Output must remain valid UTF-8 — a torn rune at the cap would // fail this check. utf8.ValidString is the canonical guard. if !utf8.ValidString(got) { t.Errorf("sanitizeError produced invalid UTF-8 at byte cap: %q", got) } } func TestSanitizeError_ShortInputUnchanged(t *testing.T) { got := sanitizeError("short message", "") if got != "short message" { t.Fatalf("sanitizeError mangled short input: %q", got) } } func TestContainerRowID_Deterministic(t *testing.T) { w := plugin.Workload{ID: "abcd1234-rest"} a := containerRowID(w) b := containerRowID(w) if a != b { t.Fatalf("containerRowID not deterministic: %q vs %q", a, b) } if a != "abcd1234-rest:site" { t.Fatalf("containerRowID = %q, want %q", a, "abcd1234-rest:site") } } func TestSaveLock_FreedWhenIdle(t *testing.T) { // After the last holder releases, the reference-counted entry must be // removed from the map so the lock table cannot grow without bound. // Suffix by t.Name() so the package-global saveLocks map cannot bleed // key state between tests (or between -count=N runs). key := t.Name() + "-wid" lk := acquireSaveLock(key) saveLocks.mu.Lock() _, present := saveLocks.locks[key] saveLocks.mu.Unlock() if !present { t.Fatal("acquireSaveLock did not register the entry while held") } releaseSaveLock(key, lk) saveLocks.mu.Lock() _, stillPresent := saveLocks.locks[key] saveLocks.mu.Unlock() if stillPresent { t.Fatal("releaseSaveLock left the entry behind after the last holder released") } } func TestSaveLock_DistinctWorkloadsDoNotSerialize(t *testing.T) { // Two different workloads must be lockable at the same time. If they // shared a mutex the second acquire would block forever (deadlock). a := acquireSaveLock(t.Name() + "-a") b := acquireSaveLock(t.Name() + "-b") releaseSaveLock(t.Name()+"-b", b) releaseSaveLock(t.Name()+"-a", a) } func TestSaveLock_SerializesConcurrentAcquisitions(t *testing.T) { // Goroutines acquiring the same workload's lock must run sequentially. // The counter would race past 1 if locking were broken; with the lock, // peak in-flight stays at 1. key := t.Name() + "-wid" var ( wg sync.WaitGroup mu sync.Mutex counter int peak int ) for i := 0; i < 10; i++ { wg.Add(1) go func() { defer wg.Done() lk := acquireSaveLock(key) defer releaseSaveLock(key, lk) mu.Lock() counter++ if counter > peak { peak = counter } mu.Unlock() mu.Lock() counter-- mu.Unlock() }() } wg.Wait() if peak != 1 { t.Fatalf("acquireSaveLock failed to serialize: peak in-flight = %d, want 1", peak) } } func TestSaveLock_ConcurrentMapAccessIsSafe(t *testing.T) { // Distinct workloads acquired+released in parallel must not panic on map // access — exercises the outer-mutex protection inside acquire/release. // Each iteration uses a unique key so the test stresses the insertion + // refcount-cleanup paths (the common case for "first deploy" callers). prefix := t.Name() + "-" var wg sync.WaitGroup for i := 0; i < 50; i++ { i := i wg.Add(1) go func() { defer wg.Done() key := prefix + strconv.Itoa(i) lk := acquireSaveLock(key) releaseSaveLock(key, lk) }() } wg.Wait() } func TestRuntimeState_JSONTagsRoundTrip(t *testing.T) { // saveState/loadState rely on these tag names to merge into the // generic extra_json map without doubling keys. in := runtimeState{ LastCommitSHA: "deadbeef", LastSyncAt: "2026-05-16T00:00:00Z", LastError: "nope", Status: "deployed", } b, err := json.Marshal(in) if err != nil { t.Fatalf("marshal: %v", err) } // Decode into a generic map and assert it has the exact same key // set as runtimeStateKeys. Renaming a JSON tag without updating // runtimeStateKeys would break the "clearing a typed field removes // the key" invariant; renaming/adding to runtimeStateKeys without // changing a tag would silently drop a sibling key from extra_json. // Both regressions fail this test. gotKeys := map[string]bool{} var asMap map[string]json.RawMessage if err := json.Unmarshal(b, &asMap); err != nil { t.Fatalf("unmarshal to map: %v", err) } for k := range asMap { gotKeys[k] = true } wantKeys := map[string]bool{} for _, k := range runtimeStateKeys { wantKeys[k] = true } if len(gotKeys) != len(wantKeys) { t.Errorf("runtimeStateKeys (%d) and JSON tag set (%d) differ in size: %v vs %v", len(wantKeys), len(gotKeys), runtimeStateKeys, asMap) } for k := range wantKeys { if !gotKeys[k] { t.Errorf("runtimeStateKeys lists %q but JSON output has no such key: %v", k, asMap) } } for k := range gotKeys { if !wantKeys[k] { t.Errorf("JSON output has %q but runtimeStateKeys does not: %v", k, runtimeStateKeys) } } var out runtimeState if err := json.Unmarshal(b, &out); err != nil { t.Fatalf("unmarshal: %v", err) } if out != in { t.Fatalf("round-trip mismatch: %+v vs %+v", out, in) } } func TestRuntimeState_OmitsEmptyFields(t *testing.T) { // The struct uses `omitempty` everywhere so a freshly-created site // with no recorded sync yet doesn't write a wall of empty strings // into extra_json. b, err := json.Marshal(runtimeState{}) if err != nil { t.Fatalf("marshal: %v", err) } if string(b) != "{}" { t.Fatalf("zero runtimeState JSON = %q, want %q", string(b), "{}") } }