Files
silo-server/internal/jellycompat/handlers_missing_endpoints_router_test.go
d3v1l1989andGitHub c81de3459e feat(jellycompat): add Filters2, LocalTrailers, UserImage, ClientLog and Sessions endpoints (#164)
* feat(jellycompat): add Filters2, LocalTrailers, UserImage and ClientLog endpoints

Four endpoints that real Jellyfin clients call were unregistered and fell
through to chi's default 404 (or, for Filters2, were swallowed by /Items/{id}).
All are additive and contract-faithful to the Jellyfin C# server:

- GET /Items/Filters2 -> 200 QueryFilters v2 shape (Genres NameGuidPair[],
  Tags, Audio/SubtitleLanguages), empty arrays. Fladder's filter UI 404'd before.
- GET /Items/{id}/LocalTrailers (+ /Users/{userId}/... alias) -> 200 bare
  BaseItemDto[] ([]); Silo indexes no local trailers. Infuse/Moonfin hit this
  on every item-detail load.
- GET|HEAD /UserImage?userId= -> the same anonymous palette avatar as the
  legacy /Users/{id}/Images/Primary route; HandleUserImage now reads the id
  from the query param when the path segment is absent (modern Jellyfin route).
- POST /ClientLog/Document -> 200 {FileName} after draining/discarding the
  body (Silo has no client-log store); 413 over 1 MiB, matching MaxDocumentSize.

Stops recurring 404 noise and lets clients that depend on these (filter sheets,
avatars, crash-log upload) work. Adds handler unit tests for each.

* feat(jellycompat): add GET /Sessions returning a contract-shaped session list

Wholphin and other jellyfin-sdk clients poll GET /Sessions (optionally
?deviceId=) every few seconds during playback; the route was unregistered, so
each poll hit a chi 404 the SDK could not deserialize — a ~289-per-4h 404 storm
in production. Register it under the same [Authorize] group Jellyfin uses and
return a correctly-typed SessionInfoDto[] (currently empty, consistent with the
existing compat stub handlers). This stops the storm and lets clients degrade
cleanly; populating live session/now-playing state from the playback store is a
follow-up.

* refactor(jellycompat): match Jellyfin client-log size limit exactly

Use 1,000,000 bytes (Jellyfin's ClientLogController.MaxDocumentSize, decimal)
instead of 1<<20, and fix the comment that wrongly called it 1 MiB. Behavior is
functionally identical (the body is discarded); this is contract-fidelity only.
Review follow-up.

* test(jellycompat): add router-level coverage for the new endpoints

The per-handler tests call handlers directly and never exercise NewRouter, so
route registration, chi static-vs-{id} ordering, and auth-group placement were
untested — the one thing this change is actually about. Add a full
NewRouter/ServeHTTP test asserting the session-auth-group routes (Filters2,
LocalTrailers x2, Sessions, ClientLog/Document) return 401 unauthenticated
(registered + behind auth, not 404 or accidentally anonymous), /UserImage serves
its anonymous palette avatar, and an authenticated Filters2 reaches the v2
filters handler (not shadowed by /Items/{id}) with /Sessions returning [].
2026-06-16 17:51:00 -04:00

83 lines
3.4 KiB
Go

package jellycompat
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/config"
)
// TestRouter_MissingEndpointsRegistered exercises the newly added routes through
// the full NewRouter/ServeHTTP stack — registration, chi static-vs-{id} ordering,
// and auth-group placement that the per-handler tests cannot see. Auth-group
// routes must answer 401 (registered + behind auth) rather than 404 (route
// dropped) or 2xx (accidentally anonymous); /UserImage must serve its anonymous
// avatar; and an authenticated Filters2/Sessions request must reach the right
// handler with the right shape.
func TestRouter_MissingEndpointsRegistered(t *testing.T) {
cfg, err := config.LoadFromDB(map[string]string{})
if err != nil {
t.Fatalf("LoadFromDB: %v", err)
}
store := NewSessionStore(time.Hour, time.Now)
const token = "router-test-token"
if err := store.Put(Session{Token: token, StreamAppUserID: 1, ProfileID: "p1", PseudoUserID: PseudoUserID(1, "p1")}); err != nil {
t.Fatalf("seed session: %v", err)
}
router := NewRouter(Dependencies{Config: cfg, SessionStore: store})
// (1) Registration + auth placement: unauthenticated requests to the
// session-auth-group routes must be 401 (registered + behind auth), never 404
// (route dropped/misordered) or 2xx (accidentally registered anonymous).
authGroup := []struct{ method, path string }{
{http.MethodGet, "/Items/Filters2"},
{http.MethodGet, "/Items/abc/LocalTrailers"},
{http.MethodGet, "/Users/u1/Items/abc/LocalTrailers"},
{http.MethodGet, "/Sessions"},
{http.MethodPost, "/ClientLog/Document"},
}
for _, tc := range authGroup {
rec := httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(tc.method, tc.path, nil))
if rec.Code != http.StatusUnauthorized {
t.Errorf("unauth %s %s = %d, want 401 (registered + behind auth)", tc.method, tc.path, rec.Code)
}
}
// (2) /UserImage is anonymous-by-design (PR #158 palette): serves with no auth.
{
rec := httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/UserImage?userId=router-test", nil))
if rec.Code != http.StatusOK {
t.Errorf("GET /UserImage = %d, want 200", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); ct != "image/png" {
t.Errorf("GET /UserImage Content-Type = %q, want image/png", ct)
}
}
// (3) Authenticated routing/shape: prove /Items/Filters2 reaches the v2
// filters handler (not shadowed by /Items/{id}, which would 404 "Item not
// found") and /Sessions returns the empty list.
authed := func(method, path string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, path, nil)
req.Header.Set("X-Emby-Token", token)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}
if rec := authed(http.MethodGet, "/Items/Filters2"); rec.Code != http.StatusOK {
t.Errorf("authed GET /Items/Filters2 = %d, want 200; body=%s", rec.Code, rec.Body.String())
} else if !strings.Contains(rec.Body.String(), "AudioLanguages") {
t.Errorf("Filters2 not the v2 QueryFilters shape (no AudioLanguages); shadowed by /Items/{id}? body=%s", rec.Body.String())
}
if rec := authed(http.MethodGet, "/Sessions"); rec.Code != http.StatusOK {
t.Errorf("authed GET /Sessions = %d, want 200", rec.Code)
} else if got := strings.TrimSpace(rec.Body.String()); got != "[]" {
t.Errorf("authed GET /Sessions body = %q, want []", got)
}
}