Halte letzten Player-Status im Backend

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Jesko Anschütz 2026-03-22 18:19:17 +01:00
parent f8a57b3e6b
commit 896eade0fb
6 changed files with 193 additions and 48 deletions

View file

@ -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
}

View file

@ -17,7 +17,8 @@ type playerStatusRequest struct {
LastHeartbeatAt string `json:"last_heartbeat_at"`
}
func handlePlayerStatus(w http.ResponseWriter, r *http.Request) {
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)
@ -54,10 +55,40 @@ func handlePlayerStatus(w http.ResponseWriter, r *http.Request) {
return
}
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 {
if strings.TrimSpace(value) == "" {

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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)

View file

@ -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)