All checks were successful
MonthPage and RecordsPage each made two backend calls sequentially (CityMonth/CityRecords, then City) where the second never depended on the first's result. fetchWithCity launches both concurrently via goroutines and a WaitGroup. Verified live against a stub with an injected 400ms delay on both endpoints: city page (1 call) and month/records pages (2 calls each) all cost ~0.404s now, not double for the two-call pages. Error priority preserved exactly: primary's error wins even when City also fails, matching the old sequential code. One trade-off: City() is now always launched even on a request about to 404 from primary, costing one extra cheap lookup on that rare path. Tests include a deterministic concurrency proof via rendezvous channels (the old sequential code would deadlock this test, not just run it slower). Full suite green under -race -count=2.
892 lines
32 KiB
Go
892 lines
32 KiB
Go
// Behaviour tests for the content.py port, driven by the same committed
|
|
// backend fixtures the Python suite used (frontend/tests/fixtures/*.json,
|
|
// captured by scripts/capture-fixtures.sh) and mirroring the assertions of
|
|
// frontend/tests/unit/test_rendering.py that belong to this layer. Full
|
|
// rendered-page assertions (200 + HTML structure) live with the template
|
|
// port / golden-diff phase — here the templates are placeholders, so these
|
|
// tests target the routes' status behavior and the exact render contexts the
|
|
// handlers build.
|
|
package content
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"html/template"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"thermograph/frontend/internal/config"
|
|
"thermograph/frontend/internal/contentapi"
|
|
"thermograph/frontend/internal/contentdata"
|
|
"thermograph/frontend/internal/format"
|
|
"thermograph/frontend/internal/render"
|
|
)
|
|
|
|
const (
|
|
testSlug = "london-england-gb" // captured into tests/fixtures/ (GB -> Celsius)
|
|
testMonth = "july"
|
|
fakeKey = "test0000indexnow0000key000000000"
|
|
)
|
|
|
|
// fixturesDir / contentDir reach the repo's committed test fixtures and SSR
|
|
// copy from this package directory.
|
|
var (
|
|
fixturesDir = filepath.Join("..", "..", "..", "tests", "fixtures")
|
|
contentDir = filepath.Join("..", "..", "..", "content")
|
|
)
|
|
|
|
func loadFixture[T any](t *testing.T, name string) *T {
|
|
t.Helper()
|
|
raw, err := os.ReadFile(filepath.Join(fixturesDir, name+".json"))
|
|
if err != nil {
|
|
t.Fatalf("fixture %s: %v", name, err)
|
|
}
|
|
out := new(T)
|
|
if err := json.Unmarshal(raw, out); err != nil {
|
|
t.Fatalf("fixture %s: %v", name, err)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// fakeAPI is the Go analogue of conftest.py's monkeypatched api_client.
|
|
type fakeAPI struct {
|
|
hub func() (*contentapi.HubPayload, error)
|
|
sitemap func() ([]contentapi.SitemapEntry, error)
|
|
indexNowKey func() (string, error)
|
|
home func() (*contentapi.HomePayload, error)
|
|
city func(slug, origin string) (*contentapi.CityPayload, error)
|
|
cityMonth func(slug, month string) (*contentapi.MonthPayload, error)
|
|
cityRecords func(slug, origin string) (*contentapi.RecordsPayload, error)
|
|
}
|
|
|
|
func (f *fakeAPI) Hub() (*contentapi.HubPayload, error) { return f.hub() }
|
|
func (f *fakeAPI) Sitemap() ([]contentapi.SitemapEntry, error) { return f.sitemap() }
|
|
func (f *fakeAPI) IndexNowKey() (string, error) { return f.indexNowKey() }
|
|
func (f *fakeAPI) Home() (*contentapi.HomePayload, error) { return f.home() }
|
|
func (f *fakeAPI) City(slug, origin string) (*contentapi.CityPayload, error) {
|
|
return f.city(slug, origin)
|
|
}
|
|
func (f *fakeAPI) CityMonth(slug, month string) (*contentapi.MonthPayload, error) {
|
|
return f.cityMonth(slug, month)
|
|
}
|
|
func (f *fakeAPI) CityRecords(slug, origin string) (*contentapi.RecordsPayload, error) {
|
|
return f.cityRecords(slug, origin)
|
|
}
|
|
|
|
func notFound(detail string) error {
|
|
return &contentapi.StatusError{
|
|
StatusCode: 404,
|
|
Body: fmt.Sprintf(`{"detail":%q}`, detail),
|
|
URL: "http://backend.invalid/x",
|
|
}
|
|
}
|
|
|
|
var errUnreachable = errors.New("connect: connection refused")
|
|
|
|
// fixtureAPI wires every method to the committed fixtures, exactly like the
|
|
// Python `client` fixture's monkeypatching (unknown slug/month -> a backend
|
|
// 404 StatusError).
|
|
func fixtureAPI(t *testing.T) *fakeAPI {
|
|
t.Helper()
|
|
city := loadFixture[contentapi.CityPayload](t, "city")
|
|
month := loadFixture[contentapi.MonthPayload](t, "month")
|
|
records := loadFixture[contentapi.RecordsPayload](t, "records")
|
|
home := loadFixture[contentapi.HomePayload](t, "home")
|
|
hub := loadFixture[contentapi.HubPayload](t, "hub")
|
|
sitemap := loadFixture[[]contentapi.SitemapEntry](t, "sitemap")
|
|
return &fakeAPI{
|
|
hub: func() (*contentapi.HubPayload, error) { return hub, nil },
|
|
sitemap: func() ([]contentapi.SitemapEntry, error) { return *sitemap, nil },
|
|
indexNowKey: func() (string, error) { return fakeKey, nil },
|
|
home: func() (*contentapi.HomePayload, error) { return home, nil },
|
|
city: func(slug, origin string) (*contentapi.CityPayload, error) {
|
|
if slug != testSlug {
|
|
return nil, notFound("Unknown city.")
|
|
}
|
|
return city, nil
|
|
},
|
|
cityMonth: func(slug, m string) (*contentapi.MonthPayload, error) {
|
|
if slug != testSlug || m != testMonth {
|
|
return nil, notFound("Unknown month.")
|
|
}
|
|
return month, nil
|
|
},
|
|
cityRecords: func(slug, origin string) (*contentapi.RecordsPayload, error) {
|
|
if slug != testSlug {
|
|
return nil, notFound("Unknown city.")
|
|
}
|
|
return records, nil
|
|
},
|
|
}
|
|
}
|
|
|
|
func testConfig() config.Config {
|
|
return config.Config{
|
|
APIBaseInternal: "http://backend.invalid",
|
|
APIVersion: "v2",
|
|
Base: "/thermograph", // matches THERMOGRAPH_BASE in the Python conftest
|
|
AssetBase: "/thermograph",
|
|
GoogleVerify: "",
|
|
BingVerify: "",
|
|
}
|
|
}
|
|
|
|
func newHandlers(t *testing.T, api API) *Handlers {
|
|
t.Helper()
|
|
cfg := testConfig()
|
|
engine, err := render.New(FuncMap(cfg))
|
|
if err != nil {
|
|
t.Fatalf("render.New: %v", err)
|
|
}
|
|
glossary, err := contentdata.LoadGlossary(contentDir)
|
|
if err != nil {
|
|
t.Fatalf("LoadGlossary: %v", err)
|
|
}
|
|
pages, err := contentdata.LoadPages(contentDir)
|
|
if err != nil {
|
|
t.Fatalf("LoadPages: %v", err)
|
|
}
|
|
h, err := New(cfg, api, engine, glossary, pages, nil)
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
return h
|
|
}
|
|
|
|
func newMux(t *testing.T, api API) *http.ServeMux {
|
|
t.Helper()
|
|
mux := http.NewServeMux()
|
|
static := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Write([]byte("STATIC")) // stands in for main.go's file server
|
|
})
|
|
h := newHandlers(t, api)
|
|
h.Register(mux, static)
|
|
// main.go ALSO registers a subtree static catch-all ("GET {base}/", via
|
|
// handlers.Server.Register) on the same mux — Register's own doc comment
|
|
// notes this. Only the LAZY IndexNow fallback claims the single-segment
|
|
// slot itself and forwards non-.txt requests to `static`; the EAGER path
|
|
// (key fetched successfully at boot) registers just the exact key-file
|
|
// route and relies on that separate subtree registration for everything
|
|
// else. Mirror both here, or the eager-route test exercises a mux with no
|
|
// handler for e.g. app.js at all — a gap in this test double, not in
|
|
// production routing.
|
|
mux.Handle("GET "+h.cfg.Base+"/", static)
|
|
return mux
|
|
}
|
|
|
|
func get(mux *http.ServeMux, path string) *httptest.ResponseRecorder {
|
|
req := httptest.NewRequest("GET", "http://testserver"+path, nil)
|
|
rec := httptest.NewRecorder()
|
|
mux.ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
|
|
// --- robots / sitemap --------------------------------------------------------
|
|
|
|
func TestRobotsTxt(t *testing.T) {
|
|
rec := get(newMux(t, fixtureAPI(t)), "/thermograph/robots.txt")
|
|
if rec.Code != 200 {
|
|
t.Fatalf("status %d", rec.Code)
|
|
}
|
|
want := "User-agent: *\n" +
|
|
"Allow: /\n" +
|
|
"Disallow: /thermograph/api/\n" +
|
|
"Disallow: /thermograph/alerts\n" +
|
|
"Sitemap: http://testserver/thermograph/sitemap.xml\n"
|
|
if rec.Body.String() != want {
|
|
t.Errorf("body:\n%s", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestSitemapListsCityURLs(t *testing.T) {
|
|
rec := get(newMux(t, fixtureAPI(t)), "/thermograph/sitemap.xml")
|
|
if rec.Code != 200 {
|
|
t.Fatalf("status %d", rec.Code)
|
|
}
|
|
body := rec.Body.String()
|
|
if !strings.Contains(body, "<urlset") {
|
|
t.Error("missing <urlset")
|
|
}
|
|
for _, frag := range []string{
|
|
"/climate/" + testSlug + "</loc>",
|
|
"/climate/" + testSlug + "/" + testMonth + "</loc>",
|
|
"/climate/" + testSlug + "/records</loc>",
|
|
} {
|
|
if !strings.Contains(body, frag) {
|
|
t.Errorf("missing %q", frag)
|
|
}
|
|
}
|
|
if ct := rec.Header().Get("Content-Type"); ct != "application/xml" {
|
|
t.Errorf("Content-Type %q", ct)
|
|
}
|
|
if cc := rec.Header().Get("Cache-Control"); cc != "public, max-age=300" {
|
|
t.Errorf("Cache-Control %q", cc)
|
|
}
|
|
// One full entry, byte-exact: loc + boot-date lastmod + changefreq + priority.
|
|
h := newHandlers(t, fixtureAPI(t))
|
|
wantURL := fmt.Sprintf("<url><loc>http://testserver/thermograph/</loc><lastmod>%s</lastmod>"+
|
|
"<changefreq>daily</changefreq><priority>1.0</priority></url>", h.bootDate)
|
|
if !strings.Contains(body, wantURL) {
|
|
t.Errorf("missing exact entry %q", wantURL)
|
|
}
|
|
}
|
|
|
|
// --- error mapping -----------------------------------------------------------
|
|
|
|
func TestUnknownCityAndMonthAre404(t *testing.T) {
|
|
mux := newMux(t, fixtureAPI(t))
|
|
for _, path := range []string{
|
|
"/thermograph/climate/nope-not-a-city",
|
|
"/thermograph/climate/" + testSlug + "/nonemonth",
|
|
"/thermograph/climate/nope-not-a-city/records",
|
|
} {
|
|
if rec := get(mux, path); rec.Code != 404 {
|
|
t.Errorf("%s -> %d, want 404", path, rec.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGlossaryUnknownTermIs404(t *testing.T) {
|
|
rec := get(newMux(t, fixtureAPI(t)), "/thermograph/glossary/not-a-term")
|
|
if rec.Code != 404 {
|
|
t.Fatalf("status %d", rec.Code)
|
|
}
|
|
if got := rec.Body.String(); got != `{"detail":"Unknown term."}` {
|
|
t.Errorf("body %q", got)
|
|
}
|
|
}
|
|
|
|
// TestBackendUnreachableMapsTo503 is the port of the Python suite's
|
|
// backend-down resilience cases: a transport-level failure (no response at
|
|
// all) must become a clean 503, not a raw 500.
|
|
func TestBackendUnreachableMapsTo503(t *testing.T) {
|
|
api := fixtureAPI(t)
|
|
api.city = func(_, _ string) (*contentapi.CityPayload, error) { return nil, errUnreachable }
|
|
api.cityMonth = func(_, _ string) (*contentapi.MonthPayload, error) { return nil, errUnreachable }
|
|
api.cityRecords = func(_, _ string) (*contentapi.RecordsPayload, error) { return nil, errUnreachable }
|
|
api.hub = func() (*contentapi.HubPayload, error) { return nil, errUnreachable }
|
|
api.home = func() (*contentapi.HomePayload, error) { return nil, errUnreachable }
|
|
api.sitemap = func() ([]contentapi.SitemapEntry, error) { return nil, errUnreachable }
|
|
mux := newMux(t, api)
|
|
for _, path := range []string{
|
|
"/thermograph/climate/" + testSlug,
|
|
"/thermograph/climate/" + testSlug + "/july",
|
|
"/thermograph/climate/" + testSlug + "/records",
|
|
"/thermograph/climate",
|
|
"/thermograph/",
|
|
"/thermograph/sitemap.xml",
|
|
} {
|
|
rec := get(mux, path)
|
|
if rec.Code != 503 {
|
|
t.Errorf("%s -> %d, want 503", path, rec.Code)
|
|
}
|
|
if body := rec.Body.String(); body != `{"detail":"backend unavailable"}` {
|
|
t.Errorf("%s body %q", path, body)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A backend 404/503 status passes straight through with its own detail text.
|
|
func TestBackendStatusPassesThrough(t *testing.T) {
|
|
rec := get(newMux(t, fixtureAPI(t)), "/thermograph/climate/nope-not-a-city")
|
|
if rec.Code != 404 {
|
|
t.Fatalf("status %d", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "Unknown city.") {
|
|
t.Errorf("detail not forwarded: %q", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
// --- IndexNow ----------------------------------------------------------------
|
|
|
|
func TestIndexNowEagerRoute(t *testing.T) {
|
|
mux := newMux(t, fixtureAPI(t))
|
|
rec := get(mux, "/thermograph/"+fakeKey+".txt")
|
|
if rec.Code != 200 || rec.Body.String() != fakeKey+"\n" {
|
|
t.Errorf("eager key file: %d %q", rec.Code, rec.Body.String())
|
|
}
|
|
// With the eager route registered there is no {token} catch-all: other
|
|
// single-segment paths stay with the static file server.
|
|
if rec := get(mux, "/thermograph/app.js"); rec.Body.String() != "STATIC" {
|
|
t.Errorf("static delegation broken: %q", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestIndexNowLazyFallback(t *testing.T) {
|
|
api := fixtureAPI(t)
|
|
down := true
|
|
api.indexNowKey = func() (string, error) {
|
|
if down {
|
|
return "", errUnreachable
|
|
}
|
|
return fakeKey, nil
|
|
}
|
|
mux := newMux(t, api) // Register's eager fetch fails -> lazy route
|
|
|
|
// Backend still down: the key file answers 503 (never a wrong/empty key).
|
|
if rec := get(mux, "/thermograph/"+fakeKey+".txt"); rec.Code != 503 {
|
|
t.Errorf("while down: %d", rec.Code)
|
|
}
|
|
// Backend back up: the correct key serves with no frontend restart.
|
|
down = false
|
|
if rec := get(mux, "/thermograph/"+fakeKey+".txt"); rec.Code != 200 || rec.Body.String() != fakeKey+"\n" {
|
|
t.Errorf("after recovery: %d %q", rec.Code, rec.Body.String())
|
|
}
|
|
// A wrong token is 404, and non-.txt single-segment paths fall through to
|
|
// the static handler the lazy route displaced.
|
|
if rec := get(mux, "/thermograph/wrongkey.txt"); rec.Code != 404 {
|
|
t.Errorf("wrong token: %d", rec.Code)
|
|
}
|
|
if rec := get(mux, "/thermograph/app.js"); rec.Body.String() != "STATIC" {
|
|
t.Errorf("static delegation broken: %q", rec.Body.String())
|
|
}
|
|
// Explicit page routes still beat the {token} pattern on specificity.
|
|
if rec := get(mux, "/thermograph/robots.txt"); !strings.HasPrefix(rec.Body.String(), "User-agent:") {
|
|
t.Errorf("robots displaced by token route: %q", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
// --- ctx builders ------------------------------------------------------------
|
|
|
|
func TestCityCtx(t *testing.T) {
|
|
h := newHandlers(t, fixtureAPI(t))
|
|
j := loadFixture[contentapi.CityPayload](t, "city")
|
|
ctx, unit, err := h.cityCtx(j)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if unit != "C" { // GB city -> Celsius (test_city_page_renders_structure)
|
|
t.Errorf("unit %q", unit)
|
|
}
|
|
months := ctx["Months"].([]map[string]any)
|
|
if len(months) != 12 {
|
|
t.Fatalf("months: %d", len(months))
|
|
}
|
|
// January high 44.5°F -> 7°C, formatted as the converter span.
|
|
if got := months[0]["High"].(template.HTML); got != `<span class="temp" data-temp-f="44.5">7°C</span>` {
|
|
t.Errorf("january high: %q", got)
|
|
}
|
|
if bar := months[0]["Bar"]; bar == nil {
|
|
t.Error("january bar missing")
|
|
}
|
|
warmest := ctx["Warmest"].(map[string]any)
|
|
if warmest["Slug"] != "july" {
|
|
t.Errorf("warmest %v", warmest["Slug"])
|
|
}
|
|
if ctx["Coldest"].(map[string]any)["Slug"] != "february" ||
|
|
ctx["Wettest"].(map[string]any)["Slug"] != "january" {
|
|
t.Error("coldest/wettest lookup broken")
|
|
}
|
|
// Full href, base + "#lat,lon" — not just the bare "lat,lon" fragment, so
|
|
// the template never has to concatenate it (html/template's URL-context
|
|
// escaper would %-escape the comma if it did).
|
|
if ctx["ToolHref"] != "/thermograph/#51.50853,-0.12574" {
|
|
t.Errorf("ToolHref %v", ctx["ToolHref"])
|
|
}
|
|
if ctx["CompareURL"] != "/thermograph/compare#loc=51.5085,-0.1257" {
|
|
t.Errorf("CompareURL %v", ctx["CompareURL"])
|
|
}
|
|
// JSON-LD: compacted raw bytes, key order preserved (starts with @context).
|
|
jsonld := string(ctx["JSONLDStr"].(template.JS))
|
|
if !strings.HasPrefix(jsonld, `{"@context":"https://schema.org"`) {
|
|
t.Errorf("JSONLDStr prefix: %.60s", jsonld)
|
|
}
|
|
if !strings.Contains(jsonld, `"@type":"Dataset"`) {
|
|
t.Error("JSONLDStr lost the Dataset block") // test_city_page_renders_structure
|
|
}
|
|
if strings.Contains(jsonld, ": ") {
|
|
t.Error("JSONLDStr not compact")
|
|
}
|
|
// Today cards: pre-formatted value; percentile stays raw for |ordinal.
|
|
today := ctx["Today"].(map[string]any)
|
|
card := today["Cards"].([]map[string]any)[0]
|
|
if card["Value"].(template.HTML) != `<span class="temp" data-temp-f="79.4">26°C</span>` {
|
|
t.Errorf("today card value: %q", card["Value"])
|
|
}
|
|
if format.PctOrdinal(card["Percentile"]) != "89th" {
|
|
t.Errorf("card percentile ordinal: %v", card["Percentile"])
|
|
}
|
|
if ctx["Display"] != j.Display || ctx["Name"] != "London" {
|
|
t.Error("display/name broken")
|
|
}
|
|
// Breadcrumb: the raw []contentapi.Crumb straight off the payload (its own
|
|
// Name/Href fields already match what the "breadcrumb" template reads).
|
|
// Terminal crumb has nil Href ({{if $c.Href}} -> false).
|
|
bc := ctx["Breadcrumb"].([]contentapi.Crumb)
|
|
if len(bc) == 0 || bc[len(bc)-1].Href != nil {
|
|
t.Errorf("breadcrumb terminal href: %v", bc)
|
|
}
|
|
}
|
|
|
|
func TestMonthCtx(t *testing.T) {
|
|
h := newHandlers(t, fixtureAPI(t))
|
|
j := loadFixture[contentapi.MonthPayload](t, "month")
|
|
city := loadFixture[contentapi.CityPayload](t, "city").City
|
|
ctx, unit, err := h.monthCtx(j, city)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if unit != "C" {
|
|
t.Errorf("unit %q", unit)
|
|
}
|
|
stats := ctx["Stats"].([]map[string]any)
|
|
labels := make([]string, len(stats))
|
|
for i, s := range stats {
|
|
labels[i] = s["Label"].(string)
|
|
}
|
|
want := []string{"Average high", "Typical high range", "Average low", "Typical low range", "Average daily precipitation"}
|
|
if strings.Join(labels, "|") != strings.Join(want, "|") {
|
|
t.Errorf("stat labels: %v", labels)
|
|
}
|
|
// "X to Y" concatenation: temp(lo) + " to " + temp(hi).
|
|
rng := string(stats[1]["Value"].(template.HTML))
|
|
if rng != `<span class="temp" data-temp-f="64.3">18°C</span> to <span class="temp" data-temp-f="79.8">27°C</span>` {
|
|
t.Errorf("typical high range: %q", rng)
|
|
}
|
|
// display falls back to the city name when the payload has none
|
|
// (j.get("display", city["name"])).
|
|
if j.Display == nil && ctx["Display"] != "London" {
|
|
t.Errorf("display fallback: %v", ctx["Display"])
|
|
}
|
|
// Record label -> metric dispatch: Humidity is a bare g/m³ figure.
|
|
recs := ctx["Records"].([]map[string]any)
|
|
var humid map[string]any
|
|
for _, r := range recs {
|
|
if r["Label"] == "Humidity" {
|
|
humid = r
|
|
}
|
|
}
|
|
if humid == nil || humid["High"].(template.HTML) != "16.0 g/m³" {
|
|
t.Errorf("humidity record: %v", humid)
|
|
}
|
|
if ctx["AvgHighCls"] != "warm" { // 71.1°F -> [70, 80)
|
|
t.Errorf("AvgHighCls %v", ctx["AvgHighCls"])
|
|
}
|
|
// Prev/Next: the raw contentapi.MonthLink (dereferenced), not a map — its
|
|
// own Name/Slug fields already match what the template reads.
|
|
if prev := ctx["Prev"].(contentapi.MonthLink); prev.Slug != "june" {
|
|
t.Errorf("prev %v", prev)
|
|
}
|
|
}
|
|
|
|
// --- fetchWithCity -----------------------------------------------------------
|
|
|
|
func TestFetchWithCityHappyPath(t *testing.T) {
|
|
api := &fakeAPI{
|
|
city: func(slug, _ string) (*contentapi.CityPayload, error) {
|
|
return &contentapi.CityPayload{City: contentapi.CityInfo{Slug: slug, Name: "Testville"}}, nil
|
|
},
|
|
}
|
|
primary, city, err := fetchWithCity(api, "testville", func() (string, error) { return "primary-value", nil })
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if primary != "primary-value" {
|
|
t.Errorf("primary = %q", primary)
|
|
}
|
|
if city.Slug != "testville" || city.Name != "Testville" {
|
|
t.Errorf("city = %+v", city)
|
|
}
|
|
}
|
|
|
|
// When only primary fails, its error must win even though City() also ran
|
|
// (and, here, succeeded) -- matching the old sequential code's behavior,
|
|
// where City() was never even called once primary had already failed.
|
|
func TestFetchWithCityPrimaryErrorWins(t *testing.T) {
|
|
primaryErr := notFound("no such month")
|
|
api := &fakeAPI{
|
|
city: func(slug, _ string) (*contentapi.CityPayload, error) {
|
|
return &contentapi.CityPayload{City: contentapi.CityInfo{Slug: slug}}, nil
|
|
},
|
|
}
|
|
_, _, err := fetchWithCity(api, "testville", func() (string, error) { return "", primaryErr })
|
|
if err != primaryErr {
|
|
t.Errorf("err = %v, want the primary error", err)
|
|
}
|
|
}
|
|
|
|
// When primary succeeds but City() fails, City's error must surface -- same
|
|
// as the old sequential code (it called City() second and returned whatever
|
|
// that call did).
|
|
func TestFetchWithCityCityErrorSurfaces(t *testing.T) {
|
|
cityErr := notFound("no such city")
|
|
api := &fakeAPI{
|
|
city: func(_, _ string) (*contentapi.CityPayload, error) { return nil, cityErr },
|
|
}
|
|
_, _, err := fetchWithCity(api, "testville", func() (string, error) { return "primary-value", nil })
|
|
if err != cityErr {
|
|
t.Errorf("err = %v, want the city error", err)
|
|
}
|
|
}
|
|
|
|
// When BOTH fail, primary's error still wins -- the same priority as the
|
|
// single-failure case above, just with both goroutines erroring.
|
|
func TestFetchWithCityBothErrorPrimaryWins(t *testing.T) {
|
|
primaryErr, cityErr := notFound("primary"), notFound("city")
|
|
api := &fakeAPI{
|
|
city: func(_, _ string) (*contentapi.CityPayload, error) { return nil, cityErr },
|
|
}
|
|
_, _, err := fetchWithCity(api, "testville", func() (string, error) { return "", primaryErr })
|
|
if err != primaryErr {
|
|
t.Errorf("err = %v, want the primary error (city error must not win)", err)
|
|
}
|
|
}
|
|
|
|
// Proves the two calls actually run CONCURRENTLY rather than sequentially --
|
|
// deterministically, via a rendezvous, not by timing (which would be flaky
|
|
// under CI load). Each fake blocks until BOTH have started; if fetchWithCity
|
|
// ran them sequentially, the second would never start until the first
|
|
// returned, and this would deadlock and fail on the test's own timeout.
|
|
func TestFetchWithCityRunsConcurrently(t *testing.T) {
|
|
started := make(chan struct{}, 2)
|
|
release := make(chan struct{})
|
|
rendezvous := func() {
|
|
started <- struct{}{}
|
|
<-release
|
|
}
|
|
|
|
api := &fakeAPI{
|
|
city: func(_, _ string) (*contentapi.CityPayload, error) {
|
|
rendezvous()
|
|
return &contentapi.CityPayload{}, nil
|
|
},
|
|
}
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
fetchWithCity(api, "testville", func() (string, error) {
|
|
rendezvous()
|
|
return "", nil
|
|
})
|
|
close(done)
|
|
}()
|
|
|
|
// Both goroutines must reach the rendezvous before either can proceed --
|
|
// only possible if they were launched concurrently.
|
|
for range 2 {
|
|
select {
|
|
case <-started:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("timed out waiting for both calls to start -- they are not running concurrently")
|
|
}
|
|
}
|
|
close(release)
|
|
|
|
select {
|
|
case <-done:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("fetchWithCity did not return after release")
|
|
}
|
|
}
|
|
|
|
func TestMonthCtxUnknownLabelIsError(t *testing.T) {
|
|
h := newHandlers(t, fixtureAPI(t))
|
|
j := loadFixture[contentapi.MonthPayload](t, "month")
|
|
city := loadFixture[contentapi.CityPayload](t, "city").City
|
|
j.Records[0].Label = "Bogus"
|
|
if _, _, err := h.monthCtx(j, city); err == nil {
|
|
t.Error("unknown record label must error (Python KeyError -> 500)")
|
|
}
|
|
}
|
|
|
|
func TestRecordsCtx(t *testing.T) {
|
|
h := newHandlers(t, fixtureAPI(t))
|
|
j := loadFixture[contentapi.RecordsPayload](t, "records")
|
|
city := loadFixture[contentapi.CityPayload](t, "city").City
|
|
ctx, unit, err := h.recordsCtx(j, city)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if unit != "C" {
|
|
t.Errorf("unit %q", unit)
|
|
}
|
|
rows := ctx["Rows"].([]map[string]any)
|
|
var precip, high map[string]any
|
|
for _, r := range rows {
|
|
switch r["Label"] {
|
|
case "Precip":
|
|
precip = r
|
|
case "High":
|
|
high = r
|
|
}
|
|
}
|
|
// The precip low is the dry streak, not a formatted value.
|
|
if precip["Low"] != "33-day dry spell" {
|
|
t.Errorf("dry spell: %v", precip["Low"])
|
|
}
|
|
if precip["LowDate"] != "1995-07-31" {
|
|
t.Errorf("dry spell date: %v", precip["LowDate"])
|
|
}
|
|
// HighF/LowF only ride along for temperature metrics.
|
|
if precip["HighF"] != nil {
|
|
t.Errorf("precip HighF should be nil: %v", precip["HighF"])
|
|
}
|
|
if high["HighF"] == nil {
|
|
t.Error("temp HighF missing")
|
|
}
|
|
// Monthly extremes carry the {Txt, Cls, Date} shape the extremes() macro
|
|
// reads; 57.9°F sits in the [45, 58) "cool" tier — boundary-adjacent.
|
|
warm := ctx["Monthly"].([]map[string]any)[0]["High"].(map[string]any)["Warm"].(map[string]any)
|
|
if warm["Cls"] != "cool" || warm["Date"] != "2022-01-01" {
|
|
t.Errorf("monthly warm extreme: %v", warm)
|
|
}
|
|
if warm["Txt"].(template.HTML) != `<span class="temp" data-temp-f="57.9">14°C</span>` {
|
|
t.Errorf("monthly warm txt: %q", warm["Txt"])
|
|
}
|
|
if ctx["Hemisphere"] != j.Hemisphere {
|
|
t.Error("hemisphere lost")
|
|
}
|
|
// ToolHref: the full href, same composition as city/month.
|
|
if ctx["ToolHref"] != fmt.Sprintf("/thermograph/#%.5f,%.5f", city.Lat, city.Lon) {
|
|
t.Errorf("ToolHref %v", ctx["ToolHref"])
|
|
}
|
|
}
|
|
|
|
func TestHubCtx(t *testing.T) {
|
|
h := newHandlers(t, fixtureAPI(t))
|
|
hub := loadFixture[contentapi.HubPayload](t, "hub")
|
|
ctx := h.hubCtx(hub)
|
|
if ctx["NCities"] != 1000 || ctx["NCountries"] != 124 {
|
|
t.Errorf("counts: %v %v", ctx["NCities"], ctx["NCountries"])
|
|
}
|
|
// Groups is the raw []contentapi.HubCountry slice, preserving the
|
|
// backend's country order (the Python dict was insertion-ordered; a Go
|
|
// map would shuffle every render) — HubCountry/HubCity's own fields
|
|
// already match what the template reads, so no rebuilding at all.
|
|
groups := ctx["Groups"].([]contentapi.HubCountry)
|
|
if groups[0].Country != "Afghanistan" {
|
|
t.Errorf("group order lost: %v", groups[0].Country)
|
|
}
|
|
first := groups[0].Cities[0]
|
|
if first.Slug != "kabul-af" || first.Name != "Kabul" {
|
|
t.Errorf("city entry: %v", first)
|
|
}
|
|
if ctx["PageTitle"] == "" || ctx["CanonicalPath"] != "/climate" {
|
|
t.Error("hub meta broken")
|
|
}
|
|
}
|
|
|
|
func TestHomeCtxJSONLD(t *testing.T) {
|
|
h := newHandlers(t, fixtureAPI(t))
|
|
hp := loadFixture[contentapi.HomePayload](t, "home")
|
|
ctx, err := h.homeCtx(hp, "http://example.test")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Byte-exact against Python's json.dumps({**_HOME_JSONLD, "url": ...})
|
|
// (default separators — spaces preserved — and insertion order).
|
|
want := `{"@context": "https://schema.org", "@type": "WebApplication", "name": "Thermograph", ` +
|
|
`"applicationCategory": "WeatherApplication", "operatingSystem": "Web, iOS, Android", ` +
|
|
`"isAccessibleForFree": true, "offers": {"@type": "Offer", "price": "0", "priceCurrency": "USD"}, ` +
|
|
`"description": "How unusual is your weather? Any day, anywhere on Earth, graded against 45 years ` +
|
|
`of that place's own history.", "url": "http://example.test/thermograph/"}`
|
|
if got := string(ctx["JSONLDStr"].(template.JS)); got != want {
|
|
t.Errorf("home jsonld:\n got %s\nwant %s", got, want)
|
|
}
|
|
if ctx["BrandTag"] != "p" || ctx["MainClass"] != "" || ctx["Section"] != "home" {
|
|
t.Error("home ctx flags broken")
|
|
}
|
|
// The captured fixture has no hero pick; nil must stay nil (template's
|
|
// {{if .Unusual}} falsiness), not a zero-valued struct.
|
|
if ctx["Unusual"] != nil {
|
|
t.Errorf("unusual: %v", ctx["Unusual"])
|
|
}
|
|
// Cities is the raw []contentapi.HomeCity slice (Slug/Name already match).
|
|
if len(ctx["Cities"].([]contentapi.HomeCity)) != 12 {
|
|
t.Error("city chips lost")
|
|
}
|
|
}
|
|
|
|
func TestHomeRankedPreservesLatLonText(t *testing.T) {
|
|
h := newHandlers(t, fixtureAPI(t))
|
|
v := 100.4
|
|
hp := &contentapi.HomePayload{Ranked: []contentapi.HomeRanked{{
|
|
Cls: "rec-hot", Display: "Testville", Lat: "51.5074", Lon: "-0.1000",
|
|
Value: &v, GradeLabel: "Near Record", MetricLabel: "High", PctLabel: "99",
|
|
}}}
|
|
ctx, err := h.homeCtx(hp, "http://x")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Ranked is the raw []contentapi.HomeRanked slice (Cls/Display/Lat/Lon/
|
|
// Value/... already match what the template reads).
|
|
r := ctx["Ranked"].([]contentapi.HomeRanked)[0]
|
|
// json.Number prints its raw text — "-0.1000" must not become "-0.1".
|
|
if fmt.Sprint(r.Lat) != "51.5074" || fmt.Sprint(r.Lon) != "-0.1000" {
|
|
t.Errorf("lat/lon text: %v %v", r.Lat, r.Lon)
|
|
}
|
|
if r.DateLabel != nil {
|
|
t.Errorf("DateLabel should be nil: %v", r.DateLabel)
|
|
}
|
|
}
|
|
|
|
func TestGlossaryCtx(t *testing.T) {
|
|
h := newHandlers(t, fixtureAPI(t))
|
|
first := h.glossary.Terms[0]
|
|
ctx, err := h.glossaryTermCtx(first.Slug, first, "http://testserver/thermograph")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// {base} substitution happens per-request in the body, like the Python's
|
|
// _glossary_body.
|
|
body := string(ctx["Body"].(template.HTML))
|
|
if strings.Contains(body, "{base}") {
|
|
t.Error("body {base} placeholder not substituted")
|
|
}
|
|
if strings.Contains(first.Body, "{base}") && !strings.Contains(body, "/thermograph") {
|
|
t.Error("body {base} not replaced with the configured base")
|
|
}
|
|
if ctx["PageTitle"] != first.Term+": what it means | Thermograph" {
|
|
t.Errorf("PageTitle: %v", ctx["PageTitle"])
|
|
}
|
|
// Others is the raw []contentdata.GlossaryTerm slice (Slug/Term already
|
|
// match what the template reads).
|
|
others := ctx["Others"].([]contentdata.GlossaryTerm)
|
|
if len(others) != len(h.glossary.Terms)-1 {
|
|
t.Errorf("others: %d", len(others))
|
|
}
|
|
for _, o := range others {
|
|
if o.Slug == first.Slug {
|
|
t.Error("others must exclude the current term")
|
|
}
|
|
}
|
|
// JSONLDStr: a raw DefinedTerm object, not a JSON-encoded STRING
|
|
// containing one (the template.HTML-vs-template.JS bug this guards
|
|
// against would wrap the whole thing in quotes with escaped internals).
|
|
jsonld := ctx["JSONLDStr"]
|
|
js, ok := jsonld.(template.JS)
|
|
if !ok {
|
|
t.Fatalf("JSONLDStr type = %T, want template.JS", jsonld)
|
|
}
|
|
want := `{"@context":"https://schema.org","@type":"DefinedTerm","name":"` + first.Term +
|
|
`","description":"` + first.Short + `","inDefinedTermSet":"http://testserver/thermograph/glossary"}`
|
|
if string(js) != want {
|
|
t.Errorf("JSONLDStr:\n got %s\nwant %s", js, want)
|
|
}
|
|
}
|
|
|
|
// --- FuncMap helpers ---------------------------------------------------------
|
|
|
|
// html/template strips literal <!-- --> comments while parsing (verified
|
|
// directly: a template of only `<p>a</p><!-- x --><p>b</p>` renders as
|
|
// `<p>a</p><p>b</p>`) -- comment must wrap its output template.HTML to insert
|
|
// it as trusted content rather than markup, so a visible comment written in a
|
|
// .tmpl file actually survives into what ships. Checked both as a bare
|
|
// function call and end to end through a real html/template parse+execute,
|
|
// since the function alone proves nothing about whether html/template's
|
|
// parser then strips it back out.
|
|
func TestCommentSurvivesParsing(t *testing.T) {
|
|
fm := FuncMap(testConfig())
|
|
comment := fm["comment"].(func(string) template.HTML)
|
|
if got := comment("hello"); got != "<!-- hello -->" {
|
|
t.Errorf("comment(%q) = %q", "hello", got)
|
|
}
|
|
|
|
tmpl, err := template.New("t").Funcs(fm).Parse(`<p>a</p>{{comment "x"}}<p>b</p>`)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var buf strings.Builder
|
|
if err := tmpl.Execute(&buf, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := buf.String(); got != `<p>a</p><!-- x --><p>b</p>` {
|
|
t.Errorf("comment through a real template = %q", got)
|
|
}
|
|
}
|
|
|
|
// html/template ALSO strips JavaScript `//` line comments from <script>
|
|
// bodies, independent of and in addition to the HTML-comment stripping
|
|
// TestCommentSurvivesParsing guards -- verified with zero {{ }} actions
|
|
// anywhere near the comment, so it isn't specific to how a value gets
|
|
// interpolated. jscomment needs template.JS, not template.HTML: inside
|
|
// <script>, html/template treats template.HTML as an untrusted value and
|
|
// re-escapes it as a quoted JS string (the same failure mode the JSONLDStr
|
|
// sites hit before being fixed to template.JS).
|
|
func TestJSCommentSurvivesParsing(t *testing.T) {
|
|
fm := FuncMap(testConfig())
|
|
jscomment := fm["jscomment"].(func(string) template.JS)
|
|
if got := jscomment("hello"); got != "// hello" {
|
|
t.Errorf("jscomment(%q) = %q", "hello", got)
|
|
}
|
|
|
|
tmpl, err := template.New("t").Funcs(fm).Parse("<script>\nvar x = 1;\n\n{{jscomment \"x\"}}\nvar y = 2;\n</script>")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var buf strings.Builder
|
|
if err := tmpl.Execute(&buf, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := "<script>\nvar x = 1;\n\n// x\nvar y = 2;\n</script>"
|
|
if got := buf.String(); got != want {
|
|
t.Errorf("jscomment through a real template:\n got %q\nwant %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestHeadVerifyHTML(t *testing.T) {
|
|
if headVerifyHTML("", "") != "" {
|
|
t.Error("no tokens -> empty")
|
|
}
|
|
got := headVerifyHTML("gtok", "btok")
|
|
want := "<meta name=\"google-site-verification\" content=\"gtok\">\n " +
|
|
"<meta name=\"msvalidate.01\" content=\"btok\">"
|
|
if string(got) != want {
|
|
t.Errorf("head_verify: %q", got)
|
|
}
|
|
// Attribute escaping matches markupsafe.
|
|
if !strings.Contains(string(headVerifyHTML(`a"b`, "")), "a"b") {
|
|
t.Error("token not attribute-escaped")
|
|
}
|
|
}
|
|
|
|
func TestToJSON(t *testing.T) {
|
|
// u builds a backslash-u escape the way json.dumps/htmlsafe_json_dumps
|
|
// emit them (lowercase hex, four digits).
|
|
u := func(r rune) string { return fmt.Sprintf(`\u%04x`, r) }
|
|
cases := []struct {
|
|
in, want string
|
|
}{
|
|
{"Feels-like temperature", `"Feels-like temperature"`},
|
|
// Jinja's htmlsafe |tojson escapes the HTML-unsafe <, >, &, '.
|
|
{`a<b>&'c`, `"a` + u('<') + `b` + u('>') + u('&') + u('\'') + `c"`},
|
|
// ...and ensure_ascii turns non-ASCII into escapes.
|
|
{"50°F", `"50` + u('°') + `F"`},
|
|
{"±7", `"` + u('±') + `7"`},
|
|
}
|
|
for _, c := range cases {
|
|
got, err := toJSON(c.in)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(got) != c.want {
|
|
t.Errorf("toJSON(%q) = %s, want %s", c.in, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestFuncMapHelpers(t *testing.T) {
|
|
fm := FuncMap(testConfig())
|
|
temp := fm["temp"].(func(any, any) template.HTML)
|
|
// Jinja's {{ temp(-10) }} literal (int) and a nullable payload pointer.
|
|
if got := temp("C", -10); got != `<span class="temp" data-temp-f="-10.0">-23°C</span>` {
|
|
t.Errorf("temp int literal: %q", got)
|
|
}
|
|
if got := temp("", (*float64)(nil)); got != "—" {
|
|
t.Errorf("temp nil pointer: %q", got)
|
|
}
|
|
tc := fm["temp_class"].(func(any) string)
|
|
if tc(nil) != "none" {
|
|
t.Error("temp_class nil")
|
|
}
|
|
ord := fm["ordinal"].(func(any) string)
|
|
if ord(89.1) != "89th" {
|
|
t.Error("ordinal")
|
|
}
|
|
}
|