package api import ( "strings" "testing" "time" "github.com/ponzischeme89/memby/server/internal/library" ) func episodeResult(season, episode int, title string) library.IngestResult { return library.IngestResult{ Source: "sonarr", Kind: library.KindEpisode, Reason: library.ReasonImport, ItemID: "emby-" + title, Name: title, SeriesName: "The Bear", Season: season, Episode: episode, } } func TestEpisodeSummaryNamesTheEpisodeBothWays(t *testing.T) { if got := episodeSummary(episodeResult(3, 5, "Children")); got != "S03E05 — Children" { t.Errorf("summary = %q, want the code and the title", got) } // Emby records plenty of episodes under the show's own name, and "S03E05 — The Bear" // reads as a mistake where the code alone reads as an episode. same := episodeResult(3, 5, "The Bear") if got := episodeSummary(same); got != "S03E05" { t.Errorf("summary = %q, want the code alone when the title repeats the series", got) } untitled := episodeResult(3, 5, "") if got := episodeSummary(untitled); got != "S03E05" { t.Errorf("summary = %q, want the code alone", got) } } // A season pack is one piece of news. Every arrival replaces the same banner, which is // what the shared anchor is for, and the wording moves from the episode to the count. func TestIngestRunsCollapseASeasonPack(t *testing.T) { var runs ingestRuns now := time.Date(2026, 8, 18, 19, 4, 0, 0, time.UTC) first := runs.record("bear|3", "emby-1", "S03E01", now, ingestRunWindow) if first.Count != 1 || first.Anchor != "emby-1" { t.Fatalf("first = %+v, want a run of one anchored on it", first) } if got := episodeRunMessage(first); got != "S03E01 is ready to watch." { t.Errorf("message = %q, want the episode named", got) } second := runs.record("bear|3", "emby-2", "S03E02", now.Add(30*time.Second), ingestRunWindow) if second.Anchor != "emby-1" { t.Errorf("anchor = %q, want the run's first episode so the banner is replaced", second.Anchor) } if second.Count != 2 { t.Errorf("count = %d, want 2", second.Count) } if got := episodeRunMessage(second); got != "2 new episodes are ready to watch." { t.Errorf("message = %q, want the count once there is more than one", got) } } // Next week's episode is its own news. Televisions dedupe on the alert id forever, so a // run that reused last week's anchor would be silently swallowed on every set in the house. func TestIngestRunsStartAfreshOnceTheWindowHasClosed(t *testing.T) { var runs ingestRuns now := time.Date(2026, 8, 18, 19, 4, 0, 0, time.UTC) runs.record("bear|3", "emby-1", "S03E01", now, ingestRunWindow) later := runs.record("bear|3", "emby-2", "S03E02", now.Add(ingestRunWindow+time.Minute), ingestRunWindow) if later.Anchor != "emby-2" { t.Errorf("anchor = %q, want a new run", later.Anchor) } if later.Count != 1 { t.Errorf("count = %d, want a run of one", later.Count) } } // Two shows importing at once are two pieces of news, not one run of four episodes. func TestIngestRunsAreKeptPerSeason(t *testing.T) { var runs ingestRuns now := time.Date(2026, 8, 18, 19, 4, 0, 0, time.UTC) runs.record(seasonRunKey("The Bear", 3), "bear-1", "S03E01", now, ingestRunWindow) other := runs.record(seasonRunKey("Slow Horses", 4), "horses-1", "S04E01", now, ingestRunWindow) if other.Count != 1 || other.Anchor != "horses-1" { t.Fatalf("other show = %+v, want a run of its own", other) } // And the same show under a different spelling is still the same show, the rule the // schedule row and the ingest worker already match titles by. same := runs.record(seasonRunKey("the bear!", 3), "bear-2", "S03E02", now, ingestRunWindow) if same.Anchor != "bear-1" || same.Count != 2 { t.Fatalf("same season = %+v, want it folded into the first run", same) } } // The tally is memory the gateway can afford to lose, so it must also be memory it cannot // grow without bound. func TestIngestRunsAreBounded(t *testing.T) { var runs ingestRuns now := time.Date(2026, 8, 18, 19, 4, 0, 0, time.UTC) for i := 0; i < trackedIngestRuns*2; i++ { runs.record(time.Duration(i).String(), "item", "S01E01", now, ingestRunWindow) } if len(runs.runs) > trackedIngestRuns { t.Fatalf("tracking %d runs, want a cap of %d", len(runs.runs), trackedIngestRuns) } } // Only an import is news. An upgrade replaced a file that was already watchable, and a // rename or a delete is housekeeping — announcing any of them trains viewers to look away. func TestOnlyAnImportIsAnnounced(t *testing.T) { s := &Server{log: discardLogger()} for _, reason := range []string{ library.ReasonUpgrade, library.ReasonRename, library.ReasonDelete, } { result := episodeResult(3, 5, "Children") result.Reason = reason // A nil cache would be reached by publishAlert if this announced anything; it // returns early on one, so the assertion is that nothing is recorded either. s.AnnounceLibraryIngest(t.Context(), result) if len(s.ingestRuns.runs) != 0 { t.Fatalf("%s was treated as news", reason) } } } // A series-level result is a rename settling or a show written ahead of its first episode. // Neither is something anybody can press Play on. func TestASeriesRefreshIsNotAnnounced(t *testing.T) { s := &Server{log: discardLogger()} s.AnnounceLibraryIngest(t.Context(), library.IngestResult{ Source: "sonarr", Kind: library.KindSeries, Reason: library.ReasonImport, ItemID: "series-1", Name: "The Bear", SeriesName: "The Bear", }) if len(s.ingestRuns.runs) != 0 { t.Fatal("a series refresh was announced") } } func TestMovieRunMessageSaysTheFilmIsThere(t *testing.T) { // The wording is the whole point of moving the announcement behind the scan: the // banner published from the webhook could only ever promise the film was coming. run := ingestRun{Anchor: "a", Count: 1, Latest: "S01E01"} if strings.Contains(episodeRunMessage(run), "shortly") { t.Error("the message still promises rather than states") } }