diff --git a/server/backend/internal/httpapi/playerstatus.go b/server/backend/internal/httpapi/playerstatus.go new file mode 100644 index 0000000..19423d2 --- /dev/null +++ b/server/backend/internal/httpapi/playerstatus.go @@ -0,0 +1,69 @@ +package httpapi + +import ( + "net/http" + "strings" + "time" +) + +type playerStatusRequest struct { + ScreenID string `json:"screen_id"` + Timestamp string `json:"ts"` + Status string `json:"status"` + ServerURL string `json:"server_url"` + MQTTBroker string `json:"mqtt_broker"` + HeartbeatEverySeconds int `json:"heartbeat_every_seconds"` + StartedAt string `json:"started_at"` + 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 + } + + 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.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.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 + } + + writeJSON(w, http.StatusOK, map[string]string{ + "status": "accepted", + }) +} + +func validateOptionalRFC3339(value string) error { + if strings.TrimSpace(value) == "" { + return nil + } + + _, err := time.Parse(time.RFC3339, value) + return err +} diff --git a/server/backend/internal/httpapi/playerstatus_test.go b/server/backend/internal/httpapi/playerstatus_test.go new file mode 100644 index 0000000..4c8cb81 --- /dev/null +++ b/server/backend/internal/httpapi/playerstatus_test.go @@ -0,0 +1,156 @@ +package httpapi + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestHandlePlayerStatusAccepted(t *testing.T) { + body := []byte(`{ + "screen_id": "info01-dev", + "ts": "2026-03-22T16:00:00Z", + "status": "running", + "server_url": "http://127.0.0.1:8080", + "mqtt_broker": "tcp://127.0.0.1:1883", + "heartbeat_every_seconds": 30, + "started_at": "2026-03-22T15:59:30Z", + "last_heartbeat_at": "2026-03-22T16:00:00Z" + }`) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body)) + w := httptest.NewRecorder() + + handlePlayerStatus(w, req) + + if got, want := w.Code, http.StatusOK; got != want { + t.Fatalf("status = %d, want %d", got, want) + } + + var response struct { + Status string `json:"status"` + } + + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + + if got, want := response.Status, "accepted"; got != want { + t.Fatalf("response status = %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) + + if got, want := w.Code, http.StatusBadRequest; got != want { + t.Fatalf("status = %d, want %d", got, want) + } +} + +func TestHandlePlayerStatusRejectsMissingScreenID(t *testing.T) { + body := []byte(`{ + "screen_id": " ", + "ts": "2026-03-22T16:00:00Z", + "status": "running" + }`) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body)) + w := httptest.NewRecorder() + + handlePlayerStatus(w, req) + + if got, want := w.Code, http.StatusBadRequest; got != want { + t.Fatalf("status = %d, want %d", got, want) + } +} + +func TestHandlePlayerStatusRejectsMissingTimestamp(t *testing.T) { + body := []byte(`{ + "screen_id": "info01-dev", + "status": "running" + }`) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body)) + w := httptest.NewRecorder() + + handlePlayerStatus(w, req) + + if got, want := w.Code, http.StatusBadRequest; got != want { + t.Fatalf("status = %d, want %d", got, want) + } +} + +func TestHandlePlayerStatusRejectsMissingStatus(t *testing.T) { + body := []byte(`{ + "screen_id": "info01-dev", + "ts": "2026-03-22T16:00:00Z" + }`) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body)) + w := httptest.NewRecorder() + + handlePlayerStatus(w, req) + + if got, want := w.Code, http.StatusBadRequest; got != want { + t.Fatalf("status = %d, want %d", got, want) + } +} + +func TestHandlePlayerStatusRejectsMalformedTimestamps(t *testing.T) { + body := []byte(`{ + "screen_id": "info01-dev", + "ts": "not-a-time", + "status": "running" + }`) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body)) + w := httptest.NewRecorder() + + handlePlayerStatus(w, req) + + if got, want := w.Code, http.StatusBadRequest; got != want { + t.Fatalf("status = %d, want %d", got, want) + } +} + +func TestHandlePlayerStatusRejectsMalformedStartedAt(t *testing.T) { + body := []byte(`{ + "screen_id": "info01-dev", + "ts": "2026-03-22T16:00:00Z", + "status": "running", + "started_at": "not-a-time" + }`) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body)) + w := httptest.NewRecorder() + + handlePlayerStatus(w, req) + + if got, want := w.Code, http.StatusBadRequest; got != want { + t.Fatalf("status = %d, want %d", got, want) + } +} + +func TestHandlePlayerStatusRejectsMalformedLastHeartbeatAt(t *testing.T) { + body := []byte(`{ + "screen_id": "info01-dev", + "ts": "2026-03-22T16:00:00Z", + "status": "running", + "last_heartbeat_at": "not-a-time" + }`) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body)) + w := httptest.NewRecorder() + + handlePlayerStatus(w, req) + + if got, want := w.Code, http.StatusBadRequest; 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 9945fce..b52f163 100644 --- a/server/backend/internal/httpapi/router.go +++ b/server/backend/internal/httpapi/router.go @@ -9,7 +9,7 @@ func NewRouter() http.Handler { mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]string{ - "status": "ok", + "status": "ok", "service": "morz-infoboard-backend", }) }) @@ -26,6 +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/tools/message-wall/resolve", handleResolveMessageWall) return mux