internal/updates is a pure, fakes-tested decision core with no production caller, so nothing could produce its "版本号状态" report. Add internal/updater as that caller: - topology: the fixed platform components and their user-set policies (felis-api and cloudflared Scheduled+manageable; k3s Notify, high-blast-radius single node; velocity Notify, off-cluster and unmanageable). Minecraft is pinned by ABSENCE, never force-tracked here, appended from the live fleet at runtime. - PaperMC Fill v3 release source: the v2 API (api.papermc.io) was retired 2026-07-01 and returns HTTP 410, so this targets fill.papermc.io/v3, sends the required non-generic User-Agent, and returns the newest STABLE version, filtering the -SNAPSHOT/rc prereleases the plan would otherwise suppress. Its test fixture is captured from the live v3 response shape (2026-07-04). - RoutingSource: the single ReleaseSource updates.Run requires, dispatching velocity to PaperMC and returning errGitHubNotWired for the GitHub-backed components so they degrade to "latest unknown" honestly, never a fabricated one. - Runner: gather current versions (seam) -> assemble Components -> updates.Run -> Report; report-only when notifier and applier are nil. Verification boundary: the parse/plan/compose logic is unit-tested (httptest + fakes, fixture grounded in the live v3 shape). Live network/TLS/User-Agent enforcement, the GitHub Releases source, the concrete version gatherer, the notifier and applier, and the felis update CLI/CronJob remain integration work, enumerated in doc.go.
167 lines
5.8 KiB
Go
167 lines
5.8 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 via the live-shape
|
|
// httptest fixture, GitHub honestly un-wired — through the runner end to end. velocity
|
|
// discovers its real latest stable (3.4.0 → notify); the GitHub-backed components
|
|
// degrade to "latest unknown" via the recorded errGitHubNotWired, never a fabricated
|
|
// version.
|
|
func TestRunnerWithRoutingSource(t *testing.T) {
|
|
now := time.Date(2026, 7, 4, 3, 30, 0, 0, time.UTC)
|
|
srv := 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 srv.Close()
|
|
|
|
rs := NewRoutingSource(Topology())
|
|
rs.paper = newTestPaperMC(srv) // point PaperMC discovery at the httptest server
|
|
|
|
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"),
|
|
}}
|
|
rn := &Runner{Gatherer: gath, Source: rs}
|
|
res, err := rn.Run(context.Background(), now, updates.Window{})
|
|
if err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
|
|
if !strings.Contains(res.Report, "velocity") || !strings.Contains(res.Report, "3.4.0") {
|
|
t.Errorf("report should show velocity's discovered latest 3.4.0; got:\n%s", res.Report)
|
|
}
|
|
for _, name := range []string{"felis-api", "k3s", "cloudflared"} {
|
|
if !errors.Is(res.RunResult.SourceErrors[name], errGitHubNotWired) {
|
|
t.Errorf("SourceErrors[%s] = %v, want errGitHubNotWired", name, res.RunResult.SourceErrors[name])
|
|
}
|
|
}
|
|
}
|