Files
gronod 80e01253a2
Build and publish / Test and build (linux) (pull_request) Canceled after 0s
Build and publish / Test and build (windows) (pull_request) Canceled after 0s
Build and publish / Build and publish Docker image (pull_request) Canceled after 0s
Build and publish / Test and build (darwin) (pull_request) Canceled after 40s
Build and publish / Test and build (darwin) (push) Successful in 2m9s
Build and publish / Test and build (linux) (push) Successful in 2m34s
Build and publish / Test and build (windows) (push) Successful in 3m11s
Build and publish / Build and publish Docker image (push) Successful in 1m55s
Fix #10, #27 and #28 from the read-tools sweep
v2 request lists sent the RAML example sort field requestDate; Ombi
looks up RequestedDate and NullReferenceException'd every non-empty
page. Browse now streams TV popular/most-watched payloads and skips
the hydrated seasonRequests graph that blew the 8 MiB read budget.
provider_summary treats an empty upstream body as an empty group_page.
2026-09-19 20:59:57 +01:00

2385 lines
72 KiB
Go

//go:build integration
package integration_test
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"strings"
"testing"
)
func testLogFileID(name string) string {
sum := sha256.Sum256([]byte(name))
return hex.EncodeToString(sum[:16])
}
// --- protocol surface ---
func TestHandshakeAndToolList(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
names := c.listTools(t)
if len(names) != 31 {
t.Fatalf("tools/list returned %d tools, want 31", len(names))
}
seen := map[string]bool{}
for _, n := range names {
seen[n] = true
if !strings.HasPrefix(n, "read_") && !strings.HasPrefix(n, "write_") {
t.Errorf("tool %q lacks read_/write_ prefix", n)
}
}
for _, want := range []string{
"read_media", "read_requests", "read_issues", "read_settings",
"write_request_create", "write_settings_patch",
} {
if !seen[want] {
t.Errorf("missing tool %q", want)
}
}
}
func TestReadLogsStringArrayList(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "read_logs", map[string]any{"action": "list"})
data := requireOK(t, out)
var logs struct {
Kind string `json:"kind"`
Files []struct {
FileID string `json:"file_id"`
Name string `json:"name"`
} `json:"files"`
}
if err := json.Unmarshal(data, &logs); err != nil {
t.Fatalf("logs decode: %v", err)
}
if logs.Kind != "logs" || len(logs.Files) != 2 {
t.Fatalf("unexpected log list: %s", data)
}
for _, f := range logs.Files {
if f.FileID != testLogFileID(f.Name) {
t.Errorf("file ID for %q = %q", f.Name, f.FileID)
}
}
}
func TestReadLogsReadByFileID(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "read_logs", map[string]any{
"action": "read", "file_id": testLogFileID("ombi-20260919.txt"), "limit": 2,
})
data := requireOK(t, out)
var logs struct {
Kind string `json:"kind"`
Lines []string `json:"lines"`
NextOffset *int `json:"next_offset"`
}
if err := json.Unmarshal(data, &logs); err != nil {
t.Fatalf("logs decode: %v", err)
}
if logs.Kind != "logs" || len(logs.Lines) != 2 || logs.NextOffset != nil {
t.Fatalf("unexpected log read: %s", data)
}
bad := c.callTool(t, "read_logs", map[string]any{
"action": "read", "file_id": "does-not-exist",
})
requireErr(t, bad, "NOT_FOUND")
}
func TestReadServerNewsRemoved(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
requireErr(t, c.callTool(t, "read_server", map[string]any{"action": "news"}), "INVALID_ARGUMENT")
}
func TestReadServerStatsRequiresRange(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
err := requireErr(t, c.callTool(t, "read_server", map[string]any{"action": "stats"}), "INVALID_ARGUMENT")
if err.Field != "from" || mock.countCalls("GET", "/api/v1/Stats") != 0 {
t.Fatalf("bare stats error/calls = %+v/%d", err, mock.countCalls("GET", "/api/v1/Stats"))
}
err = requireErr(t, c.callTool(t, "read_server", map[string]any{
"action": "stats", "from": "2026-09-01T00:00:00Z",
}), "INVALID_ARGUMENT")
if err.Field != "to" {
t.Errorf("field = %q, want to", err.Field)
}
requireErr(t, c.callTool(t, "read_server", map[string]any{
"action": "stats", "from": "2026-09-02T00:00:00Z", "to": "2026-09-01T00:00:00Z",
}), "INVALID_ARGUMENT")
out := c.callTool(t, "read_server", map[string]any{
"action": "stats", "from": "2026-09-01T00:00:00Z", "to": "2026-09-02T00:00:00Z",
})
data := requireOK(t, out)
var metrics struct {
Kind string `json:"kind"`
Values []any `json:"values"`
}
if err := json.Unmarshal(data, &metrics); err != nil {
t.Fatalf("metrics decode: %v", err)
}
if metrics.Kind != "metrics" || len(metrics.Values) != 7 {
t.Fatalf("unexpected stats: %s", data)
}
}
func TestReadServerCronValidateQuartz(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "read_server", map[string]any{
"action": "cron_validate", "expression": "0 0 0 * * ?",
})
data := requireOK(t, out)
var metrics struct {
Values []struct {
Name string `json:"name"`
Value any `json:"value"`
} `json:"values"`
}
if err := json.Unmarshal(data, &metrics); err != nil || len(metrics.Values) != 1 ||
metrics.Values[0].Name != "valid" || metrics.Values[0].Value != true {
t.Fatalf("Quartz-valid expression reported invalid: %s", data)
}
if got := mock.lastBody(t, "POST", "/api/v1/Settings/testcron")["expression"]; got != "0 0 0 * * ?" {
t.Errorf("wire expression = %#v", got)
}
out = c.callTool(t, "read_server", map[string]any{
"action": "cron_validate", "expression": "0 0 * * *",
})
data = requireOK(t, out)
if err := json.Unmarshal(data, &metrics); err != nil || len(metrics.Values) < 1 || metrics.Values[0].Value != false {
t.Fatalf("Quartz-invalid expression reported valid: %s", data)
}
if !strings.Contains(strings.Join(out.Envelope.Warnings, " "), "Quartz cron syntax") {
t.Errorf("missing Quartz warning: %v", out.Envelope.Warnings)
}
}
func TestBundleRestriction(t *testing.T) {
mock := newMockOmbi(t, "jwt")
env := mock.env()
env["OMBI_BUNDLES"] = "core"
c := spawnServer(t, env)
c.handshake(t)
names := c.listTools(t)
if len(names) != 18 {
t.Fatalf("core bundle advertised %d tools, want 18", len(names))
}
for _, n := range names {
if n == "read_settings" || n == "write_settings_patch" {
t.Fatalf("administration tool %q advertised under core bundle", n)
}
}
// Call-time enforcement: a disabled tool must not dispatch even
// if the client knows its name.
res, err := c.request("tools/call", map[string]any{
"name": "read_settings", "arguments": map[string]any{"section": "ombi"},
})
if err == nil {
t.Fatalf("disabled tool dispatched: %s", res)
}
}
// --- 1. read/projection contract ---
func TestReadMediaDetailsProjectsUndocumentedFields(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "read_media", map[string]any{
"action": "details",
"target": map[string]any{"media": "movie", "provider": "tmdb", "id": 27205},
})
data := requireOK(t, out)
var page struct {
Kind string `json:"kind"`
Items []struct {
Media string `json:"media"`
Title string `json:"title"`
Year *int `json:"year"`
Genres []string `json:"genres"`
Identifiers []struct {
Namespace string `json:"namespace"`
Value string `json:"value"`
} `json:"identifiers"`
Ratings []struct {
Name string `json:"name"`
Value any `json:"value"`
} `json:"ratings"`
} `json:"items"`
}
if err := json.Unmarshal(data, &page); err != nil {
t.Fatalf("media_page decode: %v\n%s", err, data)
}
if page.Kind != "media_page" || len(page.Items) != 1 {
t.Fatalf("bad media_page shape: %s", data)
}
it := page.Items[0]
if it.Media != "movie" || it.Title != "Inception" {
t.Fatalf("unexpected projection: %s", data)
}
if it.Year == nil || *it.Year != 2010 {
t.Errorf("year = %v, want 2010", it.Year)
}
if len(it.Genres) != 2 || it.Genres[0] != "Action" {
t.Errorf("genres = %v", it.Genres)
}
ids := map[string]string{}
imdbCount := 0
for _, id := range it.Identifiers {
ids[id.Namespace] = id.Value
if id.Namespace == "imdb" {
imdbCount++
}
}
if ids["tmdb"] != "27205" || ids["imdb"] != "tt1375666" {
t.Errorf("identifiers = %v", ids)
}
if imdbCount != 1 {
t.Errorf("imdb emitted %d times, want 1", imdbCount)
}
if ids["tmdb"] == "8091" {
t.Errorf("collection TMDB id leaked as movie identifier")
}
// Undocumented upstream fields must not appear in the projection.
for _, leaked := range []string{
"productionCompanies", "undocumentedFuture", "runtimeNewField",
"watchProviders", "popularity",
} {
if strings.Contains(string(data), leaked) {
t.Errorf("projection passed through undocumented field %q", leaked)
}
}
assertNoLeak(t, out.Raw)
}
func TestReadMediaRatingsFallsBackToSearchMetadata(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
for _, tc := range []struct {
media string
name string
year int
want string
}{
{media: "movie", name: "Alien", year: 1979, want: "tmdb_vote_average"},
{media: "tv", name: "Breaking Bad", year: 2008, want: "tvmaze_site_rating"},
} {
out := c.callTool(t, "read_media", map[string]any{
"action": "ratings", "media": tc.media, "name": tc.name, "year": tc.year,
})
data := requireOK(t, out)
var page struct {
Items []struct {
Ratings []struct {
Name string `json:"name"`
} `json:"ratings"`
} `json:"items"`
}
if err := json.Unmarshal(data, &page); err != nil {
t.Fatalf("%s ratings decode: %v\n%s", tc.media, err, data)
}
if len(page.Items) != 1 || len(page.Items[0].Ratings) == 0 ||
page.Items[0].Ratings[0].Name != tc.want {
t.Fatalf("%s fallback ratings: %s", tc.media, data)
}
if !strings.Contains(strings.Join(out.Envelope.Warnings, " "), "search metadata") {
t.Errorf("%s fallback warning missing: %v", tc.media, out.Envelope.Warnings)
}
}
if calls := mock.countCalls("GET", "/api/v2/Search/ratings/"); calls != 2 {
t.Errorf("ratings endpoint calls = %d, want 2", calls)
}
if calls := mock.countCalls("GET", "/api/v1/Search/movie/Alien"); calls != 1 {
t.Errorf("movie fallback calls = %d, want 1", calls)
}
if calls := mock.countCalls("GET", "/api/v1/Search/tv/Breaking Bad"); calls != 1 {
t.Errorf("TV fallback calls = %d, want 1", calls)
}
}
func TestReadVotesPreservesUpstreamFailures(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
cases := []map[string]any{
{"action": "list"},
{"action": "get", "media": "movie", "request_id": 2207},
}
for _, args := range cases {
out := c.callTool(t, "read_votes", args)
err := requireErr(t, out, "UPSTREAM_REJECTED")
if err.HTTPStatus == nil || *err.HTTPStatus != http.StatusInternalServerError {
t.Errorf("vote read HTTP status = %v, want 500", err.HTTPStatus)
}
if !err.Retryable {
t.Errorf("vote read 500 should retain generic retryable mapping")
}
if len(out.Envelope.Data) != 0 && string(out.Envelope.Data) != "null" {
t.Errorf("vote read fabricated data after upstream failure: %s", out.Envelope.Data)
}
}
out := c.callTool(t, "read_votes", map[string]any{
"action": "get", "media": "tv", "request_id": 759,
})
data := requireOK(t, out)
if !strings.Contains(string(data), `"kind":"vote_page"`) ||
!strings.Contains(string(data), `"items":[]`) {
t.Fatalf("empty TV votes should remain a successful empty page: %s", data)
}
}
func TestReadRequestsListProjection(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "read_requests", map[string]any{
"action": "list", "media": "movie",
})
data := requireOK(t, out)
var page struct {
Kind string `json:"kind"`
Items []struct {
Target struct {
Kind string `json:"kind"`
ID int `json:"id"`
} `json:"target"`
Title string `json:"title"`
Approved *bool `json:"approved"`
Available *bool `json:"available"`
Is4K *bool `json:"is_4k"`
} `json:"items"`
Page struct {
Mode string `json:"mode"`
Total *int `json:"total"`
} `json:"page"`
}
if err := json.Unmarshal(data, &page); err != nil {
t.Fatalf("request_page decode: %v\n%s", err, data)
}
if page.Kind != "request_page" || len(page.Items) != 1 {
t.Fatalf("bad request_page: %s", data)
}
it := page.Items[0]
if it.Target.Kind != "movie" || it.Target.ID != 10 || it.Title != "Inception" {
t.Fatalf("bad request projection: %s", data)
}
if page.Page.Mode != "upstream" || page.Page.Total == nil || *page.Page.Total != 1 {
t.Errorf("paging = %+v", page.Page)
}
if strings.Contains(string(data), "newHiddenField") {
t.Errorf("undocumented field leaked into projection")
}
}
func TestReadRequestsTVChildrenAreChildKind(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "read_requests", map[string]any{
"action": "list", "media": "tv",
})
data := requireOK(t, out)
var page struct {
Items []struct {
Target struct {
Kind string `json:"kind"`
ID int `json:"id"`
} `json:"target"`
ParentRequestID *int `json:"parent_request_id"`
Seasons []struct {
SeasonNumber int `json:"season_number"`
Episodes []struct {
EpisodeNumber int `json:"episode_number"`
} `json:"episodes"`
} `json:"seasons"`
} `json:"items"`
}
if err := json.Unmarshal(data, &page); err != nil {
t.Fatalf("decode: %v", err)
}
if len(page.Items) != 1 || page.Items[0].Target.Kind != "tv_child" {
t.Fatalf("v2 TV list must project tv_child targets: %s", data)
}
if page.Items[0].ParentRequestID == nil || *page.Items[0].ParentRequestID != 12 {
t.Errorf("parent_request_id = %v", page.Items[0].ParentRequestID)
}
if len(page.Items[0].Seasons) != 1 || len(page.Items[0].Seasons[0].Episodes) != 2 {
t.Errorf("season projection wrong: %s", data)
}
}
func TestReadRequestsChildrenSurfacesParentProviderIDs(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "read_requests", map[string]any{
"action": "children", "parent_request_id": 12,
})
data := requireOK(t, out)
var page struct {
Items []struct {
Target struct {
Kind string `json:"kind"`
ID int `json:"id"`
} `json:"target"`
ParentRequestID *int `json:"parent_request_id"`
Identifiers []struct {
Namespace string `json:"namespace"`
Value string `json:"value"`
} `json:"identifiers"`
} `json:"items"`
}
if err := json.Unmarshal(data, &page); err != nil {
t.Fatalf("decode: %v\n%s", err, data)
}
if len(page.Items) != 1 {
t.Fatalf("expected 1 child, got %d: %s", len(page.Items), data)
}
it := page.Items[0]
// The child id is the real upstream child PK — provider-shaped for
// first-request children by design — and stays callable.
if it.Target.Kind != "tv_child" || it.Target.ID != 77 {
t.Errorf("child target = %+v, want tv_child 77", it.Target)
}
if it.ParentRequestID == nil || *it.ParentRequestID != 12 {
t.Errorf("parent_request_id = %v, want 12", it.ParentRequestID)
}
ids := map[string]string{}
for _, id := range it.Identifiers {
ids[id.Namespace] = id.Value
}
if ids["tvdb"] != "81189" || ids["tmdb"] != "1396" || ids["imdb"] != "tt0903747" {
t.Errorf("parentRequest provider ids not surfaced: %v", ids)
}
assertNoLeak(t, out.Raw)
}
// --- TV identifier namespaces (Gitea issue #1) ---
func TestReadMediaDetailsTVDBUsesV1InfoRoute(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "read_media", map[string]any{
"action": "details",
"target": map[string]any{"media": "tv", "provider": "tvdb", "id": 81189},
})
data := requireOK(t, out)
var page struct {
Items []struct {
Title string `json:"title"`
Genres []string `json:"genres"`
Identifiers []struct {
Namespace string `json:"namespace"`
Value string `json:"value"`
} `json:"identifiers"`
Seasons []struct {
SeasonNumber int `json:"season_number"`
Episodes []struct {
EpisodeNumber int `json:"episode_number"`
} `json:"episodes"`
} `json:"seasons"`
} `json:"items"`
}
if err := json.Unmarshal(data, &page); err != nil {
t.Fatalf("media_page decode: %v\n%s", err, data)
}
if len(page.Items) != 1 || page.Items[0].Title != "Breaking Bad" {
t.Fatalf("unexpected projection: %s", data)
}
ids := map[string]string{}
for _, id := range page.Items[0].Identifiers {
ids[id.Namespace] = id.Value
}
// The TVMaze-backed v1 route carries the TVDB id in theMovieDbId
// and the TVMaze id in seriesId; no tmdb label may be emitted.
if ids["tvdb"] != "81189" || ids["tvmaze"] != "169" || ids["imdb"] != "tt0903747" {
t.Errorf("identifiers = %v", ids)
}
if _, ok := ids["tmdb"]; ok {
t.Errorf("v1 TV result mislabeled a tmdb identifier: %v", ids)
}
// The v1 model carries `genre` strings, not `genres` objects —
// the projection must not blank them out.
if len(page.Items[0].Genres) == 0 {
t.Errorf("v1 genre list lost in projection: %s", data)
}
if len(page.Items[0].Seasons) != 2 || len(page.Items[0].Seasons[0].Episodes) != 3 {
t.Errorf("season projection wrong: %s", data)
}
if n := mock.countCalls("GET", "/api/v1/Search/tv/info/81189"); n != 1 {
t.Errorf("v1 tv info GET count = %d, want 1", n)
}
if n := mock.countCalls("GET", "/api/v2/Search/tv/"); n != 0 {
t.Errorf("tvdb details hit the v2 TMDB-alias route %d times", n)
}
assertNoLeak(t, out.Raw)
}
func TestReadSearchTVEmitsTVDBAndTVMaze(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "read_search", map[string]any{
"action": "text", "media": "tv", "query": "Breaking Bad",
})
data := requireOK(t, out)
var page struct {
Items []struct {
Title string `json:"title"`
Identifiers []struct {
Namespace string `json:"namespace"`
Value string `json:"value"`
} `json:"identifiers"`
} `json:"items"`
}
if err := json.Unmarshal(data, &page); err != nil {
t.Fatalf("media_page decode: %v\n%s", err, data)
}
if len(page.Items) == 0 {
t.Fatalf("no tv search items: %s", data)
}
ids := map[string]string{}
for _, id := range page.Items[0].Identifiers {
ids[id.Namespace] = id.Value
}
if ids["tvdb"] != "81189" || ids["tvmaze"] != "169" || ids["imdb"] != "tt0903747" {
t.Errorf("identifiers = %v", ids)
}
if _, ok := ids["tmdb"]; ok {
t.Errorf("v1 TV search mislabeled theMovieDbId as tmdb: %v", ids)
}
assertNoLeak(t, out.Raw)
}
func TestReadMediaDetailsTVTMDBDoesNotEmitSeriesIdAsTVMaze(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "read_media", map[string]any{
"action": "details",
"target": map[string]any{"media": "tv", "provider": "tmdb", "id": 1396},
})
data := requireOK(t, out)
var page struct {
Items []struct {
Title string `json:"title"`
Identifiers []struct {
Namespace string `json:"namespace"`
Value string `json:"value"`
} `json:"identifiers"`
} `json:"items"`
}
if err := json.Unmarshal(data, &page); err != nil {
t.Fatalf("media_page decode: %v\n%s", err, data)
}
if len(page.Items) != 1 || page.Items[0].Title != "Breaking Bad" {
t.Fatalf("unexpected projection: %s", data)
}
ids := map[string]string{}
imdbCount := 0
for _, id := range page.Items[0].Identifiers {
ids[id.Namespace] = id.Value
if id.Namespace == "imdb" {
imdbCount++
}
}
if ids["tmdb"] != "1396" || ids["tvdb"] != "81189" {
t.Errorf("identifiers = %v", ids)
}
if v, ok := ids["tvmaze"]; ok {
t.Errorf("v2 seriesId labelled tvmaze %q (echoed TMDB id)", v)
}
if imdbCount != 1 {
t.Errorf("imdb emitted %d times, want 1", imdbCount)
}
assertNoLeak(t, out.Raw)
}
func TestReadDiscoverTVBrowseEmitsTMDBIdentifier(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "read_discover", map[string]any{
"action": "browse", "media": "tv", "category": "popular",
"page": map[string]any{"limit": 5},
})
data := requireOK(t, out)
var page struct {
Items []struct {
Title string `json:"title"`
Identifiers []struct {
Namespace string `json:"namespace"`
Value string `json:"value"`
} `json:"identifiers"`
} `json:"items"`
}
if err := json.Unmarshal(data, &page); err != nil {
t.Fatalf("decode: %v\n%s", err, data)
}
if len(page.Items) == 0 || page.Items[0].Title != "Reacher" {
t.Fatalf("unexpected browse page: %s", data)
}
ids := map[string]string{}
for _, id := range page.Items[0].Identifiers {
ids[id.Namespace] = id.Value
}
if ids["tmdb"] != "1668" {
t.Errorf("browse identifiers = %v, want tmdb 1668", ids)
}
assertNoLeak(t, out.Raw)
}
func TestReadDiscoverCollectionMembersEmitTMDBIdentifier(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "read_discover", map[string]any{
"action": "collection", "collection_id": 8091,
})
data := requireOK(t, out)
var page struct {
Items []struct {
Media string `json:"media"`
Title string `json:"title"`
Identifiers []struct {
Namespace string `json:"namespace"`
Value string `json:"value"`
} `json:"identifiers"`
} `json:"items"`
}
if err := json.Unmarshal(data, &page); err != nil {
t.Fatalf("decode: %v\n%s", err, data)
}
if len(page.Items) < 2 {
t.Fatalf("want collection + members, got %s", data)
}
found := false
for _, it := range page.Items {
if it.Media != "movie" {
continue
}
ids := map[string]string{}
for _, id := range it.Identifiers {
ids[id.Namespace] = id.Value
}
if it.Title == "Alien" && ids["tmdb"] == "348" {
found = true
}
}
if !found {
t.Errorf("collection member missing tmdb identifier: %s", data)
}
assertNoLeak(t, out.Raw)
}
func TestReadDiscoverCreditsUsesSuppliedNameAndEnrichesTV(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
for _, media := range []string{"movie", "tv"} {
out := c.callTool(t, "read_discover", map[string]any{
"action": "credits", "media": media,
"person_id": 10205, "person_name": "Sigourney Weaver",
})
data := requireOK(t, out)
var page struct {
Items []struct {
Title string `json:"title"`
Credits []struct {
Name string `json:"name"`
Role string `json:"role"`
Department string `json:"department"`
} `json:"credits"`
} `json:"items"`
}
if err := json.Unmarshal(data, &page); err != nil {
t.Fatalf("%s credits decode: %v\n%s", media, err, data)
}
if len(page.Items) != 1 || page.Items[0].Title == "" {
t.Fatalf("%s credits missing title: %s", media, data)
}
for _, credit := range page.Items[0].Credits {
if credit.Name != "Sigourney Weaver" {
t.Errorf("%s credit name = %q", media, credit.Name)
}
}
if media == "movie" && (len(page.Items[0].Credits) != 2 ||
page.Items[0].Credits[0].Role != "Cobb" ||
page.Items[0].Credits[1].Department != "Production") {
t.Errorf("movie cast/crew roles were not preserved: %s", data)
}
assertNoLeak(t, out.Raw)
}
if n := mock.countCalls("GET", "/api/v2/Search/tv/moviedb/1396"); n != 1 {
t.Errorf("TV credit detail calls = %d, want 1", n)
}
out := c.callTool(t, "read_discover", map[string]any{
"action": "credits", "media": "movie", "person_id": 10205,
})
e := requireErr(t, out, "INVALID_ARGUMENT")
if e.Field != "person_name" {
t.Errorf("missing person_name field = %q", e.Field)
}
}
func TestReadDiscoverRequestedFallsBackToRecentFeed(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
for _, media := range []string{"movie", "tv"} {
out := c.callTool(t, "read_discover", map[string]any{
"action": "browse", "media": media, "category": "requested",
})
data := requireOK(t, out)
var page struct {
Items []struct {
Media string `json:"media"`
Title string `json:"title"`
Requested *bool `json:"requested"`
Identifiers []struct {
Namespace string `json:"namespace"`
Value string `json:"value"`
} `json:"identifiers"`
RequestTargets []struct {
Kind string `json:"kind"`
ID int `json:"id"`
} `json:"request_targets"`
} `json:"items"`
Page struct {
Mode string `json:"mode"`
Total *int `json:"total"`
} `json:"page"`
}
if err := json.Unmarshal(data, &page); err != nil {
t.Fatalf("%s requested browse decode: %v\n%s", media, err, data)
}
if len(page.Items) == 0 || page.Items[0].Media != media ||
page.Items[0].Requested == nil || !*page.Items[0].Requested {
t.Fatalf("bad %s requested fallback: %s", media, data)
}
if page.Page.Mode != "local" || page.Page.Total != nil || !out.Envelope.Truncated || len(out.Envelope.Warnings) == 0 {
t.Errorf("fallback paging/warning incorrect: %s", out.Raw)
}
ids := map[string]string{}
for _, id := range page.Items[0].Identifiers {
ids[id.Namespace] = id.Value
}
if ids["tmdb"] == "" {
t.Errorf("%s fallback missing tmdb identifier: %s", media, data)
}
if media == "tv" {
// Recent TV rows carry child request ids; the fallback must
// resolve them to the parent target like the primary path.
rt := page.Items[0].RequestTargets
if len(rt) != 1 || rt[0].Kind != "tv_parent" || rt[0].ID != 909 {
t.Errorf("tv fallback request_targets = %+v, want [{tv_parent 909}]", rt)
}
}
assertNoLeak(t, out.Raw)
}
if n := mock.countCalls("GET", "/api/v2/Requests/recentlyRequested"); n != 2 {
t.Errorf("recently requested calls = %d, want 2", n)
}
}
func TestReadDiscoverAdvancedMovieSendsTypeAndRejectsCompanies(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "read_discover", map[string]any{
"action": "advanced_movie",
"filters": map[string]any{"genre_ids": []int{28}, "release_year": 1979},
})
data := requireOK(t, out)
if !strings.Contains(string(data), "Inception") {
t.Fatalf("advanced result missing fixture movie: %s", data)
}
body := mock.lastBody(t, "POST", "/api/v2/Search/advancedSearch/movie/")
if body["type"] != "movie" || body["releaseYear"] != float64(1979) {
t.Errorf("advanced body = %#v", body)
}
if _, ok := body["companies"]; ok {
t.Errorf("unsupported companies leaked into body: %#v", body)
}
out = c.callTool(t, "read_discover", map[string]any{
"action": "advanced_movie", "filters": map[string]any{"company_ids": []int{401}},
})
e := requireErr(t, out, "INVALID_ARGUMENT")
if e.Field != "filters.company_ids" {
t.Errorf("company error field = %q", e.Field)
}
out = c.callTool(t, "read_discover", map[string]any{
"action": "advanced_movie", "filters": map[string]any{"release_year": 1900},
})
e = requireErr(t, out, "INVALID_ARGUMENT")
if e.Field != "filters.release_year" {
t.Errorf("release-year error field = %q", e.Field)
}
}
func TestReadSearchMultiArtistMapsMusicBrainz(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "read_search", map[string]any{
"action": "multi", "query": "radiohead", "include": []string{"music"},
})
data := requireOK(t, out)
var page struct {
Items []struct {
Media string `json:"media"`
Title string `json:"title"`
Identifiers []struct {
Namespace string `json:"namespace"`
Value string `json:"value"`
} `json:"identifiers"`
} `json:"items"`
}
if err := json.Unmarshal(data, &page); err != nil {
t.Fatalf("decode: %v\n%s", err, data)
}
if len(page.Items) == 0 {
t.Fatalf("no multi items: %s", data)
}
it := page.Items[0]
if it.Media != "artist" {
t.Errorf("media = %q, want artist", it.Media)
}
ids := map[string]string{}
for _, id := range it.Identifiers {
ids[id.Namespace] = id.Value
}
if ids["musicbrainz"] != "8dc08e7c-41f1-4a04-97dc-eb00d91d1d2f" {
t.Errorf("identifiers = %v", ids)
}
if _, ok := ids["provider_unknown"]; ok {
t.Errorf("Artist mapped as provider_unknown: %v", ids)
}
assertNoLeak(t, out.Raw)
}
func TestReadRequestsRecentTVParentUsesRequestID(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "read_requests", map[string]any{
"action": "recent", "page": map[string]any{"limit": 5},
})
data := requireOK(t, out)
var page struct {
Items []struct {
Target struct {
Kind string `json:"kind"`
ID int `json:"id"`
} `json:"target"`
Title string `json:"title"`
Identifiers []struct {
Namespace string `json:"namespace"`
Value string `json:"value"`
} `json:"identifiers"`
} `json:"items"`
}
if err := json.Unmarshal(data, &page); err != nil {
t.Fatalf("decode: %v\n%s", err, data)
}
items := map[string]*struct {
Target struct {
Kind string `json:"kind"`
ID int `json:"id"`
} `json:"target"`
Title string `json:"title"`
Identifiers []struct {
Namespace string `json:"namespace"`
Value string `json:"value"`
} `json:"identifiers"`
}{}
for i := range page.Items {
it := page.Items[i]
items[it.Title] = &it
}
movie, tv, tvChildID, orphan := items["Yummy"], items["Seven Up!"],
items["trigger-500 and something"], items["Provider Only"]
if movie == nil || tv == nil || tvChildID == nil || orphan == nil {
t.Fatalf("missing recent items: %s", data)
}
if movie.Target.Kind != "movie" || movie.Target.ID != 2207 {
t.Errorf("movie target = %+v", movie.Target)
}
// TV requestId 259032 is a provider-shaped child PK upstream; the
// parent scan must resolve it to the real parent request id 909.
if tv.Target.Kind != "tv_parent" || tv.Target.ID != 909 {
t.Errorf("tv_parent target = %+v, want resolved parent id 909 not child/provider 259032", tv.Target)
}
tvIDs := map[string]string{}
for _, id := range tv.Identifiers {
tvIDs[id.Namespace] = id.Value
}
if tvIDs["tvdb"] != "259032" || tvIDs["tmdb"] != "118680" || tvIDs["imdb"] != "tt1439629" {
t.Errorf("tv identifiers missing parent provider ids: %v", tvIDs)
}
if tvIDs["ombi_tv_child"] != "259032" {
t.Errorf("child request id missing from identifiers: %v", tvIDs)
}
// A real (non-provider-shaped) child id resolves via the parent's
// embedded childRequests[].id alone.
if tvChildID.Target.Kind != "tv_parent" || tvChildID.Target.ID != 42 {
t.Errorf("child-id recent item target = %+v, want parent id 42", tvChildID.Target)
}
// Unresolvable rows keep id 0 and a provider-shaped `id` must
// never occupy the target.
if orphan.Target.Kind != "tv_parent" || orphan.Target.ID != 0 {
t.Errorf("unresolvable tv item target = %+v, want tv_parent id 0", orphan.Target)
}
if len(out.Envelope.Warnings) == 0 {
t.Errorf("expected a warning for the unresolvable tv item: %s", out.Raw)
}
// All TV rows resolve in a single bounded parent scan.
if n := mock.countCalls("GET", "/api/v1/Request/tv/"); n != 1 {
t.Errorf("tv parent scan calls = %d, want 1", n)
}
assertNoLeak(t, out.Raw)
}
// --- enum translation twins (T1 request_type, T2 issue status) ---
func TestEnumTranslationTwins(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "read_issues", map[string]any{
"action": "list", "status": "pending",
})
data := requireOK(t, out)
var page struct {
Items []struct {
ID int `json:"id"`
Status string `json:"status"`
StatusCode *int `json:"status_code"`
RequestType string `json:"request_type"`
RequestTypeCode *int `json:"request_type_code"`
} `json:"items"`
}
if err := json.Unmarshal(data, &page); err != nil {
t.Fatalf("decode: %v", err)
}
if len(page.Items) != 2 {
t.Fatalf("want 2 issues, got %s", data)
}
// status=1 → in_progress; requestType=1 → movie.
if page.Items[0].Status != "in_progress" || *page.Items[0].StatusCode != 1 {
t.Errorf("status twin = %q/%v", page.Items[0].Status, page.Items[0].StatusCode)
}
if page.Items[0].RequestType != "movie" || *page.Items[0].RequestTypeCode != 1 {
t.Errorf("request_type twin = %q/%v", page.Items[0].RequestType, page.Items[0].RequestTypeCode)
}
// status=99 is unmapped: code preserved, label empty, warning raised.
if page.Items[1].Status != "" || *page.Items[1].StatusCode != 99 {
t.Errorf("unmapped twin = %q/%v", page.Items[1].Status, page.Items[1].StatusCode)
}
foundWarn := false
for _, w := range out.Envelope.Warnings {
if strings.Contains(w, "unmapped upstream enum value 99") {
foundWarn = true
}
}
if !foundWarn {
t.Errorf("expected unmapped-enum warning, got %v", out.Envelope.Warnings)
}
}
// --- 2. expansion logic: write_request_create season mode ---
func TestSeasonExpansionWire(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "write_request_create", map[string]any{
"action": "tv",
"provider": "tmdb",
"id": 1396,
"selection": map[string]any{
"mode": "season",
"season_numbers": []int{1, 2},
},
})
data := requireOK(t, out)
var mut struct {
Kind string `json:"kind"`
Outcome string `json:"outcome"`
RequestID *int `json:"request_id"`
Target struct {
Kind string `json:"kind"`
ID int `json:"id"`
} `json:"target"`
}
if err := json.Unmarshal(data, &mut); err != nil {
t.Fatalf("mutation decode: %v", err)
}
if mut.Kind != "mutation" || mut.Outcome != "completed" {
t.Fatalf("bad mutation: %s", data)
}
if mut.RequestID == nil || *mut.RequestID != 555 {
t.Errorf("request_id = %v", mut.RequestID)
}
// The private GET must have fetched TMDB details exactly once.
if n := mock.countCalls("GET", "/api/v2/Search/tv/moviedb/1396"); n != 1 {
t.Fatalf("private TV-details GET count = %d, want 1", n)
}
// Exactly one create POST.
if n := mock.countCalls("POST", "/api/v2/Requests/tv"); n != 1 {
t.Fatalf("create POST count = %d, want 1", n)
}
body := mock.lastBody(t, "POST", "/api/v2/Requests/tv")
if body["theMovieDbId"] != float64(1396) {
t.Errorf("theMovieDbId = %v", body["theMovieDbId"])
}
if body["requestAll"] != false || body["firstSeason"] != false || body["latestSeason"] != false {
t.Errorf("selection flags wrong: %v", body)
}
seasons, ok := body["seasons"].([]any)
if !ok || len(seasons) != 2 {
t.Fatalf("seasons = %v", body["seasons"])
}
s1 := seasons[0].(map[string]any)
if s1["seasonNumber"] != float64(1) {
t.Fatalf("season[0].seasonNumber = %v", s1["seasonNumber"])
}
eps1, _ := s1["episodes"].([]any)
if len(eps1) != 7 {
t.Fatalf("season 1 expanded to %d episodes, want 7", len(eps1))
}
for i, e := range eps1 {
em := e.(map[string]any)
if em["episodeNumber"] != float64(i+1) {
t.Fatalf("episode %d = %v", i, em)
}
}
s2 := seasons[1].(map[string]any)
eps2, _ := s2["episodes"].([]any)
if s2["seasonNumber"] != float64(2) || len(eps2) != 4 {
t.Fatalf("season 2 expansion wrong: %v", s2)
}
assertNoLeak(t, out.Raw)
}
func TestSeasonExpansionTVDBUsesV1InfoRoute(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "write_request_create", map[string]any{
"action": "tv",
"provider": "tvdb",
"id": 81189,
"selection": map[string]any{
"mode": "season",
"season_numbers": []int{1},
},
})
data := requireOK(t, out)
var mut struct {
Outcome string `json:"outcome"`
RequestID *int `json:"request_id"`
}
if err := json.Unmarshal(data, &mut); err != nil || mut.Outcome != "completed" {
t.Fatalf("bad mutation: %s", data)
}
// tvdb expansion must read the v1 TVMaze info route — never the
// v2 /Search/tv/{id} TMDB alias.
if n := mock.countCalls("GET", "/api/v1/Search/tv/info/81189"); n != 1 {
t.Fatalf("v1 tv info GET count = %d, want 1", n)
}
if n := mock.countCalls("GET", "/api/v2/Search/tv/"); n != 0 {
t.Fatalf("tvdb expansion hit the v2 TMDB-alias route %d times", n)
}
body := mock.lastBody(t, "POST", "/api/v1/Request/tv")
if body["tvDbId"] != float64(81189) {
t.Errorf("tvDbId = %v", body["tvDbId"])
}
seasons, ok := body["seasons"].([]any)
if !ok || len(seasons) != 1 {
t.Fatalf("seasons = %v", body["seasons"])
}
s1 := seasons[0].(map[string]any)
eps, _ := s1["episodes"].([]any)
if s1["seasonNumber"] != float64(1) || len(eps) != 3 {
t.Fatalf("season 1 expansion wrong: %v", s1)
}
assertNoLeak(t, out.Raw)
}
func TestSeasonExpansionMissingSeason(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "write_request_create", map[string]any{
"action": "tv",
"provider": "tmdb",
"id": 1396,
"selection": map[string]any{
"mode": "season",
"season_numbers": []int{99},
},
})
e := requireErr(t, out, "INVALID_ARGUMENT")
if e.Field != "selection.season_numbers" {
t.Errorf("error field = %q", e.Field)
}
// The private GET ran but no POST must have been sent.
if n := mock.countCalls("POST", "/api/v2/Requests/tv"); n != 0 {
t.Fatalf("create POST sent on invalid selection")
}
}
func TestEpisodesModeExpansion(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "write_request_create", map[string]any{
"action": "tv",
"provider": "tvdb",
"id": 81189,
"selection": map[string]any{
"mode": "episodes",
"seasons": []any{
map[string]any{"season_number": 1, "episodes": []int{1, 3}},
},
},
})
requireOK(t, out)
// episodes mode must not hit the details route.
if n := mock.countCalls("GET", "/api/v2/Search/tv/"); n != 0 {
t.Errorf("episodes mode triggered a private details GET")
}
body := mock.lastBody(t, "POST", "/api/v1/Request/tv")
if body["tvDbId"] != float64(81189) {
t.Errorf("tvDbId = %v", body["tvDbId"])
}
seasons := body["seasons"].([]any)
s1 := seasons[0].(map[string]any)
eps := s1["episodes"].([]any)
if len(eps) != 2 {
t.Fatalf("episodes = %v", eps)
}
}
// --- 3. patch cycle ---
func readSettingsRevision(t *testing.T, c *rpcClient, section string) string {
t.Helper()
out := c.callTool(t, "read_settings", map[string]any{"section": section})
data := requireOK(t, out)
var s struct {
Kind string `json:"kind"`
Section string `json:"section"`
Revision string `json:"revision"`
Values []struct {
Name string `json:"name"`
Value any `json:"value"`
} `json:"values"`
OmittedFields []string `json:"omitted_fields"`
}
if err := json.Unmarshal(data, &s); err != nil {
t.Fatalf("settings decode: %v", err)
}
if s.Kind != "settings" || s.Section != section {
t.Fatalf("bad settings family: %s", data)
}
if s.Revision == "" {
t.Fatalf("writable section offered no revision")
}
return s.Revision
}
func TestSettingsReadProjection(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "read_settings", map[string]any{"section": "customization"})
data := requireOK(t, out)
var s struct {
Values []struct {
Name string `json:"name"`
Value any `json:"value"`
} `json:"values"`
OmittedFields []string `json:"omitted_fields"`
}
if err := json.Unmarshal(data, &s); err != nil {
t.Fatalf("decode: %v", err)
}
names := map[string]any{}
for _, v := range s.Values {
names[v.Name] = v.Value
}
if names["/applicationName"] != "Ombi" {
t.Errorf("applicationName = %v", names["/applicationName"])
}
// Secret-looking and excluded fields land in omitted_fields only.
joined := strings.Join(s.OmittedFields, ",")
for _, want := range []string{"/secretToken", "/apiKey", "/applicationUrl", "/logo", "/favicon"} {
if !strings.Contains(joined, want) {
t.Errorf("omitted_fields missing %q: %v", want, s.OmittedFields)
}
}
if strings.Contains(string(data), "tok-should-not-project") ||
strings.Contains(string(data), "key-should-not-project") ||
strings.Contains(string(data), "ombi.internal.example") {
t.Errorf("secret value leaked into settings projection: %s", data)
}
assertNoLeak(t, out.Raw)
}
func TestSettingsPatchCycle(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
rev := readSettingsRevision(t, c, "customization")
out := c.callTool(t, "write_settings_patch", map[string]any{
"action": "patch",
"section": "customization",
"revision": rev,
"changes": map[string]any{"applicationName": "OmbiPatched"},
})
data := requireOK(t, out)
var mut struct {
Kind string `json:"kind"`
Outcome string `json:"outcome"`
}
if err := json.Unmarshal(data, &mut); err != nil || mut.Outcome != "completed" {
t.Fatalf("bad mutation: %s", data)
}
// The POSTed document must carry the change AND preserve every
// untouched field — including secrets the read projection hid.
body := mock.lastBody(t, "POST", "/api/v1/Settings/customization")
if body["applicationName"] != "OmbiPatched" {
t.Errorf("applicationName = %v", body["applicationName"])
}
if body["secretToken"] != "tok-should-not-project" {
t.Errorf("secretToken not preserved: %v", body["secretToken"])
}
if body["apiKey"] != "key-should-not-project" {
t.Errorf("apiKey not preserved: %v", body["apiKey"])
}
if body["applicationUrl"] != "https://ombi.internal.example" {
t.Errorf("applicationUrl not preserved: %v", body["applicationUrl"])
}
if _, ok := body["customPage"].(map[string]any); !ok {
t.Errorf("nested customPage object lost: %v", body["customPage"])
}
// GET happened before POST (read-modify-write).
var sawGet bool
for _, r := range mock.requests() {
if r.Method == "GET" && r.Path == "/api/v1/Settings/customization" {
sawGet = true
}
if r.Method == "POST" && r.Path == "/api/v1/Settings/customization" && !sawGet {
t.Fatalf("settings POST without preceding GET")
}
}
assertNoLeak(t, out.Raw)
}
func TestSettingsPatchStaleRevision(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "write_settings_patch", map[string]any{
"action": "patch",
"section": "customization",
"revision": "deadbeefdeadbeef",
"changes": map[string]any{"applicationName": "X"},
})
requireErr(t, out, "CONFLICT")
if n := mock.countCalls("POST", "/api/v1/Settings/customization"); n != 0 {
t.Fatalf("stale revision still POSTed")
}
}
func TestSettingsPatchNullAndExcludedRejected(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
rev := readSettingsRevision(t, c, "customization")
out := c.callTool(t, "write_settings_patch", map[string]any{
"action": "patch",
"section": "customization",
"revision": rev,
"changes": map[string]any{"applicationName": nil},
})
e := requireErr(t, out, "INVALID_ARGUMENT")
if !strings.Contains(e.Message, "null") {
t.Errorf("null change error message = %q", e.Message)
}
out = c.callTool(t, "write_settings_patch", map[string]any{
"action": "patch",
"section": "customization",
"revision": rev,
"changes": map[string]any{"apiKey": "new-key"},
})
requireErr(t, out, "INVALID_ARGUMENT")
if n := mock.countCalls("GET", "/api/v1/Settings/customization"); n != 1 {
// Both rejections happen before the private GET; only the
// read_settings GET above counts.
t.Fatalf("rejected patches triggered upstream GETs: %d", n)
}
}
func TestSettingsPatchReadOnlySection(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "write_settings_patch", map[string]any{
"action": "patch",
"section": "base_url",
"revision": "x",
"changes": map[string]any{"a": 1},
})
requireErr(t, out, "INVALID_ARGUMENT")
}
// --- 4. error & security boundaries ---
func TestUnauthorized401(t *testing.T) {
mock := newMockOmbi(t, "api_key")
env := mock.env()
env["OMBI_API_KEY"] = "wrong-key"
c := spawnServer(t, env)
c.handshake(t)
out := c.callTool(t, "read_request_stats", map[string]any{"action": "counts"})
e := requireErr(t, out, "AUTHENTICATION_FAILED")
if e.HTTPStatus == nil || *e.HTTPStatus != 401 {
t.Errorf("http_status = %v", e.HTTPStatus)
}
if e.Retryable {
t.Errorf("401 must not be retryable")
}
}
func TestJWTLoginRejected(t *testing.T) {
mock := newMockOmbi(t, "jwt")
env := mock.env()
env["OMBI_PASSWORD"] = "wrong-password"
c := spawnServer(t, env)
c.handshake(t)
out := c.callTool(t, "read_request_stats", map[string]any{"action": "counts"})
requireErr(t, out, "AUTHENTICATION_FAILED")
}
func TestJWTReLoginOn401(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
// First call: login #1, request succeeds.
out := c.callTool(t, "read_request_stats", map[string]any{"action": "counts"})
requireOK(t, out)
if n := mock.tokenCallCount(); n != 1 {
t.Fatalf("token calls = %d, want 1", n)
}
// Expire the accepted token: the next request 401s, the client
// must invalidate, re-login (call #2) and retry successfully.
mock.expireToken()
out = c.callTool(t, "read_request_stats", map[string]any{"action": "counts"})
requireOK(t, out)
if n := mock.tokenCallCount(); n != 2 {
t.Fatalf("token calls = %d, want 2 after renewal", n)
}
}
func TestNotFound404(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "read_media", map[string]any{
"action": "details",
"target": map[string]any{"media": "movie", "provider": "tmdb", "id": 88888888},
})
e := requireErr(t, out, "NOT_FOUND")
if e.HTTPStatus == nil || *e.HTTPStatus != 404 {
t.Errorf("http_status = %v", e.HTTPStatus)
}
if e.Retryable {
t.Errorf("404 must not be retryable")
}
}
func TestNotFoundHTMLBodySanitized(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "read_requests", map[string]any{
"action": "get",
"target": map[string]any{"kind": "movie", "id": 404000},
})
e := requireErr(t, out, "NOT_FOUND")
// The upstream HTML page (incl. filesystem path) must not surface.
if strings.Contains(e.Message, "inetpub") || strings.Contains(e.Message, "<") {
t.Errorf("HTML leaked into error message: %q", e.Message)
}
}
func TestRateLimited429(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "read_media", map[string]any{
"action": "details",
"target": map[string]any{"media": "movie", "provider": "tmdb", "id": 429000},
})
e := requireErr(t, out, "RATE_LIMITED")
if !e.Retryable {
t.Errorf("429 must be retryable")
}
if e.RetryAfterSeconds == nil || *e.RetryAfterSeconds != 30 {
t.Errorf("retry_after_seconds = %v, want 30", e.RetryAfterSeconds)
}
}
func TestServerError500HTML(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "read_media", map[string]any{
"action": "details",
"target": map[string]any{"media": "movie", "provider": "tmdb", "id": 500000},
})
e := requireErr(t, out, "UPSTREAM_REJECTED")
if !e.Retryable {
t.Errorf("5xx must be retryable")
}
if e.HTTPStatus == nil || *e.HTTPStatus != 500 {
t.Errorf("http_status = %v", e.HTTPStatus)
}
}
func TestTransportFailureReadVsWrite(t *testing.T) {
// A dead upstream: nothing listening on this port.
deadEnv := map[string]string{
"OMBI_URL": "http://127.0.0.1:1",
"OMBI_AUTH_MODE": "api_key",
"OMBI_API_KEY": "x",
"OMBI_BUNDLES": "core,moderation,administration",
}
c := spawnServer(t, deadEnv)
c.handshake(t)
out := c.callTool(t, "read_request_stats", map[string]any{"action": "counts"})
e := requireErr(t, out, "UPSTREAM_REJECTED")
if !e.Retryable {
t.Errorf("read transport failure must be retryable")
}
out = c.callTool(t, "write_issue_comment", map[string]any{
"issue_id": 1, "comment": "x",
})
e = requireErr(t, out, "UNKNOWN_OUTCOME")
if e.Retryable {
t.Errorf("write transport failure must not be retryable")
}
}
// --- argument validation ---
func TestEmptyArgsRejected(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "write_request_create", map[string]any{})
requireErr(t, out, "INVALID_ARGUMENT")
// No upstream call must have been attempted.
if n := mock.countCalls("POST", "/api/v2/Requests"); n != 0 {
t.Fatalf("invalid args still reached upstream")
}
}
func TestUnknownFieldRejected(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "read_requests", map[string]any{
"action": "list", "media": "movie", "bogus_field": 1,
})
requireErr(t, out, "INVALID_ARGUMENT")
}
func TestAlbumUnavailableComboRejected(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "read_requests", map[string]any{
"action": "list", "media": "album", "status": "unavailable",
})
requireErr(t, out, "INVALID_ARGUMENT")
}
// --- api_key mode ---
func TestNotificationTemplateTranslation(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
rev := readSettingsRevision(t, c, "notifications.discord")
out := c.callTool(t, "write_settings_patch", map[string]any{
"action": "patch",
"section": "notifications.discord",
"revision": rev,
"changes": map[string]any{
"notificationTemplates": []any{
map[string]any{
"notification_type": "issue", // T4: issue → 1
"agent": "discord", // T3: discord → 1
"subject": "Issue", "message": "m",
},
},
},
})
requireOK(t, out)
body := mock.lastBody(t, "POST", "/api/v1/Settings/notifications/discord")
tmpls, ok := body["notificationTemplates"].([]any)
if !ok || len(tmpls) != 1 {
t.Fatalf("notificationTemplates = %v", body["notificationTemplates"])
}
t0 := tmpls[0].(map[string]any)
if t0["notificationType"] != float64(1) || t0["agent"] != float64(1) {
t.Errorf("label translation wrong: %v", t0)
}
if _, present := t0["notification_type"]; present {
t.Errorf("snake_case label key leaked onto the wire")
}
// Untouched fields preserved.
if body["webhookUrl"] != "https://discord.invalid/secret-wh" {
t.Errorf("webhookUrl not preserved: %v", body["webhookUrl"])
}
}
func TestTVSelectionModes(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
for _, tc := range []struct {
mode string
wantFlag string
}{
{"all", "requestAll"},
{"first_season", "firstSeason"},
{"latest_season", "latestSeason"},
} {
out := c.callTool(t, "write_request_create", map[string]any{
"action": "tv", "provider": "tmdb", "id": 1396,
"selection": map[string]any{"mode": tc.mode},
})
requireOK(t, out)
body := mock.lastBody(t, "POST", "/api/v2/Requests/tv")
if body[tc.wantFlag] != true {
t.Errorf("mode %s: %s = %v, want true", tc.mode, tc.wantFlag, body[tc.wantFlag])
}
// Flag modes carry no explicit season expansion and skip the
// private details GET.
if _, ok := body["seasons"]; ok && body["seasons"] != nil {
if arr, _ := body["seasons"].([]any); len(arr) > 0 {
t.Errorf("mode %s sent unexpected seasons: %v", tc.mode, body["seasons"])
}
}
}
if n := mock.countCalls("GET", "/api/v2/Search/tv/moviedb/"); n != 0 {
t.Errorf("flag selection modes triggered %d private GETs, want 0", n)
}
}
func TestSettingsNestedMerge(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
rev := readSettingsRevision(t, c, "customization")
out := c.callTool(t, "write_settings_patch", map[string]any{
"action": "patch",
"section": "customization",
"revision": rev,
"changes": map[string]any{
"customPage": map[string]any{"enabled": true},
},
})
requireOK(t, out)
body := mock.lastBody(t, "POST", "/api/v1/Settings/customization")
cp, ok := body["customPage"].(map[string]any)
if !ok {
t.Fatalf("customPage = %v", body["customPage"])
}
if cp["enabled"] != true {
t.Errorf("customPage.enabled = %v", cp["enabled"])
}
// Sibling key inside the patched object must survive the merge.
if cp["content"] != "" {
t.Errorf("customPage.content lost in merge: %v", cp)
}
}
func TestSettingsPatchNoEffectiveChange(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
rev := readSettingsRevision(t, c, "customization")
out := c.callTool(t, "write_settings_patch", map[string]any{
"action": "patch",
"section": "customization",
"revision": rev,
"changes": map[string]any{"applicationName": "Ombi"}, // already the value
})
requireErr(t, out, "INVALID_ARGUMENT")
if n := mock.countCalls("POST", "/api/v1/Settings/customization"); n != 0 {
t.Fatalf("no-op patch still POSTed")
}
}
func TestUpstreamMalformedJSON(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "read_requests", map[string]any{
"action": "search", "media": "movie", "query": "anything",
})
e := requireErr(t, out, "UPSTREAM_SCHEMA_MISMATCH")
if e.Retryable {
t.Errorf("schema mismatch must not be retryable")
}
}
func TestEngineResultBusinessFailure(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
// HTTP 200 with isError:true must surface as UPSTREAM_REJECTED,
// not a successful mutation.
out := c.callTool(t, "write_request_create", map[string]any{
"action": "movie", "tmdb_id": 666,
})
e := requireErr(t, out, "UPSTREAM_REJECTED")
if !strings.Contains(e.Message, "already been requested") {
t.Errorf("sanitized upstream message = %q", e.Message)
}
if e.Retryable {
t.Errorf("business rejection must not be retryable")
}
}
func TestAPIKeyMode(t *testing.T) {
mock := newMockOmbi(t, "api_key")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "read_request_stats", map[string]any{"action": "counts"})
data := requireOK(t, out)
var m struct {
Values []struct {
Name string `json:"name"`
Value any `json:"value"`
} `json:"values"`
}
if err := json.Unmarshal(data, &m); err != nil {
t.Fatalf("decode: %v", err)
}
if len(m.Values) == 0 {
t.Fatalf("empty metrics: %s", data)
}
// The ApiKey header must have been sent; token endpoint unused.
if mock.countCalls("POST", "/api/v1/Token") != 0 {
t.Errorf("api_key mode hit the token endpoint")
}
reqs := mock.requests()
found := false
for _, r := range reqs {
if r.Header.Get("ApiKey") == "mock-api-key-123" {
found = true
}
if r.Header.Get("Authorization") != "" {
t.Errorf("api_key mode sent Authorization header")
}
}
if !found {
t.Errorf("ApiKey header never reached upstream")
}
}
// --- M5 request state and list routes ---
func TestM5TVDBDetailsRequestStateOverlay(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "read_media", map[string]any{
"action": "details",
"target": map[string]any{"media": "tv", "provider": "tvdb", "id": 81189},
})
data := requireOK(t, out)
var m struct {
Items []struct {
Requested *bool `json:"requested"`
RequestTargets []struct {
Kind string `json:"kind"`
ID int `json:"id"`
} `json:"request_targets"`
Seasons []struct {
SeasonNumber int `json:"season_number"`
Episodes []struct {
EpisodeNumber int `json:"episode_number"`
Requested *bool `json:"requested"`
Available *bool `json:"available"`
} `json:"episodes"`
} `json:"seasons"`
} `json:"items"`
}
json.Unmarshal(data, &m)
if len(m.Items) != 1 {
t.Fatalf("expected 1 item, got %d", len(m.Items))
}
it := m.Items[0]
if it.Requested == nil || !*it.Requested {
t.Errorf("expected Requested=true from overlay")
}
if len(it.RequestTargets) != 1 || it.RequestTargets[0].Kind != "tv_parent" || it.RequestTargets[0].ID != 42 {
t.Errorf("expected RequestTargets=[{tv_parent 42}], got %v", it.RequestTargets)
}
var ep1Requested, ep1Available bool
for _, s := range it.Seasons {
if s.SeasonNumber == 1 {
for _, e := range s.Episodes {
if e.EpisodeNumber == 1 {
if e.Requested != nil {
ep1Requested = *e.Requested
}
if e.Available != nil {
ep1Available = *e.Available
}
}
}
}
}
if !ep1Requested {
t.Errorf("expected season 1 episode 1 Requested=true")
}
if !ep1Available {
t.Errorf("expected season 1 episode 1 Available=true")
}
if calls := mock.countCalls("GET", "/api/v1/Search/tv/info/"); calls != 1 {
t.Errorf("expected 1 call to v1 info route, got %d", calls)
}
if calls := mock.countCalls("GET", "/api/v1/Request/tv/"); calls < 1 {
t.Errorf("expected >=1 call to v1 tv parent list route, got %d", calls)
}
if calls := mock.countCalls("GET", "/api/v2/Search/tv/moviedb/"); calls != 0 {
t.Errorf("expected 0 calls to v2 TMDB alias route, got %d", calls)
}
}
func TestM5TVSearchFallback(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "read_requests", map[string]any{
"action": "search",
"media": "tv",
"query": "trigger-500",
})
data := requireOK(t, out)
var page struct {
Items []struct {
Title string `json:"title"`
} `json:"items"`
}
json.Unmarshal(data, &page)
if len(page.Items) != 1 {
t.Fatalf("expected 1 item from fallback, got %d", len(page.Items))
}
if page.Items[0].Title != "trigger-500 and something" {
t.Errorf("expected item title 'trigger-500 and something', got %q", page.Items[0].Title)
}
}
func TestM5V2List500ErrorSurfaced(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "read_media", map[string]any{
"action": "details",
"target": map[string]any{"media": "movie", "provider": "tmdb", "id": 500001},
})
if !out.IsError {
t.Fatalf("expected error on 500")
}
if !strings.Contains(out.Text, "System.NullReferenceException") {
t.Errorf("expected error message to contain sanitized detail, got %q", out.Text)
}
}
func TestM6ReferenceDeterministicValueAndMojibake(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
// 1. Genres: value == 53 (float64 in unmarshaled json)
outGenres := c.callTool(t, "read_reference", map[string]any{
"action": "genres",
"media": "movie",
})
dataGenres := requireOK(t, outGenres)
var pageGenres struct {
Items []struct {
ID string `json:"id"`
Name string `json:"name"`
Value any `json:"value"`
} `json:"items"`
}
if err := json.Unmarshal(dataGenres, &pageGenres); err != nil {
t.Fatalf("unmarshal genres: %v", err)
}
if len(pageGenres.Items) != 1 || pageGenres.Items[0].ID != "53" || pageGenres.Items[0].Name != "Thriller" || pageGenres.Items[0].Value != float64(53) {
t.Errorf("unexpected genres item: %+v", pageGenres.Items)
}
// 2. Profiles (options): value == 6 (not weight 7, not enabled false)
outProfiles := c.callTool(t, "read_integration", map[string]any{
"action": "options",
"service": "radarr",
"resource": "profiles",
})
dataProfiles := requireOK(t, outProfiles)
var pageProfiles struct {
Items []struct {
ID string `json:"id"`
Name string `json:"name"`
Value any `json:"value"`
} `json:"items"`
}
if err := json.Unmarshal(dataProfiles, &pageProfiles); err != nil {
t.Fatalf("unmarshal profiles: %v", err)
}
if len(pageProfiles.Items) != 1 || pageProfiles.Items[0].ID != "6" || pageProfiles.Items[0].Name != "HD" || pageProfiles.Items[0].Value != float64(6) {
t.Errorf("unexpected profiles item: %+v", pageProfiles.Items)
}
// 3. Watch providers: value == 8 (not provider_name, not logo_path)
outWP := c.callTool(t, "read_reference", map[string]any{
"action": "watch_providers",
"media": "movie",
})
dataWP := requireOK(t, outWP)
var pageWP struct {
Items []struct {
ID string `json:"id"`
Name string `json:"name"`
Value any `json:"value"`
} `json:"items"`
}
if err := json.Unmarshal(dataWP, &pageWP); err != nil {
t.Fatalf("unmarshal watch providers: %v", err)
}
if len(pageWP.Items) != 1 || pageWP.Items[0].ID != "8" || pageWP.Items[0].Name != "Netflix" || pageWP.Items[0].Value != float64(8) {
t.Errorf("unexpected watch provider item: %+v", pageWP.Items)
}
// 4. Languages: mojibake skip (ky -> Кыргызча) + all-corrupted warning (zz -> ?????? + warning)
outLang := c.callTool(t, "read_reference", map[string]any{
"action": "languages",
})
dataLang := requireOK(t, outLang)
var pageLang struct {
Items []struct {
ID string `json:"id"`
Name string `json:"name"`
Value any `json:"value"`
} `json:"items"`
}
if err := json.Unmarshal(dataLang, &pageLang); err != nil {
t.Fatalf("unmarshal languages: %v", err)
}
if len(pageLang.Items) != 2 {
t.Fatalf("expected 2 language items, got %d", len(pageLang.Items))
}
if pageLang.Items[0].ID != "ky" || pageLang.Items[0].Name != "Кыргызча" {
t.Errorf("expected ky to have name 'Кыргызча', got %+v", pageLang.Items[0])
}
if pageLang.Items[1].ID != "zz" || pageLang.Items[1].Name != "??????" {
t.Errorf("expected zz to have name '??????', got %+v", pageLang.Items[1])
}
hasWarn := false
for _, w := range outLang.Envelope.Warnings {
if strings.Contains(w, "upstream language label appears corrupted") && strings.Contains(w, "zz") {
hasWarn = true
break
}
}
if !hasWarn {
t.Errorf("expected corrupted label warning for zz in warnings: %v", outLang.Envelope.Warnings)
}
}
func TestM6IntegrationRootFolders(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "read_integration", map[string]any{
"action": "options",
"service": "sonarr",
"resource": "root_folders",
})
data := requireOK(t, out)
var page struct {
Items []struct {
ID string `json:"id"`
Name string `json:"name"`
Value any `json:"value"`
Category string `json:"category"`
} `json:"items"`
}
if err := json.Unmarshal(data, &page); err != nil {
t.Fatalf("unmarshal root_folders: %v", err)
}
if len(page.Items) != 1 {
t.Fatalf("expected 1 root folder, got %d", len(page.Items))
}
rf := page.Items[0]
if rf.ID != "21" || rf.Name != "/media/tv" || rf.Value != float64(21) || rf.Category != "root_folder" {
t.Errorf("unexpected root_folder projection: %+v", rf)
}
}
func TestM6PlexServersAndLibraries(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
// 1. Plex servers decoded from object wrapper {"success":true,"servers":[...]}
outServers := c.callTool(t, "read_integration", map[string]any{
"action": "plex",
"resource": "servers",
})
dataServers := requireOK(t, outServers)
var pageServers struct {
Items []struct {
ID string `json:"id"`
Name string `json:"name"`
Value any `json:"value"`
Category string `json:"category"`
} `json:"items"`
}
if err := json.Unmarshal(dataServers, &pageServers); err != nil {
t.Fatalf("unmarshal servers: %v", err)
}
if len(pageServers.Items) != 1 {
t.Fatalf("expected 1 plex server, got %d", len(pageServers.Items))
}
srv := pageServers.Items[0]
if srv.ID != "mach-plex-1" || srv.Name != "Main Plex" || srv.Value != float64(7) || srv.Category != "plex_server" {
t.Errorf("unexpected plex server projection: %+v", srv)
}
// 2. Plex libraries round-trip using machine_id discovered from servers
outLibs := c.callTool(t, "read_integration", map[string]any{
"action": "plex_libraries",
"machine_id": srv.ID,
})
dataLibs := requireOK(t, outLibs)
var pageLibs struct {
Items []struct {
ID string `json:"id"`
Name string `json:"name"`
Value any `json:"value"`
Category string `json:"category"`
} `json:"items"`
}
if err := json.Unmarshal(dataLibs, &pageLibs); err != nil {
t.Fatalf("unmarshal libraries: %v", err)
}
if len(pageLibs.Items) != 2 {
t.Fatalf("expected 2 libraries, got %d", len(pageLibs.Items))
}
if pageLibs.Items[0].ID != "3" || pageLibs.Items[0].Name != "TV Shows" || pageLibs.Items[0].Value != "3" {
t.Errorf("unexpected library item 0: %+v", pageLibs.Items[0])
}
if pageLibs.Items[1].ID != "1" || pageLibs.Items[1].Name != "Movies" || pageLibs.Items[1].Value != "1" {
t.Errorf("unexpected library item 1: %+v", pageLibs.Items[1])
}
// 3. Plex libraries with broken machineId surfaces UPSTREAM_REJECTED + message
outBroken := c.callTool(t, "read_integration", map[string]any{
"action": "plex_libraries",
"machine_id": "broken",
})
errBroken := requireErr(t, outBroken, "UPSTREAM_REJECTED")
if !strings.Contains(errBroken.Message, "could not reach server") {
t.Errorf("expected rejection message to contain 'could not reach server', got %q", errBroken.Message)
}
}
func TestM6PlexFriendsAndWatchlistUsers(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
// Friends -> user_page
outFriends := c.callTool(t, "read_integration", map[string]any{
"action": "plex",
"resource": "friends",
})
dataFriends := requireOK(t, outFriends)
var pageFriends struct {
Kind string `json:"kind"`
Items []struct {
ID string `json:"id"`
UserName string `json:"user_name"`
} `json:"items"`
}
if err := json.Unmarshal(dataFriends, &pageFriends); err != nil {
t.Fatalf("unmarshal friends: %v", err)
}
if pageFriends.Kind != "user_page" || len(pageFriends.Items) != 1 || pageFriends.Items[0].UserName != "plexfriend" {
t.Errorf("unexpected friends: %+v", pageFriends)
}
// Watchlist users -> user_page
outWL := c.callTool(t, "read_integration", map[string]any{
"action": "plex",
"resource": "watchlist_users",
})
dataWL := requireOK(t, outWL)
var pageWL struct {
Kind string `json:"kind"`
Items []struct {
ID string `json:"id"`
UserName string `json:"user_name"`
} `json:"items"`
}
if err := json.Unmarshal(dataWL, &pageWL); err != nil {
t.Fatalf("unmarshal watchlist_users: %v", err)
}
if pageWL.Kind != "user_page" || len(pageWL.Items) != 1 || pageWL.Items[0].UserName != "wl" {
t.Errorf("unexpected watchlist_users: %+v", pageWL)
}
}
func TestM6ServerIdentityDiscoveryAndMediaServer(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
// 1. read_settings emby: server identity leaves exposed, secrets/top-level id omitted
outSettings := c.callTool(t, "read_settings", map[string]any{
"section": "emby",
})
dataSettings := requireOK(t, outSettings)
var s struct {
Kind string `json:"kind"`
Section string `json:"section"`
Revision string `json:"revision"`
Values []struct {
Name string `json:"name"`
Value any `json:"value"`
} `json:"values"`
OmittedFields []string `json:"omitted_fields"`
}
if err := json.Unmarshal(dataSettings, &s); err != nil {
t.Fatalf("settings decode: %v", err)
}
valMap := map[string]any{}
for _, v := range s.Values {
valMap[v.Name] = v.Value
}
omittedSet := map[string]bool{}
for _, o := range s.OmittedFields {
omittedSet[o] = true
}
if valMap["/servers/0/id"] != float64(3) {
t.Errorf("expected /servers/0/id == 3, got %v", valMap["/servers/0/id"])
}
if valMap["/servers/0/serverId"] != "emby-guid" {
t.Errorf("expected /servers/0/serverId == 'emby-guid', got %v", valMap["/servers/0/serverId"])
}
if valMap["/servers/0/name"] != "Emby One" {
t.Errorf("expected /servers/0/name == 'Emby One', got %v", valMap["/servers/0/name"])
}
if _, ok := valMap["/id"]; ok {
t.Errorf("top-level /id must NOT be present in values")
}
if _, ok := valMap["/servers/0/apiKey"]; ok {
t.Errorf("apiKey must NOT be present in values")
}
if _, ok := valMap["/servers/0/ip"]; ok {
t.Errorf("ip must NOT be present in values")
}
if !omittedSet["/id"] {
t.Errorf("expected /id in omitted_fields")
}
if !omittedSet["/servers/0/apiKey"] {
t.Errorf("expected /servers/0/apiKey in omitted_fields")
}
if !omittedSet["/servers/0/ip"] {
t.Errorf("expected /servers/0/ip in omitted_fields")
}
if omittedSet["/servers/0/id"] {
t.Errorf("/servers/0/id must NOT be in omitted_fields")
}
if omittedSet["/servers/0/serverId"] {
t.Errorf("/servers/0/serverId must NOT be in omitted_fields")
}
// 2. Patch rejection: attempting to patch /servers/0/id must return INVALID_ARGUMENT
outPatch := c.callTool(t, "write_settings_patch", map[string]any{
"section": "emby",
"revision": s.Revision,
"changes": map[string]any{
"servers": []any{
map[string]any{"id": 999},
},
},
})
requireErr(t, outPatch, "INVALID_ARGUMENT")
// 3. media_server emby info round-trip using discovered serverId "emby-guid"
outInfo := c.callTool(t, "read_integration", map[string]any{
"action": "media_server",
"service": "emby",
"resource": "info",
"server_id": "emby-guid",
})
dataInfo := requireOK(t, outInfo)
var pageInfo struct {
Items []struct {
ID string `json:"id"`
Name string `json:"name"`
Value any `json:"value"`
} `json:"items"`
}
if err := json.Unmarshal(dataInfo, &pageInfo); err != nil {
t.Fatalf("unmarshal emby info: %v", err)
}
if len(pageInfo.Items) != 1 || pageInfo.Items[0].ID != "emby-guid" || pageInfo.Items[0].Name != "Emby One" {
t.Errorf("unexpected emby info item: %+v", pageInfo.Items)
}
assertNoLeak(t, outInfo.Raw)
// 4. media_server emby libraries round-trip using discovered numeric id "3"
outLibs := c.callTool(t, "read_integration", map[string]any{
"action": "media_server",
"service": "emby",
"resource": "libraries",
"server_id": "3",
})
dataLibs := requireOK(t, outLibs)
var pageLibs struct {
Items []struct {
ID string `json:"id"`
Name string `json:"name"`
Value any `json:"value"`
} `json:"items"`
}
if err := json.Unmarshal(dataLibs, &pageLibs); err != nil {
t.Fatalf("unmarshal emby libraries: %v", err)
}
if len(pageLibs.Items) != 1 || pageLibs.Items[0].ID != "f137" || pageLibs.Items[0].Name != "Movies" {
t.Errorf("unexpected emby library item: %+v", pageLibs.Items)
}
assertNoLeak(t, outLibs.Raw)
// Assert POST bodies to Emby info and Library carried the saved server object
reqs := mock.requests()
infoFound := false
libFound := false
for _, r := range reqs {
if r.Method == "POST" && r.Path == "/api/v1/Emby/info" {
infoFound = true
if !strings.Contains(string(r.Body), `"apiKey":"SECRET"`) || !strings.Contains(string(r.Body), `"emby-guid"`) {
t.Errorf("POST /api/v1/Emby/info did not carry saved server object: %s", r.Body)
}
}
if r.Method == "POST" && r.Path == "/api/v1/Emby/Library" {
libFound = true
if !strings.Contains(string(r.Body), `"apiKey":"SECRET"`) || !strings.Contains(string(r.Body), `"emby-guid"`) {
t.Errorf("POST /api/v1/Emby/Library did not carry saved server object: %s", r.Body)
}
}
}
if !infoFound {
t.Errorf("POST /api/v1/Emby/info was not called")
}
if !libFound {
t.Errorf("POST /api/v1/Emby/Library was not called")
}
}
func TestReadRequestsListUsesRequestedDateSort(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "read_requests", map[string]any{
"action": "list", "media": "movie", "status": "available",
})
requireOK(t, out)
found := false
for _, r := range mock.requests() {
if r.Method == "GET" && strings.Contains(r.Path, "/api/v2/Requests/movie/") &&
strings.Contains(r.Path, "/RequestedDate/") {
found = true
if strings.Contains(r.Path, "/requestDate/") {
t.Errorf("legacy requestDate sort segment still present: %s", r.Path)
}
}
}
if !found {
t.Fatal("expected v2 movie list to use RequestedDate sort segment")
}
}
func TestReadDiscoverTVMostWatchedSkimsHugeSeasonGraph(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "read_discover", map[string]any{
"action": "browse", "media": "tv", "category": "most_watched",
"page": map[string]any{"limit": 3},
})
data := requireOK(t, out)
var page struct {
Items []struct {
Title string `json:"title"`
Identifiers []struct {
Namespace string `json:"namespace"`
Value string `json:"value"`
} `json:"identifiers"`
Seasons []any `json:"seasons"`
} `json:"items"`
}
if err := json.Unmarshal(data, &page); err != nil {
t.Fatalf("decode: %v\n%s", err, data)
}
if len(page.Items) != 1 || page.Items[0].Title != "Reacher" {
t.Fatalf("unexpected browse page: %s", data)
}
if len(page.Items[0].Seasons) != 0 {
t.Errorf("seasonRequests leaked into projection: %+v", page.Items[0].Seasons)
}
ids := map[string]string{}
for _, id := range page.Items[0].Identifiers {
ids[id.Namespace] = id.Value
}
if ids["tmdb"] != "1668" {
t.Errorf("identifiers = %v, want tmdb 1668", ids)
}
assertNoLeak(t, out.Raw)
}
func TestReadIssuesProviderSummaryEmptyBody(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "read_issues", map[string]any{
"action": "provider_summary", "provider_id": "348",
})
data := requireOK(t, out)
var page struct {
Kind string `json:"kind"`
Items []any `json:"items"`
Page struct {
Returned int `json:"returned"`
Total *int `json:"total"`
} `json:"page"`
}
if err := json.Unmarshal(data, &page); err != nil {
t.Fatalf("decode: %v\n%s", err, data)
}
if page.Kind != "group_page" || len(page.Items) != 0 {
t.Fatalf("empty provider_summary must be an empty group_page: %s", data)
}
if page.Page.Returned != 0 {
t.Errorf("returned = %d, want 0", page.Page.Returned)
}
outHit := c.callTool(t, "read_issues", map[string]any{
"action": "provider_summary", "provider_id": "tt0089826",
})
dataHit := requireOK(t, outHit)
if !strings.Contains(string(dataHit), `"The Equalizer"`) {
t.Fatalf("non-empty provider_summary: %s", dataHit)
}
assertNoLeak(t, out.Raw)
assertNoLeak(t, outHit.Raw)
}