2026-08-12 14:13:19 +12:00
|
|
|
package api
|
|
|
|
|
|
|
|
|
|
import (
|
2026-08-19 14:25:44 +12:00
|
|
|
"strings"
|
2026-08-12 14:13:19 +12:00
|
|
|
"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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-19 14:25:44 +12:00
|
|
|
func TestRequestStatusSeparatesPendingFromSearching(t *testing.T) {
|
2026-08-12 14:13:19 +12:00
|
|
|
pending := requestStatusFor(RequestSubject{Tracked: true, Released: false})
|
|
|
|
|
if pending != RequestStatusPending {
|
|
|
|
|
t.Fatalf("expected %q, got %q", RequestStatusPending, pending)
|
|
|
|
|
}
|
2026-08-19 14:25:44 +12:00
|
|
|
// 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)
|
|
|
|
|
}
|
2026-08-12 14:13:19 +12:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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{
|
2026-08-19 14:25:44 +12:00
|
|
|
RequestStatusAvailable, RequestStatusSearching, RequestStatusFound,
|
|
|
|
|
RequestStatusDownloading, RequestStatusProcessing, RequestStatusPending,
|
|
|
|
|
RequestStatusFailed, RequestStatusUnavailable, RequestStatusRequestable,
|
|
|
|
|
RequestStatusRequested,
|
2026-08-12 14:13:19 +12:00
|
|
|
}
|
|
|
|
|
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
|
|
|
|
|
}
|
2026-08-19 14:25:44 +12:00
|
|
|
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) {
|
2026-08-12 14:13:19 +12:00
|
|
|
t.Fatal("a pending series and a pending film are waiting for different things")
|
|
|
|
|
}
|
2026-08-19 14:25:44 +12:00
|
|
|
// 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)
|
|
|
|
|
}
|
2026-08-12 14:13:19 +12:00
|
|
|
}
|