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