- DB-Package mit pgxpool, Migrations-Runner und eingebetteten SQL-Dateien
- Schema: tenants, screens, media_assets, playlists, playlist_items
- Store-Layer: alle Repositories (TenantStore, ScreenStore, MediaStore, PlaylistStore)
- JSON-API: Screens, Medien, Playlist-CRUD, Player-Sync-Endpunkt
- Admin-UI (/admin): Screens anlegen, löschen, zur Playlist navigieren
- Playlist-UI (/manage/{slug}): Drag&Drop-Sortierung, Item-Bearbeitung,
Medienbibliothek, Datei-Upload (Bild/Video/PDF) und Web-URL
- Router auf RouterDeps umgestellt; manage-Routen nur wenn Stores vorhanden
- parseOptionalTime akzeptiert nun RFC3339 und datetime-local HTML-Format
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
126 lines
4.6 KiB
Go
126 lines
4.6 KiB
Go
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(RouterDeps{StatusStore: 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)
|
||
}
|
||
}
|