morz-infoboard/server/backend/internal/httpapi/router_test.go
Jesko Anschütz cfab277dc4 Fuege HTML-Detailseite und HTML-Fehlerseite fuer den Status-UI-Pfad hinzu
Drei eng zusammenhaengende Aenderungen in einem Commit, da sie dieselbe
Template-Infrastruktur teilen und gemeinsam die HTML-Diagnoseansicht
abrunden:

1. CSS-Extraktion (Verbesserung, kein Verhalten geaendert)
   Das bisherige Inline-CSS wurde in die Konstante statusPageCSS
   ausgelagert. statusPageCSSBlock injiziert es per String-Konkatenation
   in alle Templates, sodass kein Template-Inheritance-Mechanismus
   benoetigt wird und jede Seite eigenstaendig ausfuehrbar bleibt.

2. GET /status/{screenId} -- neue HTML-Detailseite
   Zeigt den letzten bekannten Datensatz eines einzelnen Screens:
   Derived State, Player-Status, Connectivity und Frische als
   Summary-Cards; Timing- und Endpoints-Details in aufgeraeuemten
   Key-Value-Tabellen. Verlinkung zurueck auf /status und auf den
   bestehenden JSON-Endpunkt. Bei unbekanntem Screen: 404 mit HTML-
   Fehlerseite und Rueck-Link.
   Route: GET /status/{screenId}

3. HTML-Fehlerseite fuer /status bei ungueltigen Query-Parametern
   Bisher lieferte handleStatusPage einen rohen JSON-Fehler, wenn
   z.B. ?stale=banana uebergeben wurde -- inkongruent fuer einen
   HTML-Endpunkt. writeStatusPageQueryError rendert jetzt dasselbe
   statusPageErrorTemplate wie der Not-Found-Fall der Detailseite
   und gibt text/html mit 400 zurueck.

Neue Tests (router_test.go):
- ScreenDetailPageRoute: prueft Inhalt, Links, Content-Type
- ScreenDetailPageNotFound: prueft 404 + HTML + Rueck-Link
- StatusPageRejectsInvalidQueryParams: prueft jetzt auch text/html
  fuer alle Fehlerfaelle

Docs (PLAYER-STATUS-HTTP.md):
- Query-Parameter-Validierung mit erlaubten Werten und Fehlercodes
- Neue /status/{screenId}-Seite und HTML-Fehlerseiten dokumentiert

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 20:03:24 +01:00

321 lines
9.3 KiB
Go

