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
723 lines
20 KiB
Go
723 lines
20 KiB
Go
//go:build integration
|
|
|
|
package integration_test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// liveEnv resolves the live-instance configuration from the parent
|
|
// environment (sourced .env or exported vars). It skips the calling
|
|
// test when OMBI_URL is unset, so the suite degrades to mock-only
|
|
// coverage on machines without credentials.
|
|
func liveEnv(t *testing.T) map[string]string {
|
|
t.Helper()
|
|
url := os.Getenv("OMBI_URL")
|
|
if url == "" {
|
|
t.Skip("OMBI_URL not set — skipping live Ombi test")
|
|
}
|
|
env := map[string]string{
|
|
"OMBI_URL": url,
|
|
"OMBI_BUNDLES": "core,moderation,administration",
|
|
}
|
|
switch os.Getenv("OMBI_AUTH_MODE") {
|
|
case "jwt":
|
|
env["OMBI_AUTH_MODE"] = "jwt"
|
|
env["OMBI_USERNAME"] = os.Getenv("OMBI_USERNAME")
|
|
env["OMBI_PASSWORD"] = os.Getenv("OMBI_PASSWORD")
|
|
case "api_key":
|
|
env["OMBI_AUTH_MODE"] = "api_key"
|
|
env["OMBI_API_KEY"] = os.Getenv("OMBI_API_KEY")
|
|
if v := os.Getenv("OMBI_USER_NAME"); v != "" {
|
|
env["OMBI_USER_NAME"] = v
|
|
}
|
|
default:
|
|
t.Skip("OMBI_AUTH_MODE unset/invalid — skipping live Ombi test")
|
|
}
|
|
return env
|
|
}
|
|
|
|
func liveServer(t *testing.T) *rpcClient {
|
|
t.Helper()
|
|
c := spawnServer(t, liveEnv(t))
|
|
c.handshake(t)
|
|
return c
|
|
}
|
|
|
|
// --- open endpoint: exercises the whole upstream path unauthenticated-safe ---
|
|
|
|
func TestLiveServerStatus(t *testing.T) {
|
|
c := liveServer(t)
|
|
out := c.callTool(t, "read_server", map[string]any{"action": "status"})
|
|
data := requireOK(t, out)
|
|
var m struct {
|
|
Kind string `json:"kind"`
|
|
Values []struct {
|
|
Name string `json:"name"`
|
|
Value any `json:"value"`
|
|
} `json:"values"`
|
|
}
|
|
if err := json.Unmarshal(data, &m); err != nil {
|
|
t.Fatalf("metrics decode: %v\n%s", err, data)
|
|
}
|
|
if m.Kind != "metrics" || len(m.Values) == 0 {
|
|
t.Fatalf("bad metrics family: %s", data)
|
|
}
|
|
t.Logf("live status: %s", data)
|
|
assertNoLeak(t, out.Raw)
|
|
}
|
|
|
|
func TestLiveServerStats(t *testing.T) {
|
|
c := liveServer(t)
|
|
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"`
|
|
}
|
|
if err := json.Unmarshal(data, &metrics); err != nil || metrics.Kind != "metrics" {
|
|
t.Fatalf("stats result: %v: %s", err, data)
|
|
}
|
|
}
|
|
|
|
func TestLiveLogs(t *testing.T) {
|
|
c := liveServer(t)
|
|
out := c.callTool(t, "read_logs", map[string]any{"action": "list"})
|
|
data := requireOK(t, out)
|
|
var logs struct {
|
|
Kind string `json:"kind"`
|
|
}
|
|
if err := json.Unmarshal(data, &logs); err != nil || logs.Kind != "logs" {
|
|
t.Fatalf("logs result: %v: %s", err, data)
|
|
}
|
|
}
|
|
|
|
func TestLiveCronValidateQuartz(t *testing.T) {
|
|
c := liveServer(t)
|
|
out := c.callTool(t, "read_server", map[string]any{
|
|
"action": "cron_validate", "expression": "0 0 0 * * ?",
|
|
})
|
|
data := requireOK(t, out)
|
|
if !strings.Contains(string(data), `"name":"valid","value":true`) {
|
|
t.Fatalf("Quartz-valid expression reported invalid: %s", data)
|
|
}
|
|
out = c.callTool(t, "read_server", map[string]any{
|
|
"action": "cron_validate", "expression": "0 0 * * *",
|
|
})
|
|
data = requireOK(t, out)
|
|
if !strings.Contains(string(data), `"name":"valid","value":false`) {
|
|
t.Fatalf("five-field cron reported valid: %s", data)
|
|
}
|
|
}
|
|
|
|
// --- read/projection contract against real payloads ---
|
|
|
|
func TestLiveReadRequestsList(t *testing.T) {
|
|
c := liveServer(t)
|
|
for _, media := range []string{"movie", "tv", "album"} {
|
|
out := c.callTool(t, "read_requests", map[string]any{
|
|
"action": "list", "media": media,
|
|
"page": map[string]any{"offset": 0, "limit": 10},
|
|
})
|
|
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"`
|
|
} `json:"items"`
|
|
Page struct {
|
|
Mode string `json:"mode"`
|
|
Returned int `json:"returned"`
|
|
} `json:"page"`
|
|
}
|
|
if err := json.Unmarshal(data, &page); err != nil {
|
|
t.Fatalf("%s: request_page decode: %v\n%s", media, err, data)
|
|
}
|
|
if page.Kind != "request_page" {
|
|
t.Fatalf("%s: wrong family %q", media, page.Kind)
|
|
}
|
|
wantKind := media
|
|
if media == "tv" {
|
|
wantKind = "tv_child"
|
|
}
|
|
for _, it := range page.Items {
|
|
if it.Target.Kind != wantKind {
|
|
t.Errorf("%s: item target kind %q, want %q", media, it.Target.Kind, wantKind)
|
|
}
|
|
}
|
|
t.Logf("%s: %d items, paging=%s", media, page.Page.Returned, data)
|
|
assertNoLeak(t, out.Raw)
|
|
}
|
|
}
|
|
|
|
func TestLiveReadIssues(t *testing.T) {
|
|
c := liveServer(t)
|
|
for _, st := range []string{"pending", "in_progress", "resolved", "closed"} {
|
|
out := c.callTool(t, "read_issues", map[string]any{
|
|
"action": "list", "status": st,
|
|
"page": map[string]any{"offset": 0, "limit": 10},
|
|
})
|
|
data := requireOK(t, out)
|
|
var page struct {
|
|
Kind string `json:"kind"`
|
|
Items []struct {
|
|
Status string `json:"status"`
|
|
StatusCode *int `json:"status_code"`
|
|
} `json:"items"`
|
|
}
|
|
if err := json.Unmarshal(data, &page); err != nil {
|
|
t.Fatalf("%s: issue_page decode: %v\n%s", st, err, data)
|
|
}
|
|
if page.Kind != "issue_page" {
|
|
t.Fatalf("%s: wrong family %q", st, page.Kind)
|
|
}
|
|
for _, it := range page.Items {
|
|
if it.Status != st {
|
|
t.Errorf("issue status %q under %s filter (code=%v)",
|
|
it.Status, st, it.StatusCode)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestLiveSearchThenDetails is the regression test for Gitea issue
|
|
// #1: the v1 TV search emits a TVDB id (in theMovieDbId, labelled
|
|
// tvdb) and details provider:tvdb must resolve the same series via
|
|
// the v1 TVMaze info route — not the TMDB-keyed v2 alias.
|
|
func TestLiveSearchThenDetails(t *testing.T) {
|
|
c := liveServer(t)
|
|
|
|
out := c.callTool(t, "read_search", map[string]any{
|
|
"action": "text", "media": "tv", "query": "Button Moon",
|
|
})
|
|
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("search decode: %v\n%s", err, data)
|
|
}
|
|
if len(page.Items) == 0 {
|
|
t.Skip("live search returned no TV results for 'Button Moon'")
|
|
}
|
|
var tvdb int
|
|
for _, id := range page.Items[0].Identifiers {
|
|
if id.Namespace == "tvdb" {
|
|
if err := json.Unmarshal([]byte(id.Value), &tvdb); err != nil {
|
|
t.Fatalf("tvdb identifier not numeric: %q", id.Value)
|
|
}
|
|
}
|
|
}
|
|
if tvdb == 0 {
|
|
t.Fatalf("first search hit has no tvdb identifier: %s", data)
|
|
}
|
|
t.Logf("resolved tvdb id %d for %q", tvdb, page.Items[0].Title)
|
|
|
|
out = c.callTool(t, "read_media", map[string]any{
|
|
"action": "details",
|
|
"target": map[string]any{"media": "tv", "provider": "tvdb", "id": tvdb},
|
|
})
|
|
data = requireOK(t, out)
|
|
var det struct {
|
|
Items []struct {
|
|
Title string `json:"title"`
|
|
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, &det); err != nil {
|
|
t.Fatalf("details decode: %v\n%s", err, data)
|
|
}
|
|
if len(det.Items) != 1 || len(det.Items[0].Seasons) == 0 {
|
|
t.Fatalf("TV details missing season structure: %s", data)
|
|
}
|
|
// The same series the search returned must come back — the
|
|
// original bug resolved a different show entirely.
|
|
if det.Items[0].Title != page.Items[0].Title {
|
|
t.Fatalf("details title %q != search title %q (issue #1 regression)",
|
|
det.Items[0].Title, page.Items[0].Title)
|
|
}
|
|
assertNoLeak(t, out.Raw)
|
|
}
|
|
|
|
// --- expansion logic live ---
|
|
|
|
// TestLiveSeasonExpansion creates a real TV request via season mode
|
|
// and then deletes the parent request it created. It is the only
|
|
// live test that mutates upstream state.
|
|
func TestLiveSeasonExpansion(t *testing.T) {
|
|
c := liveServer(t)
|
|
|
|
// Resolve a show to request via live search.
|
|
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"`
|
|
Requested *bool `json:"requested"`
|
|
Identifiers []struct {
|
|
Namespace string `json:"namespace"`
|
|
Value string `json:"value"`
|
|
} `json:"identifiers"`
|
|
} `json:"items"`
|
|
}
|
|
if err := json.Unmarshal(data, &page); err != nil || len(page.Items) == 0 {
|
|
t.Skip("no live TV search results to request")
|
|
}
|
|
var tvdb int
|
|
for _, id := range page.Items[0].Identifiers {
|
|
if id.Namespace == "tvdb" {
|
|
_ = json.Unmarshal([]byte(id.Value), &tvdb)
|
|
}
|
|
}
|
|
if tvdb == 0 {
|
|
t.Skip("first search hit has no tvdb id")
|
|
}
|
|
|
|
// Fetch details to learn which seasons exist.
|
|
out = c.callTool(t, "read_media", map[string]any{
|
|
"action": "details",
|
|
"target": map[string]any{"media": "tv", "provider": "tvdb", "id": tvdb},
|
|
})
|
|
data = requireOK(t, out)
|
|
var det struct {
|
|
Items []struct {
|
|
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, &det); err != nil {
|
|
t.Fatalf("details decode: %v", err)
|
|
}
|
|
var season int
|
|
var epCount int
|
|
for _, s := range det.Items[0].Seasons {
|
|
if s.SeasonNumber > 0 && len(s.Episodes) > 0 {
|
|
season, epCount = s.SeasonNumber, len(s.Episodes)
|
|
break
|
|
}
|
|
}
|
|
if season == 0 {
|
|
t.Skip("show exposes no requestable season")
|
|
}
|
|
t.Logf("requesting tvdb=%d season=%d (%d episodes)", tvdb, season, epCount)
|
|
|
|
out = c.callTool(t, "write_request_create", map[string]any{
|
|
"action": "tv",
|
|
"provider": "tvdb",
|
|
"id": tvdb,
|
|
"selection": map[string]any{
|
|
"mode": "season",
|
|
"season_numbers": []int{season},
|
|
},
|
|
})
|
|
data = requireOK(t, out)
|
|
var mut struct {
|
|
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\n%s", err, data)
|
|
}
|
|
if mut.Outcome != "completed" || mut.RequestID == nil {
|
|
t.Fatalf("live create did not complete: %s", data)
|
|
}
|
|
t.Logf("created live request id=%d", *mut.RequestID)
|
|
assertNoLeak(t, out.Raw)
|
|
|
|
// Clean up: delete the TV parent we just created.
|
|
parent := *mut.RequestID
|
|
if mut.Target != nil && mut.Target.Kind == "tv_parent" {
|
|
parent = mut.Target.ID
|
|
}
|
|
out = c.callTool(t, "write_request_delete", map[string]any{
|
|
"target": map[string]any{"kind": "tv_parent", "id": parent},
|
|
})
|
|
if !out.Envelope.OK {
|
|
t.Logf("cleanup: delete of tv_parent %d failed (manual cleanup needed): %s",
|
|
parent, out.Raw)
|
|
} else {
|
|
t.Logf("cleanup: deleted tv_parent %d", parent)
|
|
}
|
|
}
|
|
|
|
// --- patch cycle live ---
|
|
|
|
// TestLiveSettingsPatch performs a read-modify-write on the
|
|
// customization section, then restores the original value. It is
|
|
// mutating but self-healing.
|
|
func TestLiveSettingsPatch(t *testing.T) {
|
|
c := liveServer(t)
|
|
|
|
out := c.callTool(t, "read_settings", map[string]any{"section": "customization"})
|
|
data := requireOK(t, out)
|
|
var s struct {
|
|
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.Revision == "" {
|
|
t.Skip("customization offered no revision — cannot patch")
|
|
}
|
|
// Pick the first scalar string field for a reversible change.
|
|
var field, orig string
|
|
for _, v := range s.Values {
|
|
if str, ok := v.Value.(string); ok && v.Name != "" {
|
|
field, orig = v.Name, str
|
|
break
|
|
}
|
|
}
|
|
if field == "" {
|
|
t.Skip("no string scalar found in customization projection")
|
|
}
|
|
key := strings.TrimPrefix(field, "/")
|
|
t.Logf("patching %s (was %q)", key, orig)
|
|
|
|
patch := map[string]any{key: orig + "-itest"}
|
|
out = c.callTool(t, "write_settings_patch", map[string]any{
|
|
"action": "patch", "section": "customization",
|
|
"revision": s.Revision, "changes": patch,
|
|
})
|
|
data = requireOK(t, out)
|
|
assertNoLeak(t, out.Raw)
|
|
|
|
// Re-read: revision must have moved, value must reflect the patch.
|
|
out = c.callTool(t, "read_settings", map[string]any{"section": "customization"})
|
|
data = requireOK(t, out)
|
|
var s2 struct {
|
|
Revision string `json:"revision"`
|
|
Values []struct {
|
|
Name string `json:"name"`
|
|
Value any `json:"value"`
|
|
} `json:"values"`
|
|
}
|
|
if err := json.Unmarshal(data, &s2); err != nil {
|
|
t.Fatalf("re-read decode: %v", err)
|
|
}
|
|
if s2.Revision == s.Revision {
|
|
t.Errorf("revision unchanged after patch")
|
|
}
|
|
got := ""
|
|
for _, v := range s2.Values {
|
|
if v.Name == field {
|
|
got, _ = v.Value.(string)
|
|
}
|
|
}
|
|
if got != orig+"-itest" {
|
|
t.Errorf("patched value = %q, want %q", got, orig+"-itest")
|
|
}
|
|
|
|
// Restore.
|
|
out = c.callTool(t, "write_settings_patch", map[string]any{
|
|
"action": "patch", "section": "customization",
|
|
"revision": s2.Revision, "changes": map[string]any{key: orig},
|
|
})
|
|
requireOK(t, out)
|
|
|
|
// Stale-revision check: replaying the first revision must conflict.
|
|
out = c.callTool(t, "write_settings_patch", map[string]any{
|
|
"action": "patch", "section": "customization",
|
|
"revision": s.Revision, "changes": map[string]any{key: "stale"},
|
|
})
|
|
requireErr(t, out, "CONFLICT")
|
|
}
|
|
|
|
// --- live error boundaries ---
|
|
|
|
func TestLiveBadCredentials(t *testing.T) {
|
|
if os.Getenv("OMBI_URL") == "" {
|
|
t.Skip("OMBI_URL not set")
|
|
}
|
|
env := map[string]string{
|
|
"OMBI_URL": os.Getenv("OMBI_URL"),
|
|
"OMBI_AUTH_MODE": "api_key",
|
|
"OMBI_API_KEY": "definitely-invalid-key",
|
|
"OMBI_BUNDLES": "core,moderation,administration",
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
|
|
func TestLiveNotFound(t *testing.T) {
|
|
c := liveServer(t)
|
|
out := c.callTool(t, "read_users", map[string]any{
|
|
"action": "get", "user_id": "nonexistent-user-id-zzz",
|
|
})
|
|
e := requireErr(t, out, "NOT_FOUND")
|
|
if e.HTTPStatus == nil || *e.HTTPStatus != 404 {
|
|
t.Errorf("http_status = %v", e.HTTPStatus)
|
|
}
|
|
}
|
|
|
|
// --- M3 identity projection live ---
|
|
|
|
func TestLiveDiscoverTVBrowseHasIdentifiers(t *testing.T) {
|
|
c := liveServer(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 {
|
|
t.Skip("live TV popular browse returned no items")
|
|
}
|
|
for _, it := range page.Items {
|
|
if len(it.Identifiers) == 0 {
|
|
t.Fatalf("browse item %q has empty identifiers (issue #2)", it.Title)
|
|
}
|
|
}
|
|
assertNoLeak(t, out.Raw)
|
|
}
|
|
|
|
func TestLiveRecentTVParentTargetIsCallable(t *testing.T) {
|
|
c := liveServer(t)
|
|
out := c.callTool(t, "read_requests", map[string]any{"action": "recent"})
|
|
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"`
|
|
} `json:"items"`
|
|
}
|
|
if err := json.Unmarshal(data, &page); err != nil {
|
|
t.Fatalf("decode: %v\n%s", err, data)
|
|
}
|
|
var tv *struct {
|
|
Target struct {
|
|
Kind string `json:"kind"`
|
|
ID int `json:"id"`
|
|
} `json:"target"`
|
|
Title string `json:"title"`
|
|
}
|
|
for i := range page.Items {
|
|
if page.Items[i].Target.Kind == "tv_parent" {
|
|
tv = &page.Items[i]
|
|
break
|
|
}
|
|
}
|
|
if tv == nil {
|
|
t.Skip("live recent feed has no TV rows")
|
|
}
|
|
// Issue #11: upstream emits a provider-shaped child request id
|
|
// here; the resolved tv_parent id must be callable via get.
|
|
if tv.Target.ID < 1 {
|
|
t.Skipf("recent tv row %q left unresolved (id 0)", tv.Title)
|
|
}
|
|
out = c.callTool(t, "read_requests", map[string]any{
|
|
"action": "get",
|
|
"target": map[string]any{"kind": "tv_parent", "id": tv.Target.ID},
|
|
})
|
|
if out.IsError {
|
|
t.Fatalf("recent tv_parent target %d not callable via get (issue #11): %s",
|
|
tv.Target.ID, out.Raw)
|
|
}
|
|
assertNoLeak(t, out.Raw)
|
|
}
|
|
|
|
func TestLiveSearchMultiArtistMapped(t *testing.T) {
|
|
c := liveServer(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.Skip("live multi music search returned no items")
|
|
}
|
|
it := page.Items[0]
|
|
if it.Media == "unknown" {
|
|
t.Fatalf("music result media=unknown (issue #9): %s", data)
|
|
}
|
|
for _, id := range it.Identifiers {
|
|
if id.Namespace == "provider_unknown" {
|
|
t.Fatalf("music result labelled provider_unknown (issue #9): %s", data)
|
|
}
|
|
}
|
|
assertNoLeak(t, out.Raw)
|
|
}
|
|
|
|
func TestLiveReferenceGenresValue(t *testing.T) {
|
|
c := liveServer(t)
|
|
out := c.callTool(t, "read_reference", map[string]any{
|
|
"action": "genres",
|
|
"media": "movie",
|
|
})
|
|
data := requireOK(t, out)
|
|
var page struct {
|
|
Items []struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Value any `json:"value"`
|
|
} `json:"items"`
|
|
}
|
|
if err := json.Unmarshal(data, &page); err != nil {
|
|
t.Fatalf("decode: %v\n%s", err, data)
|
|
}
|
|
if len(page.Items) == 0 {
|
|
t.Skip("live genres returned no items")
|
|
}
|
|
for _, it := range page.Items {
|
|
idNum, ok := it.Value.(float64)
|
|
if !ok {
|
|
t.Fatalf("genre item %q id %q has non-numeric value: %v (%T)", it.Name, it.ID, it.Value, it.Value)
|
|
}
|
|
if fmt.Sprintf("%.0f", idNum) != it.ID {
|
|
t.Fatalf("genre item %q id %q does not match value %.0f", it.Name, it.ID, idNum)
|
|
}
|
|
}
|
|
assertNoLeak(t, out.Raw)
|
|
}
|
|
|
|
func TestLivePlexServersAndLibraries(t *testing.T) {
|
|
c := liveServer(t)
|
|
out := c.callTool(t, "read_integration", map[string]any{
|
|
"action": "plex",
|
|
"resource": "servers",
|
|
})
|
|
if out.IsError {
|
|
t.Skip("live plex servers returned error")
|
|
}
|
|
data := requireOK(t, out)
|
|
var page struct {
|
|
Items []struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Value any `json:"value"`
|
|
} `json:"items"`
|
|
}
|
|
if err := json.Unmarshal(data, &page); err != nil {
|
|
t.Fatalf("decode: %v\n%s", err, data)
|
|
}
|
|
if len(page.Items) == 0 {
|
|
t.Skip("no plex servers configured")
|
|
}
|
|
server := page.Items[0]
|
|
if server.ID == "" {
|
|
t.Fatalf("plex server ID (machineId) is empty: %+v", server)
|
|
}
|
|
outLibs := c.callTool(t, "read_integration", map[string]any{
|
|
"action": "plex_libraries",
|
|
"machine_id": server.ID,
|
|
})
|
|
requireOK(t, outLibs)
|
|
assertNoLeak(t, outLibs.Raw)
|
|
}
|
|
|
|
func TestLiveServerIdentityDiscoveryAndMediaServer(t *testing.T) {
|
|
c := liveServer(t)
|
|
var foundServerID string
|
|
var foundService string
|
|
for _, svc := range []string{"emby", "jellyfin", "plex"} {
|
|
out := c.callTool(t, "read_settings", map[string]any{
|
|
"section": svc,
|
|
})
|
|
if out.IsError {
|
|
continue
|
|
}
|
|
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\n%s", err, data)
|
|
}
|
|
for _, v := range s.Values {
|
|
if strings.HasPrefix(v.Name, "/servers/") {
|
|
if strings.HasSuffix(v.Name, "/id") || strings.HasSuffix(v.Name, "/serverId") || strings.HasSuffix(v.Name, "/machineIdentifier") {
|
|
if foundServerID == "" && (svc == "emby" || svc == "jellyfin") {
|
|
foundServerID = fmt.Sprintf("%v", v.Value)
|
|
foundService = svc
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for _, o := range s.OmittedFields {
|
|
if strings.HasPrefix(o, "/servers/") && (strings.HasSuffix(o, "/id") || strings.HasSuffix(o, "/serverId") || strings.HasSuffix(o, "/machineIdentifier")) {
|
|
t.Errorf("server identity field %q found in omitted_fields for %s", o, svc)
|
|
}
|
|
}
|
|
}
|
|
if foundServerID != "" && foundService != "" {
|
|
outInfo := c.callTool(t, "read_integration", map[string]any{
|
|
"action": "media_server",
|
|
"service": foundService,
|
|
"resource": "info",
|
|
"server_id": foundServerID,
|
|
})
|
|
if !outInfo.IsError {
|
|
assertNoLeak(t, outInfo.Raw)
|
|
}
|
|
}
|
|
}
|