morz-infoboard/server/backend/internal/httpapi/router_test.go
Jesko Anschütz 57e0cdb43c Ergaenze Auto-Refresh auf Detailseite und bereinige Fehlermeldungs-Duplikat
Auto-Refresh auf GET /status/{screenId}:
  screenDetailPageData bekommt RefreshSeconds (wie statusPageData). Das
  Detail-Template rendert das Meta-Tag analog zur Uebersichtsseite mit
  denselben 15 Sekunden, damit ein Screen seinen Zustand (z.B. fresh ->
  stale, connectivity-Wechsel) auch ohne manuellen Reload sichtbar macht.
  Test: meta-refresh-Tag jetzt in TestRouterScreenDetailPageRoute geprueft.

DRY-Refactor: Fehlermeldungen vereinheitlicht:
  overviewQueryErrorMessage und overviewQueryErrorCode sind jetzt in
  playerstatus.go definiert -- dort wo auch die Validierungslogik lebt.
  writeOverviewQueryError delegiert vollstaendig an beide Helper statt
  die Meldungen selbst zu duplizieren. Die vorherige Kopie in statuspage.go
  mit abweichenden Satzendezeichen und Grossschreibung wurde entfernt.
  Beide Fehlerpfade (JSON und HTML) nutzen jetzt exakt dieselben
  Meldungstexte.

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

322 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",
"<meta http-equiv=\"refresh\" content=\"15\">",
} {
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)
}
}
}