Files
Felis/internal/updater/papermc_test.go
flyemoji 9896fe16c3 docs(updater): correct PaperMC UA/fixture overclaims, re-tier the boundary
An out-of-band curl of the live Fill v3 endpoint contradicted two claims the
previous commit shipped and surfaced a mis-tiering:

- User-Agent is NOT enforced: fill.papermc.io/v3/projects/velocity returned
  HTTP 200 to a bare curl UA. The comments claimed a generic UA "is refused"
  and the API "REQUIRES" a contact UA. Reword to what is true — PaperMC's usage
  policy asks for a descriptive UA and may block generic ones, but sending it is
  etiquette/defensive here, not a gate Felis depends on.
- The test fixture's shape was invented, not captured: the real "versions"
  object groups the entire 3.x line under a single key "3.0.0", not the
  per-minor keys the fixture used. Replace it with the real body (keys and
  version strings as returned). The key-agnostic parser already produced the
  right answer, and an independent max-stable check confirms 3.4.0.
- Re-tier doc.go: the GitHub Releases source is verifiable-here (the same
  httptest-testable shape as PaperMC), not integration remainder. It is why
  3 of 4 components report "latest unknown" today and is the next verifiable
  slice — the release-source work is only ~half done until it exists.

No production logic changed. WSL oracle: build + vet clean, internal/updater
10/10, full tree go test RC=0 (19 ok, 0 fail).
2026-07-05 00:28:36 +09:00

149 lines
5.4 KiB
Go

package updater
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// velocityV3Fixture is the PaperMC Fill v3 GET /v3/projects/velocity response body,
// captured from the live API on 2026-07-04 (keys and version strings exactly as
// returned; JSON whitespace normalized). 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 runs against real data; and
// - the "versions" object groups the ENTIRE 3.x line under a single key "3.0.0"
// (not per-minor keys), so a parser that trusted the group key to bound the
// versions inside it would be wrong — proof the key-agnostic flatten is required.
// (The v2 API this replaces now returns HTTP 410.)
const velocityV3Fixture = `{
"project": {"id": "velocity", "name": "Velocity"},
"versions": {
"3.0.0": [
"3.5.0-SNAPSHOT",
"3.4.0",
"3.4.0-SNAPSHOT",
"3.3.0-SNAPSHOT",
"3.2.0-SNAPSHOT",
"3.1.2-SNAPSHOT",
"3.1.1",
"3.1.1-SNAPSHOT",
"3.1.0"
],
"1.1.0": ["1.1.9"],
"1.0.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 a descriptive,
// contact-carrying User-Agent rather than a generic library default. PaperMC's API
// usage policy asks for this and reserves the right to block anonymous/generic agents;
// upstream enforcement was not active on the project endpoint as of 2026-07-04 (a bare
// UA got HTTP 200), so this verifies OUR compliance with the policy, not an upstream
// gate we depend on.
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())
}
}