Build and publish / Test and build (darwin) (pull_request) Successful in 1m52s
Build and publish / Test and build (linux) (pull_request) Successful in 2m30s
Build and publish / Test and build (windows) (pull_request) Successful in 3m7s
Build and publish / Test and build (windows) (push) Successful in 3m7s
Build and publish / Test and build (darwin) (push) Successful in 1m45s
Build and publish / Test and build (linux) (push) Successful in 2m12s
Build and publish / Build and publish Docker image (pull_request) Successful in 1m49s
Build and publish / Build and publish Docker image (push) Successful in 2m4s
2203 lines
66 KiB
Go
2203 lines
66 KiB
Go
//go:build integration
|
|
|
|
package integration_test
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"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 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")
|
|
}
|
|
}
|