package server import ( "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "testing" "time" ) // fakeTool writes an executable stand-in for one of the external tools and // points the matching environment variable at it. Must be called before // setupTestServer, which is what reads the configuration. func fakeTool(t *testing.T, envVar, name, body string) { t.Helper() path := filepath.Join(t.TempDir(), name) if err := os.WriteFile(path, []byte("#!/bin/sh\n"+body+"\n"), 0755); err != nil { t.Fatal(err) } t.Setenv(envVar, path) } func getHealth(t *testing.T, router http.Handler) (*httptest.ResponseRecorder, map[string]any) { t.Helper() w := getWith(router, "/healthz", nil) var body map[string]any if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { t.Fatalf("decode /healthz body %q: %v", w.Body.String(), err) } return w, body } func dependencies(t *testing.T, body map[string]any) map[string]any { t.Helper() deps, ok := body["dependencies"].(map[string]any) if !ok { t.Fatalf("no dependencies object in %v", body) } return deps } func TestHealthReportsToolVersions(t *testing.T) { fakeTool(t, "VIDARCHIVE_YTDLP_PATH", "yt-dlp", `echo 2026.01.01`) fakeTool(t, "VIDARCHIVE_FFMPEG_PATH", "ffmpeg", `echo "ffmpeg version 7.1 Copyright (c) 2000-2024"`) fakeTool(t, "VIDARCHIVE_FFPROBE_PATH", "ffprobe", `echo "ffprobe version 7.1 Copyright (c) 2000-2024"`) srv, _, cleanup := setupTestServer(t) defer cleanup() w, body := getHealth(t, srv.Router()) if w.Code != http.StatusOK { t.Errorf("status = %d, want 200", w.Code) } if got := body["status"]; got != "ok" { t.Errorf("status field = %v, want ok", got) } deps := dependencies(t, body) if got := deps["database"]; got != "ok" { t.Errorf("database = %v, want ok", got) } if got := deps["yt-dlp"]; got != "2026.01.01" { t.Errorf("yt-dlp = %v, want 2026.01.01", got) } // "ffmpeg version 7.1 ..." is reduced to the version token. if got := deps["ffmpeg"]; got != "7.1" { t.Errorf("ffmpeg = %v, want 7.1", got) } } // A missing external tool is reported but does not fail the check: the UI still // works, only downloads are affected. func TestHealthMissingToolIsNotFatal(t *testing.T) { t.Setenv("VIDARCHIVE_YTDLP_PATH", "/nonexistent/yt-dlp") srv, _, cleanup := setupTestServer(t) defer cleanup() w, body := getHealth(t, srv.Router()) if w.Code != http.StatusOK { t.Errorf("status = %d, want 200 for a missing tool", w.Code) } if got := body["status"]; got != "ok" { t.Errorf("status field = %v, want ok", got) } if got := dependencies(t, body)["yt-dlp"]; got != "not installed" { t.Errorf("yt-dlp = %v, want \"not installed\"", got) } } // An unreachable database is the one condition that must fail the check, so an // orchestrator can act on it. func TestHealthUnreachableDatabaseIs503(t *testing.T) { srv, _, db, cleanup := setupTestServerDB(t) defer cleanup() if err := db.Close(); err != nil { t.Fatalf("close db: %v", err) } w, body := getHealth(t, srv.Router()) if w.Code != http.StatusServiceUnavailable { t.Errorf("status = %d, want 503", w.Code) } if got := body["status"]; got != "unhealthy" { t.Errorf("status field = %v, want unhealthy", got) } if got := dependencies(t, body)["database"]; got != "unreachable" { t.Errorf("database = %v, want unreachable", got) } } // A wedged tool must not wedge the probe: an orchestrator reads a stuck request // as "still starting" and never restarts the container. func TestHealthBoundsAHangingTool(t *testing.T) { fakeTool(t, "VIDARCHIVE_YTDLP_PATH", "yt-dlp", `sleep 60`) srv, _, cleanup := setupTestServer(t) defer cleanup() start := time.Now() w, body := getHealth(t, srv.Router()) elapsed := time.Since(start) // The handler allows 3s; anything near the 60s sleep means it is unbounded. if elapsed > 15*time.Second { t.Errorf("probe took %v, want it bounded by the handler timeout", elapsed) } if w.Code != http.StatusOK { t.Errorf("status = %d, want 200", w.Code) } if got := dependencies(t, body)["yt-dlp"]; got != "timed out" { t.Errorf("yt-dlp = %v, want \"timed out\"", got) } }