package httpapi
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestRouterHealthz(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
w := httptest.NewRecorder()
NewRouter(newInMemoryPlayerStatusStore()).ServeHTTP(w, req)
if got, want := w.Code, http.StatusOK; got != want {
t.Fatalf("status = %d, want %d", got, want)
}
var response struct {
Service string `json:"service"`
Status string `json:"status"`
}
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if got, want := response.Service, "morz-infoboard-backend"; got != want {
t.Fatalf("service = %q, want %q", got, want)
}
if got, want := response.Status, "ok"; got != want {
t.Fatalf("status field = %q, want %q", got, want)
}
}
func TestRouterBaseAPI(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1", nil)
w := httptest.NewRecorder()
NewRouter(newInMemoryPlayerStatusStore()).ServeHTTP(w, req)
if got, want := w.Code, http.StatusOK; got != want {
t.Fatalf("status = %d, want %d", got, want)
}
var response struct {
Name string `json:"name"`
Version string `json:"version"`
Tools []string `json:"tools"`
}
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if got, want := response.Name, "morz-infoboard-backend"; got != want {
t.Fatalf("name = %q, want %q", got, want)
}
if got, want := response.Version, "dev"; got != want {
t.Fatalf("version = %q, want %q", got, want)
}
if got, want := len(response.Tools), 3; got != want {
t.Fatalf("len(tools) = %d, want %d", got, want)
}
if got, want := response.Tools[0], "message-wall-resolve"; got != want {
t.Fatalf("tool[0] = %q, want %q", got, want)
}
if got, want := response.Tools[1], "screen-status-list"; got != want {
t.Fatalf("tool[1] = %q, want %q", got, want)
}
if got, want := response.Tools[2], "screen-status-detail"; got != want {
t.Fatalf("tool[2] = %q, want %q", got, want)
}
}
func TestRouterMeta(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/meta", nil)
w := httptest.NewRecorder()
NewRouter(newInMemoryPlayerStatusStore()).ServeHTTP(w, req)
if got, want := w.Code, http.StatusOK; got != want {
t.Fatalf("status = %d, want %d", got, want)
}
var response struct {
Service string `json:"service"`
Version string `json:"version"`
API struct {
BasePath string `json:"base_path"`
Health string `json:"health"`
Tools []struct {
Name string `json:"name"`
Method string `json:"method"`
Path string `json:"path"`
} `json:"tools"`
} `json:"api"`
}
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if got, want := response.API.BasePath, "/api/v1"; got != want {
t.Fatalf("api.base_path = %q, want %q", got, want)
}
if got, want := response.API.Health, "/healthz"; got != want {
t.Fatalf("api.health = %q, want %q", got, want)
}
if got, want := len(response.API.Tools), 4; got != want {
t.Fatalf("len(api.tools) = %d, want %d", got, want)
}
if got, want := response.API.Tools[0].Path, "/api/v1/tools/message-wall/resolve"; got != want {
t.Fatalf("api.tools[0].path = %q, want %q", got, want)
}
if got, want := response.API.Tools[1].Path, "/api/v1/screens/status"; got != want {
t.Fatalf("api.tools[1].path = %q, want %q", got, want)
}
if got, want := response.API.Tools[2].Path, "/api/v1/screens/{screenId}/status"; got != want {
t.Fatalf("api.tools[2].path = %q, want %q", got, want)
}
if got, want := response.API.Tools[3].Path, "/api/v1/player/status"; got != want {
t.Fatalf("api.tools[3].path = %q, want %q", got, want)
}
}
func TestRouterPlayerStatusRoute(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewBufferString(`{"screen_id":"demo","ts":"2026-03-22T16:00:00Z","status":"running","heartbeat_every_seconds":30}`))
w := httptest.NewRecorder()
NewRouter(newInMemoryPlayerStatusStore()).ServeHTTP(w, req)
if got, want := w.Code, http.StatusOK; got != want {
t.Fatalf("status = %d, want %d", got, want)
}
}
func TestRouterScreenStatusRoute(t *testing.T) {
store := newInMemoryPlayerStatusStore()
store.Save(playerStatusRecord{ScreenID: "demo", Timestamp: "2026-03-22T16:00:00Z", Status: "running"})
req := httptest.NewRequest(http.MethodGet, "/api/v1/screens/demo/status", nil)
w := httptest.NewRecorder()
NewRouter(store).ServeHTTP(w, req)
if got, want := w.Code, http.StatusOK; got != want {
t.Fatalf("status = %d, want %d", got, want)
}
}
func TestRouterScreenStatusListRoute(t *testing.T) {
store := newInMemoryPlayerStatusStore()
req := httptest.NewRequest(http.MethodGet, "/api/v1/screens/status", nil)
w := httptest.NewRecorder()
NewRouter(store).ServeHTTP(w, req)
if got, want := w.Code, http.StatusOK; got != want {
t.Fatalf("status = %d, want %d", got, want)
}
}
func TestRouterScreenDetailPageRoute(t *testing.T) {
store := newInMemoryPlayerStatusStore()
store.now = func() time.Time {
return time.Date(2026, 3, 22, 16, 10, 0, 0, time.UTC)
}
store.Save(playerStatusRecord{
ScreenID: "info01-dev",
Timestamp: "2026-03-22T16:09:30Z",
Status: "running",
ServerConnectivity: "online",
ReceivedAt: "2026-03-22T16:09:30Z",
HeartbeatEverySeconds: 30,
ServerURL: "http://127.0.0.1:8080",
MQTTBroker: "tcp://127.0.0.1:1883",
})
req := httptest.NewRequest(http.MethodGet, "/status/info01-dev", nil)
w := httptest.NewRecorder()
NewRouter(store).ServeHTTP(w, req)
if got, want := w.Code, http.StatusOK; got != want {
t.Fatalf("status = %d, want %d", got, want)
}
if got := w.Header().Get("Content-Type"); !strings.Contains(got, "text/html") {
t.Fatalf("Content-Type = %q, want text/html", got)
}
body := w.Body.String()
for _, want := range []string{
"info01-dev",
"online",
"running",
"fresh",
"http://127.0.0.1:8080",
"tcp://127.0.0.1:1883",
"2026-03-22T16:09:30Z",
"/api/v1/screens/info01-dev/status",
"← All screens",
"Timing",
"Endpoints",
} {
if !strings.Contains(body, want) {
t.Fatalf("body missing %q", want)
}
}
}
func TestRouterScreenDetailPageNotFound(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/status/missing-screen", nil)
w := httptest.NewRecorder()
NewRouter(newInMemoryPlayerStatusStore()).ServeHTTP(w, req)
if got, want := w.Code, http.StatusNotFound; got != want {
t.Fatalf("status = %d, want %d", got, want)
}
if got := w.Header().Get("Content-Type"); !strings.Contains(got, "text/html") {
t.Fatalf("Content-Type = %q, want text/html", got)
}
if !strings.Contains(w.Body.String(), "← Back to Screen Status") {
t.Fatal("body missing back link")
}
}
func TestRouterStatusPageRejectsInvalidQueryParams(t *testing.T) {
cases := []struct {
name string
query string
}{
{"invalid server_connectivity", "?server_connectivity=garbage"},
{"invalid stale", "?stale=maybe"},
{"invalid updated_since", "?updated_since=not-a-time"},
{"invalid limit zero", "?limit=0"},
{"invalid limit negative", "?limit=-1"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/status"+tc.query, nil)
w := httptest.NewRecorder()
NewRouter(newInMemoryPlayerStatusStore()).ServeHTTP(w, req)
if got, want := w.Code, http.StatusBadRequest; got != want {
t.Fatalf("status = %d, want %d", got, want)
}
if got := w.Header().Get("Content-Type"); !strings.Contains(got, "text/html") {
t.Fatalf("Content-Type = %q, want text/html (error page must be HTML, not JSON)", got)
}
})
}
}
func TestRouterStatusPageRoute(t *testing.T) {
store := newInMemoryPlayerStatusStore()
store.now = func() time.Time {
return time.Date(2026, 3, 22, 16, 10, 0, 0, time.UTC)
}
store.Save(playerStatusRecord{ScreenID: "screen-online", Timestamp: "2026-03-22T16:09:30Z", Status: "running", ServerConnectivity: "online", ReceivedAt: "2026-03-22T16:09:30Z", HeartbeatEverySeconds: 30})
store.Save(playerStatusRecord{ScreenID: "screen-offline", Timestamp: "2026-03-22T16:00:00Z", Status: "running", ServerConnectivity: "offline", ReceivedAt: "2026-03-22T16:00:00Z", HeartbeatEverySeconds: 30})
req := httptest.NewRequest(http.MethodGet, "/status?server_connectivity=offline&stale=true&updated_since=2026-03-22T15:55:00Z&limit=10", nil)
w := httptest.NewRecorder()
NewRouter(store).ServeHTTP(w, req)
if got, want := w.Code, http.StatusOK; got != want {
t.Fatalf("status = %d, want %d", got, want)
}
if got := w.Header().Get("Content-Type"); !strings.Contains(got, "text/html") {
t.Fatalf("Content-Type = %q, want text/html", got)
}
body := w.Body.String()
for _, want := range []string{
"Screen Status",
"2 screens",
"<meta http-equiv=\"refresh\" content=\"15\">",
"Connectivity offline",
"Connectivity degraded",
"Stale reports",
"Fresh reports",
"updated_since=2026-03-22T15%3A55%3A00Z",
"screen-offline",
"offline",
"/api/v1/screens/screen-offline/status",
"name=\"server_connectivity\"",
"name=\"stale\"",
"name=\"limit\"",
"server_connectivity=offline",
"stale=true",
"value=\"10\"",
} {
if !strings.Contains(body, want) {
t.Fatalf("body missing %q", want)
}
}
}