Audiobook and ebook libraries previously never entered the Recently Added pipeline: availability detection only ran for TV/movie/mixed libraries and release_events only knew episode/movie kinds, so server channels (Discord/generic webhooks) could not announce new audiobooks or ebooks. Generalize the movie path into a flat-item-kind registry (internal/notifications/item_kind.go) driving availability detection, recording, channel toggles, payload rendering, test fixtures, and the admin backfill seeder. New kinds share a kind-discriminated item_availability table; movie_availability stays as-is. Channels gain notify_new_audiobooks/notify_new_ebooks toggles (default on, additive API fields) and embeds carry the author from item_people. Flood-safe by construction: existing libraries seed silently on their first post-upgrade full scan. Extract internal/librarykind to replace the is*LibraryType helper copies that had drifted across scanner, libraryingest, and metadata (metadata's movie check silently included mixed; now spelled explicitly). Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
661 lines
22 KiB
Go
661 lines
22 KiB
Go
package notifications
|
||
|
||
import (
|
||
"encoding/json"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
const (
|
||
testMovieTitle = "Dune"
|
||
testSeriesTitle = "Severance"
|
||
testSeriesPosterCDN = "https://image.tmdb.org/t/p/w500/severance.jpg"
|
||
testSeriesPosterPath = "tmdb://poster/severance.jpg"
|
||
)
|
||
|
||
func episodeEvent(id string, libraryID int, seriesID string, season, episode int) ReleaseEvent {
|
||
return ReleaseEvent{
|
||
ID: id,
|
||
LibraryID: libraryID,
|
||
Kind: EventKindEpisode,
|
||
SeriesID: seriesID,
|
||
EpisodeID: "ep-" + id,
|
||
SeasonNumber: season,
|
||
EpisodeNumber: episode,
|
||
EpisodeKey: EpisodeKey(season, episode),
|
||
}
|
||
}
|
||
|
||
func movieEvent(id string, libraryID int, itemID string) ReleaseEvent {
|
||
return itemEvent(id, libraryID, EventKindMovie, itemID)
|
||
}
|
||
|
||
func itemEvent(id string, libraryID int, kind, itemID string) ReleaseEvent {
|
||
return ReleaseEvent{
|
||
ID: id,
|
||
LibraryID: libraryID,
|
||
Kind: kind,
|
||
ItemID: itemID,
|
||
}
|
||
}
|
||
|
||
func TestGroupContentEvents(t *testing.T) {
|
||
titles := map[string]ContentMeta{
|
||
"series-1": {Title: testSeriesTitle},
|
||
"movie-1": {Title: testMovieTitle, Year: 2026},
|
||
}
|
||
|
||
t.Run("season pack groups into one entry with a range", func(t *testing.T) {
|
||
events := []ReleaseEvent{
|
||
episodeEvent("3", 1, "series-1", 2, 3),
|
||
episodeEvent("1", 1, "series-1", 2, 1),
|
||
episodeEvent("2", 1, "series-1", 2, 2),
|
||
}
|
||
groups := GroupContentEvents(events, titles)
|
||
if len(groups) != 1 {
|
||
t.Fatalf("got %d groups, want 1", len(groups))
|
||
}
|
||
if got := contentGroupTitle(groups[0]); got != "Severance — 3 new episodes (S2 E1–E3)" {
|
||
t.Fatalf("unexpected group title %q", got)
|
||
}
|
||
})
|
||
|
||
t.Run("single episode renders its code", func(t *testing.T) {
|
||
groups := GroupContentEvents([]ReleaseEvent{episodeEvent("1", 1, "series-1", 2, 5)}, titles)
|
||
if got := contentGroupTitle(groups[0]); got != "Severance — S2 E5" {
|
||
t.Fatalf("unexpected group title %q", got)
|
||
}
|
||
})
|
||
|
||
t.Run("cross-season range spells both codes", func(t *testing.T) {
|
||
events := []ReleaseEvent{
|
||
episodeEvent("1", 1, "series-1", 1, 10),
|
||
episodeEvent("2", 1, "series-1", 2, 1),
|
||
}
|
||
groups := GroupContentEvents(events, titles)
|
||
if got := contentGroupTitle(groups[0]); got != "Severance — 2 new episodes (S1 E10 – S2 E1)" {
|
||
t.Fatalf("unexpected group title %q", got)
|
||
}
|
||
})
|
||
|
||
t.Run("movies render individually with year", func(t *testing.T) {
|
||
groups := GroupContentEvents([]ReleaseEvent{movieEvent("1", 1, "movie-1")}, titles)
|
||
if len(groups) != 1 || groups[0].Kind != EventKindMovie {
|
||
t.Fatalf("unexpected groups %+v", groups)
|
||
}
|
||
if got := contentGroupTitle(groups[0]); got != "Dune (2026)" {
|
||
t.Fatalf("unexpected movie title %q", got)
|
||
}
|
||
})
|
||
|
||
t.Run("same movie in two libraries announces once", func(t *testing.T) {
|
||
events := []ReleaseEvent{
|
||
movieEvent("1", 1, "movie-1"),
|
||
movieEvent("2", 2, "movie-1"),
|
||
}
|
||
if groups := GroupContentEvents(events, titles); len(groups) != 1 {
|
||
t.Fatalf("got %d groups, want 1 (cross-library movie dedupe)", len(groups))
|
||
}
|
||
})
|
||
|
||
t.Run("same series in two libraries stays separate", func(t *testing.T) {
|
||
// Episode-level cross-library dedupe is the per-profile path's job;
|
||
// the broadcast feed reflects each library's catalog.
|
||
events := []ReleaseEvent{
|
||
episodeEvent("1", 1, "series-1", 1, 1),
|
||
episodeEvent("2", 2, "series-1", 1, 1),
|
||
}
|
||
if groups := GroupContentEvents(events, titles); len(groups) != 2 {
|
||
t.Fatalf("got %d groups, want 2", len(groups))
|
||
}
|
||
})
|
||
|
||
t.Run("missing titles fall back to generic labels", func(t *testing.T) {
|
||
groups := GroupContentEvents([]ReleaseEvent{
|
||
episodeEvent("1", 1, "unknown-series", 1, 1),
|
||
movieEvent("2", 1, "unknown-movie"),
|
||
}, nil)
|
||
if groups[0].Meta.Title != genericEpisodeTitle {
|
||
t.Fatalf("unexpected series fallback %q", groups[0].Meta.Title)
|
||
}
|
||
if groups[1].Meta.Title != "New movie" {
|
||
t.Fatalf("unexpected movie fallback %q", groups[1].Meta.Title)
|
||
}
|
||
})
|
||
|
||
t.Run("pre-discriminator rows with empty kind group as episodes", func(t *testing.T) {
|
||
event := episodeEvent("1", 1, "series-1", 1, 1)
|
||
event.Kind = ""
|
||
groups := GroupContentEvents([]ReleaseEvent{event}, titles)
|
||
if len(groups) != 1 || groups[0].Kind != EventKindEpisode {
|
||
t.Fatalf("unexpected groups %+v", groups)
|
||
}
|
||
})
|
||
|
||
t.Run("audiobooks and ebooks render like movies with author metadata", func(t *testing.T) {
|
||
metas := map[string]ContentMeta{
|
||
"book-1": {Title: "Project Hail Mary", Year: 2021, Author: "Andy Weir"},
|
||
}
|
||
groups := GroupContentEvents([]ReleaseEvent{
|
||
itemEvent("1", 1, EventKindAudiobook, "book-1"),
|
||
}, metas)
|
||
if len(groups) != 1 || groups[0].Kind != EventKindAudiobook {
|
||
t.Fatalf("unexpected groups %+v", groups)
|
||
}
|
||
if got := contentGroupTitle(groups[0]); got != "Project Hail Mary (2021)" {
|
||
t.Fatalf("unexpected audiobook title %q", got)
|
||
}
|
||
if groups[0].Meta.Author != "Andy Weir" {
|
||
t.Fatalf("author not carried: %+v", groups[0].Meta)
|
||
}
|
||
})
|
||
|
||
t.Run("same item id in two kinds never cross-dedupes", func(t *testing.T) {
|
||
events := []ReleaseEvent{
|
||
itemEvent("1", 1, EventKindAudiobook, "item-1"),
|
||
itemEvent("2", 1, EventKindEbook, "item-1"),
|
||
}
|
||
if groups := GroupContentEvents(events, nil); len(groups) != 2 {
|
||
t.Fatalf("got %d groups, want 2 (dedupe must be kind-scoped)", len(groups))
|
||
}
|
||
})
|
||
|
||
t.Run("same audiobook in two libraries announces once", func(t *testing.T) {
|
||
events := []ReleaseEvent{
|
||
itemEvent("1", 1, EventKindAudiobook, "book-1"),
|
||
itemEvent("2", 2, EventKindAudiobook, "book-1"),
|
||
}
|
||
if groups := GroupContentEvents(events, nil); len(groups) != 1 {
|
||
t.Fatalf("got %d groups, want 1 (cross-library dedupe)", len(groups))
|
||
}
|
||
})
|
||
|
||
t.Run("missing flat item titles fall back per kind", func(t *testing.T) {
|
||
groups := GroupContentEvents([]ReleaseEvent{
|
||
itemEvent("1", 1, EventKindAudiobook, "a"),
|
||
itemEvent("2", 1, EventKindEbook, "b"),
|
||
}, nil)
|
||
if groups[0].Meta.Title != "New audiobook" || groups[1].Meta.Title != "New ebook" {
|
||
t.Fatalf("unexpected fallbacks %q / %q", groups[0].Meta.Title, groups[1].Meta.Title)
|
||
}
|
||
})
|
||
|
||
t.Run("unknown future kinds are skipped, not misrendered", func(t *testing.T) {
|
||
events := []ReleaseEvent{
|
||
itemEvent("1", 1, "music", "album-1"),
|
||
movieEvent("2", 1, "movie-1"),
|
||
}
|
||
groups := GroupContentEvents(events, titles)
|
||
if len(groups) != 1 || groups[0].Kind != EventKindMovie {
|
||
t.Fatalf("unexpected groups %+v", groups)
|
||
}
|
||
})
|
||
}
|
||
|
||
func TestServerChannelWantsToggles(t *testing.T) {
|
||
ch := ServerChannel{
|
||
NotifyNewMovies: true,
|
||
NotifyNewEpisodes: false,
|
||
NotifyNewAudiobooks: true,
|
||
NotifyNewEbooks: false,
|
||
NotifyRequestSubmitted: true,
|
||
NotifyRequestFulfilled: false,
|
||
}
|
||
cases := []struct {
|
||
kind string
|
||
want bool
|
||
}{
|
||
{EventKindMovie, true},
|
||
{EventKindEpisode, false},
|
||
{EventKindAudiobook, true},
|
||
{EventKindEbook, false},
|
||
{"", false}, // legacy rows follow the episode toggle
|
||
{"music", false}, // unknown future kinds are never announced
|
||
}
|
||
for _, tc := range cases {
|
||
if got := ch.WantsContentKind(tc.kind); got != tc.want {
|
||
t.Errorf("WantsContentKind(%q) = %v, want %v", tc.kind, got, tc.want)
|
||
}
|
||
}
|
||
|
||
eventCases := []struct {
|
||
event string
|
||
want bool
|
||
}{
|
||
{ServerChannelEventRequestSubmitted, true},
|
||
{ServerChannelEventRequestApproved, false},
|
||
{ServerChannelEventRequestDeclined, false},
|
||
{ServerChannelEventRequestFulfilled, false},
|
||
{"request.unknown", false},
|
||
}
|
||
for _, tc := range eventCases {
|
||
if got := ch.WantsRequestEvent(tc.event); got != tc.want {
|
||
t.Errorf("WantsRequestEvent(%q) = %v, want %v", tc.event, got, tc.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestBuildServerChannelDiscordContent(t *testing.T) {
|
||
titles := map[string]ContentMeta{
|
||
"series-1": {
|
||
Title: testSeriesTitle,
|
||
Type: "series",
|
||
Overview: "Mark leads a team whose memories have been surgically divided.",
|
||
PosterPath: testSeriesPosterPath,
|
||
PosterURL: testSeriesPosterCDN,
|
||
Genres: []string{"Drama", "Sci-Fi & Fantasy", "Mystery", "Thriller"},
|
||
ContentRating: "TV-MA",
|
||
RatingIMDB: 8.7,
|
||
IMDBID: "tt11280740",
|
||
TMDBID: "95396",
|
||
TVDBID: "371980",
|
||
},
|
||
"movie-1": {Title: testMovieTitle, Year: 2026, Type: "movie"},
|
||
}
|
||
groups := GroupContentEvents([]ReleaseEvent{
|
||
episodeEvent("1", 1, "series-1", 2, 1),
|
||
episodeEvent("2", 1, "series-1", 2, 2),
|
||
movieEvent("3", 1, "movie-1"),
|
||
}, titles)
|
||
|
||
body, err := BuildServerChannelDiscordContent(groups, false)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var decoded struct {
|
||
Content string `json:"content"`
|
||
Username string `json:"username"`
|
||
Embeds []discordEmbed `json:"embeds"`
|
||
}
|
||
if err := json.Unmarshal(body, &decoded); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(decoded.Embeds) != 2 {
|
||
t.Fatalf("got %d embeds, want 2", len(decoded.Embeds))
|
||
}
|
||
if decoded.Username != "Silo" {
|
||
t.Fatalf("unexpected username %q", decoded.Username)
|
||
}
|
||
if decoded.Content != "" {
|
||
t.Fatalf("no overflow expected, got content %q", decoded.Content)
|
||
}
|
||
|
||
episodes := decoded.Embeds[0]
|
||
if episodes.Author == nil || episodes.Author.Name != "New episodes available on Silo" {
|
||
t.Fatalf("unexpected author %+v", episodes.Author)
|
||
}
|
||
if episodes.URL != "https://www.themoviedb.org/tv/95396" {
|
||
t.Fatalf("unexpected title URL %q", episodes.URL)
|
||
}
|
||
if episodes.Thumbnail == nil || episodes.Thumbnail.URL != testSeriesPosterCDN {
|
||
t.Fatalf("unexpected thumbnail %+v", episodes.Thumbnail)
|
||
}
|
||
if !strings.HasPrefix(episodes.Description, "Mark leads a team") ||
|
||
!strings.Contains(episodes.Description, "[IMDb](https://www.imdb.com/title/tt11280740/)") ||
|
||
!strings.Contains(episodes.Description, "[TVDB](https://thetvdb.com/dereferrer/series/371980)") {
|
||
t.Fatalf("unexpected description %q", episodes.Description)
|
||
}
|
||
if len(episodes.Fields) != 2 ||
|
||
episodes.Fields[0].Value != "★ 8.7 IMDb" ||
|
||
episodes.Fields[1].Value != "Drama, Sci-Fi & Fantasy, Mystery" {
|
||
t.Fatalf("unexpected fields %+v", episodes.Fields)
|
||
}
|
||
if episodes.Footer == nil || episodes.Footer.Text != "Silo • TV-MA" {
|
||
t.Fatalf("unexpected footer %+v", episodes.Footer)
|
||
}
|
||
|
||
// Embeds may name public provider origins only — never this server's.
|
||
movie := decoded.Embeds[1]
|
||
if movie.URL != "" || movie.Thumbnail != nil {
|
||
t.Fatalf("metadata-less movie must omit url/thumbnail, got %+v", movie)
|
||
}
|
||
}
|
||
|
||
func TestBuildServerChannelDiscordContentAudiobook(t *testing.T) {
|
||
metas := map[string]ContentMeta{
|
||
"book-1": {
|
||
Title: "Project Hail Mary",
|
||
Year: 2021,
|
||
Type: "audiobook",
|
||
Author: "Andy Weir",
|
||
Genres: []string{"Sci-Fi"},
|
||
},
|
||
"ebook-1": {Title: "The Martian", Year: 2011, Type: "ebook", Author: "Andy Weir"},
|
||
}
|
||
groups := GroupContentEvents([]ReleaseEvent{
|
||
itemEvent("1", 1, EventKindAudiobook, "book-1"),
|
||
itemEvent("2", 1, EventKindEbook, "ebook-1"),
|
||
}, metas)
|
||
|
||
body, err := BuildServerChannelDiscordContent(groups, false)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var decoded struct {
|
||
Embeds []discordEmbed `json:"embeds"`
|
||
}
|
||
if err := json.Unmarshal(body, &decoded); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(decoded.Embeds) != 2 {
|
||
t.Fatalf("got %d embeds, want 2", len(decoded.Embeds))
|
||
}
|
||
|
||
audiobook := decoded.Embeds[0]
|
||
if audiobook.Author == nil || audiobook.Author.Name != "New audiobook available on Silo" {
|
||
t.Fatalf("unexpected author line %+v", audiobook.Author)
|
||
}
|
||
if audiobook.Title != "Project Hail Mary (2021)" {
|
||
t.Fatalf("unexpected title %q", audiobook.Title)
|
||
}
|
||
if len(audiobook.Fields) != 2 ||
|
||
audiobook.Fields[0].Name != "Author" || audiobook.Fields[0].Value != "Andy Weir" ||
|
||
audiobook.Fields[1].Name != "Genres" || audiobook.Fields[1].Value != "Sci-Fi" {
|
||
t.Fatalf("unexpected fields %+v", audiobook.Fields)
|
||
}
|
||
|
||
ebook := decoded.Embeds[1]
|
||
if ebook.Author == nil || ebook.Author.Name != "New ebook available on Silo" {
|
||
t.Fatalf("unexpected ebook author line %+v", ebook.Author)
|
||
}
|
||
if len(ebook.Fields) != 1 || ebook.Fields[0].Name != "Author" {
|
||
t.Fatalf("unexpected ebook fields %+v", ebook.Fields)
|
||
}
|
||
}
|
||
|
||
func TestBuildServerChannelGenericContentAudiobook(t *testing.T) {
|
||
metas := map[string]ContentMeta{
|
||
"book-1": {Title: "Project Hail Mary", Year: 2021, Author: "Andy Weir"},
|
||
}
|
||
groups := GroupContentEvents([]ReleaseEvent{
|
||
itemEvent("1", 4, EventKindAudiobook, "book-1"),
|
||
}, metas)
|
||
|
||
body, err := BuildServerChannelGenericContent(groups, "chan-1", false)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var decoded serverChannelContentBody
|
||
if err := json.Unmarshal(body, &decoded); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(decoded.Items) != 1 {
|
||
t.Fatalf("got %d items, want 1", len(decoded.Items))
|
||
}
|
||
item := decoded.Items[0]
|
||
if item.Kind != EventKindAudiobook || item.ItemID != "book-1" ||
|
||
item.Title != "Project Hail Mary" || item.Year != 2021 ||
|
||
item.Author != "Andy Weir" || item.LibraryID != 4 {
|
||
t.Fatalf("unexpected audiobook item %+v", item)
|
||
}
|
||
// Flat items never carry episode span fields.
|
||
if item.EpisodeCount != 0 || item.SeriesID != "" {
|
||
t.Fatalf("unexpected episode fields on flat item %+v", item)
|
||
}
|
||
}
|
||
|
||
func TestBuildServerChannelDiscordContentOverflow(t *testing.T) {
|
||
groups := make([]ContentGroup, 0, 14)
|
||
for i := 0; i < 14; i++ {
|
||
groups = append(groups, ContentGroup{
|
||
Kind: EventKindMovie,
|
||
ItemID: "movie",
|
||
Meta: ContentMeta{Title: "Movie", Year: 2000 + i},
|
||
})
|
||
}
|
||
body, err := BuildServerChannelDiscordContent(groups, false)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var decoded struct {
|
||
Content string `json:"content"`
|
||
Embeds []discordEmbed `json:"embeds"`
|
||
}
|
||
if err := json.Unmarshal(body, &decoded); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(decoded.Embeds) != serverChannelMaxEmbeds {
|
||
t.Fatalf("got %d embeds, want %d", len(decoded.Embeds), serverChannelMaxEmbeds)
|
||
}
|
||
if !strings.Contains(decoded.Content, "4 more") {
|
||
t.Fatalf("overflow line missing, got %q", decoded.Content)
|
||
}
|
||
// Newest groups are kept: the first four (oldest) drop.
|
||
if decoded.Embeds[0].Title != "Movie (2004)" {
|
||
t.Fatalf("expected oldest retained embed to be Movie (2004), got %q", decoded.Embeds[0].Title)
|
||
}
|
||
}
|
||
|
||
func TestBuildServerChannelGenericContent(t *testing.T) {
|
||
titles := map[string]ContentMeta{"series-1": {Title: testSeriesTitle}, "movie-1": {Title: testMovieTitle, Year: 2026}}
|
||
groups := GroupContentEvents([]ReleaseEvent{
|
||
episodeEvent("1", 7, "series-1", 2, 1),
|
||
episodeEvent("2", 7, "series-1", 2, 3),
|
||
movieEvent("3", 7, "movie-1"),
|
||
}, titles)
|
||
|
||
body, err := BuildServerChannelGenericContent(groups, "chan-1", true)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var decoded serverChannelContentBody
|
||
if err := json.Unmarshal(body, &decoded); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if decoded.Event != ServerChannelEventContentAdded || decoded.ChannelID != "chan-1" || !decoded.Test {
|
||
t.Fatalf("unexpected envelope %+v", decoded)
|
||
}
|
||
if len(decoded.Items) != 2 {
|
||
t.Fatalf("got %d items, want 2", len(decoded.Items))
|
||
}
|
||
episodes := decoded.Items[0]
|
||
if episodes.SeriesTitle != testSeriesTitle || episodes.EpisodeCount != 2 ||
|
||
episodes.FirstSeason != 2 || episodes.FirstEpisode != 1 ||
|
||
episodes.LastSeason != 2 || episodes.LastEpisode != 3 {
|
||
t.Fatalf("unexpected episode item %+v", episodes)
|
||
}
|
||
movie := decoded.Items[1]
|
||
if movie.Kind != EventKindMovie || movie.Title != testMovieTitle || movie.Year != 2026 || movie.LibraryID != 7 {
|
||
t.Fatalf("unexpected movie item %+v", movie)
|
||
}
|
||
}
|
||
|
||
func TestBuildServerChannelRequestPayloads(t *testing.T) {
|
||
info := RequestEventInfo{
|
||
RequestID: "req-1",
|
||
TMDBID: 42,
|
||
MediaType: "movie",
|
||
Title: testMovieTitle,
|
||
Year: 2026,
|
||
Overview: "Paul Atreides unites with the Fremen.",
|
||
PosterPath: "/dune.jpg",
|
||
RequesterName: "quick",
|
||
}
|
||
|
||
body, err := BuildServerChannelRequestDiscord(ServerChannelEventRequestSubmitted, info)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var discord discordWebhookBody
|
||
if err := json.Unmarshal(body, &discord); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(discord.Embeds) != 1 {
|
||
t.Fatalf("got %d embeds, want 1", len(discord.Embeds))
|
||
}
|
||
embed := discord.Embeds[0]
|
||
if embed.Title != "Dune (2026)" {
|
||
t.Fatalf("unexpected title %q", embed.Title)
|
||
}
|
||
if embed.Author == nil || embed.Author.Name != "New media request on Silo" {
|
||
t.Fatalf("unexpected author %+v", embed.Author)
|
||
}
|
||
if embed.URL != "https://www.themoviedb.org/movie/42" {
|
||
t.Fatalf("unexpected title URL %q", embed.URL)
|
||
}
|
||
if !strings.HasPrefix(embed.Description, "Paul Atreides unites") ||
|
||
!strings.Contains(embed.Description, "[TMDB](https://www.themoviedb.org/movie/42)") {
|
||
t.Fatalf("unexpected description %q", embed.Description)
|
||
}
|
||
if embed.Thumbnail == nil || embed.Thumbnail.URL != "https://image.tmdb.org/t/p/w500/dune.jpg" {
|
||
t.Fatalf("unexpected thumbnail %+v", embed.Thumbnail)
|
||
}
|
||
if len(embed.Fields) != 2 || embed.Fields[1].Value != "quick" {
|
||
t.Fatalf("unexpected fields %+v", embed.Fields)
|
||
}
|
||
// Without a resolved Discord identity there is no mention.
|
||
if discord.Content != "" || discord.AllowedMentions != nil {
|
||
t.Fatalf("unexpected mention without discord id: content=%q mentions=%+v",
|
||
discord.Content, discord.AllowedMentions)
|
||
}
|
||
|
||
generic, err := BuildServerChannelRequestGeneric(ServerChannelEventRequestDeclined, info, "chan-1")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var decoded serverChannelRequestBody
|
||
if err := json.Unmarshal(generic, &decoded); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if decoded.Event != ServerChannelEventRequestDeclined || decoded.Request.ID != "req-1" ||
|
||
decoded.Request.RequesterName != "quick" {
|
||
t.Fatalf("unexpected generic body %+v", decoded)
|
||
}
|
||
}
|
||
|
||
func TestBuildServerChannelRequestDiscordMentionsRequester(t *testing.T) {
|
||
info := RequestEventInfo{
|
||
RequestID: "req-1",
|
||
TMDBID: 42,
|
||
MediaType: "movie",
|
||
Title: testMovieTitle,
|
||
RequesterName: "quick",
|
||
RequesterUserID: 7,
|
||
RequesterDiscordID: "123456789",
|
||
}
|
||
|
||
body, err := BuildServerChannelRequestDiscord(ServerChannelEventRequestApproved, info)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var discord discordWebhookBody
|
||
if err := json.Unmarshal(body, &discord); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if discord.Content != "<@123456789>" {
|
||
t.Fatalf("unexpected content %q", discord.Content)
|
||
}
|
||
if discord.AllowedMentions == nil ||
|
||
len(discord.AllowedMentions.Users) != 1 || discord.AllowedMentions.Users[0] != "123456789" {
|
||
t.Fatalf("unexpected allowed mentions %+v", discord.AllowedMentions)
|
||
}
|
||
// Parse must serialize as an empty list (not be omitted) so Discord's
|
||
// implicit mention parsing stays disabled.
|
||
if !strings.Contains(string(body), `"parse":[]`) {
|
||
t.Fatalf("allowed_mentions.parse not serialized: %s", body)
|
||
}
|
||
// The embed keeps the plain username; only the content line pings.
|
||
if len(discord.Embeds) != 1 || discord.Embeds[0].Fields[1].Value != "quick" {
|
||
t.Fatalf("unexpected embed fields %+v", discord.Embeds)
|
||
}
|
||
|
||
// The generic payload never carries the Discord identity.
|
||
generic, err := BuildServerChannelRequestGeneric(ServerChannelEventRequestApproved, info, "chan-1")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if strings.Contains(string(generic), "123456789") {
|
||
t.Fatalf("discord id leaked into generic payload: %s", generic)
|
||
}
|
||
}
|
||
|
||
func TestServerChannelHeadersSigned(t *testing.T) {
|
||
body := []byte(`{"event":"content.added"}`)
|
||
now := time.Unix(1_700_000_000, 0)
|
||
headers := serverChannelHeaders(ServerChannelEventContentAdded, "chan-1", "secret", now, body)
|
||
if headers["X-Silo-Event"] != ServerChannelEventContentAdded {
|
||
t.Fatalf("unexpected event header %q", headers["X-Silo-Event"])
|
||
}
|
||
if headers["X-Silo-Channel-Id"] != "chan-1" {
|
||
t.Fatalf("unexpected channel header %q", headers["X-Silo-Channel-Id"])
|
||
}
|
||
want := SignGenericWebhook("secret", now.Unix(), body)
|
||
if headers["X-Silo-Signature"] != want {
|
||
t.Fatalf("signature mismatch: %q != %q", headers["X-Silo-Signature"], want)
|
||
}
|
||
}
|
||
|
||
func TestReleaseDedupeKeysAreDisjoint(t *testing.T) {
|
||
episodeKey := EpisodeDedupeKey(3, "series-abc", EpisodeKey(2, 4))
|
||
if episodeKey != "episode:3:series-abc:2000004" {
|
||
t.Fatalf("unexpected episode dedupe key %q", episodeKey)
|
||
}
|
||
if ItemDedupeKey(EventKindMovie, 3, "series-abc:2000004") == episodeKey {
|
||
t.Fatal("movie and episode dedupe keys must live in separate keyspaces")
|
||
}
|
||
if got := ItemDedupeKey(EventKindMovie, 3, "abc"); got != "movie:3:abc" {
|
||
t.Fatalf("unexpected movie dedupe key %q", got)
|
||
}
|
||
if got := ItemDedupeKey(EventKindAudiobook, 3, "abc"); got != "audiobook:3:abc" {
|
||
t.Fatalf("unexpected audiobook dedupe key %q", got)
|
||
}
|
||
if ItemDedupeKey(EventKindAudiobook, 3, "abc") == ItemDedupeKey(EventKindEbook, 3, "abc") {
|
||
t.Fatal("flat kinds must keep disjoint dedupe keyspaces")
|
||
}
|
||
}
|
||
|
||
func TestPartitionEventsByKind(t *testing.T) {
|
||
legacy := episodeEvent("2", 1, "s", 1, 2)
|
||
legacy.Kind = "" // rows that predate the kind column
|
||
events := []ReleaseEvent{
|
||
episodeEvent("1", 1, "s", 1, 1),
|
||
legacy,
|
||
movieEvent("3", 1, "m"),
|
||
episodeEvent("4", 1, "s", 1, 3),
|
||
itemEvent("5", 1, EventKindAudiobook, "b"),
|
||
itemEvent("6", 1, EventKindEbook, "e"),
|
||
}
|
||
episodes, others := PartitionEventsByKind(events)
|
||
if len(episodes) != 3 || len(others) != 3 {
|
||
t.Fatalf("got %d/%d, want 3 episodes and 3 others", len(episodes), len(others))
|
||
}
|
||
if episodes[0].ID != "1" || episodes[1].ID != "2" || episodes[2].ID != "4" {
|
||
t.Fatalf("episode order not preserved: %+v", episodes)
|
||
}
|
||
if others[0].ID != "3" || others[1].ID != "5" || others[2].ID != "6" {
|
||
t.Fatalf("unexpected non-episode partition: %+v", others)
|
||
}
|
||
|
||
// The fanout path applies the burst cap only to the episode partition;
|
||
// movies with empty series_id must never reach it, or they would all
|
||
// collapse into one (library, "") burst group.
|
||
fanout, suppressed := ApplyBurstCap(episodes, 2)
|
||
if len(fanout)+len(suppressed) != len(episodes) {
|
||
t.Fatalf("burst cap lost events: %d + %d != %d", len(fanout), len(suppressed), len(episodes))
|
||
}
|
||
for _, event := range append(fanout, suppressed...) {
|
||
if event.Kind == EventKindMovie {
|
||
t.Fatal("movie event leaked into burst-capped fanout set")
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestMaxCursor(t *testing.T) {
|
||
// Watermark advancement clamps through maxCursor so a watermark can never
|
||
// move backward (used by the server-channel sweep and digest legs).
|
||
earlier := Cursor{CreatedAt: time.Unix(1000, 0), ID: "a"}
|
||
later := Cursor{CreatedAt: time.Unix(2000, 0), ID: "b"}
|
||
if got := maxCursor(earlier, later); got != later {
|
||
t.Fatalf("maxCursor(earlier, later) = %+v, want later", got)
|
||
}
|
||
if got := maxCursor(later, earlier); got != later {
|
||
t.Fatalf("maxCursor(later, earlier) = %+v, want later", got)
|
||
}
|
||
// Same timestamp orders by id, matching the delivery queries.
|
||
tie := Cursor{CreatedAt: time.Unix(2000, 0), ID: "c"}
|
||
if got := maxCursor(later, tie); got != tie {
|
||
t.Fatalf("maxCursor id tiebreak = %+v, want %+v", got, tie)
|
||
}
|
||
}
|