feat(updater): wire updates.Run to a caller with PaperMC v3 release discovery

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.
This commit is contained in:
flyemoji committed 2026-07-04 23:41:46 +09:00
1 parent 0a2accd245
commit 96b3cc901c
8 files changed
+683

No files matched your search

+134
View File
@@ -0,0 +1,134 @@
package updater
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// velocityV3Fixture is the PaperMC Fill v3 GET /v3/projects/velocity body — its shape
// and version strings captured verbatim from the live API on 2026-07-04. Grounding
// the fixture in the real response is what makes this a contract test rather than a
// self-referential one: the newest overall version is a -SNAPSHOT (3.5.0-SNAPSHOT)
// while the newest stable release is 3.4.0, so the stable filter is exercised against
// real-world data, not an invented shape. (The v2 API this replaces now returns 410.)
const velocityV3Fixture = `{
"project": {"id": "velocity", "name": "Velocity"},
"versions": {
"3.5": ["3.5.0-SNAPSHOT"],
"3.4": ["3.4.0", "3.4.0-SNAPSHOT"],
"3.3": ["3.3.0-SNAPSHOT"],
"3.2": ["3.2.0-SNAPSHOT"],
"3.1": ["3.1.2-SNAPSHOT", "3.1.1", "3.1.1-SNAPSHOT", "3.1.0"],
"1.1": ["1.1.9"],
"1.0": ["1.0.10"]
}
}`
func newTestPaperMC(srv *httptest.Server) paperMC {
return paperMC{
baseURL: srv.URL,
userAgent: "felis-updater/0.1 (+https://felis.lolicon.best)",
hc: srv.Client(),
}
}
// TestPaperMCLatestStableFiltersSnapshots is the core assertion: against the real v3
// shape, latestStable returns the newest STABLE release (3.4.0), never the newer
// 3.5.0-SNAPSHOT prerelease.
func TestPaperMCLatestStableFiltersSnapshots(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v3/projects/velocity" {
http.Error(w, "unexpected path "+r.URL.Path, http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(velocityV3Fixture))
}))
defer srv.Close()
v, err := newTestPaperMC(srv).latestStable(context.Background(), "velocity")
if err != nil {
t.Fatalf("latestStable: %v", err)
}
if v.String() != "3.4.0" {
t.Errorf("latestStable = %q, want 3.4.0 (newest stable; SNAPSHOTs filtered)", v.String())
}
if v.IsPrerelease() {
t.Errorf("latestStable returned a prerelease %q", v.String())
}
}
// TestPaperMCSendsNonGenericUserAgent proves Felis transmits the contact-carrying,
// non-generic User-Agent the Fill v3 API requires (a generic UA is refused upstream).
func TestPaperMCSendsNonGenericUserAgent(t *testing.T) {
var gotUA string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotUA = r.Header.Get("User-Agent")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(velocityV3Fixture))
}))
defer srv.Close()
if _, err := newTestPaperMC(srv).latestStable(context.Background(), "velocity"); err != nil {
t.Fatalf("latestStable: %v", err)
}
if gotUA == "" {
t.Fatal("no User-Agent sent")
}
for _, bad := range []string{"Go-http-client", "curl", "wget"} {
if strings.Contains(gotUA, bad) {
t.Errorf("User-Agent %q looks generic (contains %q)", gotUA, bad)
}
}
if !strings.Contains(gotUA, "felis") || !strings.Contains(gotUA, "http") {
t.Errorf("User-Agent %q should name the software and carry a contact URL", gotUA)
}
}
// TestPaperMCFailsClosedOnHTTPError proves a non-200 (like the real 410 Gone the dead
// v2 endpoint now returns) yields an error, never a bogus zero version.
func TestPaperMCFailsClosedOnHTTPError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "Gone", http.StatusGone)
}))
defer srv.Close()
if v, err := newTestPaperMC(srv).latestStable(context.Background(), "velocity"); err == nil {
t.Fatalf("want error on HTTP 410, got version %q", v.String())
}
}
// TestPaperMCFailsClosedWhenOnlySnapshots proves a feed with no stable release is an
// error, not a silent latest — so a SNAPSHOT-only line never drives an update.
func TestPaperMCFailsClosedWhenOnlySnapshots(t *testing.T) {
const onlySnapshots = `{"versions":{"9.9":["9.9.0-SNAPSHOT","9.8.0-SNAPSHOT"]}}`
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(onlySnapshots))
}))
defer srv.Close()
if _, err := newTestPaperMC(srv).latestStable(context.Background(), "velocity"); err == nil {
t.Fatal("want error when only prereleases are published, got nil")
}
}
// TestPaperMCSkipsUnparseableTags proves one garbled entry does not blind discovery:
// the max stable among the parseable versions is still returned.
func TestPaperMCSkipsUnparseableTags(t *testing.T) {
const withJunk = `{"versions":{"g":["not-a-version","3.4.0","","3.4.0-SNAPSHOT"]}}`
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(withJunk))
}))
defer srv.Close()
v, err := newTestPaperMC(srv).latestStable(context.Background(), "velocity")
if err != nil {
t.Fatalf("latestStable: %v", err)
}
if v.String() != "3.4.0" {
t.Errorf("latestStable = %q, want 3.4.0 (junk/empty/snapshot skipped)", v.String())
}
}