package notifications import ( "fmt" "strings" "testing" "time" ) func TestEffectiveChannelMode(t *testing.T) { if got := effectiveChannelMode(ChannelModePerEpisode, true); got != ChannelModePerEpisode { t.Fatalf("allowed per-episode coerced to %q", got) } if got := effectiveChannelMode(ChannelModePerEpisode, false); got != ChannelModeDailyDigest { t.Fatalf("disallowed per-episode should coerce to digest, got %q", got) } if got := effectiveChannelMode(ChannelModeDailyDigest, false); got != ChannelModeDailyDigest { t.Fatalf("digest mode changed to %q", got) } if got := effectiveChannelMode(ChannelModeOff, true); got != ChannelModeOff { t.Fatalf("off mode changed to %q", got) } if got := effectiveChannelMode(ChannelModePerEpisodeAndDigest, true); got != ChannelModePerEpisodeAndDigest { t.Fatalf("allowed combined mode coerced to %q", got) } if got := effectiveChannelMode(ChannelModePerEpisodeAndDigest, false); got != ChannelModeDailyDigest { t.Fatalf("disallowed combined mode should coerce to digest, got %q", got) } } func TestChannelDigestDue(t *testing.T) { loc := time.UTC morning := time.Date(2026, 6, 11, 7, 30, 0, 0, loc) afternoon := time.Date(2026, 6, 11, 14, 0, 0, 0, loc) yesterday := time.Date(2026, 6, 10, 9, 0, 0, 0, loc) today := time.Date(2026, 6, 11, 8, 5, 0, 0, loc) if channelDigestDue(morning, 8, nil) { t.Fatal("digest due before today's send hour") } if !channelDigestDue(afternoon, 8, nil) { t.Fatal("first-ever digest not due after send hour") } if !channelDigestDue(afternoon, 8, &yesterday) { t.Fatal("digest not due when last one was yesterday") } if channelDigestDue(afternoon, 8, &today) { t.Fatal("digest due twice in one day") } } func TestChannelRetryEligible(t *testing.T) { now := time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC) recent := now.Add(-30 * time.Second) stale := now.Add(-10 * time.Minute) if !channelRetryEligible(now, nil, 0) { t.Fatal("clean account not eligible") } if !channelRetryEligible(now, &recent, 0) { t.Fatal("successful account not eligible") } if channelRetryEligible(now, &recent, 1) { t.Fatal("eligible 30s after first failure (backoff is 1m)") } if !channelRetryEligible(now, &stale, 3) { t.Fatal("not eligible 10m after third failure (backoff is 4m)") } // Large failure counts must not overflow the shift; cap applies. old := now.Add(-7 * time.Hour) if !channelRetryEligible(now, &old, 60) { t.Fatal("not eligible past the 6h backoff cap") } if channelRetryEligible(now, &recent, 60) { t.Fatal("eligible 30s after many failures") } } // emailEpisodeRow builds an episode.available row for one profile. func emailEpisodeRow(id, profileID, episodeID string, season, episode int) DeliveryRow { seriesID := "series-123" return DeliveryRow{ Delivery: Delivery{ ID: id, ProfileID: profileID, SeriesID: &seriesID, EpisodeID: &episodeID, Type: DeliveryTypeEpisodeAvailable, ReasonFlags: []byte(`{"favorite":true}`), CreatedAt: time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC), }, SeriesTitle: "Severance", EpisodeTitle: fmt.Sprintf("Episode %d", episode), SeasonNumber: &season, EpisodeNumber: &episode, } } func TestCollateEmailItemsDedupesAcrossProfiles(t *testing.T) { rows := []DeliveryRow{ emailEpisodeRow("01A", "profile-1", "ep-1", 2, 3), emailEpisodeRow("01B", "profile-2", "ep-1", 2, 3), // same episode, second profile emailEpisodeRow("01C", "profile-1", "ep-2", 2, 4), requestFulfilledTestRow(), {Delivery: Delivery{ID: "01D", Type: DeliveryTypeWebhookAutoDisabled}}, } items := collateEmailItems(rows) if items.episodes != 2 { t.Fatalf("expected 2 deduped episodes, got %d", items.episodes) } if len(items.series) != 1 || items.series[0].title != "Severance" { t.Fatalf("unexpected series groups: %+v", items.series) } if len(items.requests) != 1 || len(items.others) != 1 { t.Fatalf("unexpected request/other split: %d/%d", len(items.requests), len(items.others)) } } func TestCollateEmailItemsSortsEpisodesWithinSeries(t *testing.T) { rows := []DeliveryRow{ emailEpisodeRow("01A", "profile-1", "ep-2", 2, 4), emailEpisodeRow("01B", "profile-1", "ep-1", 2, 3), } items := collateEmailItems(rows) first := items.series[0].episodes[0] if *first.EpisodeNumber != 3 { t.Fatalf("episodes not sorted by number: got E%d first", *first.EpisodeNumber) } } func TestEmailSubject(t *testing.T) { single := collateEmailItems([]DeliveryRow{emailEpisodeRow("01A", "p1", "ep-1", 2, 3)}) if got := emailSubject(EmailModePerEpisode, single); got != "New episode of Severance: S02E03 — Episode 3" { t.Fatalf("unexpected single-episode subject %q", got) } request := collateEmailItems([]DeliveryRow{requestFulfilledTestRow()}) if got := emailSubject(EmailModePerEpisode, request); got != "Dune is now available" { t.Fatalf("unexpected single-request subject %q", got) } mixed := collateEmailItems([]DeliveryRow{ emailEpisodeRow("01A", "p1", "ep-1", 2, 3), emailEpisodeRow("01B", "p1", "ep-2", 2, 4), requestFulfilledTestRow(), }) if got := emailSubject(EmailModePerEpisode, mixed); got != "Silo: 2 new episodes, 1 request ready" { t.Fatalf("unexpected mixed subject %q", got) } if got := emailSubject(EmailModeDailyDigest, mixed); got != "Silo daily digest: 2 new episodes, 1 request ready" { t.Fatalf("unexpected digest subject %q", got) } } func TestCollateEmailItemsKeepsLifecycleAndFulfilledForSameRequest(t *testing.T) { // One request can produce an approved row and a fulfilled row in the same // window; both must render. rows := []DeliveryRow{ requestDeclinedTestRow(), requestFulfilledTestRow(), // different request id is irrelevant; types differ {Delivery: Delivery{ ID: "01APPROVED", Type: DeliveryTypeRequestApproved, ReasonFlags: []byte(`{"request_id":"01REQ","media_type":"movie","title":"Dune"}`), }}, } items := collateEmailItems(rows) if len(items.requests) != 3 { t.Fatalf("expected 3 request rows (distinct types), got %d", len(items.requests)) } if requestsAllFulfilled(items) { t.Fatal("mixed request rows must not report all-fulfilled") } } func TestRequestLineLifecycle(t *testing.T) { declined := requestDeclinedTestRow() if got := requestLine(declined); got != "Your request for Dune was declined — Already available in 4K" { t.Fatalf("unexpected declined line %q", got) } approved := requestDeclinedTestRow() approved.Type = DeliveryTypeRequestApproved approved.ReasonFlags = []byte(`{"request_id":"01REQ","title":"Dune"}`) if got := requestLine(approved); got != "Your request for Dune was approved" { t.Fatalf("unexpected approved line %q", got) } subject := emailSubject(EmailModePerEpisode, collateEmailItems([]DeliveryRow{approved})) if subject != "Your request for Dune was approved" { t.Fatalf("unexpected single-update subject %q", subject) } mixed := collateEmailItems([]DeliveryRow{approved, requestFulfilledTestRow()}) if got := emailSubject(EmailModeDailyDigest, mixed); got != "Silo daily digest: 2 request updates" { t.Fatalf("unexpected mixed subject %q", got) } } func TestComposeNotificationEmailLinks(t *testing.T) { rows := []DeliveryRow{emailEpisodeRow("01A", "p1", "ep-1", 2, 3)} withLinks := composeNotificationEmail(EmailModePerEpisode, rows, emailComposeOptions{BaseURL: "https://silo.example.com"}) if !strings.Contains(withLinks.HTML, `href="https://silo.example.com/item/ep-1"`) { t.Fatalf("episode link missing from HTML:\n%s", withLinks.HTML) } if !strings.Contains(withLinks.HTML, `href="https://silo.example.com/settings/notifications"`) { t.Fatalf("settings link missing from HTML footer:\n%s", withLinks.HTML) } withoutLinks := composeNotificationEmail(EmailModePerEpisode, rows, emailComposeOptions{}) if strings.Contains(withoutLinks.HTML, "href=") { t.Fatalf("HTML contains links with no external URL configured:\n%s", withoutLinks.HTML) } if !strings.Contains(withoutLinks.Text, "S02E03 — Episode 3") { t.Fatalf("text body missing episode line:\n%s", withoutLinks.Text) } } func TestComposeNotificationEmailEscapesHTML(t *testing.T) { row := emailEpisodeRow("01A", "p1", "ep-1", 2, 3) row.SeriesTitle = `` content := composeNotificationEmail(EmailModePerEpisode, []DeliveryRow{row}, emailComposeOptions{}) if strings.Contains(content.HTML, "