Android TV client for Emby (Kotlin, Compose for TV) and the Memby gateway (Go, Postgres, Redis) that fronts it. Client: - Setup, profiles, home rows, Media3 playback, system screensaver (Dream) - Backend chosen at build time: gateway when memby.gatewayUrl is set, otherwise direct to Emby. Both paths stay working. - Server-composed home rows, rendered verbatim so new row types ship without an app release - Full-screen animated maintenance state, row engagement telemetry Gateway: - One request per TV screen; auth, caching, search and row shaping - Library import from Emby into Postgres (manual, then hourly incremental) - Recommendations from viewing history (recency-weighted genre affinity) - Admin page for imports, an offline switch, and per-row analytics - Video always direct-plays from Emby; only metadata passes through Identity is com.ponzischeme89.memby throughout, replacing com.mattcohen.embyclientsname. A changed applicationId installs as a new app: TVs need a fresh sign-in and the old package uninstalled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
197 lines
6.3 KiB
Go
197 lines
6.3 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/ponzischeme89/memby/server/internal/config"
|
|
"github.com/ponzischeme89/memby/server/internal/store"
|
|
)
|
|
|
|
func testServer(cfg config.Config) *Server {
|
|
return New(cfg, Deps{Log: slog.New(slog.NewTextHandler(io.Discard, nil))})
|
|
}
|
|
|
|
func TestMaintenanceGatePassesTrafficWhenOnline(t *testing.T) {
|
|
server := testServer(config.Config{})
|
|
var reached bool
|
|
handler := server.maintenanceGate(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
|
reached = true
|
|
}))
|
|
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/v1/home", nil))
|
|
|
|
if !reached || rec.Code != http.StatusOK {
|
|
t.Fatalf("request should have passed through, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestMaintenanceGateBlocksWithTheOperatorsMessage(t *testing.T) {
|
|
server := testServer(config.Config{})
|
|
server.maintenance.set(store.Maintenance{Enabled: true, Message: "Back at 9pm"})
|
|
|
|
handler := server.maintenanceGate(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
|
t.Fatal("handler must not run while offline")
|
|
}))
|
|
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/v1/home", nil))
|
|
|
|
if rec.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("expected 503, got %d", rec.Code)
|
|
}
|
|
if rec.Header().Get("Retry-After") == "" {
|
|
t.Fatal("expected a Retry-After header")
|
|
}
|
|
|
|
var body map[string]any
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("body: %v", err)
|
|
}
|
|
// The TV keys off `maintenance` to tell "we turned it off" from "the network died".
|
|
if body["maintenance"] != true {
|
|
t.Fatalf("expected maintenance:true, got %v", body)
|
|
}
|
|
if body["message"] != "Back at 9pm" {
|
|
t.Fatalf("operator message not surfaced: %v", body["message"])
|
|
}
|
|
}
|
|
|
|
func TestMaintenanceGateFallsBackToADefaultMessage(t *testing.T) {
|
|
server := testServer(config.Config{})
|
|
server.maintenance.set(store.Maintenance{Enabled: true})
|
|
|
|
rec := httptest.NewRecorder()
|
|
server.maintenanceGate(http.NotFoundHandler()).
|
|
ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/v1/home", nil))
|
|
|
|
var body map[string]any
|
|
_ = json.Unmarshal(rec.Body.Bytes(), &body)
|
|
if body["message"] != store.DefaultMaintenanceMessage {
|
|
t.Fatalf("expected the default message, got %v", body["message"])
|
|
}
|
|
}
|
|
|
|
func TestHealthAndAdminStayReachableDuringMaintenance(t *testing.T) {
|
|
// Health checks and the admin page sit outside the gate on purpose: they are what
|
|
// you need most while the app is deliberately down.
|
|
server := testServer(config.Config{AdminToken: "secret"})
|
|
server.maintenance.set(store.Maintenance{Enabled: true})
|
|
|
|
rec := httptest.NewRecorder()
|
|
server.handleHealth(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("healthz should stay 200 during maintenance, got %d", rec.Code)
|
|
}
|
|
|
|
rec = httptest.NewRecorder()
|
|
server.adminRoutes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/admin/", nil))
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("admin page should stay reachable, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestAdminIsDisabledWithoutAToken(t *testing.T) {
|
|
server := testServer(config.Config{})
|
|
|
|
for _, path := range []string{"/admin/", "/admin/api/status"} {
|
|
rec := httptest.NewRecorder()
|
|
server.adminRoutes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("%s should 404 when no admin token is configured, got %d", path, rec.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAdminAuthRejectsAWrongToken(t *testing.T) {
|
|
server := testServer(config.Config{AdminToken: "secret"})
|
|
handler := server.adminAuth(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
cases := map[string]string{
|
|
"missing": "",
|
|
"wrong": "Bearer nope",
|
|
"prefix": "Bearer secretish",
|
|
}
|
|
for name, header := range cases {
|
|
req := httptest.NewRequest(http.MethodGet, "/admin/api/status", nil)
|
|
if header != "" {
|
|
req.Header.Set("Authorization", header)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("%s token should be rejected, got %d", name, rec.Code)
|
|
}
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/admin/api/status", nil)
|
|
req.Header.Set("Authorization", "Bearer secret")
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("the correct token should be accepted, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestToRowEventValidatesAndClamps(t *testing.T) {
|
|
now := time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC)
|
|
|
|
t.Run("rejects unknown event kinds", func(t *testing.T) {
|
|
if _, ok := toRowEvent(rowEventPayload{RowID: "r", Event: "scrolled"}, "u", now); ok {
|
|
t.Fatal("unknown event kind should be dropped")
|
|
}
|
|
})
|
|
|
|
t.Run("rejects events with no row", func(t *testing.T) {
|
|
if _, ok := toRowEvent(rowEventPayload{Event: "focus"}, "u", now); ok {
|
|
t.Fatal("an event with no row id should be dropped")
|
|
}
|
|
})
|
|
|
|
t.Run("clamps implausible dwell", func(t *testing.T) {
|
|
event, ok := toRowEvent(rowEventPayload{RowID: "r", Event: "focus", DwellMs: 99 * 60 * 60 * 1000}, "u", now)
|
|
if !ok {
|
|
t.Fatal("event should be accepted")
|
|
}
|
|
if event.DwellMs != maxDwellMs {
|
|
t.Fatalf("dwell = %d, want clamped to %d", event.DwellMs, maxDwellMs)
|
|
}
|
|
|
|
event, _ = toRowEvent(rowEventPayload{RowID: "r", Event: "focus", DwellMs: -5}, "u", now)
|
|
if event.DwellMs != 0 {
|
|
t.Fatalf("negative dwell should floor at 0, got %d", event.DwellMs)
|
|
}
|
|
})
|
|
|
|
t.Run("ignores a device clock that is wildly wrong", func(t *testing.T) {
|
|
event, _ := toRowEvent(
|
|
rowEventPayload{RowID: "r", Event: "impression", OccurredAt: "1970-01-01T00:00:00Z"}, "u", now)
|
|
if !event.OccurredAt.Equal(now) {
|
|
t.Fatalf("expected the server clock to win, got %v", event.OccurredAt)
|
|
}
|
|
})
|
|
|
|
t.Run("accepts a plausible device timestamp", func(t *testing.T) {
|
|
earlier := now.Add(-30 * time.Second).Format(time.RFC3339)
|
|
event, _ := toRowEvent(rowEventPayload{RowID: "r", Event: "select", OccurredAt: earlier}, "u", now)
|
|
if event.OccurredAt.Equal(now) {
|
|
t.Fatal("a recent device timestamp should be kept")
|
|
}
|
|
})
|
|
|
|
t.Run("stamps the session's user", func(t *testing.T) {
|
|
event, _ := toRowEvent(rowEventPayload{RowID: "r", Event: "focus"}, "user-9", now)
|
|
if event.UserID != "user-9" {
|
|
t.Fatalf("user should come from the session, got %q", event.UserID)
|
|
}
|
|
})
|
|
}
|