package api import ( "encoding/json" "testing" "time" "github.com/ponzischeme89/memby/server/internal/sonarr" ) func calendarEpisode(id int, air time.Time, title string) sonarr.Episode { utc := air.UTC() return sonarr.Episode{ ID: id, SeriesID: id, SeasonNumber: 1, EpisodeNumber: id, Title: title, AirDateUTC: &utc, Monitored: true, Series: sonarr.Series{ID: id, Title: title}, } } func TestBuildCalendarMonthDescribesTheGrid(t *testing.T) { location := time.UTC // August 2026 begins on a Saturday and runs 31 days. month := time.Date(2026, 8, 1, 0, 0, 0, 0, location) now := time.Date(2026, 8, 11, 9, 0, 0, 0, location) calendar := buildCalendarMonth(nil, month, now, location, nil) if !calendar.Available { t.Fatal("a configured Sonarr must report the month as available") } if calendar.Month != "2026-08" || calendar.Label != "August 2026" { t.Fatalf("unexpected identity: %+v", calendar) } if calendar.FirstWeekday != int(time.Saturday) || calendar.DayCount != 31 { t.Fatalf("unexpected grid shape: %+v", calendar) } if calendar.Previous != "2026-07" || calendar.Next != "2026-09" { t.Fatalf("unexpected neighbours: %+v", calendar) } if calendar.Today != "2026-08-11" { t.Fatalf("today was not marked: %+v", calendar) } if calendar.Days == nil { t.Fatal("days must encode as an array rather than null") } } func TestBuildCalendarMonthCountsFebruaryInALeapYear(t *testing.T) { location := time.UTC now := time.Date(2028, 2, 1, 0, 0, 0, 0, location) calendar := buildCalendarMonth(nil, now, now, location, nil) if calendar.DayCount != 29 { t.Fatalf("expected 29 days, got %d", calendar.DayCount) } } func TestBuildCalendarMonthOmitsTodayOutsideTheMonth(t *testing.T) { location := time.UTC month := time.Date(2026, 10, 1, 0, 0, 0, 0, location) now := time.Date(2026, 8, 11, 9, 0, 0, 0, location) if today := buildCalendarMonth(nil, month, now, location, nil).Today; today != "" { t.Fatalf("a month the household is not in must carry no today: %q", today) } } // The arrows are what stop a held D-pad walking Sonarr into the next decade, so the edge of // the range must be reported as having no neighbour rather than as one more request. func TestBuildCalendarMonthStopsAtTheTravelLimit(t *testing.T) { location := time.UTC now := time.Date(2026, 8, 11, 9, 0, 0, 0, location) last := time.Date(2027, 8, 1, 0, 0, 0, 0, location) if next := buildCalendarMonth(nil, last, now, location, nil).Next; next != "" { t.Fatalf("expected no next month at the limit, got %q", next) } first := time.Date(2025, 8, 1, 0, 0, 0, 0, location) if previous := buildCalendarMonth(nil, first, now, location, nil).Previous; previous != "" { t.Fatalf("expected no previous month at the limit, got %q", previous) } } func TestBuildCalendarMonthGroupsEpisodesByLocalDay(t *testing.T) { location := time.FixedZone("NZST", 12*60*60) month := time.Date(2026, 8, 1, 0, 0, 0, 0, location) now := time.Date(2026, 8, 11, 9, 0, 0, 0, location) // Two on the 12th local, one on the 13th, and one that is the 31st in UTC but already // September where the household lives. episodes := []sonarr.Episode{ calendarEpisode(2, time.Date(2026, 8, 12, 21, 0, 0, 0, location), "Harbour"), calendarEpisode(1, time.Date(2026, 8, 12, 20, 0, 0, 0, location), "Northbound"), calendarEpisode(3, time.Date(2026, 8, 13, 20, 0, 0, 0, location), "Deep Water"), calendarEpisode(4, time.Date(2026, 8, 31, 20, 0, 0, 0, time.UTC), "Rolled Over"), } calendar := buildCalendarMonth(episodes, month, now, location, nil) if len(calendar.Days) != 2 { t.Fatalf("expected two populated days, got %d: %+v", len(calendar.Days), calendar.Days) } if calendar.Days[0].Date != "2026-08-12" || calendar.Days[0].Day != 12 { t.Fatalf("unexpected first day: %+v", calendar.Days[0]) } if len(calendar.Days[0].Items) != 2 { t.Fatalf("expected two episodes on the 12th: %+v", calendar.Days[0]) } // Within a day the order is Sonarr's air time, which is the order they will be watched. var first sonarrScheduleItem if err := json.Unmarshal(calendar.Days[0].Items[0], &first); err != nil { t.Fatal(err) } if first.Name != "Northbound" { t.Fatalf("episodes within a day must be ordered by air time: %+v", first) } if first.Type != "MembySonarrEpisode" || first.MembyPlayable { t.Fatalf("a calendar card must be the schedule row's informational item: %+v", first) } if calendar.Days[1].Date != "2026-08-13" { t.Fatalf("unexpected second day: %+v", calendar.Days[1]) } } func TestParseCalendarMonthDefaultsToThePresent(t *testing.T) { location := time.UTC now := time.Date(2026, 8, 11, 9, 0, 0, 0, location) month, ok := parseCalendarMonth(" ", now, location) if !ok || month.Format("2006-01") != "2026-08" { t.Fatalf("unexpected default month: %v %v", month, ok) } } func TestParseCalendarMonthRefusesNonsenseAndDistantMonths(t *testing.T) { location := time.UTC now := time.Date(2026, 8, 11, 9, 0, 0, 0, location) for _, value := range []string{"2026-13", "August", "2026-08-11", "2031-01", "2020-01"} { if _, ok := parseCalendarMonth(value, now, location); ok { t.Fatalf("expected %q to be refused", value) } } for _, value := range []string{"2026-09", "2027-08", "2025-08"} { if _, ok := parseCalendarMonth(value, now, location); !ok { t.Fatalf("expected %q to be accepted", value) } } }