Files
petrbalvin f8ed33df83
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Initial commit
Assisted-by: GLM 5.3
2026-09-29 10:03:32 +02:00

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)
}
}
}