package api import ( "context" "encoding/json" "testing" "time" "github.com/ponzischeme89/memby/server/internal/store" ) // The television has one UserData reader and it must not be able to tell where the block // came from. This pins the field names against the client's UserItemData, which is the // contract that keeps viewers from becoming a special case in every screen. func TestViewerUserDataIsShapedLikeEmbys(t *testing.T) { played := time.Date(2026, 8, 19, 21, 14, 0, 0, time.UTC) raw := viewerUserData(store.ViewerState{ ItemID: "982173", PositionTicks: 15_420_000_000, RuntimeTicks: 30_840_000_000, PlayCount: 2, Favourite: true, LastPlayedAt: &played, }) var parsed struct { IsFavorite bool `json:"IsFavorite"` Played bool `json:"Played"` PlaybackPositionTicks int64 `json:"PlaybackPositionTicks"` PlayCount int `json:"PlayCount"` PlayedPercentage *float64 `json:"PlayedPercentage"` LastPlayedDate string `json:"LastPlayedDate"` } if err := json.Unmarshal(raw, &parsed); err != nil { t.Fatalf("decode viewer user data: %v", err) } if !parsed.IsFavorite { t.Error("favourite was not carried") } if parsed.Played { t.Error("a part-watched title was reported as played") } if parsed.PlaybackPositionTicks != 15_420_000_000 { t.Errorf("position = %d", parsed.PlaybackPositionTicks) } if parsed.PlayCount != 2 { t.Errorf("play count = %d", parsed.PlayCount) } if parsed.PlayedPercentage == nil || *parsed.PlayedPercentage < 49 || *parsed.PlayedPercentage > 51 { t.Errorf("played percentage = %v, want about 50", parsed.PlayedPercentage) } if parsed.LastPlayedDate != "2026-08-19T21:14:00Z" { t.Errorf("last played = %q", parsed.LastPlayedDate) } } // A title nobody has touched has to render as untouched rather than as a card claiming a // zero-length progress bar, so the two optional fields are omitted rather than sent empty. func TestViewerUserDataOmitsWhatItDoesNotKnow(t *testing.T) { raw := viewerUserData(store.ViewerState{ItemID: "982173"}) var fields map[string]any if err := json.Unmarshal(raw, &fields); err != nil { t.Fatalf("decode viewer user data: %v", err) } if _, ok := fields["PlayedPercentage"]; ok { t.Error("a percentage was claimed for a title with no runtime or position") } if _, ok := fields["LastPlayedDate"]; ok { t.Error("a play date was claimed for a title that has never been played") } if fields["Played"] != false || fields["IsFavorite"] != false { t.Errorf("untouched state rendered as %v", fields) } } // IsMain is what every mutation branches on, so it is worth stating that it reads the // stored kind rather than guessing from the id. func TestViewerIsMainReadsTheStoredKind(t *testing.T) { if !(store.Viewer{ID: "abc", Kind: store.ViewerMain}).IsMain() { t.Error("a main viewer did not report as main") } if (store.Viewer{ID: "abc", Kind: store.ViewerShadow}).IsMain() { t.Error("a shadow viewer reported as main") } if (store.Viewer{ID: "abc"}).IsMain() { t.Error("a viewer with no kind reported as main") } } // The ids are the answer, not just the selection: a shadow viewer's Continue Watching is // ordered by their own history in Postgres, and Emby answers an Ids= query in its own // order. Handing that back would keep the right titles and discard the reason for them. func TestOrderItemsByIDRestoresTheOrderAskedFor(t *testing.T) { item := func(id string) json.RawMessage { return json.RawMessage(`{"Id":"` + id + `","Name":"` + id + `"}`) } idsOf := func(items []json.RawMessage) []string { out := []string{} for _, raw := range items { out = append(out, itemIDOf(raw)) } return out } t.Run("emby's order is replaced", func(t *testing.T) { got := orderItemsByID( []json.RawMessage{item("c"), item("a"), item("b")}, []string{"b", "c", "a"}, ) want := []string{"b", "c", "a"} if diff := idsOf(got); !equalStrings(diff, want) { t.Fatalf("order = %v, want %v", diff, want) } }) // A title the catalogue still names but Emby will not answer for leaves a card that // cannot be opened, so it is dropped instead. t.Run("a missing title is dropped", func(t *testing.T) { got := orderItemsByID([]json.RawMessage{item("a")}, []string{"a", "gone", "b"}) if diff := idsOf(got); !equalStrings(diff, []string{"a"}) { t.Fatalf("order = %v, want [a]", diff) } }) // Every keyed list on the television throws on a repeated key, and a paging boundary // or a duplicated row is exactly where an id comes back twice. t.Run("a repeated id draws one card", func(t *testing.T) { got := orderItemsByID([]json.RawMessage{item("a"), item("a")}, []string{"a", "a"}) if diff := idsOf(got); !equalStrings(diff, []string{"a"}) { t.Fatalf("order = %v, want one card", diff) } }) // Anything Emby volunteers that was not asked for is not part of the answer. t.Run("an unasked title is dropped", func(t *testing.T) { got := orderItemsByID([]json.RawMessage{item("a"), item("z")}, []string{"a"}) if diff := idsOf(got); !equalStrings(diff, []string{"a"}) { t.Fatalf("order = %v, want [a]", diff) } }) } // The account's UserData must be replaced outright rather than merged: a partial overlay // leaves whichever fields Memby had nothing to say about carrying the account's values. func TestInjectItemUserDataReplacesRatherThanMerges(t *testing.T) { raw := json.RawMessage( `{"Id":"1","Name":"Anatomy of a Fall",` + `"UserData":{"Played":true,"PlaybackPositionTicks":9999,"IsFavorite":true,"PlayCount":4}}`) out := injectItemUserData(raw, viewerUserData(store.ViewerState{ItemID: "1"})) var parsed struct { Name string `json:"Name"` UserData struct { Played bool `json:"Played"` PlaybackPositionTicks int64 `json:"PlaybackPositionTicks"` IsFavorite bool `json:"IsFavorite"` PlayCount int `json:"PlayCount"` } `json:"UserData"` } if err := json.Unmarshal(out, &parsed); err != nil { t.Fatalf("decode: %v", err) } if parsed.Name != "Anatomy of a Fall" { t.Errorf("the rest of the item was disturbed: name = %q", parsed.Name) } if parsed.UserData.Played || parsed.UserData.IsFavorite { t.Error("the account's watched or favourite state survived") } if parsed.UserData.PlaybackPositionTicks != 0 || parsed.UserData.PlayCount != 0 { t.Errorf("the account's position or play count survived: %+v", parsed.UserData) } } // A series and a season are answered from a count of episodes rather than from a row of // their own, so they have to be told apart from the leaf items around them. func TestAggregateItemsAreRecognised(t *testing.T) { for _, tc := range []struct { itemType string want bool }{ {"Series", true}, {"Season", true}, {"Episode", false}, {"Movie", false}, {"", false}, } { raw := json.RawMessage(`{"Id":"1","Type":"` + tc.itemType + `"}`) if got := isAggregateItem(raw); got != tc.want { t.Errorf("isAggregateItem(%q) = %v, want %v", tc.itemType, got, tc.want) } } } // Everything downstream keys its cached views on this, so a request that never passed // through authed must still answer with the value those keys held before viewers existed. func TestViewerOfFallsBackToTheAccount(t *testing.T) { sess := store.Session{EmbyUserID: "emby-user-1", Username: "Matt"} viewer := viewerOf(context.Background(), sess) if !viewer.IsMain() || viewer.ID != "emby-user-1" { t.Fatalf("fallback viewer = %+v, want the account as main", viewer) } if got := viewerKeyOf(context.Background(), sess); got != "emby-user-1" { t.Fatalf("cache key = %q, want the Emby user id", got) } shadow := store.Viewer{ID: "v0123", Name: "Alessandra", Kind: store.ViewerShadow} ctx := withViewer(context.Background(), shadow) if got := viewerKeyOf(ctx, sess); got != "v0123" { t.Fatalf("cache key = %q, want the shadow viewer", got) } } func equalStrings(a, b []string) bool { if len(a) != len(b) { return false } for i := range a { if a[i] != b[i] { return false } } return true } // The last place a shadow viewer was shown the account's answer. A series card draws its // tick and its "left to watch" from these fields, and Emby fills them in from children it // has never heard of for this person. func TestViewerAggregateUserDataCountsWhatIsLeft(t *testing.T) { played := time.Date(2026, 8, 18, 20, 5, 0, 0, time.UTC) raw := viewerAggregateUserData( store.ViewerState{ItemID: "series-1", Favourite: true}, store.ViewerAggregate{Total: 10, Played: 4, LastPlayedAt: &played}, ) var parsed struct { IsFavorite bool `json:"IsFavorite"` Played bool `json:"Played"` PlaybackPositionTicks int64 `json:"PlaybackPositionTicks"` UnplayedItemCount *int `json:"UnplayedItemCount"` PlayedPercentage *float64 `json:"PlayedPercentage"` LastPlayedDate string `json:"LastPlayedDate"` } if err := json.Unmarshal(raw, &parsed); err != nil { t.Fatalf("decode aggregate user data: %v", err) } // The favourite is the container's own — somebody marks a show, not the sum of its // episodes — so it comes from the row rather than from the count. if !parsed.IsFavorite { t.Error("the viewer's own favourite on the series was dropped") } if parsed.Played { t.Error("a part-watched series reported as finished") } if parsed.UnplayedItemCount == nil || *parsed.UnplayedItemCount != 6 { t.Errorf("unplayed = %v, want 6", parsed.UnplayedItemCount) } if parsed.PlayedPercentage == nil || *parsed.PlayedPercentage < 39 || *parsed.PlayedPercentage > 41 { t.Errorf("played percentage = %v, want about 40", parsed.PlayedPercentage) } // A container is never resumable; what resumes is an episode. if parsed.PlaybackPositionTicks != 0 { t.Errorf("a series carried a resume position: %d", parsed.PlaybackPositionTicks) } if parsed.LastPlayedDate != "2026-08-18T20:05:00Z" { t.Errorf("last played = %q", parsed.LastPlayedDate) } finished := viewerAggregateUserData( store.ViewerState{}, store.ViewerAggregate{Total: 10, Played: 10}, ) var done map[string]any if err := json.Unmarshal(finished, &done); err != nil { t.Fatalf("decode: %v", err) } if done["Played"] != true { t.Errorf("a fully watched series did not report as played: %v", done) } if count, ok := done["UnplayedItemCount"].(float64); !ok || count != 0 { t.Errorf("unplayed on a finished series = %v, want 0", done["UnplayedItemCount"]) } } // "Nothing left to watch" and "I cannot say how much there is" are different answers, and // only one of them is true for a library the catalogue has not imported yet. Ticking every // show in the house is the worst thing this could do. func TestViewerAggregateUserDataSaysNothingItCannotCount(t *testing.T) { raw := viewerAggregateUserData(store.ViewerState{}, store.ViewerAggregate{}) var fields map[string]any if err := json.Unmarshal(raw, &fields); err != nil { t.Fatalf("decode: %v", err) } if _, ok := fields["UnplayedItemCount"]; ok { t.Error("a count was claimed for a series the catalogue cannot count") } if _, ok := fields["PlayedPercentage"]; ok { t.Error("a percentage was claimed with nothing to divide by") } if _, ok := fields["LastPlayedDate"]; ok { t.Error("a play date was claimed for a series nobody has watched") } if fields["Played"] != false { t.Errorf("an uncountable series reported as watched: %v", fields) } } // A season is looked up by its own id, and its series is asked for alongside it, because // the catalogue's indexed column is the series. Getting that wrong costs the season card // its count on exactly the page — a series detail page — where seasons appear. func TestContainerIDsCollectSeriesAndSeasons(t *testing.T) { items := []json.RawMessage{ json.RawMessage(`{"Id":"ep-1","Type":"Episode","SeriesId":"show-1"}`), json.RawMessage(`{"Id":"show-1","Type":"Series"}`), json.RawMessage(`{"Id":"season-2","Type":"Season","SeriesId":"show-2"}`), json.RawMessage(`{"Id":"season-2","Type":"Season","SeriesId":"show-2"}`), json.RawMessage(`{"Id":"film-1","Type":"Movie"}`), } seriesIDs, seasonIDs := containerIDsIn([][]json.RawMessage{items}) if !equalStrings(seasonIDs, []string{"season-2"}) { t.Errorf("seasons = %v, want one season and no repeat", seasonIDs) } // show-2 is there because the season named it; show-1 because it is a card in its own // right. Neither the episode nor the film contributes a container. if !equalStrings(seriesIDs, []string{"show-1", "show-2"}) { t.Errorf("series = %v, want the series card and the season's parent", seriesIDs) } } // Switched off, an account looks like one nobody has added anybody to — which is how the // television decides not to offer the picker. It must never look like an account whose // list failed to load. func TestMainViewerOnlyLeavesTheAccount(t *testing.T) { viewers := []store.Viewer{ {ID: "emby-user-1", Name: "Matt", Kind: store.ViewerMain}, {ID: "v01", Name: "Alessandra", Kind: store.ViewerShadow}, {ID: "v02", Name: "Guest", Kind: store.ViewerShadow}, } got := mainViewerOnly(viewers) if len(got) != 1 || !got[0].IsMain() || got[0].ID != "emby-user-1" { t.Fatalf("switched-off list = %+v, want the account alone", got) } if mainViewerOnly(nil) != nil { t.Error("a list with no main viewer invented one") } } // The switch is the operator's and rides the ordinary feature machinery, so what is worth // pinning is that it is *in* the catalogue and gated on a capability — a household half of // whose televisions cannot choose between people must not be offered it. func TestViewersIsAnOperatorFeature(t *testing.T) { definition, ok := knownFeature(featureViewers) if !ok { t.Fatal("viewers is not in the feature catalogue") } if definition.Capability != "viewers_v1" { t.Errorf("capability = %q, want viewers_v1", definition.Capability) } // Off by default, and deliberately so: this is the switch deciding where a household's // watched state is written, and a feature that arrives already on is one every server // running this build starts using before anybody decided to. if definition.DefaultEnabled { t.Error("viewers defaults on; it is opted into rather than out of") } }