Files
memby/server/internal/api/preferences_test.go
T

199 lines
7.7 KiB
Go
Raw Normal View History

2026-08-06 22:33:56 +12:00
package api
import (
"encoding/json"
"reflect"
"testing"
)
// The document a television applies must always be complete. A missing key is a setting
// with no value on the other end, which is how a launcher ends up with no rows.
func TestNormalizePreferencesFillsEveryKnownKey(t *testing.T) {
result := normalizePreferences(nil)
if len(result) != len(preferenceCatalogue) {
t.Fatalf("got %d keys, want %d", len(result), len(preferenceCatalogue))
}
for _, definition := range preferenceCatalogue {
if _, ok := result[definition.Key]; !ok {
t.Errorf("%s missing from a normalised document", definition.Key)
}
}
}
func TestNormalizePreferencesDropsUnknownKeys(t *testing.T) {
result := normalizePreferences(map[string]any{"embyToken": "secret", "showTitleLogo": false})
if _, ok := result["embyToken"]; ok {
t.Error("an unknown key survived normalisation")
}
if result["showTitleLogo"] != false {
t.Errorf("showTitleLogo = %v, want false", result["showTitleLogo"])
}
}
2026-08-17 11:41:36 +12:00
func TestNormalizePreferencesBoundsAndNormalisesProfileInitials(t *testing.T) {
if got := normalizePreferences(map[string]any{"profileInitials": " mc "})["profileInitials"]; got != "MC" {
t.Errorf("profileInitials = %v, want MC", got)
}
for _, value := range []any{"MAT", "M\nC", 12} {
if got := normalizePreferences(map[string]any{"profileInitials": value})["profileInitials"]; got != "" {
t.Errorf("profileInitials for %v = %v, want automatic fallback", value, got)
}
}
}
func TestDevicePreferenceWritePreservesAdminInitials(t *testing.T) {
stored, err := json.Marshal(normalizePreferences(map[string]any{"profileInitials": "MC"}))
if err != nil {
t.Fatal(err)
}
merged := preserveAdminPreferences(map[string]any{
"profileInitials": "XX", "showTitleLogo": false,
}, stored)
normalised := normalizePreferences(merged)
if normalised["profileInitials"] != "MC" {
t.Errorf("profileInitials = %v, want preserved MC", normalised["profileInitials"])
}
if normalised["showTitleLogo"] != false {
t.Errorf("showTitleLogo = %v, want device change false", normalised["showTitleLogo"])
}
}
2026-08-06 22:33:56 +12:00
func TestNormalizePreferencesRejectsIllegalValues(t *testing.T) {
result := normalizePreferences(map[string]any{
"homeCardDensity": "enormous",
"showRatingsStrip": "yes",
"forYouMinutes": 45,
"homeSections": []any{"nonsense"},
})
if result["homeCardDensity"] != "standard" {
t.Errorf("homeCardDensity = %v, want the default", result["homeCardDensity"])
}
if result["showRatingsStrip"] != true {
t.Errorf("showRatingsStrip = %v, want the default", result["showRatingsStrip"])
}
if result["forYouMinutes"] != 0 {
t.Errorf("forYouMinutes = %v, want the default", result["forYouMinutes"])
}
// Every named row was unknown, which leaves nothing to draw — the default stands in
// rather than an empty launcher being honoured as a choice.
want := []string{"continue", "favorites", "latest"}
if !reflect.DeepEqual(result["homeSections"], want) {
t.Errorf("homeSections = %v, want %v", result["homeSections"], want)
}
}
// The order of a multi-select is the setting, not an implementation detail: it is the
// order the rows appear in on the launcher.
func TestNormalizePreferencesKeepsMultiOrderAndDedupes(t *testing.T) {
result := normalizePreferences(map[string]any{
"homeSections": []any{"latest", "continue", "latest", "unknown"},
})
want := []string{"latest", "continue"}
if !reflect.DeepEqual(result["homeSections"], want) {
t.Errorf("homeSections = %v, want %v", result["homeSections"], want)
}
}
// Free-form row ids are stored newline-separated on the television, so an id containing
// one would come back as two rows on the next sync.
func TestNormalizePreferencesRejectsNewlinesInRowIds(t *testing.T) {
result := normalizePreferences(map[string]any{
"homeRowOrder": []any{"recommended", "bad\nid", " ", "recommended", "latest"},
})
want := []string{"recommended", "latest"}
if !reflect.DeepEqual(result["homeRowOrder"], want) {
t.Errorf("homeRowOrder = %v, want %v", result["homeRowOrder"], want)
}
}
func TestNormalizePreferencesBoundsListLength(t *testing.T) {
ids := make([]any, 0, maxListEntries+50)
for i := 0; i < maxListEntries+50; i++ {
ids = append(ids, string(rune('a'+i%26))+string(rune('a'+i/26)))
}
result := normalizePreferences(map[string]any{"homeHiddenRows": ids})
if got := len(result["homeHiddenRows"].([]string)); got > maxListEntries {
t.Errorf("kept %d ids, want at most %d", got, maxListEntries)
}
}
// A default that is a slice must be copied out, or a caller mutating one user's document
// edits the catalogue for every user for the life of the process.
func TestNormalizePreferencesDoesNotShareSliceDefaults(t *testing.T) {
first := normalizePreferences(nil)
first["homeSections"].([]string)[0] = "tampered"
second := normalizePreferences(nil)
if second["homeSections"].([]string)[0] != "continue" {
t.Fatal("mutating one document changed the catalogue default")
}
}
// Numbers arrive from encoding/json as float64; a document that has been through the wire
// must normalise identically to one built in Go.
func TestNormalizePreferencesAcceptsJSONNumbers(t *testing.T) {
var decoded map[string]any
if err := json.Unmarshal([]byte(`{"forYouMinutes":60}`), &decoded); err != nil {
t.Fatal(err)
}
if got := normalizePreferences(decoded)["forYouMinutes"]; got != 60 {
t.Errorf("forYouMinutes = %v, want 60", got)
}
}
// The client applies whatever comes back, so a round trip through storage must be a
// fixed point — otherwise two televisions could disagree about what they just agreed on.
func TestNormalizePreferencesIsIdempotentThroughJSON(t *testing.T) {
once := normalizePreferences(map[string]any{
"homeSections": []any{"favorites"}, "homeCardDensity": "large",
"hideWatchedMovies": true, "homeRowOrder": []any{"recommended"},
})
raw, err := json.Marshal(once)
if err != nil {
t.Fatal(err)
}
twice := decodePreferences(raw)
rawOnce, _ := json.Marshal(once)
rawTwice, _ := json.Marshal(twice)
if string(rawOnce) != string(rawTwice) {
t.Errorf("round trip changed the document:\n once: %s\ntwice: %s", rawOnce, rawTwice)
}
}
// The catalogue is the contract the admin console and the television both read. A default
// that is not itself a legal value would hand every new viewer something the editor
// cannot represent.
func TestPreferenceCatalogueDefaultsAreLegal(t *testing.T) {
seen := map[string]bool{}
for _, definition := range preferenceCatalogue {
if seen[definition.Key] {
t.Errorf("%s appears in the catalogue twice", definition.Key)
}
seen[definition.Key] = true
if definition.Name == "" || definition.Area == "" {
t.Errorf("%s needs a name and an area to render in the console", definition.Key)
}
normalised := normalizePreference(definition, defaultValue(definition))
if !reflect.DeepEqual(normalised, defaultValue(definition)) {
t.Errorf("%s default %v is not a legal value (normalises to %v)",
definition.Key, definition.Default, normalised)
}
}
}
// The skip interval is a number from a fixed list, and the television normalises it a
// second time. Both ends have to agree on which values exist, or a viewer's choice comes
// back as the default the moment they change some other setting.
func TestNormalizePreferencesSeekInterval(t *testing.T) {
if got := normalizePreferences(map[string]any{"seekIntervalSeconds": 30})["seekIntervalSeconds"]; got != 30 {
t.Errorf("seekIntervalSeconds = %v, want 30", got)
}
// 15 is not offered; neither is a string. Both fall back rather than reaching a player
// as a step size nothing on the television knows how to label.
for _, value := range []any{15, "30", 0, nil} {
got := normalizePreferences(map[string]any{"seekIntervalSeconds": value})["seekIntervalSeconds"]
if got != 10 {
t.Errorf("seekIntervalSeconds for %v = %v, want the default 10", value, got)
}
}
}