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 <noreply@anthropic.com>
This commit is contained in:
parent
ea90af1403
commit
56635554c7
12 changed files with 493 additions and 7 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
126
server/backend/internal/httpapi/integration_test.go
Normal file
126
server/backend/internal/httpapi/integration_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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}",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
89
server/backend/internal/httpapi/playerstatus_store_file.go
Normal file
89
server/backend/internal/httpapi/playerstatus_store_file.go
Normal file
|
|
@ -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))
|
||||
}
|
||||
122
server/backend/internal/httpapi/playerstatus_store_file_test.go
Normal file
122
server/backend/internal/httpapi/playerstatus_store_file_test.go
Normal file
|
|
@ -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()")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue