From 56635554c79608bd4742a937db04900e1f0a40ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesko=20Ansch=C3=BCtz?= Date: Sun, 22 Mar 2026 20:34:37 +0100 Subject: [PATCH] Fuege Screen-Loeschung, Meta-Update, Datei-Persistenz und Lifecycle-Test hinzu - DELETE /api/v1/screens/{screenId}/status loescht einzelne Screen-Eintraege - /api/v1/meta listet jetzt 5 Tools inkl. screen-status-delete und diagnostic_ui-Pfade - filePlayerStatusStore persistiert den Status-Store atomar in einer JSON-Datei - MORZ_INFOBOARD_STATUS_STORE_PATH aktiviert die Datei-Persistenz (leer = In-Memory) - Integration-Test deckt den vollstaendigen Lifecycle: POST -> list -> HTML -> JSON -> DELETE -> 404 ab - DEVELOPMENT.md beschreibt End-to-End-Entwicklungstest und neue Env-Variable Co-Authored-By: Claude Sonnet 4.6 --- DEVELOPMENT.md | 55 +++++++- server/backend/internal/app/app.go | 7 +- server/backend/internal/config/config.go | 6 +- .../internal/httpapi/integration_test.go | 126 ++++++++++++++++++ server/backend/internal/httpapi/meta.go | 9 ++ .../backend/internal/httpapi/playerstatus.go | 15 +++ .../internal/httpapi/playerstatus_store.go | 11 ++ .../httpapi/playerstatus_store_file.go | 89 +++++++++++++ .../httpapi/playerstatus_store_file_test.go | 122 +++++++++++++++++ .../internal/httpapi/playerstatus_test.go | 41 ++++++ server/backend/internal/httpapi/router.go | 1 + .../backend/internal/httpapi/router_test.go | 18 ++- 12 files changed, 493 insertions(+), 7 deletions(-) create mode 100644 server/backend/internal/httpapi/integration_test.go create mode 100644 server/backend/internal/httpapi/playerstatus_store_file.go create mode 100644 server/backend/internal/httpapi/playerstatus_store_file_test.go diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index c701a37..761d981 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -138,14 +138,21 @@ Standard: Konfigurierbar ueber: -- `MORZ_INFOBOARD_HTTP_ADDR` +- `MORZ_INFOBOARD_HTTP_ADDR` – HTTP-Adresse (Standard: `:8080`) +- `MORZ_INFOBOARD_STATUS_STORE_PATH` – Pfad zur JSON-Datei fuer persistenten Status-Store; leer lassen fuer reinen In-Memory-Betrieb -Beispiel: +Beispiele: ```bash MORZ_INFOBOARD_HTTP_ADDR=:18080 go run ./cmd/api ``` +```bash +MORZ_INFOBOARD_HTTP_ADDR=:8080 \ +MORZ_INFOBOARD_STATUS_STORE_PATH=/tmp/screen-status.json \ +go run ./cmd/api +``` + ### Agent lokal starten ```bash @@ -190,7 +197,45 @@ go run ./cmd/agent 4. Agent: danach MQTT-spezifische Reachability und feinere Connectivity-Schwellenlogik aufsetzen 5. Danach die Netzwerk-, Sync- und Kommandopfade schrittweise produktionsnah ausbauen -Ergaenzt seit dem ersten Geruest: +## End-to-End-Entwicklungstest (Backend + Agent) + +Backend und Agent koennen lokal gegeneinander laufen. Reihenfolge: + +1. Backend starten (Terminal 1): + +```bash +cd server/backend +MORZ_INFOBOARD_STATUS_STORE_PATH=/tmp/screen-status.json go run ./cmd/api +``` + +2. Agent starten (Terminal 2): + +```bash +cd player/agent +MORZ_INFOBOARD_SCREEN_ID=info01-dev \ +MORZ_INFOBOARD_SERVER_URL=http://127.0.0.1:8080 \ +go run ./cmd/agent +``` + +3. Status pruefen: + +```bash +# JSON-Uebersicht +curl http://127.0.0.1:8080/api/v1/screens/status + +# HTML-Diagnoseseite +open http://127.0.0.1:8080/status + +# Einzelner Screen +curl http://127.0.0.1:8080/api/v1/screens/info01-dev/status + +# Screen loeschen +curl -X DELETE http://127.0.0.1:8080/api/v1/screens/info01-dev/status +``` + +Die Datei `/tmp/screen-status.json` enthaelt nach dem ersten Heartbeat den persistierten Status-Store. + +## Ergaenzt seit dem ersten Geruest: - `message_wall`-Resolver im Backend - Basisendpunkte und `message_wall`-Validierung im Backend testseitig breiter abgedeckt @@ -201,6 +246,10 @@ Ergaenzt seit dem ersten Geruest: - Backend validiert den Statuspfad jetzt enger auf erlaubte Lifecycle-/Connectivity-Werte und leitet `stale` aus dem gemeldeten Intervall ab - Backend leitet im Read-Pfad zusaetzlich ein kompaktes `derived_state` fuer Diagnosekonsumenten ab - Backend liefert unter `/status` eine erste sichtbare HTML-Diagnoseseite auf Basis derselben Statusdaten, inklusive Auto-Refresh, leichten Filtern und JSON-Drill-down +- Backend unterstuetzt `q=` (Screen-ID-Substring), `derived_state=`, `server_connectivity=`, `stale=`, `updated_since=`, `limit=` als Query-Filter +- Backend leitet `derived_state` (online / degraded / offline) aus `stale`, `server_connectivity` und `status` ab +- Backend erlaubt das Loeschen einzelner Screen-Eintraege via `DELETE /api/v1/screens/{screenId}/status` +- Backend persistiert den Status-Store optional in einer JSON-Datei (`MORZ_INFOBOARD_STATUS_STORE_PATH`) - dateibasierte Agent-Konfiguration zusaetzlich zu Env-Overrides - strukturierte Agent-Logs mit internem Health-Snapshot und signalgesteuertem Shutdown - erster periodischer HTTP-Status-Reporter im Agent diff --git a/server/backend/internal/app/app.go b/server/backend/internal/app/app.go index 64e6888..efc45ce 100644 --- a/server/backend/internal/app/app.go +++ b/server/backend/internal/app/app.go @@ -16,11 +16,16 @@ type App struct { func New() (*App, error) { cfg := config.Load() + store, err := httpapi.NewStoreFromConfig(cfg.StatusStorePath) + if err != nil { + return nil, err + } + return &App{ Config: cfg, server: &http.Server{ Addr: cfg.HTTPAddress, - Handler: httpapi.NewRouter(httpapi.NewPlayerStatusStore()), + Handler: httpapi.NewRouter(store), }, }, nil } diff --git a/server/backend/internal/config/config.go b/server/backend/internal/config/config.go index eb1bdb3..943d41f 100644 --- a/server/backend/internal/config/config.go +++ b/server/backend/internal/config/config.go @@ -3,12 +3,14 @@ package config import "os" type Config struct { - HTTPAddress string + HTTPAddress string + StatusStorePath string } func Load() Config { return Config{ - HTTPAddress: getenv("MORZ_INFOBOARD_HTTP_ADDR", ":8080"), + HTTPAddress: getenv("MORZ_INFOBOARD_HTTP_ADDR", ":8080"), + StatusStorePath: os.Getenv("MORZ_INFOBOARD_STATUS_STORE_PATH"), } } diff --git a/server/backend/internal/httpapi/integration_test.go b/server/backend/internal/httpapi/integration_test.go new file mode 100644 index 0000000..265c77c --- /dev/null +++ b/server/backend/internal/httpapi/integration_test.go @@ -0,0 +1,126 @@ +package httpapi + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// TestPlayerStatusLifecycle covers the full lifecycle of a screen status entry: +// ingest → list → HTML detail → JSON detail → delete → verify gone. +func TestPlayerStatusLifecycle(t *testing.T) { + store := newInMemoryPlayerStatusStore() + store.now = func() time.Time { + return time.Date(2026, 3, 22, 16, 10, 0, 0, time.UTC) + } + router := NewRouter(store) + + // 1. POST /api/v1/player/status – ingest a status report + body := `{ + "screen_id": "lifecycle-screen", + "ts": "2026-03-22T16:09:30Z", + "status": "running", + "server_connectivity": "online", + "server_url": "http://127.0.0.1:8080", + "mqtt_broker": "tcp://127.0.0.1:1883", + "heartbeat_every_seconds": 30, + "started_at": "2026-03-22T16:00:00Z", + "last_heartbeat_at": "2026-03-22T16:09:30Z" + }` + req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewBufferString(body)) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if got, want := w.Code, http.StatusOK; got != want { + t.Fatalf("POST status: got %d, want %d – body: %s", got, want, w.Body.String()) + } + + // 2. GET /api/v1/screens/status – appears in list with derived_state=online + req = httptest.NewRequest(http.MethodGet, "/api/v1/screens/status", nil) + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + if got, want := w.Code, http.StatusOK; got != want { + t.Fatalf("GET list: got %d, want %d", got, want) + } + var list struct { + Summary struct{ Total int } `json:"summary"` + Screens []playerStatusRecord `json:"screens"` + } + if err := json.Unmarshal(w.Body.Bytes(), &list); err != nil { + t.Fatalf("GET list Unmarshal: %v", err) + } + if got, want := list.Summary.Total, 1; got != want { + t.Fatalf("summary.total = %d, want %d", got, want) + } + if got, want := list.Screens[0].ScreenID, "lifecycle-screen"; got != want { + t.Fatalf("Screens[0].ScreenID = %q, want %q", got, want) + } + if got, want := list.Screens[0].DerivedState, "online"; got != want { + t.Fatalf("Screens[0].DerivedState = %q, want %q", got, want) + } + + // 3. GET /status/lifecycle-screen – HTML detail page contains key fields + req = httptest.NewRequest(http.MethodGet, "/status/lifecycle-screen", nil) + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + if got, want := w.Code, http.StatusOK; got != want { + t.Fatalf("GET HTML detail: got %d, want %d", got, want) + } + if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "text/html") { + t.Fatalf("Content-Type = %q, want text/html", ct) + } + htmlBody := w.Body.String() + for _, want := range []string{"lifecycle-screen", "online", "running"} { + if !strings.Contains(htmlBody, want) { + t.Fatalf("HTML detail page missing %q", want) + } + } + + // 4. GET /api/v1/screens/lifecycle-screen/status – JSON detail + req = httptest.NewRequest(http.MethodGet, "/api/v1/screens/lifecycle-screen/status", nil) + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + if got, want := w.Code, http.StatusOK; got != want { + t.Fatalf("GET JSON detail: got %d, want %d", got, want) + } + var detail playerStatusRecord + if err := json.Unmarshal(w.Body.Bytes(), &detail); err != nil { + t.Fatalf("GET JSON detail Unmarshal: %v", err) + } + if got, want := detail.ScreenID, "lifecycle-screen"; got != want { + t.Fatalf("detail.ScreenID = %q, want %q", got, want) + } + if got, want := detail.ServerURL, "http://127.0.0.1:8080"; got != want { + t.Fatalf("detail.ServerURL = %q, want %q", got, want) + } + + // 5. DELETE /api/v1/screens/lifecycle-screen/status – remove the record + req = httptest.NewRequest(http.MethodDelete, "/api/v1/screens/lifecycle-screen/status", nil) + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + if got, want := w.Code, http.StatusOK; got != want { + t.Fatalf("DELETE: got %d, want %d – body: %s", got, want, w.Body.String()) + } + + // 6. GET /api/v1/screens/lifecycle-screen/status – must be 404 now + req = httptest.NewRequest(http.MethodGet, "/api/v1/screens/lifecycle-screen/status", nil) + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + if got, want := w.Code, http.StatusNotFound; got != want { + t.Fatalf("GET after delete: got %d, want %d", got, want) + } + + // 7. GET /api/v1/screens/status – list must be empty again + req = httptest.NewRequest(http.MethodGet, "/api/v1/screens/status", nil) + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + if err := json.Unmarshal(w.Body.Bytes(), &list); err != nil { + t.Fatalf("GET list after delete Unmarshal: %v", err) + } + if got, want := list.Summary.Total, 0; got != want { + t.Fatalf("summary.total after delete = %d, want %d", got, want) + } +} diff --git a/server/backend/internal/httpapi/meta.go b/server/backend/internal/httpapi/meta.go index 4192c08..a70f31b 100644 --- a/server/backend/internal/httpapi/meta.go +++ b/server/backend/internal/httpapi/meta.go @@ -30,6 +30,15 @@ func handleMeta(w http.ResponseWriter, _ *http.Request) { "method": http.MethodPost, "path": "/api/v1/player/status", }, + { + "name": "screen-status-delete", + "method": http.MethodDelete, + "path": "/api/v1/screens/{screenId}/status", + }, + }, + "diagnostic_ui": map[string]string{ + "screen_list": "/status", + "screen_detail": "/status/{screenId}", }, }, }) diff --git a/server/backend/internal/httpapi/playerstatus.go b/server/backend/internal/httpapi/playerstatus.go index 5944720..a13455a 100644 --- a/server/backend/internal/httpapi/playerstatus.go +++ b/server/backend/internal/httpapi/playerstatus.go @@ -143,6 +143,21 @@ func handleGetLatestPlayerStatus(store playerStatusStore) http.HandlerFunc { } } +func handleDeletePlayerStatus(store playerStatusStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + screenID := strings.TrimSpace(r.PathValue("screenId")) + if screenID == "" { + writeError(w, http.StatusBadRequest, "screen_id_required", "screenId ist erforderlich", nil) + return + } + if !store.Delete(screenID) { + writeError(w, http.StatusNotFound, "screen_status_not_found", "Fuer diesen Screen liegt noch kein Status vor", nil) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) + } +} + func handleListLatestPlayerStatuses(store playerStatusStore) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { overview, err := buildScreenStatusOverview(store, r.URL.Query()) diff --git a/server/backend/internal/httpapi/playerstatus_store.go b/server/backend/internal/httpapi/playerstatus_store.go index 3ffc345..30cef1f 100644 --- a/server/backend/internal/httpapi/playerstatus_store.go +++ b/server/backend/internal/httpapi/playerstatus_store.go @@ -26,6 +26,7 @@ type playerStatusStore interface { Get(screenID string) (playerStatusRecord, bool) List() []playerStatusRecord Now() time.Time + Delete(screenID string) bool } type inMemoryPlayerStatusStore struct { @@ -74,6 +75,16 @@ func (s *inMemoryPlayerStatusStore) List() []playerStatusRecord { return records } +func (s *inMemoryPlayerStatusStore) Delete(screenID string) bool { + s.mu.Lock() + defer s.mu.Unlock() + _, ok := s.records[screenID] + if ok { + delete(s.records, screenID) + } + return ok +} + func (s *inMemoryPlayerStatusStore) Now() time.Time { if s.now == nil { return time.Now() diff --git a/server/backend/internal/httpapi/playerstatus_store_file.go b/server/backend/internal/httpapi/playerstatus_store_file.go new file mode 100644 index 0000000..1f315d3 --- /dev/null +++ b/server/backend/internal/httpapi/playerstatus_store_file.go @@ -0,0 +1,89 @@ +package httpapi + +import ( + "encoding/json" + "os" + "path/filepath" +) + +// filePlayerStatusStore wraps inMemoryPlayerStatusStore and persists the +// records to a JSON file on every mutation. Reads are served from memory. +type filePlayerStatusStore struct { + *inMemoryPlayerStatusStore + path string +} + +func newFilePlayerStatusStore(path string) (*filePlayerStatusStore, error) { + s := &filePlayerStatusStore{ + inMemoryPlayerStatusStore: newInMemoryPlayerStatusStore(), + path: path, + } + if err := s.load(); err != nil { + return nil, err + } + return s, nil +} + +// NewStoreFromConfig returns a playerStatusStore. If path is non-empty a +// file-backed store is used; otherwise an in-memory store is returned. +func NewStoreFromConfig(path string) (playerStatusStore, error) { + if path == "" { + return newInMemoryPlayerStatusStore(), nil + } + return newFilePlayerStatusStore(path) +} + +func (s *filePlayerStatusStore) Save(record playerStatusRecord) { + s.inMemoryPlayerStatusStore.Save(record) + _ = s.persist() +} + +func (s *filePlayerStatusStore) Delete(screenID string) bool { + ok := s.inMemoryPlayerStatusStore.Delete(screenID) + if ok { + _ = s.persist() + } + return ok +} + +func (s *filePlayerStatusStore) load() error { + data, err := os.ReadFile(s.path) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + + var records []playerStatusRecord + if err := json.Unmarshal(data, &records); err != nil { + return err + } + + s.mu.Lock() + defer s.mu.Unlock() + for _, r := range records { + s.records[r.ScreenID] = r + } + return nil +} + +func (s *filePlayerStatusStore) persist() error { + s.mu.RLock() + records := make([]playerStatusRecord, 0, len(s.records)) + for _, r := range s.records { + records = append(records, r) + } + s.mu.RUnlock() + + data, err := json.Marshal(records) + if err != nil { + return err + } + + tmp := s.path + ".tmp" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return err + } + return os.Rename(tmp, filepath.Clean(s.path)) +} diff --git a/server/backend/internal/httpapi/playerstatus_store_file_test.go b/server/backend/internal/httpapi/playerstatus_store_file_test.go new file mode 100644 index 0000000..b13796e --- /dev/null +++ b/server/backend/internal/httpapi/playerstatus_store_file_test.go @@ -0,0 +1,122 @@ +package httpapi + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestFilePlayerStatusStorePersistsOnSave(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "status.json") + + s, err := newFilePlayerStatusStore(path) + if err != nil { + t.Fatalf("newFilePlayerStatusStore() error = %v", err) + } + + s.Save(playerStatusRecord{ScreenID: "screen-a", Timestamp: "2026-03-22T16:00:00Z", Status: "running"}) + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + + var records []playerStatusRecord + if err := json.Unmarshal(data, &records); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + + if got, want := len(records), 1; got != want { + t.Fatalf("len(records) = %d, want %d", got, want) + } + if got, want := records[0].ScreenID, "screen-a"; got != want { + t.Fatalf("ScreenID = %q, want %q", got, want) + } +} + +func TestFilePlayerStatusStorePersistsOnDelete(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "status.json") + + s, err := newFilePlayerStatusStore(path) + if err != nil { + t.Fatalf("newFilePlayerStatusStore() error = %v", err) + } + + s.Save(playerStatusRecord{ScreenID: "screen-a", Timestamp: "2026-03-22T16:00:00Z", Status: "running"}) + s.Save(playerStatusRecord{ScreenID: "screen-b", Timestamp: "2026-03-22T16:00:00Z", Status: "running"}) + + if ok := s.Delete("screen-a"); !ok { + t.Fatal("Delete() = false, want true") + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + + var records []playerStatusRecord + if err := json.Unmarshal(data, &records); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + + if got, want := len(records), 1; got != want { + t.Fatalf("len(records) = %d, want %d", got, want) + } + if got, want := records[0].ScreenID, "screen-b"; got != want { + t.Fatalf("ScreenID = %q, want %q", got, want) + } +} + +func TestFilePlayerStatusStoreLoadsExistingData(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "status.json") + + existing := []playerStatusRecord{ + {ScreenID: "screen-x", Timestamp: "2026-03-22T16:00:00Z", Status: "running"}, + } + data, _ := json.Marshal(existing) + if err := os.WriteFile(path, data, 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + s, err := newFilePlayerStatusStore(path) + if err != nil { + t.Fatalf("newFilePlayerStatusStore() error = %v", err) + } + + rec, ok := s.Get("screen-x") + if !ok { + t.Fatal("Get() = false, want true") + } + if got, want := rec.ScreenID, "screen-x"; got != want { + t.Fatalf("ScreenID = %q, want %q", got, want) + } +} + +func TestFilePlayerStatusStoreMissingFileIsOK(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "nonexistent.json") + + s, err := newFilePlayerStatusStore(path) + if err != nil { + t.Fatalf("newFilePlayerStatusStore() with missing file error = %v", err) + } + + if got, want := len(s.List()), 0; got != want { + t.Fatalf("len(List()) = %d, want %d", got, want) + } +} + +func TestNewStoreFromConfigInMemoryWhenNoPath(t *testing.T) { + store, err := NewStoreFromConfig("") + if err != nil { + t.Fatalf("NewStoreFromConfig(\"\") error = %v", err) + } + store.Save(playerStatusRecord{ScreenID: "s1", Timestamp: "2026-03-22T16:00:00Z", Status: "running"}) + if _, ok := store.Get("s1"); !ok { + t.Fatal("Get() = false after Save()") + } +} diff --git a/server/backend/internal/httpapi/playerstatus_test.go b/server/backend/internal/httpapi/playerstatus_test.go index 8ab7dbb..c4e8aab 100644 --- a/server/backend/internal/httpapi/playerstatus_test.go +++ b/server/backend/internal/httpapi/playerstatus_test.go @@ -738,3 +738,44 @@ func TestHandleListLatestPlayerStatusesFiltersByUpdatedSince(t *testing.T) { t.Fatalf("response.Screens[0].ScreenID = %q, want %q", got, want) } } + +func TestHandleDeletePlayerStatusOK(t *testing.T) { + store := newInMemoryPlayerStatusStore() + store.Save(playerStatusRecord{ScreenID: "info01-dev", Timestamp: "2026-03-22T16:00:00Z", Status: "running"}) + + req := httptest.NewRequest(http.MethodDelete, "/api/v1/screens/info01-dev/status", nil) + req.SetPathValue("screenId", "info01-dev") + w := httptest.NewRecorder() + + handleDeletePlayerStatus(store)(w, req) + + if got, want := w.Code, http.StatusOK; got != want { + t.Fatalf("status = %d, want %d", got, want) + } + + var response map[string]string + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if got, want := response["status"], "deleted"; got != want { + t.Fatalf("status = %q, want %q", got, want) + } + + if _, ok := store.Get("info01-dev"); ok { + t.Fatal("record still present after delete") + } +} + +func TestHandleDeletePlayerStatusNotFound(t *testing.T) { + store := newInMemoryPlayerStatusStore() + + req := httptest.NewRequest(http.MethodDelete, "/api/v1/screens/missing/status", nil) + req.SetPathValue("screenId", "missing") + w := httptest.NewRecorder() + + handleDeletePlayerStatus(store)(w, req) + + if got, want := w.Code, http.StatusNotFound; got != want { + t.Fatalf("status = %d, want %d", got, want) + } +} diff --git a/server/backend/internal/httpapi/router.go b/server/backend/internal/httpapi/router.go index 2fde419..c23a066 100644 --- a/server/backend/internal/httpapi/router.go +++ b/server/backend/internal/httpapi/router.go @@ -34,6 +34,7 @@ func NewRouter(store playerStatusStore) http.Handler { mux.HandleFunc("POST /api/v1/player/status", handlePlayerStatus(store)) mux.HandleFunc("GET /api/v1/screens/status", handleListLatestPlayerStatuses(store)) mux.HandleFunc("GET /api/v1/screens/{screenId}/status", handleGetLatestPlayerStatus(store)) + mux.HandleFunc("DELETE /api/v1/screens/{screenId}/status", handleDeletePlayerStatus(store)) mux.HandleFunc("POST /api/v1/tools/message-wall/resolve", handleResolveMessageWall) diff --git a/server/backend/internal/httpapi/router_test.go b/server/backend/internal/httpapi/router_test.go index 7e70106..ec54888 100644 --- a/server/backend/internal/httpapi/router_test.go +++ b/server/backend/internal/httpapi/router_test.go @@ -104,6 +104,10 @@ func TestRouterMeta(t *testing.T) { Method string `json:"method"` Path string `json:"path"` } `json:"tools"` + DiagnosticUI struct { + ScreenList string `json:"screen_list"` + ScreenDetail string `json:"screen_detail"` + } `json:"diagnostic_ui"` } `json:"api"` } @@ -119,7 +123,7 @@ func TestRouterMeta(t *testing.T) { t.Fatalf("api.health = %q, want %q", got, want) } - if got, want := len(response.API.Tools), 4; got != want { + if got, want := len(response.API.Tools), 5; got != want { t.Fatalf("len(api.tools) = %d, want %d", got, want) } @@ -138,6 +142,18 @@ func TestRouterMeta(t *testing.T) { 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) } + + if got, want := response.API.Tools[4].Path, "/api/v1/screens/{screenId}/status"; got != want { + t.Fatalf("api.tools[4].path = %q, want %q", got, want) + } + + if got, want := response.API.DiagnosticUI.ScreenList, "/status"; got != want { + t.Fatalf("api.diagnostic_ui.screen_list = %q, want %q", got, want) + } + + if got, want := response.API.DiagnosticUI.ScreenDetail, "/status/{screenId}"; got != want { + t.Fatalf("api.diagnostic_ui.screen_detail = %q, want %q", got, want) + } } func TestRouterPlayerStatusRoute(t *testing.T) {