All checks were successful
Sync infra to hosts / sync-beta (push) Successful in 13s
Sync infra to hosts / sync-prod (push) Successful in 12s
secrets-guard / encrypted (push) Successful in 7s
shell-lint / shellcheck (push) Successful in 8s
Build + push frontend image (Forgejo registry) / build-push (push) Successful in 53s
Deploy frontend to beta VPS / deploy (push) Successful in 1m16s
secrets-guard / encrypted (pull_request) Successful in 5s
shell-lint / shellcheck (pull_request) Successful in 6s
Ports frontend/ (Jinja2/FastAPI, ~1180 LOC) to Go with html/template. No climate math, no DB, no auth -- every route fetches from the backend's /content/* API. Verified with a golden-HTML diff, not just unit tests: both the Python original and the Go rewrite were run against the same committed fixtures and every route compared byte-for-byte, confirmed programmatically. That process caught defects unit tests alone missed, since map[string]any has no compile-time field check: - Render-context keys were snake_case throughout while the templates read PascalCase fields. A missing map key doesn't error, it silently renders empty -- title, meta description, canonical URL, OpenGraph tags, and the homepage's entire ranked list were blank on every page despite every route returning 200. Fixed by renaming every key to match each template's own documented field contract, and passing API structs straight through wherever their fields already matched (removes a whole layer of future drift risk). - Three pages 500'd: ToolHref needed a composed href, not a bare "lat,lon" fragment; the records table needed the raw API struct. - JSON-LD was double-encoded: <script type="application/ld+json"> is JAVASCRIPT context to html/template's escaper regardless of the script's type attribute, so template.HTML gets re-escaped as a quoted JS string. Needed template.JS. The glossary term page's JSON-LD was never built at all -- added. - html/template silently strips literal HTML and JS comments from parsed output (verified in isolation) -- both need a FuncMap function returning template.HTML/template.JS to survive. Packaging: 187MB -> 22.6MB. Two defects caught before reaching a host: the Swarm stack's entrypoint override with no explicit command drops the image's CMD entirely (every deploy would have exited 127), and COPY --chown by name fails under the classic Docker builder on Alpine. Both fixed. go build/vet/test -race clean; docker build passes its embedded test step under both BuildKit and the classic builder; shellcheck 0 findings.
781 lines
28 KiB
Go
781 lines
28 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"
|
|
|
|
"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)
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|