package api import ( "encoding/json" "fmt" "net/http" "os" "testing" "time" ) // A canned API for a local React console preview with no gateway behind it. // // In one terminal: // // cd server && ADMIN_PREVIEW=1 go test ./internal/api -run TestAdminPreview -timeout 0 // // In another: // // cd admin-ui && npm run dev // // Vite owns the React shell at http://127.0.0.1:5180/admin/ and proxies its /admin/api/* // requests here. The preview therefore fakes only data, leaving the same built console // responsible for every route and asset it will own in deployment. It skips unless // ADMIN_PREVIEW is set, so an ordinary `go test ./...` never blocks on this server. func TestAdminPreview(t *testing.T) { if os.Getenv("ADMIN_PREVIEW") == "" { t.Skip("set ADMIN_PREVIEW=1 to serve the console locally") } address := os.Getenv("ADMIN_PREVIEW_ADDR") if address == "" { address = "127.0.0.1:7777" } mux := http.NewServeMux() mux.HandleFunc("/admin/api/", func(w http.ResponseWriter, r *http.Request) { body, ok := adminPreviewData()[r.URL.Path] if !ok { body = map[string]any{} } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(body) }) fmt.Printf("\ncanned admin API → http://%s; open Vite at http://127.0.0.1:5180/admin/ (ctrl-c to stop)\n\n", address) if err := http.ListenAndServe(address, mux); err != nil { t.Fatal(err) } } // Enough of a household for every page to draw itself: a library, two viewers, two // televisions, a run that succeeded and one that did not. func adminPreviewData() map[string]any { now := time.Now().UTC() stamp := func(d time.Duration) string { return now.Add(-d).Format(time.RFC3339) } // One set of each presence state, because the dot is three-valued and a preview that // only ever showed green would not be a preview of it. build := func(version string, age time.Duration) map[string]any { return map[string]any{"version": version, "firstSeen": stamp(age + 24*time.Hour), "lastSeen": stamp(age)} } clients := []any{ map[string]any{"deviceId": "tv-1", "deviceName": "Living room", "username": "matt", "version": "0.1.74", "lastSeen": stamp(30 * time.Second), "capabilities": []string{"server_features_v1", "install_permission_v1"}, "versions": []any{build("0.1.74", 30*time.Second), build("0.1.72", 9*24*time.Hour), build("0.1.68", 40*24*time.Hour)}}, map[string]any{"deviceId": "tv-2", "deviceName": "Bedroom", "username": "sam", "version": "0.1.71", "lastSeen": stamp(2 * 24 * time.Hour), "capabilities": []string{}, "versions": []any{build("0.1.71", 2*24*time.Hour)}}, map[string]any{"deviceId": "tv-3", "deviceName": "Spare room", "username": "sam", "version": "0.1.62", "lastSeen": stamp(70 * 24 * time.Hour), "capabilities": []string{"server_features_v1"}, "versions": []any{build("0.1.62", 70*24*time.Hour)}}, } features := []any{ map[string]any{"key": "subtitle_download", "label": "Download subtitles", "description": "Fetch a missing subtitle from the player.", "enabled": true, "source": "default"}, map[string]any{"key": "install_permission_prompt", "label": "Install permission step", "description": "Show the sideloading permission screen during setup.", "enabled": true, "source": "override"}, map[string]any{"key": "media_requests", "label": "Request it", "description": "Let viewers ask for a title the library does not have.", "enabled": false, "source": "default"}, } status := map[string]any{ "serverVersion": "preview", "library": map[string]any{"total": 18422, "lastSynced": stamp(42 * time.Minute), "byType": map[string]any{"Movie": 4210, "Series": 612, "Episode": 13600}}, "requestUsers": []any{"matt", "sam", "alex", "ros"}, "clients": clients, "features": map[string]any{"revision": 12, "safeMode": false, "features": features}, "maintenance": map[string]any{"enabled": false, "message": ""}, "updatePolicy": map[string]any{"enabled": true, "latestVersion": "0.1.74", "minimumVersion": "", "downloadUrl": "https://example.invalid/memby.apk"}, "playbackPolicy": map[string]any{"prerollEnabled": true, "prerollDurationMs": 6500}, "syncRunning": false, "syncEvery": "1h", "radarrReady": true, "sonarrReady": false, "mdblist": map[string]any{"enabled": true, "cachedTitles": 3121, "staleTitles": 44, "apiKeyConfigured": true, "sources": []any{ map[string]any{"key": "imdb", "label": "IMDb", "enabled": true}, map[string]any{"key": "tmdb", "label": "TMDb", "enabled": true}, map[string]any{"key": "tomatoes", "label": "Rotten Tomatoes", "enabled": false}, }}, "forYou": map[string]any{"candidates": 2400, "profiles": 5, "tracearrSessions": 812, "lastFullImport": stamp(30 * time.Hour)}, "forYouRunning": false, "runs": []any{ map[string]any{"startedAt": stamp(42 * time.Minute), "kind": "incremental", "status": "success", "itemsUpserted": 24}, map[string]any{"startedAt": stamp(102 * time.Minute), "kind": "incremental", "status": "running", "itemsUpserted": 0}, map[string]any{"startedAt": stamp(162 * time.Minute), "kind": "full", "status": "failed", "itemsUpserted": 0, "error": "dial tcp: connection refused"}, }, } return map[string]any{ "/admin/api/status": status, "/admin/api/runtime": map[string]any{"goroutines": 84, "heapInuse": 41943040, "sys": 92274688, "numGc": 311, "nextGc": 62914560, "gomaxprocs": 4}, "/admin/api/accounts": map[string]any{ "accounts": []any{ map[string]any{"userId": "u-1", "username": "matt", "onboardingCompleted": true, "preferencesRevision": 12, "devices": []any{clients[0]}}, map[string]any{"userId": "u-2", "username": "sam jones", "onboardingCompleted": false, "preferencesRevision": 3, "devices": []any{clients[1], clients[2]}}, }, "preferenceCatalogue": []any{}, }, "/admin/api/events": map[string]any{"next": 1, "hasMore": false, "dropped": 0, "events": []any{ map[string]any{"occurredAt": stamp(9 * time.Second), "level": "INFO", "message": "playback started", "attributes": map[string]any{"component": "playback", "user": "matt", "device": "Living room", "client": "0.1.74", "title": "Arrival"}}, map[string]any{"occurredAt": stamp(24 * time.Second), "level": "DEBUG", "message": "search", "attributes": map[string]any{"component": "search", "user": "sam", "query": "arr"}}, map[string]any{"occurredAt": stamp(51 * time.Second), "level": "WARN", "message": "emby slow to answer", "attributes": map[string]any{"component": "emby", "duration": "4.2s", "path": "/Items"}}, map[string]any{"occurredAt": stamp(2 * time.Minute), "level": "ERROR", "message": "library sync failed", "attributes": map[string]any{"component": "library", "error": "dial tcp: connection refused"}}, }}, "/admin/api/engagement": map[string]any{"rows": []any{ map[string]any{"rowId": "continue", "title": "Continue watching", "impressions": 1840, "focuses": 620, "selections": 210, "averageDwellMs": 2400}, map[string]any{"rowId": "favorites", "title": "Favourites", "impressions": 1610, "focuses": 300, "selections": 74, "averageDwellMs": 1800}, }}, "/admin/api/journeys": map[string]any{ "days": 30, "retentionDays": 90, "stats": map[string]any{"events": 184, "journeys": 28, "viewers": 2, "completed": 21, "abandoned": 5, "active": 2, "averageSteps": 6.6, "averageTimeMs": 1140000, "completionRate": 0.81}, "users": []any{ map[string]any{"userId": "u-1", "username": "matt", "events": 120, "journeys": 18, "lastActiveAt": stamp(9 * time.Minute)}, map[string]any{"userId": "u-2", "username": "sam", "events": 64, "journeys": 10, "lastActiveAt": stamp(3 * time.Hour)}, }, "features": []any{ map[string]any{"feature": "playback", "uses": 31, "lastUsedAt": stamp(9 * time.Minute)}, map[string]any{"feature": "search", "uses": 12, "lastUsedAt": stamp(3 * time.Hour)}, }, "actions": []any{ map[string]any{"category": "content", "action": "open", "events": 44, "journeys": 22}, map[string]any{"category": "playback", "action": "start", "events": 24, "journeys": 18}, }, "paths": []any{ map[string]any{"from": "home", "to": "details", "count": 31}, map[string]any{"from": "details", "to": "playback", "count": 18}, }, }, "/admin/api/requests": map[string]any{"requests": []any{}}, // A prefix among the terms and an unattributed row in the log, because both are // ordinary here and a preview showing neither would not be a preview of this page. "/admin/api/searches": map[string]any{ "days": 7, "retentionDays": searchWindowDays, "termLimit": searchTermLimit, "eventLimit": searchEventLimit, "totals": map[string]any{"searches": 214, "queries": 96, "viewers": 2}, "terms": []any{ map[string]any{"query": "severance", "searches": 18, "viewers": 2, "lastAt": stamp(40 * time.Minute)}, map[string]any{"query": "dune", "searches": 11, "viewers": 1, "lastAt": stamp(3 * time.Hour)}, map[string]any{"query": "sev", "searches": 9, "viewers": 2, "lastAt": stamp(40 * time.Minute)}, map[string]any{"query": "the bear", "searches": 4, "viewers": 1, "lastAt": stamp(2 * 24 * time.Hour)}, }, "recent": []any{ map[string]any{"occurredAt": stamp(40 * time.Minute), "userId": "u-1", "username": "matt", "query": "severance"}, map[string]any{"occurredAt": stamp(3 * time.Hour), "userId": "u-2", "username": "sam", "query": "dune"}, map[string]any{"occurredAt": stamp(26 * time.Hour), "userId": "u-9", "username": "", "query": "the bear"}, }, }, } }