usagerow_test.go
Rama main · réplica de lectura del repositorio original.
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
}