Files
ombi-mcp/internal/integration_test/mockombi_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

980 lines
35 KiB
Go

//go:build integration
package integration_test
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
)
// recordedReq captures one upstream call for wire-shape assertions.
type recordedReq struct {
Method string
Path string
Body json.RawMessage
Header http.Header
}
// mockOmbi emulates the subset of the Ombi API the tools exercise,
// enforcing the configured auth mode and serving fixtures that carry
// realistic undocumented fields (the projection layer must tolerate
// them — it decodes into maps, never strict structs).
type mockOmbi struct {
*httptest.Server
mu sync.Mutex
reqs []recordedReq
mode string // "jwt" | "api_key"
apiKey string
userName string // expected UserName header (api_key mode)
user string
pass string
token string // currently accepted Bearer token (jwt mode)
tokenCalls int
settings map[string]map[string]any // live settings docs by path
}
func newMockOmbi(t *testing.T, mode string) *mockOmbi {
t.Helper()
m := &mockOmbi{
mode: mode,
apiKey: "mock-api-key-123",
user: "mockuser",
pass: "mockpass",
}
m.settings = map[string]map[string]any{
"/api/v1/Settings/customization": mockCustomization(),
"/api/v1/Settings/notifications/discord": mockDiscordSettings(),
"/api/v1/Settings/emby": mockMediaServerSettings("Emby One", "emby-guid", 3),
"/api/v1/Settings/jellyfin": mockMediaServerSettings("Jellyfin One", "jf-guid", 4),
"/api/v1/Settings/plex": mockMediaServerSettings("Plex One", "plex-guid", 5),
}
mux := http.NewServeMux()
mux.HandleFunc("POST /api/v1/Token", m.handleToken)
mux.HandleFunc("GET /api/v1/Status", m.wrap(m.fixed(`200`)))
mux.HandleFunc("GET /api/v1/Status/info", m.wrap(m.fixed(`"mock-status-info"`)))
mux.HandleFunc("GET /api/v1/Settings/about", m.wrap(m.json(mockAbout())))
mux.HandleFunc("GET /api/v1/Stats", m.wrap(m.stats))
mux.HandleFunc("POST /api/v1/Settings/testcron", m.wrap(m.testcron))
mux.HandleFunc("GET /api/v2/System/logs", m.wrap(m.fixed(`["ombi-20260918.txt","ombi-20260919.txt"]`)))
mux.HandleFunc("GET /api/v2/System/logs/{logFileName}", m.wrap(m.logsRead))
mux.HandleFunc("GET /api/v1/Request/tv/{count}/{pos}/{o}/{s}/{a}", m.wrap(m.tvParentList))
mux.HandleFunc("GET /api/v1/Request/tv/{id}/child", m.wrap(m.tvChildren))
mux.HandleFunc("GET /api/v2/Requests/movie/{amt}/{pos}/requestDate/{order}", m.wrap(m.movieList))
mux.HandleFunc("GET /api/v2/Requests/tv/{amt}/{pos}/requestDate/{order}", m.wrap(m.tvList))
mux.HandleFunc("GET /api/v2/Search/movie/{id}", m.wrap(m.movieDetails))
mux.HandleFunc("GET /api/v2/Search/movie/collection/{id}", m.wrap(m.movieCollection))
mux.HandleFunc("GET /api/v2/Search/movie/requested/{pos}/{amt}", m.wrap(m.emptyBrowse))
mux.HandleFunc("GET /api/v2/Search/tv/moviedb/{id}", m.wrap(m.tvDetailsTMDB))
mux.HandleFunc("GET /api/v2/Search/tv/{id}", m.wrap(m.tvDetailsTVDB))
mux.HandleFunc("GET /api/v2/Search/tv/popular/{pos}/{amt}", m.wrap(m.tvBrowse))
mux.HandleFunc("GET /api/v2/Search/tv/anticipated/{pos}/{amt}", m.wrap(m.tvBrowse))
mux.HandleFunc("GET /api/v2/Search/tv/trending/{pos}/{amt}", m.wrap(m.tvBrowse))
mux.HandleFunc("GET /api/v2/Search/tv/requested/{pos}/{amt}", m.wrap(m.emptyBrowse))
mux.HandleFunc("GET /api/v2/Search/actor/{id}/movie", m.wrap(m.actorMovieCredits))
mux.HandleFunc("GET /api/v2/Search/actor/{id}/tv", m.wrap(m.actorTVCredits))
mux.HandleFunc("POST /api/v2/Search/advancedSearch/movie/{pos}/{amt}", m.wrap(m.advancedMovie))
mux.HandleFunc("POST /api/v2/Search/multi/{term}", m.wrap(m.multiSearch))
mux.HandleFunc("GET /api/v2/Requests/recentlyRequested", m.wrap(m.recentlyRequested))
mux.HandleFunc("GET /api/v1/Search/tv/info/{id}", m.wrap(m.tvInfoTVDB))
mux.HandleFunc("GET /api/v1/Search/tv/{term}", m.wrap(m.tvSearch))
mux.HandleFunc("GET /api/v1/Request/movie/info/{id}", m.wrap(m.movieInfo))
mux.HandleFunc("POST /api/v2/Requests/tv", m.wrap(m.createTV))
mux.HandleFunc("POST /api/v1/Request/tv", m.wrap(m.createTV))
mux.HandleFunc("POST /api/v1/Request/movie", m.wrap(m.createMovie))
mux.HandleFunc("GET /api/v1/Issues/count", m.wrap(m.fixed(`{"pending":2,"inProgress":1,"resolved":5,"extraCountField":9}`)))
mux.HandleFunc("GET /api/v1/Issues/{limit}/{offset}/{code}", m.wrap(m.issueList))
mux.HandleFunc("GET /api/v1/Issues/{id}", m.wrap(m.issueGet))
mux.HandleFunc("GET /api/v1/Issues/{id}/comments", m.wrap(m.issueComments))
mux.HandleFunc("GET /api/v1/Settings/customization", m.wrap(m.settingsGet))
mux.HandleFunc("POST /api/v1/Settings/customization", m.wrap(m.settingsPost))
mux.HandleFunc("GET /api/v1/Settings/notifications/discord", m.wrap(m.settingsGet))
mux.HandleFunc("POST /api/v1/Settings/notifications/discord", m.wrap(m.settingsPost))
mux.HandleFunc("GET /api/v1/Settings/emby", m.wrap(m.settingsGet))
mux.HandleFunc("POST /api/v1/Settings/emby", m.wrap(m.settingsPost))
mux.HandleFunc("GET /api/v1/Settings/jellyfin", m.wrap(m.settingsGet))
mux.HandleFunc("POST /api/v1/Settings/jellyfin", m.wrap(m.settingsPost))
mux.HandleFunc("GET /api/v1/Settings/plex", m.wrap(m.settingsGet))
mux.HandleFunc("POST /api/v1/Settings/plex", m.wrap(m.settingsPost))
mux.HandleFunc("GET /api/v1/Emby/users", m.wrap(m.fixed(`[{"id":"e-1","username":"emb"}]`)))
mux.HandleFunc("POST /api/v1/Emby/info", m.wrap(m.fixed(`{"id":"emby-guid","serverName":"Emby One","version":"4.8.0"}`)))
mux.HandleFunc("POST /api/v1/Emby/Library", m.wrap(m.fixed(`{"items":[{"name":"Movies","serverId":"emby-guid","id":"f137","collectionType":"movies"}]}`)))
mux.HandleFunc("GET /api/v1/Request/movie/search/{q}", m.wrap(m.malformed))
mux.HandleFunc("GET /api/v1/Request/count", m.wrap(m.fixed(`{"pending":3,"approved":2,"available":5,"denied":1}`)))
mux.HandleFunc("GET /api/v1/Request/movie/total", m.wrap(m.fixed(`7`)))
mux.HandleFunc("GET /api/v1/Identity", m.wrap(m.json(mockUser("u-self", "mockuser"))))
mux.HandleFunc("GET /api/v1/Identity/Users", m.wrap(m.json([]any{
mockUser("u-1", "alice"), mockUser("u-2", "bob"),
})))
mux.HandleFunc("GET /api/v1/Identity/User/{id}", m.wrap(m.userGet))
mux.HandleFunc("GET /api/v1/Radarr/Profiles", m.wrap(m.fixed(`[{"id":6,"name":"HD","weight":7,"enabled":false}]`)))
mux.HandleFunc("GET /api/v2/Search/Genres/{media}", m.wrap(m.fixed(`[{"id":53,"name":"Thriller"}]`)))
mux.HandleFunc("GET /api/v2/Search/Languages", m.wrap(m.fixed(`[{"iso_639_1":"ky","english_name":"??????","name":"Кыргызча"},{"iso_639_1":"zz","english_name":"??????","name":"??????"}]`)))
mux.HandleFunc("GET /api/v1/TheMovieDb/WatchProviders/{media}", m.wrap(m.fixed(`[{"provider_id":8,"provider_name":"Netflix","logo_path":"/x.png"}]`)))
mux.HandleFunc("GET /api/v1/Sonarr/RootFolders", m.wrap(m.fixed(`[{"id":21,"path":"/media/tv","freespace":9e9}]`)))
mux.HandleFunc("GET /api/v1/Plex/servers", m.wrap(m.fixed(`{"success":true,"servers":[{"serverId":7,"machineId":"mach-plex-1","serverName":"Main Plex"}]}`)))
mux.HandleFunc("GET /api/v1/Plex/Libraries/{machineId}", m.wrap(m.plexLibraries))
mux.HandleFunc("GET /api/v1/Plex/friends", m.wrap(m.fixed(`[{"id":"pf-1","username":"plexfriend"}]`)))
mux.HandleFunc("GET /api/v1/Plex/WatchlistUsers", m.wrap(m.fixed(`[{"userId":"w-1","userName":"wl","syncStatus":1}]`)))
mux.HandleFunc("/api/v1/", m.wrap(m.catchAll)) // fallthrough: 404
m.Server = httptest.NewServer(mux)
t.Cleanup(m.Server.Close)
return m
}
// --- auth plumbing ---
// mintToken issues and stores the next accepted Bearer token
// (jwt mode). Callers hold m.mu.
func (m *mockOmbi) mintToken() string {
m.tokenCalls++
hdr := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`))
exp := time.Now().Add(time.Hour).Unix()
pl := base64.RawURLEncoding.EncodeToString(
[]byte(fmt.Sprintf(`{"sub":"%s","exp":%d,"seq":%d}`, m.user, exp, m.tokenCalls)))
m.token = hdr + "." + pl + ".mocksig"
return m.token
}
// currentToken returns the currently accepted Bearer token.
func (m *mockOmbi) currentToken() string {
m.mu.Lock()
defer m.mu.Unlock()
return m.token
}
// expireToken rotates the accepted token so the next authenticated
// request 401s, forcing the client to re-login.
func (m *mockOmbi) expireToken() {
m.mu.Lock()
m.token = "expired." + m.token
m.mu.Unlock()
}
func (m *mockOmbi) handleToken(w http.ResponseWriter, r *http.Request) {
m.record(r)
if m.mode != "jwt" {
http.Error(w, `{"errorMessage":"token endpoint disabled"}`, http.StatusNotFound)
return
}
var body struct {
Username string `json:"username"`
Password string `json:"password"`
}
b, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(b, &body)
if body.Username != m.user || body.Password != m.pass {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`{"errorMessage":"Invalid credentials"}`))
return
}
m.mu.Lock()
tok := m.mintToken()
m.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"access_token":%q,"expiration":%q}`,
tok, time.Now().Add(time.Hour).UTC().Format(time.RFC3339))
}
// wrap records the request and enforces the configured auth mode.
func (m *mockOmbi) wrap(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
m.record(r)
if m.mode == "jwt" {
if r.Header.Get("Authorization") != "Bearer "+m.currentToken() {
m.jsonErr(w, http.StatusUnauthorized, "Token is invalid or expired")
return
}
} else {
if r.Header.Get("ApiKey") != m.apiKey {
m.jsonErr(w, http.StatusUnauthorized, "Invalid API key")
return
}
if m.userName != "" && r.Header.Get("UserName") != m.userName {
m.jsonErr(w, http.StatusForbidden, "UserName binding mismatch")
return
}
}
h(w, r)
}
}
func (m *mockOmbi) record(r *http.Request) {
var body json.RawMessage
if r.Body != nil {
b, _ := io.ReadAll(r.Body)
// Restore so the handler sees the body too.
r.Body = io.NopCloser(bytes.NewReader(b))
if len(b) > 0 {
body = json.RawMessage(b)
}
}
m.mu.Lock()
m.reqs = append(m.reqs, recordedReq{
Method: r.Method, Path: r.URL.Path, Body: body, Header: r.Header.Clone(),
})
m.mu.Unlock()
}
func (m *mockOmbi) jsonErr(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
fmt.Fprintf(w, `{"errorMessage":%q,"title":%q}`, msg, msg)
}
func (m *mockOmbi) fixed(body string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(body))
}
}
func (m *mockOmbi) json(v any) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}
}
func (m *mockOmbi) catchAll(w http.ResponseWriter, r *http.Request) {
m.jsonErr(w, http.StatusNotFound, "The resource was not found")
}
// requests returns the recorded upstream calls in order.
func (m *mockOmbi) requests() []recordedReq {
m.mu.Lock()
defer m.mu.Unlock()
return append([]recordedReq{}, m.reqs...)
}
// lastBody decodes the body of the most recent call matching
// method+pathPrefix, failing the test when absent.
func (m *mockOmbi) lastBody(t *testing.T, method, prefix string) map[string]any {
t.Helper()
m.mu.Lock()
defer m.mu.Unlock()
for i := len(m.reqs) - 1; i >= 0; i-- {
r := m.reqs[i]
if r.Method == method && strings.HasPrefix(r.Path, prefix) {
var out map[string]any
if err := json.Unmarshal(r.Body, &out); err != nil {
t.Fatalf("%s %s body not JSON: %v", method, prefix, err)
}
return out
}
}
t.Fatalf("no recorded %s %s call", method, prefix)
return nil
}
func (m *mockOmbi) countCalls(method, prefix string) int {
m.mu.Lock()
defer m.mu.Unlock()
n := 0
for _, r := range m.reqs {
if r.Method == method && strings.HasPrefix(r.Path, prefix) {
n++
}
}
return n
}
func (m *mockOmbi) tokenCallCount() int {
m.mu.Lock()
defer m.mu.Unlock()
return m.tokenCalls
}
// --- fixtures ---
func mockAbout() map[string]any {
return map[string]any{
"version": "4.43.5", "branch": "develop",
"frameworkDescription": ".NET 8.0.4",
"osDescription": "Linux 6.1", "osArchitecture": "X64",
"processArchitecture": "X64", "ombiDatabaseType": "SQLite",
"externalDatabaseType": "", "settingsDatabaseType": "SQLite",
"notSupported": false,
// Undocumented extras the projection must ignore.
"applicationUrl": "https://secret-internal.example",
"databaseConnectionString": "Data Source=/secret/ombi.db",
"extraFutureField": map[string]any{"a": 1},
}
}
func mockUser(id, name string) map[string]any {
return map[string]any{
"id": id, "userName": name, "alias": name + " alias",
"language": "en", "streamingCountry": "US",
"emailAddress": name + "@example.internal",
"passwordHash": "SUPERSECRETHASH-that-must-not-leak",
"claims": []any{}, "userType": 1, "userAccess": 3,
}
}
// mockMovieDetail: SearchMovieViewModel-ish with extra fields.
func mockMovieDetail() map[string]any {
return map[string]any{
// Live MovieFullInfoViewModel carries TMDB in `id` and often
// omits theMovieDbId — the projection must fall back to id.
"id": 27205, "imdbId": "tt1375666",
"title": "Inception", "originalTitle": "Inception",
"overview": "A thief who steals corporate secrets.",
"releaseDate": "2010-07-15T00:00:00", "status": "Released",
"available": false, "requested": false, "approved": false,
"requestId": 0, "posterPath": "/9gk7adHYeDvHkCSEqAvQNLV5Uge.jpg",
"backdropPath": "/s3TBrRGB1iav7gFOCNx3H31MoES.jpg",
"voteAverage": 8.4, "voteCount": 35000, "popularity": 42.5,
"genres": []any{
map[string]any{"id": 28, "name": "Action"},
map[string]any{"id": 878, "name": "Science Fiction"},
},
"cast": []any{
map[string]any{"id": 6193, "person": "Leonardo DiCaprio", "character": "Cobb"},
},
"crew": []any{
map[string]any{"id": 525, "name": "Christopher Nolan", "department": "Directing", "job": "Director"},
},
"externalIds": map[string]any{"imdbId": "tt1375666"},
"belongsToCollection": map[string]any{
"id": 8091, "name": "Alien Collection",
},
// Undocumented upstream fields:
"productionCompanies": []any{map[string]any{"id": 1, "name": "Legendary"}},
"videos": map[string]any{"results": []any{}},
"watchProviders": map[string]any{"US": []any{}},
"undocumentedFuture": map[string]any{"nested": []any{1, 2, 3}},
"runtimeNewField": "some-value",
}
}
// mockTVDetail: SearchFullInfoTvShowViewModel-ish; seasonRequests feed
// both the seasons projection and the write expansion path.
func mockTVDetail() map[string]any {
return map[string]any{
// Live v2 moviedb payloads put the TMDB id in seriesId too;
// that must not be labelled tvmaze.
"id": 1396, "theMovieDbId": 1396, "theTvDbId": 81189,
"seriesId": 1396,
"title": "Breaking Bad", "name": "Breaking Bad",
"overview": "A chemistry teacher turns to cooking meth.",
"firstAired": "2008-01-20T00:00:00", "status": "Ended",
"available": false, "requested": false, "fullyAvailable": false,
"posterPath": "/ggFHVNu6YYI5L9pCfOacjizRGt.jpg",
"backdropPath": "/tsRy63Mu5cu8etL1X7ZLyf7UP1M.jpg",
"banner": "/tsRy63Mu5cu8etL1X7ZLyf7UP1M.jpg",
"genres": []any{map[string]any{"id": 18, "name": "Drama"}},
"cast": []any{
map[string]any{"id": 17419, "person": "Bryan Cranston", "character": "Walter White"},
},
"externalIds": map[string]any{"imdbId": "tt0903747", "tvdbId": 81189},
"seasonRequests": []any{
map[string]any{
"seasonNumber": 0,
"episodes": []any{map[string]any{"episodeNumber": 1, "title": "Special"}},
},
map[string]any{
"seasonNumber": 1,
"episodes": []any{
map[string]any{"episodeNumber": 1, "title": "Pilot", "available": true},
map[string]any{"episodeNumber": 2, "title": "Cat's in the Bag...", "available": true},
map[string]any{"episodeNumber": 3, "title": "...And the Bag's in the River"},
map[string]any{"episodeNumber": 4, "title": "Cancer Man"},
map[string]any{"episodeNumber": 5, "title": "Gray Matter"},
map[string]any{"episodeNumber": 6, "title": "Crazy Handful of Nothin'"},
map[string]any{"episodeNumber": 7, "title": "A No-Rough-Stuff-Type Deal"},
},
},
map[string]any{
"seasonNumber": 2,
"episodes": []any{
map[string]any{"episodeNumber": 1, "title": "Seven Thirty-Seven"},
map[string]any{"episodeNumber": 2, "title": "Grilled"},
map[string]any{"episodeNumber": 3, "title": "Bit by a Dead Bee"},
map[string]any{"episodeNumber": 4, "title": "Down"},
},
},
},
"networks": []any{map[string]any{"name": "AMC"}},
"createdBy": []any{map[string]any{"name": "Vince Gilligan"}},
"episodeRunTime": []any{49},
"undocumentedTVField": []any{"x", "y"},
"futureObject": map[string]any{"deeply": map[string]any{"nested": true}},
}
}
// mockTVInfoV1: SearchTvShowViewModel-ish as served by the legacy
// TVMaze-backed v1 routes. Here theMovieDbId carries the TVDB id
// (ProcessResult copies Id←externals.thetvdb into it), seriesId is
// the TVMaze id, genres arrive as a `genre` string list and there is
// no `genres`/`cast` object array.
func mockTVInfoV1() map[string]any {
return map[string]any{
"id": 81189, "theMovieDbId": 81189, "seriesId": 169,
"theTvDbId": 81189, "imdbId": "tt0903747",
"title": "Breaking Bad", "name": "Breaking Bad",
"overview": "A chemistry teacher turns to cooking meth.",
"firstAired": "2008-01-20T00:00:00", "status": "Ended",
// Live sends requestId 0 (not absent) on unrequested shows.
"available": false, "requested": false, "requestId": 0, "fullyAvailable": false,
"poster": "/ggFHVNu6YYI5L9pCfOacjizRGt.jpg",
"banner": "/tsRy63Mu5cu8etL1X7ZLyf7UP1M.jpg",
"genre": []any{"Crime", "Drama", "Thriller"},
"seasonRequests": []any{
map[string]any{
"seasonNumber": 1,
"episodes": []any{
map[string]any{"episodeNumber": 1, "title": "Pilot", "available": true},
map[string]any{"episodeNumber": 2, "title": "Cat's in the Bag..."},
map[string]any{"episodeNumber": 3, "title": "...And the Bag's in the River"},
},
},
map[string]any{
"seasonNumber": 2,
"episodes": []any{
map[string]any{"episodeNumber": 1, "title": "Seven Thirty-Seven"},
map[string]any{"episodeNumber": 2, "title": "Grilled"},
},
},
},
"network": "AMC",
"undocumentedTVV1Field": map[string]any{"x": 1},
}
}
// mockTVSearchHit: one SearchTvShowViewModel entry for the v1 tv
// search route — same TVMaze id layout as mockTVInfoV1 without the
// season structure.
func mockTVSearchHit() map[string]any {
return map[string]any{
"id": 81189, "theMovieDbId": 81189, "seriesId": 169,
"imdbId": "tt0903747",
"title": "Breaking Bad", "name": "Breaking Bad",
"overview": "A chemistry teacher turns to cooking meth.",
"firstAired": "2008-01-20T00:00:00",
"available": false, "requested": false,
"poster": "/ggFHVNu6YYI5L9pCfOacjizRGt.jpg",
"genre": []any{"Crime", "Drama"},
"extraSearchField": []any{"z"},
}
}
func mockMovieRequest() map[string]any {
return map[string]any{
"id": 10, "requestId": 10, "theMovieDbId": 27205, "imdbId": "tt1375666",
"title": "Inception", "approved": true, "available": true,
"denied": false, "is4kRequest": false, "approved4K": false,
"available4K": false, "denied4K": false, "subscribed": true,
"canApprove": false, "requestStatus": "Common.UI.Available",
"requestedDate": "2026-09-01T10:00:00", "requestedUserId": "u-self",
"markedAsApproved": "2026-09-01T10:05:00",
// Undocumented extras:
"qualityOverrideTitle": "HD-1080p", "rootFolder": "/movies",
"issues": []any{}, "requestSource": 0,
"newHiddenField": "should-be-ignored",
}
}
func mockTVChild() map[string]any {
return map[string]any{
"id": 77, "parentRequestId": 12, "title": "Breaking Bad",
"approved": true, "available": false, "denied": false,
"requestedDate": "2026-09-05T12:00:00", "requestedUserId": "u-1",
"requestStatus": "Common.UI.Pending", "subscribed": false,
"canApprove": true, "seriesType": 0, "issueId": nil,
"seasonRequests": []any{
map[string]any{
"seasonNumber": 1,
"episodes": []any{
map[string]any{"episodeNumber": 1, "title": "Pilot", "available": true},
map[string]any{"episodeNumber": 2, "title": "Cat's in the Bag...", "requested": true},
},
},
},
// ChildRequests carries no top-level provider ids upstream —
// they live on the embedded parent navigation property.
"parentRequest": map[string]any{
"id": 12, "tvDbId": 81189,
"externalProviderId": 1396, "imdbId": "tt0903747",
},
"childFieldFuture": []any{1, 2},
}
}
func mockIssue(id int, status int) map[string]any {
return map[string]any{
"id": id, "subject": "Playback stops at 12 minutes",
"title": "Inception", "description": "Transcode failure.",
"issueCategoryId": 2, "status": status, "requestType": 1,
"requestId": 10, "providerId": "27205",
"createdDate": "2026-09-10T08:30:00",
"resovledDate": nil, // upstream typo preserved in fixture
"userReportedId": "u-2",
"comments": []any{}, "issueCategory": map[string]any{"id": 2, "value": "Playback"},
"undocumentedIssueField": "x",
}
}
func mockCustomization() map[string]any {
return map[string]any{
"applicationName": "Ombi", "logo": "/img/logo.png",
"favicon": "/img/favicon.ico", "preset": "",
"peopleToFollow": []any{}, "themes": []any{},
"customPage": map[string]any{"enabled": false, "content": ""},
// Fields the projection must omit and the patch must preserve:
"applicationUrl": "https://ombi.internal.example",
"secretToken": "tok-should-not-project",
"apiKey": "key-should-not-project",
}
}
// mockDiscordSettings carries notificationTemplates with upstream
// integer agent/type codes — the patch path must translate labels
// back to ints on write (T3/T4).
func mockDiscordSettings() map[string]any {
return map[string]any{
"enabled": false, "botName": "Ombi", "webhookUrl": "https://discord.invalid/secret-wh",
"username": "ombi-bot", "avatar": "",
"notificationTemplates": []any{
map[string]any{
"notificationType": 0, "agent": 1,
"subject": "New request", "message": "{RequestedBy} added {Title}",
},
},
}
}
func mockMediaServerSettings(name, serverGuid string, serverID int) map[string]any {
return map[string]any{
"enable": true,
"id": 9,
"servers": []any{
map[string]any{
"id": serverID,
"serverId": serverGuid,
"name": name,
"apiKey": "SECRET",
"administratorId": "a",
"ip": "10.0.0.5",
"port": 8096,
},
},
}
}
// --- handlers ---
func (m *mockOmbi) movieList(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"collection": []any{mockMovieRequest()},
"total": 1,
// Undocumented page-wrapper fields:
"extraPageField": true, "facets": map[string]any{},
})
}
func (m *mockOmbi) tvParentList(w http.ResponseWriter, r *http.Request) {
pos := r.PathValue("pos")
if pos != "0" {
m.json(map[string]any{"collection": []any{}, "total": 1})(w, r)
return
}
// RequestsViewModel<TvRequests>: the v1 paged route wraps rows in
// {collection,total} — scans that decode a bare array break on live.
m.json(map[string]any{
"collection": []any{
map[string]any{
"id": 42,
"tvDbId": 81189,
"externalProviderId": 1396,
"imdbId": "tt0903747",
"title": "trigger-500 and something", // For search fallback test
"childRequests": []any{
map[string]any{
// A real (non-provider-shaped) child request
// id — recent rows reference this in requestId.
"id": 88,
"seasonRequests": []any{
map[string]any{
"childRequestId": 88,
"seasonNumber": 1,
"episodes": []any{
map[string]any{"episodeNumber": 1, "requested": true},
map[string]any{"episodeNumber": 2, "requested": true},
map[string]any{"episodeNumber": 3, "requested": true},
},
},
},
},
},
},
map[string]any{
// Upstream persists the provider id as the child PK
// for new-request children — this child's id is the
// parent's tvDbId, exactly like live rows.
"id": 909,
"tvDbId": 259032,
"externalProviderId": 118680,
"imdbId": "tt1439629",
"title": "Seven Up!",
"childRequests": []any{
map[string]any{
"id": 259032,
"seasonRequests": []any{
map[string]any{
"childRequestId": 259032,
"seasonNumber": 1,
"episodes": []any{},
},
},
},
},
},
},
"total": 2,
})(w, r)
}
func (m *mockOmbi) tvChildren(w http.ResponseWriter, r *http.Request) {
if r.PathValue("id") != "12" {
m.jsonErr(w, http.StatusNotFound, "Parent request not found")
return
}
m.json([]any{mockTVChild()})(w, r)
}
func (m *mockOmbi) tvList(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"collection": []any{mockTVChild()}, "total": 1, "newField": []any{},
})
}
func (m *mockOmbi) movieDetails(w http.ResponseWriter, r *http.Request) {
switch r.PathValue("id") {
case "27205":
m.json(mockMovieDetail())(w, r)
case "429000":
w.Header().Set("Retry-After", "30")
m.jsonErr(w, http.StatusTooManyRequests, "Rate limit exceeded")
case "500000":
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("<html><body><h1>500 Internal Server Error</h1>" +
"System.NullReferenceException at Ombi.Core.Engine.MovieRequestEngine" +
"Authorization: Bearer should-never-appear</body></html>"))
case "500001":
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(`{"error": "Object reference not set to an instance of an object. System.NullReferenceException"}`))
default:
m.jsonErr(w, http.StatusNotFound, "Movie not found")
}
}
func (m *mockOmbi) movieInfo(w http.ResponseWriter, r *http.Request) {
if r.PathValue("id") == "404000" {
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("<html><head><title>IIS 404</title></head>" +
"<body>Physical path C:\\inetpub\\ombi\\secret</body></html>"))
return
}
m.json(mockMovieRequest())(w, r)
}
func (m *mockOmbi) tvDetailsTMDB(w http.ResponseWriter, r *http.Request) {
if r.PathValue("id") != "1396" {
m.jsonErr(w, http.StatusNotFound, "TV show not found")
return
}
m.json(mockTVDetail())(w, r)
}
func (m *mockOmbi) tvDetailsTVDB(w http.ResponseWriter, r *http.Request) {
if r.PathValue("id") != "81189" {
m.jsonErr(w, http.StatusNotFound, "TV show not found")
return
}
m.json(mockTVDetail())(w, r)
}
// tvInfoTVDB serves the legacy v1 TVMaze info route — the only real
// TVDB-keyed TV details route.
func (m *mockOmbi) tvInfoTVDB(w http.ResponseWriter, r *http.Request) {
if r.PathValue("id") != "81189" {
m.jsonErr(w, http.StatusNotFound, "TV show not found")
return
}
m.json(mockTVInfoV1())(w, r)
}
func (m *mockOmbi) tvSearch(w http.ResponseWriter, r *http.Request) {
if r.PathValue("term") == "trigger-500" {
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("<html><body><h1>500 Internal Server Error</h1>" +
"System.NullReferenceException at Ombi.Core.Engine.TvRequestEngine</body></html>"))
return
}
m.json([]any{mockTVSearchHit()})(w, r)
}
// tvBrowse is the v2 TMDB-keyed popular/anticipated/trending list —
// members carry the provider id only in `id`.
func (m *mockOmbi) tvBrowse(w http.ResponseWriter, r *http.Request) {
m.json([]any{
map[string]any{
"id": 1668, "title": "Reacher",
"overview": "A former military policeman.",
"posterPath": "/reacher.jpg",
},
})(w, r)
}
func (m *mockOmbi) movieCollection(w http.ResponseWriter, r *http.Request) {
m.json(map[string]any{
"id": 8091, "name": "Alien Collection", "overview": "In space…",
"collection": []any{
map[string]any{
"id": 348, "title": "Alien",
"releaseDate": "1979-05-25T00:00:00",
"posterPath": "/alien.jpg",
},
},
})(w, r)
}
func (m *mockOmbi) emptyBrowse(w http.ResponseWriter, r *http.Request) {
m.json([]any{})(w, r)
}
func (m *mockOmbi) actorMovieCredits(w http.ResponseWriter, r *http.Request) {
m.json(map[string]any{
"id": r.PathValue("id"),
"cast": []any{map[string]any{
"id": 27205, "title": "Inception", "overview": "A thief who steals corporate secrets.",
"release_date": "2010-07-15", "poster_path": "/inception.jpg", "character": "Cobb",
}},
"crew": []any{map[string]any{
"id": 27205, "title": "Inception", "job": "Producer", "department": "Production",
}},
})(w, r)
}
func (m *mockOmbi) actorTVCredits(w http.ResponseWriter, r *http.Request) {
// Ombi's ActorCredits DTO discards TMDB's TV name/original_name fields.
m.json(map[string]any{
"id": r.PathValue("id"),
"cast": []any{map[string]any{
"id": 1396, "character": "Walter White", "overview": "A chemistry teacher.",
}},
})(w, r)
}
func (m *mockOmbi) advancedMovie(w http.ResponseWriter, r *http.Request) {
m.json([]any{mockMovieDetail()})(w, r)
}
func (m *mockOmbi) multiSearch(w http.ResponseWriter, r *http.Request) {
m.json([]any{
map[string]any{
"id": "8dc08e7c-41f1-4a04-97dc-eb00d91d1d2f",
"mediaType": "Artist", "title": "Radiohead",
"poster": "/radiohead.jpg",
},
})(w, r)
}
func (m *mockOmbi) recentlyRequested(w http.ResponseWriter, r *http.Request) {
m.json([]any{
map[string]any{
"requestId": 2207, "type": 1, "title": "Yummy",
"mediaId": "12345", "userId": "u-1",
"requestDate": "2026-09-01T10:00:00",
},
map[string]any{
// Live TV rows carry the CHILD request id in requestId —
// for new-request children upstream persists the provider
// id as the child PK — and the parent's externalProviderId
// in mediaId. The parent request id is absent entirely.
"requestId": 259032, "type": 0,
"title": "Seven Up!", "mediaId": "118680",
"userId": "u-1", "requestDate": "2026-09-02T10:00:00",
},
map[string]any{
// A real (non-provider-shaped) child id — resolvable only
// via the parent's embedded childRequests[].id.
"requestId": 88, "type": 0,
"title": "trigger-500 and something", "mediaId": "1396",
"userId": "u-2", "requestDate": "2026-09-03T10:00:00",
},
map[string]any{
// Unresolvable: no requestId, no parent match; a stray
// provider-shaped `id` must never occupy target.id.
"id": 111, "type": 0, "title": "Provider Only",
"mediaId": "111", "userId": "u-1",
"requestDate": "2026-09-04T10:00:00",
},
})(w, r)
}
func (m *mockOmbi) createTV(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"result": true, "requestId": 555, "isError": false,
"message": "Request for Breaking Bad has been added successfully",
"errorMessage": "", "errorCode": 0,
"newEngineField": "undocumented",
})
}
func (m *mockOmbi) createMovie(w http.ResponseWriter, r *http.Request) {
var body map[string]any
b, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(b, &body)
w.Header().Set("Content-Type", "application/json")
if body["theMovieDbId"] == float64(666) {
// HTTP 200 carrying a business failure.
json.NewEncoder(w).Encode(map[string]any{
"result": false, "isError": true, "requestId": nil,
"errorMessage": "Movie has already been requested",
"errorCode": 12, "message": "",
})
return
}
json.NewEncoder(w).Encode(map[string]any{
"result": true, "requestId": 42, "isError": false,
"message": "Request for Inception has been added successfully",
})
}
func (m *mockOmbi) issueList(w http.ResponseWriter, r *http.Request) {
m.json([]any{mockIssue(7, 1), mockIssue(8, 99)})(w, r) // 99 = unmapped enum
}
func (m *mockOmbi) issueGet(w http.ResponseWriter, r *http.Request) {
if r.PathValue("id") != "7" {
m.jsonErr(w, http.StatusNotFound, "Issue not found")
return
}
m.json(mockIssue(7, 2))(w, r)
}
func (m *mockOmbi) issueComments(w http.ResponseWriter, r *http.Request) {
m.json([]any{
map[string]any{"id": 3, "issuesId": 7, "comment": "Still broken",
"userId": "u-2", "date": "2026-09-11T09:00:00", "extra": 1},
})(w, r)
}
func (m *mockOmbi) settingsGet(w http.ResponseWriter, r *http.Request) {
m.mu.Lock()
doc := m.settings[r.URL.Path]
m.mu.Unlock()
m.json(doc)(w, r)
}
func (m *mockOmbi) settingsPost(w http.ResponseWriter, r *http.Request) {
var body map[string]any
b, _ := io.ReadAll(r.Body)
if err := json.Unmarshal(b, &body); err != nil {
m.jsonErr(w, http.StatusBadRequest, "invalid settings body")
return
}
m.mu.Lock()
m.settings[r.URL.Path] = body
m.mu.Unlock()
m.fixed(`true`)(w, r)
}
// malformed returns a non-JSON body to exercise the
// UPSTREAM_SCHEMA_MISMATCH path on an otherwise-200 response.
func (m *mockOmbi) malformed(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`<<<not json at all`))
}
func (m *mockOmbi) userGet(w http.ResponseWriter, r *http.Request) {
switch r.PathValue("id") {
case "u-1":
m.json(mockUser("u-1", "alice"))(w, r)
case "u-2":
m.json(mockUser("u-2", "bob"))(w, r)
default:
m.jsonErr(w, http.StatusNotFound, "User not found")
}
}
func (m *mockOmbi) plexLibraries(w http.ResponseWriter, r *http.Request) {
if r.PathValue("machineId") == "broken" {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"successful":false,"message":"could not reach server"}`))
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"successful":true,"data":[{"id":"3","key":"3","type":"show","title":"TV Shows"},{"id":"1","key":"1","type":"movie","title":"Movies"}]}`))
}
func (m *mockOmbi) stats(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("from") == "" || r.URL.Query().Get("to") == "" {
m.jsonErr(w, http.StatusInternalServerError, "Object reference not set to an instance of an object")
return
}
m.fixed(`{"totalRequests":9,"totalMovieRequests":4,"totalTvRequests":5,"totalIssues":0,"completedRequestsMovies":2,"completedRequestsTv":1,"completedRequests":3}`)(w, r)
}
func (m *mockOmbi) testcron(w http.ResponseWriter, r *http.Request) {
var body struct {
Expression string `json:"expression"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
m.jsonErr(w, http.StatusBadRequest, "invalid JSON")
return
}
fields := strings.Fields(body.Expression)
valid := len(fields) >= 6 && len(fields) <= 7 &&
(fields[3] == "?") != (fields[5] == "?")
if valid {
m.fixed(`{"success":true}`)(w, r)
return
}
m.fixed(fmt.Sprintf(`{"success":false,"message":%q}`, "CRON Expression "+body.Expression+" is not valid"))(w, r)
}
func (m *mockOmbi) logsRead(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Write([]byte("2026-09-19 10:00:00 [INF] boot\n2026-09-19 10:01:00 [INF] tick\n"))
}
// mockEnv returns the server env pointing at this mock in the
// requested mode, plus all three bundles.
func (m *mockOmbi) env() map[string]string {
env := map[string]string{
"OMBI_URL": m.URL,
"OMBI_BUNDLES": "core,moderation,administration",
}
if m.mode == "jwt" {
env["OMBI_AUTH_MODE"] = "jwt"
env["OMBI_USERNAME"] = m.user
env["OMBI_PASSWORD"] = m.pass
} else {
env["OMBI_AUTH_MODE"] = "api_key"
env["OMBI_API_KEY"] = m.apiKey
if m.userName != "" {
env["OMBI_USER_NAME"] = m.userName
}
}
return env
}