Files
tiny-forge/internal/workload/plugin/source/static/helpers_test.go
T
alexei.dolgolyov bd7a11d4e7 refactor(source): dedup shared helpers across static + dockerfile plugins
Extract the verbatim-duplicated helpers into shared homes:
- buildEnv -> plugin.BuildWorkloadEnv (base plugin pkg; a sourceName param
  preserves each plugin's slog prefix / log-scraper text)
- idShort -> plugin.IDShort
- commitStatusReporter -> staticsite.CommitStatusReporter, re-parameterized
  on primitives (owner/repo/sha/targetURL/enabled) so staticsite needs no
  dependency on the plugin package; reporter tests ported to staticsite
  (plus a new nil-provider case)

containerNameFor/imageTagFor are intentionally left per-plugin: their
prefixes differ (dw-site- vs tf-build-) and name real Docker resources,
so merging them would risk mis-routing. Behavior-preserving; the
static/dockerfile test suites pass unchanged.

Reviewed: go APPROVE (0 CRITICAL/HIGH).
2026-05-29 14:57:30 +03:00

323 lines
9.8 KiB
Go

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 := plugin.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 := plugin.IDShort(plugin.Workload{ID: "abc"})
if got != "abc" {
t.Fatalf("idShort = %q, want %q", got, "abc")
}
}
func TestIdShort_ExactlyEightChars(t *testing.T) {
got := plugin.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), "{}")
}
}