256 lines
9.3 KiB
Go
256 lines
9.3 KiB
Go
package api
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"testing"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/ponzischeme89/memby/server/internal/radarr"
|
||
|
|
)
|
||
|
|
|
||
|
|
func TestWorkStateReadsTheThreeVocabulariesInOrder(t *testing.T) {
|
||
|
|
cases := []struct {
|
||
|
|
name string
|
||
|
|
work requestWork
|
||
|
|
want string
|
||
|
|
}{
|
||
|
|
{
|
||
|
|
// The case the ordering exists for: everything about this row looks finished,
|
||
|
|
// and the *arr has decided it cannot use what arrived.
|
||
|
|
name: "an error verdict outranks a completed download",
|
||
|
|
work: requestWork{Status: "completed", TrackedState: "importPending", TrackedStatus: "error"},
|
||
|
|
want: RequestStatusFailed,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: "importing is processing even while the client still says downloading",
|
||
|
|
work: requestWork{Status: "downloading", TrackedState: "importing", TrackedStatus: "ok"},
|
||
|
|
want: RequestStatusProcessing,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: "moving bytes",
|
||
|
|
work: requestWork{Status: "downloading", TrackedState: "downloading", TrackedStatus: "ok"},
|
||
|
|
want: RequestStatusDownloading,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: "queued is a wait, not a download",
|
||
|
|
work: requestWork{Status: "queued", TrackedState: "downloading", TrackedStatus: "ok"},
|
||
|
|
want: RequestStatusFound,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: "a delay profile is a wait",
|
||
|
|
work: requestWork{Status: "delay", TrackedStatus: "ok"},
|
||
|
|
want: RequestStatusFound,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
// A future Radarr inventing a word must not tell a viewer their film is broken.
|
||
|
|
name: "an unknown word degrades to found",
|
||
|
|
work: requestWork{Status: "reticulatingSplines", TrackedState: "somethingNew"},
|
||
|
|
want: RequestStatusFound,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
// A warning is not a failure. Radarr flags a stalled download this way and will
|
||
|
|
// carry on with it.
|
||
|
|
name: "a warning verdict is not a failure",
|
||
|
|
work: requestWork{Status: "downloading", TrackedStatus: "warning"},
|
||
|
|
want: RequestStatusDownloading,
|
||
|
|
},
|
||
|
|
}
|
||
|
|
for _, tc := range cases {
|
||
|
|
t.Run(tc.name, func(t *testing.T) {
|
||
|
|
if got := workState(tc.work); got != tc.want {
|
||
|
|
t.Fatalf("expected %q, got %q", tc.want, got)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestDownloadProgressSaysNothingAboutAnEmptyQueue(t *testing.T) {
|
||
|
|
// The absence of a queue row is the caller's question, not a state: it means something
|
||
|
|
// different for a film already on the shelf, one nobody has released, and one that has
|
||
|
|
// been searched for all afternoon.
|
||
|
|
got := downloadProgress(nil)
|
||
|
|
if got.Status != "" {
|
||
|
|
t.Fatalf("an empty queue must claim no state, got %q", got.Status)
|
||
|
|
}
|
||
|
|
if got.Progress != 0 || got.EstimatedReadySeconds != 0 {
|
||
|
|
t.Fatal("an empty queue must carry no figures")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestDownloadProgressReportsPercentAndEstimate(t *testing.T) {
|
||
|
|
got := downloadProgress([]requestWork{{
|
||
|
|
Size: 1000, SizeLeft: 320, TimeLeft: "00:14:00",
|
||
|
|
Status: "downloading", TrackedState: "downloading", TrackedStatus: "ok",
|
||
|
|
}})
|
||
|
|
if got.Status != RequestStatusDownloading {
|
||
|
|
t.Fatalf("expected %q, got %q", RequestStatusDownloading, got.Status)
|
||
|
|
}
|
||
|
|
if got.Progress != 68 {
|
||
|
|
t.Fatalf("expected 68%%, got %d%%", got.Progress)
|
||
|
|
}
|
||
|
|
if got.EstimatedReadySeconds != 840 {
|
||
|
|
t.Fatalf("expected 840 seconds, got %d", got.EstimatedReadySeconds)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestASeasonIsOneAnswerOverManyRows(t *testing.T) {
|
||
|
|
// A season pack is one row per episode at a dozen different stages. What the card says
|
||
|
|
// is the most active of them, and the percentage is over the whole pack rather than
|
||
|
|
// whichever episode happens to be first.
|
||
|
|
work := []requestWork{
|
||
|
|
{Size: 100, SizeLeft: 0, Status: "completed", TrackedState: "imported"},
|
||
|
|
{Size: 100, SizeLeft: 50, TimeLeft: "00:05:00", Status: "downloading", TrackedState: "downloading"},
|
||
|
|
{Size: 100, SizeLeft: 100, TimeLeft: "00:20:00", Status: "queued", TrackedState: "downloading"},
|
||
|
|
}
|
||
|
|
got := downloadProgress(work)
|
||
|
|
if got.Status != RequestStatusDownloading {
|
||
|
|
t.Fatalf("a pack with one episode downloading is downloading, got %q", got.Status)
|
||
|
|
}
|
||
|
|
if got.Progress != 50 {
|
||
|
|
t.Fatalf("expected 50%% over the whole pack, got %d%%", got.Progress)
|
||
|
|
}
|
||
|
|
// The pack is ready when its slowest episode is, not its fastest.
|
||
|
|
if got.EstimatedReadySeconds != 1200 {
|
||
|
|
t.Fatalf("expected the longest remaining time, got %d", got.EstimatedReadySeconds)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestOneFailedEpisodeDoesNotFailTheWholeShow(t *testing.T) {
|
||
|
|
work := []requestWork{
|
||
|
|
{Size: 100, SizeLeft: 100, Status: "failed", TrackedStatus: "error"},
|
||
|
|
{Size: 100, SizeLeft: 40, TimeLeft: "00:03:00", Status: "downloading"},
|
||
|
|
}
|
||
|
|
if got := downloadProgress(work).Status; got != RequestStatusDownloading {
|
||
|
|
t.Fatalf("expected %q, got %q", RequestStatusDownloading, got)
|
||
|
|
}
|
||
|
|
// With nothing else happening, though, failed is the honest answer.
|
||
|
|
only := []requestWork{{Size: 100, SizeLeft: 100, Status: "failed", TrackedStatus: "error"}}
|
||
|
|
if got := downloadProgress(only).Status; got != RequestStatusFailed {
|
||
|
|
t.Fatalf("expected %q, got %q", RequestStatusFailed, got)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestAnEstimateIsNeverManufactured(t *testing.T) {
|
||
|
|
// A download client that will not say how long it has left leaves the estimate absent.
|
||
|
|
// This is the acceptance criterion the feature is judged on, so it is asserted from
|
||
|
|
// several directions.
|
||
|
|
silent := downloadProgress([]requestWork{{Size: 100, SizeLeft: 60, Status: "downloading"}})
|
||
|
|
if silent.EstimatedReadySeconds != 0 {
|
||
|
|
t.Fatalf("no time left reported must produce no estimate, got %d", silent.EstimatedReadySeconds)
|
||
|
|
}
|
||
|
|
if silent.Progress != 40 {
|
||
|
|
t.Fatalf("a missing estimate must not cost the percentage, got %d%%", silent.Progress)
|
||
|
|
}
|
||
|
|
|
||
|
|
// One silent row makes the whole pack unknowable: reporting the rest of it as the whole
|
||
|
|
// would be an estimate that quietly expires while the download carries on.
|
||
|
|
partly := downloadProgress([]requestWork{
|
||
|
|
{Size: 100, SizeLeft: 50, TimeLeft: "00:05:00", Status: "downloading"},
|
||
|
|
{Size: 100, SizeLeft: 90, Status: "downloading"},
|
||
|
|
})
|
||
|
|
if partly.EstimatedReadySeconds != 0 {
|
||
|
|
t.Fatalf("one silent row must make the pack unknowable, got %d", partly.EstimatedReadySeconds)
|
||
|
|
}
|
||
|
|
|
||
|
|
// An import has no measured duration, so its "few minutes" is wording rather than a
|
||
|
|
// number — see requestStatusDetail.
|
||
|
|
importing := downloadProgress([]requestWork{
|
||
|
|
{Size: 100, SizeLeft: 0, TimeLeft: "00:00:30", Status: "completed", TrackedState: "importing"},
|
||
|
|
})
|
||
|
|
if importing.EstimatedReadySeconds != 0 {
|
||
|
|
t.Fatalf("an import must not carry an estimate, got %d", importing.EstimatedReadySeconds)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestSizeIsOnlyReadWhenTheClientGaveOne(t *testing.T) {
|
||
|
|
// Zero size and zero left is a client that has not said, and reading it as
|
||
|
|
// (0-0)/0 finished would draw a full bar over a download that has not started.
|
||
|
|
got := downloadProgress([]requestWork{{Status: "downloading"}})
|
||
|
|
if got.Progress != 0 {
|
||
|
|
t.Fatalf("an unsized download must report no percentage, got %d%%", got.Progress)
|
||
|
|
}
|
||
|
|
// A client reporting more left than the whole is clamped rather than trusted into a
|
||
|
|
// negative percentage.
|
||
|
|
odd := downloadProgress([]requestWork{{Size: 100, SizeLeft: 400, Status: "downloading"}})
|
||
|
|
if odd.Progress != 0 {
|
||
|
|
t.Fatalf("expected a clamped 0%%, got %d%%", odd.Progress)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestParseTimeLeftReadsTheArrsFormat(t *testing.T) {
|
||
|
|
cases := []struct {
|
||
|
|
in string
|
||
|
|
want time.Duration
|
||
|
|
ok bool
|
||
|
|
}{
|
||
|
|
{"00:14:32", 14*time.Minute + 32*time.Second, true},
|
||
|
|
{"01:00:00", time.Hour, true},
|
||
|
|
{"1.02:03:04", 26*time.Hour + 3*time.Minute + 4*time.Second, true},
|
||
|
|
{"00:00:30.5000000", 30 * time.Second, true},
|
||
|
|
// Everything it cannot read completely is refused rather than salvaged. This is the
|
||
|
|
// one input that becomes a promise to a viewer.
|
||
|
|
{"", 0, false},
|
||
|
|
{"14:32", 0, false},
|
||
|
|
{"soon", 0, false},
|
||
|
|
{"00:xx:32", 0, false},
|
||
|
|
{"-1.00:00:01", 0, false},
|
||
|
|
}
|
||
|
|
for _, tc := range cases {
|
||
|
|
got, ok := parseTimeLeft(tc.in)
|
||
|
|
if ok != tc.ok {
|
||
|
|
t.Fatalf("%q: expected ok=%v, got %v", tc.in, tc.ok, ok)
|
||
|
|
}
|
||
|
|
if ok && got != tc.want {
|
||
|
|
t.Fatalf("%q: expected %s, got %s", tc.in, tc.want, got)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestEstimatedReadyLabelIsCoarserThanTheNumberItWasGiven(t *testing.T) {
|
||
|
|
// The download client's seconds are not precision anybody has, so printing them would
|
||
|
|
// claim an accuracy the estimate does not carry.
|
||
|
|
cases := []struct {
|
||
|
|
seconds int
|
||
|
|
want string
|
||
|
|
}{
|
||
|
|
{0, ""},
|
||
|
|
{-5, ""},
|
||
|
|
{45, "Estimated ready in under a minute"},
|
||
|
|
{840, "Estimated ready in ~14 minutes"},
|
||
|
|
{4500, "Estimated ready in about an hour"},
|
||
|
|
{5 * 3600, "Estimated ready in ~5 hours"},
|
||
|
|
{50 * 3600, "Estimated ready in over a day"},
|
||
|
|
}
|
||
|
|
for _, tc := range cases {
|
||
|
|
if got := estimatedReadyLabel(tc.seconds); got != tc.want {
|
||
|
|
t.Fatalf("%d seconds: expected %q, got %q", tc.seconds, tc.want, got)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestQueueIsGroupedByTheIdTheRequestWasRecordedAgainst(t *testing.T) {
|
||
|
|
// A queue row names the *arr's own internal id, which nothing outside it has seen. The
|
||
|
|
// translation to TMDb is what lets a stored request find its own download.
|
||
|
|
work := groupMovieWork(
|
||
|
|
[]radarr.QueueItem{
|
||
|
|
{MovieID: 7, Size: 100, Sizeleft: 25, Status: "downloading"},
|
||
|
|
// Radarr id 99 is not in the catalogue map: a film added since it was cached.
|
||
|
|
{MovieID: 99, Size: 100, Sizeleft: 100, Status: "queued"},
|
||
|
|
},
|
||
|
|
map[int]int{7: 550, 8: 603},
|
||
|
|
)
|
||
|
|
if len(work[550]) != 1 {
|
||
|
|
t.Fatalf("expected one row against tmdb 550, got %d", len(work[550]))
|
||
|
|
}
|
||
|
|
if work[550][0].Status != "downloading" {
|
||
|
|
t.Fatalf("the row lost its status: %+v", work[550][0])
|
||
|
|
}
|
||
|
|
// A download for a film added since the catalogue was cached cannot be translated, and
|
||
|
|
// is dropped rather than guessed at.
|
||
|
|
if _, ok := work[0]; ok {
|
||
|
|
t.Fatal("an untranslatable row must not be filed under a zero id")
|
||
|
|
}
|
||
|
|
if len(work) != 1 {
|
||
|
|
t.Fatalf("expected one translated title, got %d", len(work))
|
||
|
|
}
|
||
|
|
}
|