Ctrl K
ComunidadGuías…

usagerow_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package main

// Regression tests for the documented data-usage claim about the row a FAILED
// run leaves behind (docs/metrics.md, the pingularity_speed_data_used_bytes
// bullet - it lived in README.md until the metrics inventory was split out).
//
// History, because it is the point: recordFailedUsage used to write an UNMARKED
// row - byte counts set, every measurement left at its zero value. Since
// SpeedSample.DownMbps/UpMbps/PingMS carry no omitempty, that row was served as
// 0/0/0, and the dashboard's measured-signal is BYTE PRESENCE rather than a
// null speed (spMeasured in internal/web/ui/index.html), which a usage row
// satisfies for whichever direction moved bytes. A failed run therefore read as
// a real 0.0 Mbps measurement. The repair added store.SpeedSample.Failed and
// filters it out of every MEASUREMENT query (speedNotFailed) while the
// data-usage sums keep counting its bytes.
//
// These tests assert the repair AT THE SURFACE, through the HTTP API the
// dashboard actually reads - the store's own tests prove the filter is complete
// across every query, but they never cross into JSON serialization, and the
// original bug was only visible there. TestREADMEUsageBulletMatchesTheRowTheCodeServes
// then holds the README to whichever row the API really serves, so the sentence
// cannot go stale in either direction.

import (
	"context"
	"encoding/json"
	"io"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"
	"strconv"
	"strings"
	"testing"
	"time"

	"github.com/pingular/pingularity/internal/settings"
	"github.com/pingular/pingularity/internal/store"
	"github.com/pingular/pingularity/internal/web"
)

const (
	uiPath = "internal/web/ui/index.html"
	// The sentence the data-usage bullet's failed-run claim hangs off.
	usageBulletMarker = "still contributes the bytes its"
	// What each row in the fixture moved, distinct so a sum identifies its parts.
	usageRowBytes = int64(37_000_000)
	realRunBytes  = int64(11_000_000)
)

// speedFixture is the API under test plus the timestamps of the two rows behind
// it, so an assertion can name a row without hardcoding a date.
type speedFixture struct {
	h               http.Handler
	usageTS, realTS int64
}

// speedAPI serves one store holding exactly two rows through the real HTTP
// handler: the accounting row recordFailedUsage writes for a run that died
// after moving download bytes (scheduler.go:695 - Failed set, byte counts set,
// every measurement left at its zero value), and one ordinary measured run to
// prove the filter removes the marked row rather than emptying the table. Both
// are dated minutes ago so every data-usage window covers them.
func speedAPI(t *testing.T) speedFixture {
	t.Helper()
	ctx := context.Background()
	st, err := store.Open(":memory:")
	if err != nil {
		t.Fatalf("open store: %v", err)
	}
	t.Cleanup(func() { st.Close() })

	now := time.Now().Unix()
	fx := speedFixture{usageTS: now - 60, realTS: now - 120}
	moved, real := usageRowBytes, realRunBytes
	if err := st.InsertSpeed(ctx, store.SpeedSample{
		TS: fx.usageTS, Server: "Sponsor, City", Trigger: "scheduled", Engine: "ookla",
		Failed:    true,
		DownBytes: &moved, // UpBytes stays nil: that direction never ran
	}); err != nil {
		t.Fatalf("insert usage row: %v", err)
	}
	if err := st.InsertSpeed(ctx, store.SpeedSample{
		TS: fx.realTS, Server: "Sponsor, City", Trigger: "scheduled", Engine: "ookla",
		DownMbps: 94.5, UpMbps: 12.5, PingMS: 8.5, DownBytes: &real,
	}); err != nil {
		t.Fatalf("insert measured run: %v", err)
	}

	set, err := settings.New(ctx, st, settings.Values{
		Latency: 5 * time.Second, Speed: time.Hour, Timeout: 2 * time.Second,
		DownAfter: 3, UpAfter: 2, AccessLocalOnly: true,
	})
	if err != nil {
		t.Fatalf("settings: %v", err)
	}
	fx.h = web.New(st, nil, nil, set, nil, "test", slog.New(slog.DiscardHandler)).Handler()
	return fx
}

// getJSON drives one API request from loopback (local-only access is the
// default) and returns the decoded body.
func getJSON(t *testing.T, h http.Handler, path string, into any) {
	t.Helper()
	req := httptest.NewRequest("GET", path, nil)
	// Loopback peer AND a loopback Host: local-only access is the default, and
	// the DNS-rebinding guard refuses httptest's stock example.com Host.
	req.RemoteAddr = "127.0.0.1:54321"
	req.Host = "127.0.0.1:9000"
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, req)
	if rec.Code != http.StatusOK {
		body, _ := io.ReadAll(rec.Body)
		t.Fatalf("GET %s: status %d, body %s", path, rec.Code, body)
	}
	if err := json.NewDecoder(rec.Body).Decode(into); err != nil {
		t.Fatalf("decode %s: %v", path, err)
	}
}

// servedRuns is what /api/speed/runs hands the dashboard.
func servedRuns(t *testing.T, fx speedFixture) []map[string]json.RawMessage {
	t.Helper()
	var page struct {
		Runs  []map[string]json.RawMessage `json:"runs"`
		Total int                          `json:"total"`
	}
	getJSON(t, fx.h, "/api/speed/runs?limit=50", &page)
	return page.Runs
}

