package web
import (
"bytes"
"net/url"
"strings"
"testing"
)
func renderRoute(t *testing.T, v opsRouteView, store bool) string {
t.Helper()
var ops opsView
ops.BudgetMs = 500
ops.Store.Configured = store
var buf bytes.Buffer
if err := noHooks.template(opsRoutePage).ExecuteTemplate(&buf, "content",
pageData{Title: "route", OpsRoute: v, Ops: ops}); err != nil {
t.Fatal(err)
}
return buf.String()
}
func trendPoint(day string, n int, p50, p95, max int64) struct {
Day string `json:"day"`
N int `json:"n"`
P50Ms int64 `json:"p50Ms"`
P95Ms int64 `json:"p95Ms"`
MaxMs int64 `json:"maxMs"`
} {
var p struct {
Day string `json:"day"`
N int `json:"n"`
P50Ms int64 `json:"p50Ms"`
P95Ms int64 `json:"p95Ms"`
MaxMs int64 `json:"maxMs"`
}
p.Day, p.N, p.P50Ms, p.P95Ms, p.MaxMs = day, n, p50, p95, max
return p
}
// The question after a deploy is not "how fast is this now" but "is it
// worse than it was", and a snapshot of one process's memory cannot
// answer it.
func TestRoutePageShowsTheTrend(t *testing.T) {
var v opsRouteView
v.Path = "GET /{$}"
v.TrendDays = 7
v.Trend = append(v.Trend, trendPoint("2026-08-24", 20, 19, 28, 29))
v.Trend = append(v.Trend, trendPoint("2026-08-26", 20, 190, 280, 290))
body := renderRoute(t, v, true)
for _, want := range []string{"Across days", "2026-08-24", "28ms", "2026-08-26", "280ms"} {
if !strings.Contains(body, want) {
t.Errorf("the trend table is missing %q:\n%s", want, body)
}
}
}
// A day over budget is marked, the same way the panel marks one.
func TestRouteTrendMarksDaysOverBudget(t *testing.T) {
var v opsRouteView
v.Path = "GET /{$}"
v.Trend = append(v.Trend, trendPoint("2026-08-26", 20, 100, 900, 1200))
if !strings.Contains(renderRoute(t, v, true), "ci-fail-text") {
t.Error("a 900ms p95 against a 500ms budget renders unmarked")
}
var ok opsRouteView
ok.Path = "GET /{$}"
ok.Trend = append(ok.Trend, trendPoint("2026-08-26", 20, 10, 20, 30))
if strings.Contains(renderRoute(t, ok, true), "ci-fail-text") {
t.Error("a route comfortably inside budget renders as failing")
}
}
// No history and history switched off are different answers, and a page
// that gives one for the other sends an operator looking for traffic
// that was never recorded.
func TestRoutePageSaysWhyTheTrendIsEmpty(t *testing.T) {
var v opsRouteView
v.Path = "GET /{$}"
v.TrendDays = 14
on := renderRoute(t, v, true)
if !strings.Contains(on, "No history for this route in the last 14 days") {
t.Errorf("a configured store with no rows does not say so:\n%s", on)
}
off := renderRoute(t, v, false)
if !strings.Contains(off, "History is off") {
t.Errorf("an unconfigured store is reported as no traffic:\n%s", off)
}
if strings.Contains(off, "No history for this route") {
t.Error("history-off is reported as an empty window")
}
}
// An absent query parameter and an empty one are different things to
// the validator: a numeric field with a default takes it when the key
// is MISSING and refuses when the key is present and empty. Forwarding
// Get() unconditionally turns every absent optional into a 400 — which
// is how the drilldown started answering 500 to its own links the
// moment it gained a days control.
func TestPresentOmitsWhatWasNotSent(t *testing.T) {
q := url.Values{"path": {"GET /{$}"}, "days": {""}}
got := present(q, "path", "days", "sort")
if got.Get("path") != "GET /{$}" {
t.Errorf("path=%q", got.Get("path"))
}
if _, ok := got["days"]; ok {
t.Error("an empty days was forwarded; the validator refuses it")
}
if _, ok := got["sort"]; ok {
t.Error("a key that was never sent was invented")
}
}
// html/template escapes a value in URL query context already, so
// escaping it first produces %252F for a slash. The ops page linked to
// its own drilldown with a double-encoded route, and the drilldown
// answered 500 to every link the panel above it drew.
func TestDrilldownLinksAreEncodedExactlyOnce(t *testing.T) {
var v opsView
v.BudgetMs = 500
var p struct {
Path string `json:"path"`
Count int `json:"count"`
P50Ms int64 `json:"p50Ms"`
P95Ms int64 `json:"p95Ms"`
MaxMs int64 `json:"maxMs"`
}
p.Path, p.Count = "GET /r/{repo}/timeline", 3
v.Paths = append(v.Paths, p)
body := renderOps(t, v)
i := strings.Index(body, "/fleet/ops/route?path=")
if i < 0 {
t.Fatalf("no drilldown link rendered:\n%s", body)
}
href := body[i+len("/fleet/ops/route?path="):]
href = href[:strings.IndexAny(href, `"`)]
if strings.Contains(href, "%25") {
t.Errorf("double-encoded link %q — %%25 is an escaped escape", href)
}
// It must survive the round trip a browser and the mux perform.
got, err := url.QueryUnescape(href)
if err != nil {
t.Fatalf("link %q does not decode: %v", href, err)
}
if got != "GET /r/{repo}/timeline" {
t.Errorf("link decodes to %q, want the route it names", got)
}
}