This commit is contained in:
ponzischeme89
2026-08-20 15:06:00 +12:00
parent 549f9c5eed
commit f1164db2c5
52 changed files with 5441 additions and 158 deletions
+364
View File
@@ -0,0 +1,364 @@
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")
}
}