Files
ombi-mcp/internal/tools/project_test.go
gronod 40155b5981 Resolve recent TV rows to real tv_parent request ids
Closes #11. Ombi builds recentlyRequested TV rows from child requests,
so requestId is a child id — and upstream persists the provider id as
the child PK for new-request children, so the value is provider-shaped
(TVDB/TMDB) while the parent request id never appears in the payload.
Consumers following target into read_requests get hit upstream 500s.

TV rows now resolve through one bounded v1 parent scan: an exact
child-id match against embedded childRequests (authoritative), then a
provider-id fallback against tvDbId/externalProviderId covering
versions that put the provider id in requestId directly. The child id
is preserved as an ombi_tv_child identifier, provider ids land under
tvdb/tmdb/imdb, and unresolvable rows emit target.id 0 with a warning
rather than a fabricated or provider-shaped target.
2026-09-19 16:15:18 +01:00

335 lines
9.5 KiB
Go

package tools
import (
"testing"
)
func idMap(ids []Identifier) map[string]string {
out := map[string]string{}
for _, id := range ids {
out[id.Namespace] = id.Value
}
return out
}
func countNS(ids []Identifier, ns string) int {
n := 0
for _, id := range ids {
if id.Namespace == ns {
n++
}
}
return n
}
func TestProjectSearchMediaMovieFallsBackToID(t *testing.T) {
m := projectSearchMedia(map[string]any{
"id": 348, "title": "Alien", "imdbId": "tt0078748",
}, "movie", "tmdb")
ids := idMap(m.Identifiers)
if ids["tmdb"] != "348" || ids["imdb"] != "tt0078748" {
t.Fatalf("identifiers = %v", m.Identifiers)
}
}
func TestProjectFullMovieDedupsImdbAndSkipsCollectionID(t *testing.T) {
m := projectFullMovie(map[string]any{
"id": 348, "title": "Alien", "imdbId": "tt0078748",
"externalIds": map[string]any{"imdbId": "tt0078748"},
"belongsToCollection": map[string]any{"id": 8091, "name": "Alien Collection"},
})
ids := idMap(m.Identifiers)
if ids["tmdb"] != "348" {
t.Errorf("tmdb = %q, want 348 (not collection 8091)", ids["tmdb"])
}
if _, ok := ids["tmdb"]; ok && countNS(m.Identifiers, "tmdb") != 1 {
t.Errorf("tmdb emitted %d times: %v", countNS(m.Identifiers, "tmdb"), m.Identifiers)
}
if countNS(m.Identifiers, "imdb") != 1 {
t.Errorf("imdb emitted %d times: %v", countNS(m.Identifiers, "imdb"), m.Identifiers)
}
}
func TestProjectSearchMediaTVV2IDFallbackIsTMDBNotTVMaze(t *testing.T) {
m := projectSearchMedia(map[string]any{
"id": 1668, "title": "Reacher", "seriesId": 1668,
}, "tv", "tmdb")
ids := idMap(m.Identifiers)
if ids["tmdb"] != "1668" {
t.Errorf("tmdb = %q", ids["tmdb"])
}
if _, ok := ids["tvmaze"]; ok {
t.Errorf("v2 seriesId must not be labelled tvmaze: %v", m.Identifiers)
}
if _, ok := ids["tvdb"]; ok {
t.Errorf("unexpected tvdb: %v", m.Identifiers)
}
}
func TestProjectSearchMediaTVV1KeepsTVMazeAndDoesNotInventTMDB(t *testing.T) {
m := projectSearchMedia(map[string]any{
"id": 81189, "theMovieDbId": 81189, "seriesId": 169,
"imdbId": "tt0903747", "title": "Breaking Bad",
}, "tv", "tvdb")
ids := idMap(m.Identifiers)
if ids["tvdb"] != "81189" || ids["tvmaze"] != "169" || ids["imdb"] != "tt0903747" {
t.Errorf("identifiers = %v", m.Identifiers)
}
if _, ok := ids["tmdb"]; ok {
t.Errorf("v1 id fallback must not emit tmdb: %v", m.Identifiers)
}
}
func TestProjectFullTVTMDBDoesNotEchoSeriesIdAsTVMaze(t *testing.T) {
o := &op{}
m := o.projectFullTV(map[string]any{
"id": 1206, "theMovieDbId": 1206, "seriesId": 1206,
"theTvDbId": 75150, "imdbId": "tt0227882",
"externalIds": map[string]any{"imdbId": "tt0227882", "tvdbId": 75150},
"title": "Button Moon",
}, "tmdb")
ids := idMap(m.Identifiers)
if ids["tmdb"] != "1206" || ids["tvdb"] != "75150" || ids["imdb"] != "tt0227882" {
t.Errorf("identifiers = %v", m.Identifiers)
}
if v, ok := ids["tvmaze"]; ok {
t.Errorf("bogus tvmaze %q (echoed TMDB id)", v)
}
if countNS(m.Identifiers, "imdb") != 1 {
t.Errorf("imdb emitted %d times: %v", countNS(m.Identifiers, "imdb"), m.Identifiers)
}
}
func TestProjectMultiResultArtistCaseInsensitive(t *testing.T) {
o := &op{}
m := o.projectMultiResult(map[string]any{
"id": "8dc08e7c-41f1-4a04-97dc-eb00d91d1d2f",
"mediaType": "Artist", "title": "Radiohead",
})
if m.Media != "artist" {
t.Errorf("media = %q, want artist", m.Media)
}
ids := idMap(m.Identifiers)
if ids["musicbrainz"] != "8dc08e7c-41f1-4a04-97dc-eb00d91d1d2f" {
t.Errorf("identifiers = %v", m.Identifiers)
}
if _, ok := ids["provider_unknown"]; ok {
t.Errorf("Artist mapped as provider_unknown: %v", m.Identifiers)
}
}
func TestProjectRequestPrefersRequestIDOverProviderID(t *testing.T) {
o := &op{}
r := o.projectRequest(map[string]any{
"id": 259032, "requestId": 88, "title": "Seven Up!",
"mediaId": "259032", "tvDbId": 259032,
}, "tv_parent")
if r.Target.Kind != "tv_parent" || r.Target.ID != 88 {
t.Errorf("target = %+v, want id 88", r.Target)
}
ids := idMap(r.Identifiers)
if ids["tvdb"] != "259032" {
t.Errorf("tvdb identifier = %q", ids["tvdb"])
}
if ids["tmdb"] != "259032" {
t.Errorf("mediaId tmdb identifier = %q", ids["tmdb"])
}
}
func TestProjectRequestListStillUsesEntityID(t *testing.T) {
o := &op{}
r := o.projectRequest(map[string]any{
"id": 10, "theMovieDbId": 27205, "title": "Inception",
}, "movie")
if r.Target.ID != 10 {
t.Errorf("target.id = %d, want 10", r.Target.ID)
}
}
func TestMergeTVRequestState(t *testing.T) {
it := &Media{
Seasons: []SeasonOut{
{
SeasonNumber: 1,
Episodes: []EpisodeOut{
{EpisodeNumber: 1},
{EpisodeNumber: 2},
},
},
},
}
parent := map[string]any{
"id": 909,
"tvDbId": 81189,
"childRequests": []any{
map[string]any{
"seasonRequests": []any{
map[string]any{
"seasonNumber": 1,
"episodes": []any{
map[string]any{"episodeNumber": 1, "requested": true, "available": true},
},
},
},
},
},
}
matched := mergeTVRequestState(it, parent, 81189, "")
if !matched {
t.Fatalf("expected match")
}
if it.Requested == nil || !*it.Requested {
t.Errorf("expected media Requested=true")
}
if len(it.RequestTargets) != 1 || it.RequestTargets[0].ID != 909 {
t.Errorf("expected target ID 909")
}
if it.Seasons[0].Episodes[0].Requested == nil || !*it.Seasons[0].Episodes[0].Requested {
t.Errorf("expected ep1 Requested=true")
}
if it.Seasons[0].Episodes[0].Available == nil || !*it.Seasons[0].Episodes[0].Available {
t.Errorf("expected ep1 Available=true")
}
if it.Seasons[0].Episodes[1].Requested != nil {
t.Errorf("expected ep2 Requested=nil")
}
}
func TestMergeTVRequestStateNoMatch(t *testing.T) {
it := &Media{}
parent := map[string]any{"tvDbId": 99999}
matched := mergeTVRequestState(it, parent, 81189, "")
if matched {
t.Fatalf("expected no match")
}
if it.Requested != nil {
t.Errorf("expected Requested=nil on no match")
}
}
func TestTVParentMatchChildIDBeatsProviderID(t *testing.T) {
parent := map[string]any{
"id": 909, "tvDbId": 259032, "externalProviderId": 118680,
"childRequests": []any{
map[string]any{
"id": 88,
"seasonRequests": []any{
map[string]any{"childRequestId": 88, "seasonNumber": 1},
},
},
},
}
if got := tvParentMatch(parent, tvRecentKey{requestID: 88, mediaID: "1396"}); got != tvMatchChild {
t.Errorf("real child id must match tier 1, got %d", got)
}
if got := tvParentMatch(parent, tvRecentKey{requestID: 259032}); got != tvMatchProvider {
t.Errorf("provider-shaped child pk falls back to tier 2 without embedded child, got %d", got)
}
if got := tvParentMatch(parent, tvRecentKey{requestID: 0, mediaID: "118680"}); got != tvMatchProvider {
t.Errorf("mediaId provider match = %d, want %d", got, tvMatchProvider)
}
if got := tvParentMatch(parent, tvRecentKey{requestID: 777, mediaID: "999"}); got != tvMatchNone {
t.Errorf("unrelated key matched: %d", got)
}
}
func TestTVParentMatchProviderShapedChildPK(t *testing.T) {
// Live rows: upstream persists the provider id as the child PK, so
// childRequests[].id equals tvDbId/externalProviderId.
parent := map[string]any{
"id": 909, "tvDbId": 259032,
"childRequests": []any{
map[string]any{"id": 259032},
},
}
if got := tvParentMatch(parent, tvRecentKey{requestID: 259032}); got != tvMatchChild {
t.Errorf("provider-shaped child id is still an exact tier-1 match, got %d", got)
}
}
func TestRefOfDeterministicValue(t *testing.T) {
o := &op{}
m := map[string]any{
"id": 53,
"name": "Thriller",
"logoPath": "/x",
"enabled": false,
}
for i := 0; i < 50; i++ {
r := o.refOf(m, []string{"id"}, []string{"name"}, nil, "genre")
if r.ID != "53" {
t.Fatalf("iteration %d: id = %q, want \"53\"", i, r.ID)
}
if r.Name != "Thriller" {
t.Fatalf("iteration %d: name = %q, want \"Thriller\"", i, r.Name)
}
if r.Value != 53 {
t.Fatalf("iteration %d: value = %v (%T), want 53", i, r.Value, r.Value)
}
}
}
func TestRefOfValueKeysOverride(t *testing.T) {
o := &op{}
m := map[string]any{
"id": 53,
"name": "Thriller",
"customVal": "override",
}
r := o.refOf(m, []string{"id"}, []string{"name"}, []string{"customVal"}, "genre")
if r.Value != "override" {
t.Fatalf("value = %v, want \"override\"", r.Value)
}
}
func TestRefOfCorruptedNameSkip(t *testing.T) {
o := &op{}
m := map[string]any{
"id": 1,
"english_name": "?????",
"name": "Clean",
}
r := o.refOf(m, []string{"id"}, []string{"english_name", "name"}, nil, "language")
if r.Name != "Clean" {
t.Fatalf("name = %q, want \"Clean\"", r.Name)
}
if len(o.warn) != 0 {
t.Fatalf("unexpected warnings: %v", o.warn)
}
o = &op{}
m2 := map[string]any{
"id": 2,
"english_name": "Bad\uFFFDName",
"name": "CleanAlternate",
}
r2 := o.refOf(m2, []string{"id"}, []string{"english_name", "name"}, nil, "language")
if r2.Name != "CleanAlternate" {
t.Fatalf("name = %q, want \"CleanAlternate\"", r2.Name)
}
if len(o.warn) != 0 {
t.Fatalf("unexpected warnings: %v", o.warn)
}
}
func TestRefOfAllCorruptedEmitsAndWarns(t *testing.T) {
o := &op{}
m := map[string]any{
"id": "ky",
"english_name": "?????",
"name": "?????",
}
r := o.refOf(m, []string{"id"}, []string{"english_name", "name"}, nil, "language")
if r.Name != "?????" {
t.Fatalf("name = %q, want \"?????\"", r.Name)
}
if len(o.warn) == 0 {
t.Fatalf("expected warning for corrupted name, got none")
}
expectedWarn := "upstream language label appears corrupted (id \"ky\")"
if o.warn[0] != expectedWarn {
t.Fatalf("warn = %q, want %q", o.warn[0], expectedWarn)
}
}