Files
Felis/internal/updater/runner_test.go

211 lines
7.5 KiB
Go

package updater
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"felis.lolicon.best/internal/updates"
)
func mustV(t *testing.T, s string) updates.Version {
t.Helper()
v, err := updates.Parse(s)
if err != nil {
t.Fatalf("parse %q: %v", s, err)
}
return v
}
// fakeGatherer returns canned current versions, or an error for names in failFor.
type fakeGatherer struct {
cur map[string]updates.Version
failFor map[string]bool
}
func (f fakeGatherer) Current(_ context.Context, s Spec) (updates.Version, error) {
if f.failFor[s.Name] {
return updates.Version{}, errors.New("gather boom")
}
v, ok := f.cur[s.Name]
if !ok {
return updates.Version{}, errors.New("no current for " + s.Name)
}
return v, nil
}
// fakeSource returns canned latest versions, isolating the runner's wiring from any
// real release source.
type fakeSource struct{ latest map[string]updates.Version }
func (f fakeSource) Latest(_ context.Context, c updates.Component) (updates.Version, error) {
v, ok := f.latest[c.Name]
if !ok {
return updates.Version{}, errors.New("not found")
}
return v, nil
}
// TestRunnerReportOnlyComposesPlan drives the whole slice with fakes and nil
// notifier/applier (report-only): gather → assemble → updates.Run → Report. With the
// window open, the two Scheduled+manageable components (felis-api, cloudflared) plan
// to Apply, but with no applier wired the runner applies nothing and records the
// no-applier error — the honest report-only state, not a silent success.
func TestRunnerReportOnlyComposesPlan(t *testing.T) {
now := time.Date(2026, 7, 4, 3, 30, 0, 0, time.UTC)
window := updates.Window{
Start: time.Date(2026, 7, 4, 3, 0, 0, 0, time.UTC),
End: time.Date(2026, 7, 4, 4, 0, 0, 0, time.UTC),
}
gath := fakeGatherer{cur: map[string]updates.Version{
"felis-api": mustV(t, "1.4.0"),
"k3s": mustV(t, "v1.30.2+k3s1"),
"cloudflared": mustV(t, "2024.2.1"),
"velocity": mustV(t, "3.1.1"),
}}
src := fakeSource{latest: map[string]updates.Version{
"felis-api": mustV(t, "1.5.0"), // newer stable → apply (scheduled+manageable, window open)
"k3s": mustV(t, "v1.30.3+k3s1"), // newer → notify (high blast radius, unmanageable)
"cloudflared": mustV(t, "2024.3.0"), // newer stable → apply
"velocity": mustV(t, "3.4.0"), // newer stable → notify (off-cluster, unmanageable)
}}
rn := &Runner{Gatherer: gath, Source: src} // nil Notifier + nil Applier = report-only
res, err := rn.Run(context.Background(), now, window)
if err != nil {
t.Fatalf("Run: %v", err)
}
for _, want := range []string{
"felis-api", "1.5.0", "apply (scheduled window)",
"cloudflared", "2024.3.0",
"k3s", "update available (notify)",
"velocity", "3.4.0",
} {
if !strings.Contains(res.Report, want) {
t.Errorf("report missing %q; got:\n%s", want, res.Report)
}
}
if len(res.RunResult.Applied) != 0 {
t.Errorf("report-only run applied %+v, want nothing", res.RunResult.Applied)
}
for _, name := range []string{"felis-api", "cloudflared"} {
if res.RunResult.ApplyErrors[name] == nil {
t.Errorf("report-only run should record a no-applier error for %s (apply wanted, none wired)", name)
}
}
}
// TestRunnerSkipsUngatherableComponent proves a component whose current version cannot
// be read is dropped from the plan and recorded, not planned against a zero version.
func TestRunnerSkipsUngatherableComponent(t *testing.T) {
now := time.Date(2026, 7, 4, 3, 30, 0, 0, time.UTC)
gath := fakeGatherer{
cur: map[string]updates.Version{"velocity": mustV(t, "3.1.1")},
failFor: map[string]bool{"felis-api": true, "k3s": true, "cloudflared": true},
}
src := fakeSource{latest: map[string]updates.Version{"velocity": mustV(t, "3.4.0")}}
rn := &Runner{Gatherer: gath, Source: src}
res, err := rn.Run(context.Background(), now, updates.Window{})
if err != nil {
t.Fatalf("Run: %v", err)
}
if res.GatherErrors["felis-api"] == nil {
t.Error("felis-api gather failure should be recorded")
}
if len(res.RunResult.Plan) != 1 || res.RunResult.Plan[0].Component != "velocity" {
t.Errorf("plan = %+v, want just velocity (the only gatherable component)", res.RunResult.Plan)
}
}
// TestRunnerWithRoutingSource wires the real RoutingSource — PaperMC and GitHub both
// pointed at live-shape httptest fixtures — through the runner end to end. Every
// component now discovers its real latest stable (velocity 3.4.0 via PaperMC; felis-api,
// k3s and cloudflared via GitHub Releases), the raw upstream tags survive into the report
// (k3s's "+k3s1"), and NO component falls back to a recorded source error.
func TestRunnerWithRoutingSource(t *testing.T) {
now := time.Date(2026, 7, 5, 3, 30, 0, 0, time.UTC)
paperSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/v3/projects/") {
http.Error(w, "unexpected "+r.URL.Path, http.StatusNotFound)
return
}
_, _ = w.Write([]byte(velocityV3Fixture))
}))
defer paperSrv.Close()
// GitHub fixtures keyed by repo. The handler also mirrors GitHub's real gate: a
// UA-less request is refused.
ghBodies := map[string]string{
"/repos/FelisMC/Felis/releases/latest": `{"tag_name":"1.5.0","prerelease":false,"draft":false}`,
"/repos/k3s-io/k3s/releases/latest": k3sLatestFixture,
"/repos/cloudflare/cloudflared/releases/latest": cloudflaredLatestFixture,
"/repos/adoptium/temurin25-binaries/releases/latest": temurinLatestFixture,
}
ghSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("User-Agent") == "" {
http.Error(w, "Request forbidden by administrative rules", http.StatusForbidden)
return
}
body, ok := ghBodies[r.URL.Path]
if !ok {
http.Error(w, "unexpected "+r.URL.Path, http.StatusNotFound)
return
}
_, _ = w.Write([]byte(body))
}))
defer ghSrv.Close()
rs := NewRoutingSource(Topology())
rs.paper = newTestPaperMC(paperSrv) // point PaperMC discovery at its httptest server
rs.gh = newTestGitHub(ghSrv) // and GitHub discovery at its own
pgSrv := pgFixtureServer(postgresFeedFixture)
defer pgSrv.Close()
rs.pg = newTestPostgresFeed(pgSrv)
gath := fakeGatherer{cur: map[string]updates.Version{
"felis-api": mustV(t, "1.4.0"),
"k3s": mustV(t, "v1.35.6+k3s1"),
"cloudflared": mustV(t, "2026.5.0"),
"velocity": mustV(t, "3.1.1"),
"jre": mustV(t, "25.0.4+8"),
"postgresql": mustV(t, "13.22"),
}}
rn := &Runner{Gatherer: gath, Source: rs}
res, err := rn.Run(context.Background(), now, updates.Window{})
if err != nil {
t.Fatalf("Run: %v", err)
}
// Every component's real discovered latest is in the report (each current is older).
for _, want := range []string{
"velocity", "3.4.0",
"felis-api", "1.5.0",
"k3s", "v1.36.2+k3s1",
"cloudflared", "2026.6.1",
"jre", "25.0.4.1+1",
"postgresql", "13.23",
} {
if !strings.Contains(res.Report, want) {
t.Errorf("report missing discovered %q; got:\n%s", want, res.Report)
}
}
// Every route is wired, so nothing degrades to a source error.
if len(res.RunResult.SourceErrors) != 0 {
t.Errorf("expected no source errors with every route wired, got %v", res.RunResult.SourceErrors)
}
if len(res.GatherErrors) != 0 {
t.Errorf("expected every component gathered, got %v", res.GatherErrors)
}
if note := rs.Notes()["postgresql"]; !strings.Contains(note, "end of life on 2025-11-13") || !strings.Contains(note, "current major is 18") {
t.Errorf("postgresql note = %q, want the EOL date and the current major", note)
}
}