package api import ( "strings" "testing" "time" ) func TestRequestStatusPrefersAvailabilityOverEverything(t *testing.T) { // A title the household can watch is available whether or not the *arr still tracks it // and whether or not anything thinks it has been released. Being watchable is the // strongest fact there is about a request, so nothing below may override it. cases := []struct { name string subject RequestSubject }{ {"in library, untracked, unreleased", RequestSubject{InLibrary: true}}, {"has file but not imported", RequestSubject{Tracked: true, HasFile: true}}, {"in library and still being worked on", RequestSubject{ Tracked: true, InLibrary: true, Released: true, }}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { if got := requestStatusFor(tc.subject); got != RequestStatusAvailable { t.Fatalf("expected %q, got %q", RequestStatusAvailable, got) } }) } } func TestRequestStatusReportsRemovalBeforeReleaseState(t *testing.T) { // An untracked title's release date says nothing: nobody is working on it either way, // so "unavailable" must win over "pending". Getting this the wrong way round would park // a removed request on "waiting for release" for ever. got := requestStatusFor(RequestSubject{Tracked: false, Released: false}) if got != RequestStatusUnavailable { t.Fatalf("expected %q for an untracked title, got %q", RequestStatusUnavailable, got) } } func TestRequestStatusSeparatesPendingFromSearching(t *testing.T) { pending := requestStatusFor(RequestSubject{Tracked: true, Released: false}) if pending != RequestStatusPending { t.Fatalf("expected %q, got %q", RequestStatusPending, pending) } // Tracked, released, and the download client has never heard of it: nothing has been // found. This is the distinction the whole feature rests on — before it, this case and // a download two minutes from finishing were the same word. searching := requestStatusFor(RequestSubject{Tracked: true, Released: true}) if searching != RequestStatusSearching { t.Fatalf("expected %q, got %q", RequestStatusSearching, searching) } } func TestQueueOutranksTheReleaseState(t *testing.T) { // Radarr's minimum-availability setting decides when to start looking, not whether bytes // are moving. A household that fetches on the cinema date has films that are "not // released" and half downloaded at once, and the honest answer is the half. got := requestStatusFor(RequestSubject{ Tracked: true, Released: false, Progress: requestProgress{Status: RequestStatusDownloading, Progress: 50}, }) if got != RequestStatusDownloading { t.Fatalf("expected %q, got %q", RequestStatusDownloading, got) } // But nothing outranks having it. A queue row against a title already on the shelf is an // upgrade, and a card somebody can press Play on must not read as downloading. got = requestStatusFor(RequestSubject{ Tracked: true, InLibrary: true, Progress: requestProgress{Status: RequestStatusDownloading, Progress: 50}, }) if got != RequestStatusAvailable { t.Fatalf("expected %q, got %q", RequestStatusAvailable, got) } } func TestCollapseNarrowsOnlyTheDownloadStates(t *testing.T) { // An older television is told "processing" for the whole span it used to cover, and // nothing else it understands is disturbed. for _, state := range []string{ RequestStatusSearching, RequestStatusFound, RequestStatusDownloading, RequestStatusFailed, } { if got := collapseRequestStatus(state); got != RequestStatusProcessing { t.Fatalf("%q should collapse to %q, got %q", state, RequestStatusProcessing, got) } } for _, state := range []string{ RequestStatusAvailable, RequestStatusPending, RequestStatusRequested, RequestStatusUnavailable, RequestStatusRequestable, RequestStatusProcessing, } { if got := collapseRequestStatus(state); got != state { t.Fatalf("%q must survive collapsing, got %q", state, got) } } } func TestLookupStatusOffersRequestableOnlyWhenNothingHasIt(t *testing.T) { got := lookupStatusFor(RequestSubject{}, false) if got != RequestStatusRequestable { t.Fatalf("expected %q, got %q", RequestStatusRequestable, got) } // The request list rule must never produce it: there, an untracked title is one somebody // removed rather than one nobody has asked for. if requestStatusFor(RequestSubject{}) == RequestStatusRequestable { t.Fatal("requestStatusFor must not return requestable") } } func TestLookupStatusPutsOwnRequestAboveHouseholdButBelowAvailability(t *testing.T) { // Somebody needs to be told they already asked, before being told the household happens // to track it — that is what stops them pressing the button again. mine := lookupStatusFor(RequestSubject{Tracked: true, Released: true}, true) if mine != RequestStatusRequested { t.Fatalf("expected %q for the viewer's own ask, got %q", RequestStatusRequested, mine) } // But being watchable now is better news than having asked. available := lookupStatusFor(RequestSubject{InLibrary: true}, true) if available != RequestStatusAvailable { t.Fatalf("expected %q, got %q", RequestStatusAvailable, available) } } func TestMovieReleasedTreatsUnknownStatusAsReleased(t *testing.T) { // A Radarr vocabulary this build has never seen must degrade to "processing", not park // the request on "pending" for ever. for _, status := range []string{"released", "deleted", "somethingNew", ""} { if !movieReleased(status) { t.Fatalf("expected %q to count as released", status) } } for _, status := range []string{"announced", "inCinemas", "INCINEMAS", " announced "} { if movieReleased(status) { t.Fatalf("expected %q to count as unreleased", status) } } } func TestSeriesReleasedReadsUpcomingAndFutureAirings(t *testing.T) { now := time.Date(2026, 8, 12, 0, 0, 0, 0, time.UTC) later := now.Add(48 * time.Hour) earlier := now.Add(-48 * time.Hour) if seriesReleased("upcoming", nil, now) { t.Fatal("an upcoming series has nothing to fetch yet") } if seriesReleased("", &later, now) { t.Fatal("a series whose only airing is in the future has nothing to fetch yet") } if !seriesReleased("continuing", &later, now) { t.Fatal("a continuing series with a future episode is still fetchable now") } if !seriesReleased("", &earlier, now) { t.Fatal("a series that has already aired is fetchable") } } func TestRequestStatusLabelAndDetailCoverEveryState(t *testing.T) { // Every state must carry its own wording: the television renders what it is handed, so // a state that fell through to the default would show "Requested" on a card that is // actually available. states := []string{ RequestStatusAvailable, RequestStatusSearching, RequestStatusFound, RequestStatusDownloading, RequestStatusProcessing, RequestStatusPending, RequestStatusFailed, RequestStatusUnavailable, RequestStatusRequestable, RequestStatusRequested, } seen := map[string]bool{} for _, state := range states { label := requestStatusLabel(state) if label == "" { t.Fatalf("state %q has no label", state) } if seen[label] { t.Fatalf("state %q reuses the label %q", state, label) } seen[label] = true } for _, state := range states { if requestStatusDetail(state, "movie", 0) == "" { t.Fatalf("state %q has no detail line", state) } } if requestStatusDetail(RequestStatusPending, "series", 0) == requestStatusDetail(RequestStatusPending, "movie", 0) { t.Fatal("a pending series and a pending film are waiting for different things") } // The one rule this feature is judged on: an estimate is offered when the download // client gave one and never otherwise. Both sentences are legitimate; saying the second // while holding a number, or the first while holding none, is not. withEstimate := requestStatusDetail(RequestStatusDownloading, "movie", 840) withoutEstimate := requestStatusDetail(RequestStatusDownloading, "movie", 0) if withEstimate == withoutEstimate { t.Fatal("a download with an estimate must not read the same as one without") } if !strings.Contains(withEstimate, "14 minutes") { t.Fatalf("840 seconds should read as about 14 minutes, got %q", withEstimate) } if strings.Contains(withoutEstimate, "~") { t.Fatalf("a download with no estimate must not print one, got %q", withoutEstimate) } }