Files
ombi-mcp/internal/integration_test/live_test.go
gronod 6926a91c80
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
Fix M7 admin/server wire contracts
2026-09-19 18:36:03 +01:00

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)
}
}
}