Build and publish / Test and build (linux) (pull_request) Canceled after 0s
Build and publish / Test and build (windows) (pull_request) Canceled after 0s
Build and publish / Build and publish Docker image (pull_request) Canceled after 0s
Build and publish / Test and build (darwin) (pull_request) Canceled after 40s
Build and publish / Test and build (darwin) (push) Successful in 2m9s
Build and publish / Test and build (linux) (push) Successful in 2m34s
Build and publish / Test and build (windows) (push) Successful in 3m11s
Build and publish / Build and publish Docker image (push) Successful in 1m55s
v2 request lists sent the RAML example sort field requestDate; Ombi looks up RequestedDate and NullReferenceException'd every non-empty page. Browse now streams TV popular/most-watched payloads and skips the hydrated seasonRequests graph that blew the 8 MiB read budget. provider_summary treats an empty upstream body as an empty group_page.
1052 lines
38 KiB
Go
1052 lines
38 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}/RequestedDate/{order}", m.wrap(m.movieList))
|
|
mux.HandleFunc("GET /api/v2/Requests/tv/{amt}/{pos}/RequestedDate/{order}", m.wrap(m.tvList))
|
|
mux.HandleFunc("GET /api/v2/Requests/movie/{status}/{amt}/{pos}/RequestedDate/{order}", m.wrap(m.movieList))
|
|
mux.HandleFunc("GET /api/v2/Requests/tv/{status}/{amt}/{pos}/RequestedDate/{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/mostwatched/{pos}/{amt}", m.wrap(m.tvBrowseHuge))
|
|
mux.HandleFunc("GET /api/v2/Search/tv/trending/{pos}/{amt}", m.wrap(m.tvBrowse))
|
|
mux.HandleFunc("GET /api/v2/Issues/details/{providerId}", m.wrap(m.issueProviderSummary))
|
|
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/Search/ratings/{media}/{name}/{year}", m.wrap(m.ratingsUnavailable))
|
|
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/Search/movie/{term}", m.wrap(m.movieSearch))
|
|
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/Vote", m.wrap(m.voteListFailure))
|
|
mux.HandleFunc("GET /api/v1/Vote/{media}/{id}", m.wrap(m.voteGet))
|
|
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", "siteRating": 9,
|
|
"available": false, "requested": false,
|
|
"poster": "/ggFHVNu6YYI5L9pCfOacjizRGt.jpg",
|
|
"genre": []any{"Crime", "Drama"},
|
|
"extraSearchField": []any{"z"},
|
|
}
|
|
}
|
|
|
|
func mockMovieSearchHit() map[string]any {
|
|
return map[string]any{
|
|
"id": 348, "title": "Alien", "releaseDate": "1979-05-25T00:00:00",
|
|
"voteAverage": 8.2, "voteCount": 15400,
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
func (m *mockOmbi) movieSearch(w http.ResponseWriter, r *http.Request) {
|
|
m.json([]any{mockMovieSearchHit()})(w, r)
|
|
}
|
|
|
|
func (m *mockOmbi) ratingsUnavailable(w http.ResponseWriter, r *http.Request) {
|
|
m.jsonErr(w, http.StatusInternalServerError, "external ratings provider failed")
|
|
}
|
|
|
|
func (m *mockOmbi) voteListFailure(w http.ResponseWriter, r *http.Request) {
|
|
m.jsonErr(w, http.StatusInternalServerError, "vote view could not be generated")
|
|
}
|
|
|
|
func (m *mockOmbi) voteGet(w http.ResponseWriter, r *http.Request) {
|
|
if r.PathValue("media") == "tv" {
|
|
m.json([]any{})(w, r)
|
|
return
|
|
}
|
|
m.jsonErr(w, http.StatusInternalServerError, "vote records could not be read")
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// tvBrowseHuge emulates HideAvailableFromDiscover season hydration:
|
|
// a compact title plus a seasonRequests graph larger than the
|
|
// default 8 MiB read budget (#27).
|
|
func (m *mockOmbi) tvBrowseHuge(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode([]any{
|
|
map[string]any{
|
|
"id": 1668,
|
|
"title": "Reacher",
|
|
"overview": "A former military policeman.",
|
|
"posterPath": "/reacher.jpg",
|
|
"seasonRequests": []any{
|
|
map[string]any{
|
|
"seasonNumber": 1,
|
|
"overview": strings.Repeat("x", 9<<20),
|
|
},
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
func (m *mockOmbi) issueProviderSummary(w http.ResponseWriter, r *http.Request) {
|
|
if r.PathValue("providerId") == "348" {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
m.json(map[string]any{
|
|
"providerId": r.PathValue("providerId"),
|
|
"title": "The Equalizer",
|
|
"count": 1,
|
|
"issues": []any{
|
|
map[string]any{"id": 1, "title": "Audio missing", "status": 0},
|
|
},
|
|
})(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
|
|
}
|