// usageRowIsInvisibleToMeasurements reports the state the README must describe:
// the accounting row is filtered out of the runs the API serves, while its
// bytes still land in the data-usage totals.
func usageRowIsInvisibleToMeasurements(t *testing.T, fx speedFixture) bool {
	t.Helper()
	for _, r := range servedRuns(t, fx) {
		if string(r["ts"]) == strconv.FormatInt(fx.usageTS, 10) {
			return false
		}
	}
	var usage struct {
		All int64 `json:"all"`
	}
	getJSON(t, fx.h, "/api/speed/usage", &usage)
	return usage.All == usageRowBytes+realRunBytes
}

func TestFailedUsageRowIsFilteredOutOfTheAPIButStillCounted(t *testing.T) {
	fx := speedAPI(t)

	runs := servedRuns(t, fx)
	if len(runs) != 1 {
		t.Fatalf("/api/speed/runs served %d runs, want 1 (the measured run only; the accounting row must be filtered)", len(runs))
	}
	if got, want := string(runs[0]["ts"]), strconv.FormatInt(fx.realTS, 10); got != want {
		t.Fatalf("served run ts = %s, want %s (the measured run) - the accounting row reached the dashboard", got, want)
	}
	// The original defect, asserted at the surface it appeared on: the served
	// row must not be a zero speed sitting next to a byte count, which is what
	// the UI's measured-signal reads as a real 0.0 reading.
	if string(runs[0]["down_mbps"]) == "0" {
		t.Error("served run carries down_mbps:0 - a zero speed beside a byte count still reads as a measurement in the UI")
	}

	// Both the retention-wide total and the windows count the failed run's
	// bytes - the README's "totals and windows above count its bytes".
	var usage struct {
		All int64 `json:"all"`
		D30 int64 `json:"30d"`
		H24 int64 `json:"24h"`
	}
	getJSON(t, fx.h, "/api/speed/usage", &usage)
	want := usageRowBytes + realRunBytes
	for _, got := range []struct {
		name string
		v    int64
	}{{"all", usage.All}, {"30d", usage.D30}, {"24h", usage.H24}} {
		if got.v != want {
			t.Errorf("/api/speed/usage %s = %d, want %d (the failed run's bytes landed on the user's bill and must still be counted)",
				got.name, got.v, want)
		}
	}

	// A byte count still makes a zero speed read as a measurement in the UI,
	// which is WHY the row had to be filtered rather than given null speeds (see
	// the schema comment at store.go's failed column). The gate also accepts a
	// positive speed with no byte count at all - an old row that measured before
	// those columns existed - but that arm cannot rescue an accounting row,
	// whose speeds are exactly zero.
	ui, err := os.ReadFile(uiPath)
	if err != nil {
		t.Fatalf("read %s: %v", uiPath, err)
	}
	if !strings.Contains(string(ui), `if(key==='down_mbps') return isNum(p[key]) && (p.download_bytes!=null || p[key]>0);`) {
		t.Errorf("%s: spMeasured's byte-presence gate changed - recheck the README bullet and this test's premise", uiPath)
	}
}

func TestREADMEUsageBulletMatchesTheRowTheCodeServes(t *testing.T) {
	// The data-usage bullet moved to docs/metrics.md when the metrics inventory
	// was split out of the README; the claim it makes is unchanged.
	doc, err := os.ReadFile(filepath.Join("docs", "metrics.md"))
	if err != nil {
		t.Fatalf("read docs/metrics.md: %v", err)
	}
	bullet := bulletContaining(t, string(doc), usageBulletMarker)

	if usageRowIsInvisibleToMeasurements(t, speedAPI(t)) {
		// The row exists for accounting and is filtered out of everything that
		// reads as a measurement. The bullet must not describe the old wart.
		for _, forbidden := range []string{"0.0", "pulls the speed chart", "wart"} {
			if strings.Contains(bullet, forbidden) {
				t.Errorf("docs/metrics.md data-usage bullet still describes the pre-repair wart (%q), but the accounting row no longer reaches /api/speed/runs", forbidden)
			}
		}
		if !strings.Contains(bullet, "flagged") {
			t.Error("docs/metrics.md data-usage bullet does not say the accounting row is flagged (and so kept out of the measurement views)")
		}
		return
	}
	// The other branch: the row is reaching measurement surfaces again. Then
	// the reader has to be warned, in the words the wart earned.
	if !strings.Contains(bullet, "0.0") {
		t.Error("the accounting row is reaching /api/speed/runs (or its bytes stopped counting) - docs/metrics.md must warn that its zeros read as a 0.0 measurement")
	}
}

// bulletContaining returns the single top-level "- " list item of doc that
// holds marker, so an assertion about one claim can't accidentally be satisfied
// by wording elsewhere in the file.
func bulletContaining(t *testing.T, doc, marker string) string {
	t.Helper()
	at := strings.Index(doc, marker)
	if at < 0 {
		t.Fatalf("docs/metrics.md no longer contains %q - the data-usage bullet was reworded; recheck this test's claim", marker)
	}
	start := strings.LastIndex(doc[:at], "\n- ")
	if start < 0 {
		t.Fatalf("no list item starts before %q", marker)
	}
	rest := doc[start+1:]
	if end := strings.Index(rest[1:], "\n- "); end >= 0 {
		rest = rest[:end+1]
	}
	return rest
}