diff --git a/server/backend/internal/app/app.go b/server/backend/internal/app/app.go index 2053244..64e6888 100644 --- a/server/backend/internal/app/app.go +++ b/server/backend/internal/app/app.go @@ -20,7 +20,7 @@ func New() (*App, error) { Config: cfg, server: &http.Server{ Addr: cfg.HTTPAddress, - Handler: httpapi.NewRouter(), + Handler: httpapi.NewRouter(httpapi.NewPlayerStatusStore()), }, }, nil } diff --git a/server/backend/internal/httpapi/playerstatus.go b/server/backend/internal/httpapi/playerstatus.go index 19423d2..2f4fb51 100644 --- a/server/backend/internal/httpapi/playerstatus.go +++ b/server/backend/internal/httpapi/playerstatus.go @@ -17,46 +17,77 @@ type playerStatusRequest struct { LastHeartbeatAt string `json:"last_heartbeat_at"` } -func handlePlayerStatus(w http.ResponseWriter, r *http.Request) { - var request playerStatusRequest - if err := decodeJSON(r, &request); err != nil { - writeError(w, http.StatusBadRequest, "invalid_json", "ungueltiger JSON-Body", nil) - return - } +func handlePlayerStatus(store playerStatusStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var request playerStatusRequest + if err := decodeJSON(r, &request); err != nil { + writeError(w, http.StatusBadRequest, "invalid_json", "ungueltiger JSON-Body", nil) + return + } - if strings.TrimSpace(request.ScreenID) == "" { - writeError(w, http.StatusBadRequest, "screen_id_required", "screen_id ist erforderlich", nil) - return - } + if strings.TrimSpace(request.ScreenID) == "" { + writeError(w, http.StatusBadRequest, "screen_id_required", "screen_id ist erforderlich", nil) + return + } - if strings.TrimSpace(request.Timestamp) == "" { - writeError(w, http.StatusBadRequest, "timestamp_required", "ts ist erforderlich", nil) - return - } + if strings.TrimSpace(request.Timestamp) == "" { + writeError(w, http.StatusBadRequest, "timestamp_required", "ts ist erforderlich", nil) + return + } - if strings.TrimSpace(request.Status) == "" { - writeError(w, http.StatusBadRequest, "status_required", "status ist erforderlich", nil) - return - } + if strings.TrimSpace(request.Status) == "" { + writeError(w, http.StatusBadRequest, "status_required", "status ist erforderlich", nil) + return + } - if err := validateOptionalRFC3339(request.Timestamp); err != nil { - writeError(w, http.StatusBadRequest, "invalid_timestamp", "ts ist kein gueltiger RFC3339-Zeitstempel", nil) - return - } + if err := validateOptionalRFC3339(request.Timestamp); err != nil { + writeError(w, http.StatusBadRequest, "invalid_timestamp", "ts ist kein gueltiger RFC3339-Zeitstempel", nil) + return + } - if err := validateOptionalRFC3339(request.StartedAt); err != nil { - writeError(w, http.StatusBadRequest, "invalid_started_at", "started_at ist kein gueltiger RFC3339-Zeitstempel", nil) - return - } + if err := validateOptionalRFC3339(request.StartedAt); err != nil { + writeError(w, http.StatusBadRequest, "invalid_started_at", "started_at ist kein gueltiger RFC3339-Zeitstempel", nil) + return + } - if err := validateOptionalRFC3339(request.LastHeartbeatAt); err != nil { - writeError(w, http.StatusBadRequest, "invalid_last_heartbeat_at", "last_heartbeat_at ist kein gueltiger RFC3339-Zeitstempel", nil) - return - } + if err := validateOptionalRFC3339(request.LastHeartbeatAt); err != nil { + writeError(w, http.StatusBadRequest, "invalid_last_heartbeat_at", "last_heartbeat_at ist kein gueltiger RFC3339-Zeitstempel", nil) + return + } - writeJSON(w, http.StatusOK, map[string]string{ - "status": "accepted", - }) + store.Save(playerStatusRecord{ + ScreenID: request.ScreenID, + Timestamp: request.Timestamp, + Status: request.Status, + ServerURL: request.ServerURL, + MQTTBroker: request.MQTTBroker, + HeartbeatEverySeconds: request.HeartbeatEverySeconds, + StartedAt: request.StartedAt, + LastHeartbeatAt: request.LastHeartbeatAt, + }) + + writeJSON(w, http.StatusOK, map[string]string{ + "status": "accepted", + }) + } +} + +func handleGetLatestPlayerStatus(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 + } + + record, ok := store.Get(screenID) + if !ok { + writeError(w, http.StatusNotFound, "screen_status_not_found", "Fuer diesen Screen liegt noch kein Status vor", nil) + return + } + + writeJSON(w, http.StatusOK, record) + } } func validateOptionalRFC3339(value string) error { diff --git a/server/backend/internal/httpapi/playerstatus_store.go b/server/backend/internal/httpapi/playerstatus_store.go new file mode 100644 index 0000000..37b5dd9 --- /dev/null +++ b/server/backend/internal/httpapi/playerstatus_store.go @@ -0,0 +1,45 @@ +package httpapi + +import "sync" + +type playerStatusRecord struct { + ScreenID string `json:"screen_id"` + Timestamp string `json:"ts"` + Status string `json:"status"` + ServerURL string `json:"server_url,omitempty"` + MQTTBroker string `json:"mqtt_broker,omitempty"` + HeartbeatEverySeconds int `json:"heartbeat_every_seconds,omitempty"` + StartedAt string `json:"started_at,omitempty"` + LastHeartbeatAt string `json:"last_heartbeat_at,omitempty"` +} + +type playerStatusStore interface { + Save(record playerStatusRecord) + Get(screenID string) (playerStatusRecord, bool) +} + +type inMemoryPlayerStatusStore struct { + mu sync.RWMutex + records map[string]playerStatusRecord +} + +func newInMemoryPlayerStatusStore() *inMemoryPlayerStatusStore { + return &inMemoryPlayerStatusStore{records: make(map[string]playerStatusRecord)} +} + +func NewPlayerStatusStore() playerStatusStore { + return newInMemoryPlayerStatusStore() +} + +func (s *inMemoryPlayerStatusStore) Save(record playerStatusRecord) { + s.mu.Lock() + defer s.mu.Unlock() + s.records[record.ScreenID] = record +} + +func (s *inMemoryPlayerStatusStore) Get(screenID string) (playerStatusRecord, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + record, ok := s.records[screenID] + return record, ok +} diff --git a/server/backend/internal/httpapi/playerstatus_test.go b/server/backend/internal/httpapi/playerstatus_test.go index 4c8cb81..baf0b10 100644 --- a/server/backend/internal/httpapi/playerstatus_test.go +++ b/server/backend/internal/httpapi/playerstatus_test.go @@ -9,6 +9,7 @@ import ( ) func TestHandlePlayerStatusAccepted(t *testing.T) { + store := newInMemoryPlayerStatusStore() body := []byte(`{ "screen_id": "info01-dev", "ts": "2026-03-22T16:00:00Z", @@ -23,7 +24,7 @@ func TestHandlePlayerStatusAccepted(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body)) w := httptest.NewRecorder() - handlePlayerStatus(w, req) + handlePlayerStatus(store)(w, req) if got, want := w.Code, http.StatusOK; got != want { t.Fatalf("status = %d, want %d", got, want) @@ -40,13 +41,22 @@ func TestHandlePlayerStatusAccepted(t *testing.T) { if got, want := response.Status, "accepted"; got != want { t.Fatalf("response status = %q, want %q", got, want) } + + stored, ok := store.Get("info01-dev") + if !ok { + t.Fatal("store.Get() ok = false, want true") + } + + if got, want := stored.ScreenID, "info01-dev"; got != want { + t.Fatalf("stored.ScreenID = %q, want %q", got, want) + } } func TestHandlePlayerStatusRejectsInvalidJSON(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewBufferString("{")) w := httptest.NewRecorder() - handlePlayerStatus(w, req) + handlePlayerStatus(newInMemoryPlayerStatusStore())(w, req) if got, want := w.Code, http.StatusBadRequest; got != want { t.Fatalf("status = %d, want %d", got, want) @@ -63,7 +73,7 @@ func TestHandlePlayerStatusRejectsMissingScreenID(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body)) w := httptest.NewRecorder() - handlePlayerStatus(w, req) + handlePlayerStatus(newInMemoryPlayerStatusStore())(w, req) if got, want := w.Code, http.StatusBadRequest; got != want { t.Fatalf("status = %d, want %d", got, want) @@ -79,7 +89,7 @@ func TestHandlePlayerStatusRejectsMissingTimestamp(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body)) w := httptest.NewRecorder() - handlePlayerStatus(w, req) + handlePlayerStatus(newInMemoryPlayerStatusStore())(w, req) if got, want := w.Code, http.StatusBadRequest; got != want { t.Fatalf("status = %d, want %d", got, want) @@ -95,7 +105,7 @@ func TestHandlePlayerStatusRejectsMissingStatus(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body)) w := httptest.NewRecorder() - handlePlayerStatus(w, req) + handlePlayerStatus(newInMemoryPlayerStatusStore())(w, req) if got, want := w.Code, http.StatusBadRequest; got != want { t.Fatalf("status = %d, want %d", got, want) @@ -112,7 +122,7 @@ func TestHandlePlayerStatusRejectsMalformedTimestamps(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body)) w := httptest.NewRecorder() - handlePlayerStatus(w, req) + handlePlayerStatus(newInMemoryPlayerStatusStore())(w, req) if got, want := w.Code, http.StatusBadRequest; got != want { t.Fatalf("status = %d, want %d", got, want) @@ -130,7 +140,7 @@ func TestHandlePlayerStatusRejectsMalformedStartedAt(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body)) w := httptest.NewRecorder() - handlePlayerStatus(w, req) + handlePlayerStatus(newInMemoryPlayerStatusStore())(w, req) if got, want := w.Code, http.StatusBadRequest; got != want { t.Fatalf("status = %d, want %d", got, want) @@ -148,9 +158,54 @@ func TestHandlePlayerStatusRejectsMalformedLastHeartbeatAt(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body)) w := httptest.NewRecorder() - handlePlayerStatus(w, req) + handlePlayerStatus(newInMemoryPlayerStatusStore())(w, req) if got, want := w.Code, http.StatusBadRequest; got != want { t.Fatalf("status = %d, want %d", got, want) } } + +func TestHandleGetLatestPlayerStatus(t *testing.T) { + store := newInMemoryPlayerStatusStore() + store.Save(playerStatusRecord{ + ScreenID: "info01-dev", + Timestamp: "2026-03-22T16:00:00Z", + Status: "running", + ServerURL: "http://127.0.0.1:8080", + MQTTBroker: "tcp://127.0.0.1:1883", + HeartbeatEverySeconds: 30, + StartedAt: "2026-03-22T15:59:30Z", + LastHeartbeatAt: "2026-03-22T16:00:00Z", + }) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/screens/info01-dev/status", nil) + req.SetPathValue("screenId", "info01-dev") + w := httptest.NewRecorder() + + handleGetLatestPlayerStatus(store)(w, req) + + if got, want := w.Code, http.StatusOK; got != want { + t.Fatalf("status = %d, want %d", got, want) + } + + var response playerStatusRecord + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + + if got, want := response.ScreenID, "info01-dev"; got != want { + t.Fatalf("response.ScreenID = %q, want %q", got, want) + } +} + +func TestHandleGetLatestPlayerStatusNotFound(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/screens/missing/status", nil) + req.SetPathValue("screenId", "missing") + w := httptest.NewRecorder() + + handleGetLatestPlayerStatus(newInMemoryPlayerStatusStore())(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 6e63676..85137b9 100644 --- a/server/backend/internal/httpapi/router.go +++ b/server/backend/internal/httpapi/router.go @@ -4,7 +4,7 @@ import ( "net/http" ) -func NewRouter() http.Handler { +func NewRouter(store playerStatusStore) http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { @@ -26,7 +26,8 @@ func NewRouter() http.Handler { mux.HandleFunc("GET /api/v1/meta", handleMeta) - mux.HandleFunc("POST /api/v1/player/status", handlePlayerStatus) + mux.HandleFunc("POST /api/v1/player/status", handlePlayerStatus(store)) + mux.HandleFunc("GET /api/v1/screens/{screenId}/status", handleGetLatestPlayerStatus(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 b35b4ce..7902f5f 100644 --- a/server/backend/internal/httpapi/router_test.go +++ b/server/backend/internal/httpapi/router_test.go @@ -12,7 +12,7 @@ func TestRouterHealthz(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/healthz", nil) w := httptest.NewRecorder() - NewRouter().ServeHTTP(w, req) + NewRouter(newInMemoryPlayerStatusStore()).ServeHTTP(w, req) if got, want := w.Code, http.StatusOK; got != want { t.Fatalf("status = %d, want %d", got, want) @@ -40,7 +40,7 @@ func TestRouterBaseAPI(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/api/v1", nil) w := httptest.NewRecorder() - NewRouter().ServeHTTP(w, req) + NewRouter(newInMemoryPlayerStatusStore()).ServeHTTP(w, req) if got, want := w.Code, http.StatusOK; got != want { t.Fatalf("status = %d, want %d", got, want) @@ -77,7 +77,7 @@ func TestRouterMeta(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/api/v1/meta", nil) w := httptest.NewRecorder() - NewRouter().ServeHTTP(w, req) + NewRouter(newInMemoryPlayerStatusStore()).ServeHTTP(w, req) if got, want := w.Code, http.StatusOK; got != want { t.Fatalf("status = %d, want %d", got, want) @@ -122,7 +122,20 @@ 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"}`)) w := httptest.NewRecorder() - NewRouter().ServeHTTP(w, req) + 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)