146 lines
4.5 KiB
Go
146 lines
4.5 KiB
Go
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|