Files
silo-server/internal/jellycompat/handlers_missing_endpoints_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

160 lines
5.3 KiB
Go

package jellycompat
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// TestHandleFilters2Stub verifies /Items/Filters2 returns 200 with Jellyfin's
// v2 QueryFilters shape (every field an array, never null).
func TestHandleFilters2Stub(t *testing.T) {
h := &ItemsHandler{}
req := httptest.NewRequest(http.MethodGet, "/Items/Filters2?IncludeItemTypes=Movie&Recursive=true", nil)
rec := httptest.NewRecorder()
h.HandleFilters2Stub(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
body := rec.Body.String()
for _, field := range []string{`"Genres":[]`, `"Tags":[]`, `"AudioLanguages":[]`, `"SubtitleLanguages":[]`} {
if !strings.Contains(body, field) {
t.Errorf("body missing %s: %s", field, body)
}
}
// Must not be the legacy QueryFiltersLegacy shape.
if strings.Contains(body, "OfficialRatings") || strings.Contains(body, "Years") {
t.Errorf("Filters2 returned legacy v1 fields: %s", body)
}
var dto queryFiltersDTO
if err := json.Unmarshal(rec.Body.Bytes(), &dto); err != nil {
t.Fatalf("response is not valid QueryFilters JSON: %v", err)
}
}
// TestHandleLocalTrailers verifies the endpoint returns a bare empty array (not
// the {Items,...} envelope) for an authenticated request, and 401 without a
// session.
func TestHandleLocalTrailers(t *testing.T) {
h := &ItemsHandler{}
t.Run("authenticated returns empty array", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/Items/abc/LocalTrailers", nil)
req = req.WithContext(context.WithValue(req.Context(), compatSessionKey, &Session{}))
rec := httptest.NewRecorder()
h.HandleLocalTrailers(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if got := strings.TrimSpace(rec.Body.String()); got != "[]" {
t.Fatalf("body = %q, want bare empty array %q", got, "[]")
}
})
t.Run("missing session returns 401", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/Items/abc/LocalTrailers", nil)
rec := httptest.NewRecorder()
h.HandleLocalTrailers(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", rec.Code)
}
})
}
// TestHandleClientLogDocument verifies the upload endpoint drains the body and
// answers 200 with a FileName (Jellyfin's contract), and rejects oversized
// uploads with 413.
func TestHandleClientLogDocument(t *testing.T) {
t.Run("accepts and returns FileName", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/ClientLog/Document",
bytes.NewBufferString("type: crash_report\nclient: test\n"))
rec := httptest.NewRecorder()
HandleClientLogDocument(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var resp clientLogDocumentResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("invalid response JSON: %v", err)
}
if resp.FileName == "" {
t.Fatalf("FileName empty; clients parse it from the 200 body")
}
})
t.Run("rejects oversized upload with 413", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/ClientLog/Document", strings.NewReader("x"))
req.ContentLength = maxClientLogBytes + 1
rec := httptest.NewRecorder()
HandleClientLogDocument(rec, req)
if rec.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("status = %d, want 413", rec.Code)
}
})
}
// TestHandleSessions verifies GET /Sessions returns a 200 JSON array (not the
// chi 404 that broke client session polling).
func TestHandleSessions(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/Sessions?deviceId=abc123", nil)
rec := httptest.NewRecorder()
HandleSessions(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if got := strings.TrimSpace(rec.Body.String()); got != "[]" {
t.Fatalf("body = %q, want JSON array %q", got, "[]")
}
var sessions []sessionInfoDTO
if err := json.Unmarshal(rec.Body.Bytes(), &sessions); err != nil {
t.Fatalf("response is not a SessionInfoDto array: %v", err)
}
}
// TestHandleUserImageQueryFallback verifies /UserImage?userId= resolves the id
// from the query string (the path param is empty on this route) and serves the
// same deterministic palette avatar as the legacy path form — not the empty-id
// avatar every caller would otherwise share.
func TestHandleUserImageQueryFallback(t *testing.T) {
h := &ImagesHandler{}
const userID = "45def085-5dd8-5ad7-b972-9c7a499fa846"
req := httptest.NewRequest(http.MethodGet, "/UserImage?userId="+userID, nil)
rec := httptest.NewRecorder()
h.HandleUserImage(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); ct != "image/png" {
t.Fatalf("Content-Type = %q, want image/png", ct)
}
want := avatarPalette[avatarPaletteIndex(userID)]
if !bytes.Equal(rec.Body.Bytes(), want) {
t.Fatalf("served avatar does not match the userId-derived palette entry; query fallback not applied")
}
// Guard against regression to the empty-id avatar.
if empty := avatarPalette[avatarPaletteIndex("")]; bytes.Equal(want, empty) {
t.Skip("palette collision: userID and empty id hash to the same entry; pick another userID")
} else if bytes.Equal(rec.Body.Bytes(), empty) {
t.Fatalf("served the empty-id avatar; query fallback not applied")
}
}