Files
silo-server/internal/notifications/server_channel_logic_test.go
866392fecd feat(notifications): announce new audiobooks and ebooks on server channels (#260)
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>
2026-07-02 11:42:28 -04:00

661 lines
22 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}