package store import ( "testing" "time" ) // A shadow id must never be mistaken for an Emby user id: the two are substituted for one // another in twenty tables that cannot tell the difference, so telling them apart by // inspection is the safety property the whole scheme rests on. func TestShadowViewerIDIsDistinguishableFromEmbyUserID(t *testing.T) { id, err := NewShadowViewerID() if err != nil { t.Fatalf("mint shadow id: %v", err) } if !IsShadowViewerID(id) { t.Fatalf("minted id %q not recognised as a shadow id", id) } // Emby's are 32 hex characters with no prefix. if IsShadowViewerID("8f14e45fceea167a5a36dedd4bea2543") { t.Fatal("an Emby user id was read as a shadow viewer") } if IsShadowViewerID("") || IsShadowViewerID("v") || IsShadowViewerID("viewer") { t.Fatal("a short string was read as a shadow viewer") } other, err := NewShadowViewerID() if err != nil { t.Fatalf("mint second shadow id: %v", err) } if other == id { t.Fatal("two minted ids collided") } } func TestPlayedFromPosition(t *testing.T) { const hour = int64(36_000_000_000) // one hour in Emby ticks for _, tc := range []struct { name string position int64 runtime int64 want bool }{ {"finished", hour, hour, true}, {"at the threshold", hour * 9 / 10, hour, true}, {"just short of it", hour*9/10 - 1, hour, false}, {"barely started", hour / 100, hour, false}, // A runtime of zero means the length was not known, not that the title is zero // long. Reading it the other way marks everything watched at the first second. {"unknown runtime", hour, 0, false}, {"nothing watched", 0, hour, false}, {"negative position", -hour, hour, false}, // Playing past the stated runtime is ordinary — a container whose duration is a // little short of its own last frame. {"past the end", hour * 2, hour, true}, } { t.Run(tc.name, func(t *testing.T) { if got := PlayedFromPosition(tc.position, tc.runtime); got != tc.want { t.Fatalf("PlayedFromPosition(%d, %d) = %v, want %v", tc.position, tc.runtime, got, tc.want) } }) } } // The rollup a series card's tick and count are built from. It is done in Go rather than in // SQL — the query groups by the season/series pair and this folds it twice — so it is worth // pinning that both directions add up and that the date is the latest of them. func TestViewerAggregateRollup(t *testing.T) { earlier := time.Date(2026, 8, 1, 20, 0, 0, 0, time.UTC) later := time.Date(2026, 8, 18, 21, 30, 0, 0, time.UTC) aggregates := map[string]ViewerAggregate{} // Two seasons of one show, folded into the series and kept apart per season. addViewerAggregate(aggregates, "show-1", 10, 10, &earlier) addViewerAggregate(aggregates, "season-1", 10, 10, &earlier) addViewerAggregate(aggregates, "show-1", 8, 3, &later) addViewerAggregate(aggregates, "season-2", 8, 3, &later) series := aggregates["show-1"] if series.Total != 18 || series.Played != 13 { t.Errorf("series rollup = %d of %d, want 13 of 18", series.Played, series.Total) } if series.LastPlayedAt == nil || !series.LastPlayedAt.Equal(later) { t.Errorf("series last played = %v, want the later of the two", series.LastPlayedAt) } if got := aggregates["season-1"]; got.Total != 10 || got.Played != 10 { t.Errorf("season one = %d of %d, want 10 of 10", got.Played, got.Total) } if got := aggregates["season-2"]; got.Total != 8 || got.Played != 3 { t.Errorf("season two = %d of %d, want 3 of 8", got.Played, got.Total) } // An episode filed under no series or no season contributes to neither, rather than to // a row keyed on the empty string — which would be an aggregate about nothing. addViewerAggregate(aggregates, "", 5, 5, &later) if _, ok := aggregates[""]; ok { t.Error("an unfiled episode produced an aggregate") } }