// Copyright (c) 2026 Petr BalvĂ­n (https://petrbalvin.org) // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 package httpapi import ( "encoding/json" "flag" "net/http" "net/http/httptest" "os" "path/filepath" "reflect" "testing" ) var updateContract = flag.Bool("update-contract", false, "rewrite the API contract goldens") // contractRequests pin the public JSON API contract. Any change to a // response body shows up as a golden diff, which is exactly the point: // the API serves the front-end, so its shape is a contract. var contractRequests = map[string]string{ "site": "/api/volumen/site", "posts": "/api/volumen/posts", "posts_page2": "/api/volumen/posts?limit=1&page=2", "posts_cursor": "/api/volumen/posts?cursor=alpha&limit=1", "posts_filtered": "/api/volumen/posts?tag=go&q=alpha&lang=cs", "posts_batch": "/api/volumen/posts/batch?slugs=alpha,draft,missing", "post_detail": "/api/volumen/posts/alpha", "post_not_found": "/api/volumen/posts/ghost", "post_draft": "/api/volumen/posts/draft", "tags": "/api/volumen/tags", "tag_posts": "/api/volumen/tags/go", "series": "/api/volumen/series", "series_detail": "/api/volumen/series/Series", "feed_json": "/api/volumen/feed.json", } const contractPost = `+++ title = "Alpha" slug = "alpha" date = 2026-08-18 lang = "cs" author = "Petr" tags = ["go", "research"] series = "Series" series_order = 1 fediverse_creator = "@petr@social" cover = "/media/c.webp" cover_alt = "alt" cover_caption = "caption" aliases = ["old-alpha"] [translations] en = "alpha-en" +++ Alpha **body** with a [link](https://example.com). ` const contractSecond = `+++ title = "Beta" slug = "beta" date = 2026-07-01 tags = ["go"] +++ Beta body. ` // TestAPIContract snapshots every public JSON response. Regenerate with // `go test ./internal/httpapi -update-contract` after an intentional // contract change, and record it in the CHANGELOG. func TestAPIContract(t *testing.T) { f := newFixture(t, map[string]string{ "alpha.md": contractPost, "beta.md": contractSecond, "draft.md": "+++\nslug = \"draft\"\ntitle = \"Draft\"\ndraft = true\n+++\nDraft body.\n", }) for name, path := range contractRequests { t.Run(name, func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, path, nil) rec := f.do(t, req) got := rec.Body.Bytes() golden := filepath.Join("testdata", "contract", name+".json") if *updateContract { if err := os.MkdirAll(filepath.Dir(golden), 0o755); err != nil { t.Fatalf("mkdir: %v", err) } if err := os.WriteFile(golden, got, 0o644); err != nil { t.Fatalf("write golden: %v", err) } return } want, err := os.ReadFile(golden) if err != nil { t.Fatalf("read golden (run with -update-contract): %v", err) } // JSON objects are unordered, so compare parsed values // rather than bytes; keys and values must match exactly. var gotJSON, wantJSON any if err := json.Unmarshal(got, &gotJSON); err != nil { t.Fatalf("response is not JSON: %v\n%s", err, got) } if err := json.Unmarshal(want, &wantJSON); err != nil { t.Fatalf("golden is not JSON: %v", err) } if !reflect.DeepEqual(gotJSON, wantJSON) { t.Fatalf("contract changed for %s:\n--- got ---\n%s\n--- want ---\n%s", path, got, want) } }) } } // TestContractStatusCodes pins the status codes that accompany the // bodies above. func TestContractStatusCodes(t *testing.T) { f := newFixture(t, map[string]string{ "alpha.md": contractPost, "draft.md": "+++\nslug = \"draft\"\ndraft = true\n+++\nx\n", }) want := map[string]int{ "/api/volumen/site": http.StatusOK, "/api/volumen/posts": http.StatusOK, "/api/volumen/posts/alpha": http.StatusOK, "/api/volumen/posts/ghost": http.StatusNotFound, "/api/volumen/posts/draft": http.StatusNotFound, "/api/volumen/posts/old-alpha": http.StatusMovedPermanently, "/api/volumen/tags/go": http.StatusOK, "/api/volumen/tags/none": http.StatusNotFound, "/api/volumen/series/None": http.StatusNotFound, "/api/volumen/posts?page=0": http.StatusUnprocessableEntity, "/api/volumen/posts?limit=101": http.StatusUnprocessableEntity, "/api/volumen/posts?page=1000001": http.StatusUnprocessableEntity, } for path, code := range want { req := httptest.NewRequest(http.MethodGet, path, nil) if rec := f.do(t, req); rec.Code != code { t.Fatalf("%s: code = %d, want %d", path, rec.Code, code) } } }