228 lines
11 KiB
Go
228 lines
11 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/ponzischeme89/memby/server/internal/config"
|
|
"github.com/ponzischeme89/memby/server/internal/store"
|
|
)
|
|
|
|
func TestFeatureEvaluationSupportsDefaultsOverridesAndSafeMode(t *testing.T) {
|
|
definition, ok := knownFeature(featureSonarrPreroll)
|
|
if !ok {
|
|
t.Fatal("preroll is not registered")
|
|
}
|
|
if got := evaluateFeature(store.DefaultFeaturePolicy(), definition, 1); !got.Enabled || got.Source != "default" {
|
|
t.Fatalf("default evaluation = %+v", got)
|
|
}
|
|
off := store.FeaturePolicy{Overrides: map[string]bool{featureSonarrPreroll: false}}
|
|
if got := evaluateFeature(off, definition, 1); got.Enabled || got.Source != "override" {
|
|
t.Fatalf("off evaluation = %+v", got)
|
|
}
|
|
safe := store.FeaturePolicy{Overrides: map[string]bool{featureSonarrPreroll: true}, SafeMode: true}
|
|
if got := evaluateFeature(safe, definition, 1); got.Enabled || got.Source != "safe_mode" {
|
|
t.Fatalf("safe-mode evaluation = %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestFeatureEvaluationFailsClosedForAnIncompatibleClient(t *testing.T) {
|
|
definition := featureDefinition{Key: "future", DefaultEnabled: true, MinimumProtocol: 2}
|
|
got := evaluateFeature(store.DefaultFeaturePolicy(), definition, 1)
|
|
if got.Enabled || got.Compatible || got.Source != "incompatible_client" {
|
|
t.Fatalf("incompatible evaluation = %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestHEVCDirectPlayIsVisibleAndServerControllable(t *testing.T) {
|
|
definition, ok := knownFeature(featureHEVCDirectPlay)
|
|
if !ok || definition.Area != "Playback" || definition.Capability != "video_hevc_decode" {
|
|
t.Fatalf("HEVC feature definition = %+v, found=%v", definition, ok)
|
|
}
|
|
disabled := store.FeaturePolicy{Overrides: map[string]bool{featureHEVCDirectPlay: false}}
|
|
if got := evaluateFeature(disabled, definition, 1); got.Enabled {
|
|
t.Fatalf("HEVC override = %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestGenreBrowserIsServerControlledAndOffByDefault(t *testing.T) {
|
|
definition, ok := knownFeature(featureGenreBrowser)
|
|
if !ok || definition.Capability != "genre_browser_v1" {
|
|
t.Fatalf("genre browser feature definition = %+v, found=%v", definition, ok)
|
|
}
|
|
if got := evaluateFeature(store.DefaultFeaturePolicy(), definition, 1); got.Enabled {
|
|
t.Fatalf("genre browser default = %+v, want disabled", got)
|
|
}
|
|
enabled := store.FeaturePolicy{Overrides: map[string]bool{featureGenreBrowser: true}}
|
|
if got := evaluateFeature(enabled, definition, 1); !got.Enabled || got.Source != "override" {
|
|
t.Fatalf("genre browser override = %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestContinueWatchingIsServerControlledAndOnByDefault(t *testing.T) {
|
|
definition, ok := knownFeature(featureContinueWatching)
|
|
if !ok || definition.Area != "Home" {
|
|
t.Fatalf("Continue Watching feature definition = %+v, found=%v", definition, ok)
|
|
}
|
|
if got := evaluateFeature(store.DefaultFeaturePolicy(), definition, ProtocolVersion); !got.Enabled {
|
|
t.Fatalf("Continue Watching default = %+v, want enabled", got)
|
|
}
|
|
disabled := store.FeaturePolicy{Overrides: map[string]bool{featureContinueWatching: false}}
|
|
if got := evaluateFeature(disabled, definition, ProtocolVersion); got.Enabled || got.Source != "override" {
|
|
t.Fatalf("Continue Watching override = %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestAppFontFamilyIsAValidatedGlobalConfiguration(t *testing.T) {
|
|
definition, ok := configurationDefinitionFor("presentation.fontFamily")
|
|
if !ok || definition.Type != "enum" || len(definition.Scopes) != 1 || definition.Scopes[0] != "global" {
|
|
t.Fatalf("font family definition = %+v, found=%v", definition, ok)
|
|
}
|
|
if err := validateConfigurationValue(definition, json.RawMessage(`"inter"`)); err != nil {
|
|
t.Fatalf("Inter was rejected: %v", err)
|
|
}
|
|
if err := validateConfigurationValue(definition, json.RawMessage(`"unknown"`)); err == nil {
|
|
t.Fatal("unknown font family was accepted")
|
|
}
|
|
}
|
|
|
|
func TestDetailExperienceResolvesDeviceThenUserThenGlobalThenDefault(t *testing.T) {
|
|
sess := store.Session{EmbyUserID: "user-1", DeviceID: "device-1"}
|
|
if got := detailExperienceFor(store.DefaultFeaturePolicy(), sess); got != "v1" {
|
|
t.Fatalf("default detail experience = %q, want v1", got)
|
|
}
|
|
global := store.FeaturePolicy{Values: map[string]json.RawMessage{"detailExperience": json.RawMessage(`"v2"`)}}
|
|
if got := detailExperienceFor(global, sess); got != "v2" {
|
|
t.Fatalf("global detail experience = %q, want v2", got)
|
|
}
|
|
perUser := store.FeaturePolicy{
|
|
Values: map[string]json.RawMessage{"detailExperience": json.RawMessage(`"v2"`)},
|
|
UserValues: map[string]map[string]json.RawMessage{"user-1": {"detailExperience": json.RawMessage(`"v1"`)}},
|
|
}
|
|
if got := detailExperienceFor(perUser, sess); got != "v1" {
|
|
t.Fatalf("per-user detail experience = %q, want v1 to outrank the global v2", got)
|
|
}
|
|
perDevice := store.FeaturePolicy{
|
|
UserValues: map[string]map[string]json.RawMessage{"user-1": {"detailExperience": json.RawMessage(`"v1"`)}},
|
|
DeviceValues: map[string]map[string]json.RawMessage{"device-1": {"detailExperience": json.RawMessage(`"v2"`)}},
|
|
}
|
|
if got := detailExperienceFor(perDevice, sess); got != "v2" {
|
|
t.Fatalf("per-device detail experience = %q, want v2 to outrank the per-user v1", got)
|
|
}
|
|
}
|
|
|
|
func TestDetailExperienceFallsBackToV1OnAnUnrecognisedStoredValue(t *testing.T) {
|
|
sess := store.Session{EmbyUserID: "user-1", DeviceID: "device-1"}
|
|
garbage := store.FeaturePolicy{Values: map[string]json.RawMessage{"detailExperience": json.RawMessage(`"v3"`)}}
|
|
if got := detailExperienceFor(garbage, sess); got != "v1" {
|
|
t.Fatalf("garbage detail experience = %q, want v1", got)
|
|
}
|
|
notAString := store.FeaturePolicy{Values: map[string]json.RawMessage{"detailExperience": json.RawMessage(`42`)}}
|
|
if got := detailExperienceFor(notAString, sess); got != "v1" {
|
|
t.Fatalf("non-string detail experience = %q, want v1", got)
|
|
}
|
|
}
|
|
|
|
func TestClientCapabilitiesAreNormalizedAndBounded(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/status", nil)
|
|
req.Header.Set("X-Memby-Capabilities", " Sonarr_Preroll_V1,server_features_v1,sonarr_preroll_v1,"+
|
|
"this-capability-name-is-deliberately-longer-than-sixty-four-characters-and-is-rejected")
|
|
got := clientCapabilities(req)
|
|
if len(got) != 2 || got[0] != "server_features_v1" || got[1] != "sonarr_preroll_v1" {
|
|
t.Fatalf("capabilities = %#v", got)
|
|
}
|
|
}
|
|
|
|
func TestValidateSectionDefinitionsRejectsDuplicateIDs(t *testing.T) {
|
|
raw := json.RawMessage(`[
|
|
{"id":"a","type":"favorites","title":"Favourites","enabled":true,"position":10,"dataSource":"emby.favourites","component":"mediaRow","destination":"home"},
|
|
{"id":"a","type":"latest","title":"Recently Added","enabled":true,"position":20,"dataSource":"emby.latest","component":"mediaRow","destination":"home"}
|
|
]`)
|
|
if err := validateSectionDefinitions("home.sectionDefinitions", raw); err == nil {
|
|
t.Fatal("duplicate row id was accepted")
|
|
}
|
|
}
|
|
|
|
func TestValidateSectionDefinitionsRejectsDuplicatePositions(t *testing.T) {
|
|
raw := json.RawMessage(`[
|
|
{"id":"a","type":"favorites","title":"Favourites","enabled":true,"position":10,"dataSource":"emby.favourites","component":"mediaRow","destination":"home"},
|
|
{"id":"b","type":"latest","title":"Recently Added","enabled":true,"position":10,"dataSource":"emby.latest","component":"mediaRow","destination":"home"}
|
|
]`)
|
|
if err := validateSectionDefinitions("home.sectionDefinitions", raw); err == nil {
|
|
t.Fatal("duplicate row position was accepted")
|
|
}
|
|
}
|
|
|
|
func TestValidateSectionDefinitionsRejectsMissingTitle(t *testing.T) {
|
|
raw := json.RawMessage(`[{"id":"a","type":"favorites","title":"","enabled":true,"position":10,"dataSource":"emby.favourites","component":"mediaRow","destination":"home"}]`)
|
|
if err := validateSectionDefinitions("home.sectionDefinitions", raw); err == nil {
|
|
t.Fatal("missing title was accepted")
|
|
}
|
|
}
|
|
|
|
func TestValidateSectionDefinitionsRejectsInvalidMaxItems(t *testing.T) {
|
|
raw := json.RawMessage(`[{"id":"a","type":"favorites","title":"Favourites","enabled":true,"position":10,"maxItems":500,"dataSource":"emby.favourites","component":"mediaRow","destination":"home"}]`)
|
|
if err := validateSectionDefinitions("home.sectionDefinitions", raw); err == nil {
|
|
t.Fatal("out-of-range maxItems was accepted")
|
|
}
|
|
}
|
|
|
|
func TestValidateSectionDefinitionsRejectsUnsupportedRowPageCombination(t *testing.T) {
|
|
// Genres is a Movies/TV row type; it has no business on Home.
|
|
raw := json.RawMessage(`[{"id":"a","type":"genres","title":"Genres","enabled":true,"position":10,"dataSource":"emby.genres","component":"genreBrowser","destination":"movies"}]`)
|
|
if err := validateSectionDefinitions("home.sectionDefinitions", raw); err == nil {
|
|
t.Fatal("genres row was accepted on the Home page")
|
|
}
|
|
}
|
|
|
|
func TestValidateSectionDefinitionsRejectsIncompatibleDataSource(t *testing.T) {
|
|
raw := json.RawMessage(`[{"id":"a","type":"favorites","title":"Favourites","enabled":true,"position":10,"dataSource":"emby.latest","component":"mediaRow","destination":"home"}]`)
|
|
if err := validateSectionDefinitions("home.sectionDefinitions", raw); err == nil {
|
|
t.Fatal("mismatched data source was accepted")
|
|
}
|
|
}
|
|
|
|
func TestValidateSectionDefinitionsRejectsDestinationMismatchedToPage(t *testing.T) {
|
|
raw := json.RawMessage(`[{"id":"a","type":"genres","title":"Genres","enabled":true,"position":10,"dataSource":"emby.genres","component":"genreBrowser","destination":"tv"}]`)
|
|
if err := validateSectionDefinitions("movies.sectionDefinitions", raw); err == nil {
|
|
t.Fatal("genres row on Movies pointed at TV was accepted")
|
|
}
|
|
}
|
|
|
|
func TestValidateSectionDefinitionsAcceptsTheDefaultDocument(t *testing.T) {
|
|
for key, definitions := range map[string][]config.RemoteSectionDefinition{
|
|
"home.sectionDefinitions": config.DefaultRemoteConfig().Home.SectionDefinitions,
|
|
"movies.sectionDefinitions": config.DefaultRemoteConfig().Movies.SectionDefinitions,
|
|
"tv.sectionDefinitions": config.DefaultRemoteConfig().TV.SectionDefinitions,
|
|
} {
|
|
raw, err := json.Marshal(definitions)
|
|
if err != nil {
|
|
t.Fatalf("marshal %s: %v", key, err)
|
|
}
|
|
if err := validateSectionDefinitions(key, raw); err != nil {
|
|
t.Fatalf("default %s was rejected: %v", key, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestValidateSectionDefinitionsAllowsCustomRowsOutsideTheCatalogue(t *testing.T) {
|
|
raw := json.RawMessage(`[{"id":"a","type":"custom","title":"Something new","enabled":true,"position":10,"dataSource":"gateway.whatever","component":"mediaRow","destination":"home"}]`)
|
|
if err := validateSectionDefinitions("home.sectionDefinitions", raw); err != nil {
|
|
t.Fatalf("custom row was rejected: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestFeatureAdminRejectsUnknownFlagsBeforeWriting(t *testing.T) {
|
|
server := testServer(config.Config{})
|
|
req := httptest.NewRequest(http.MethodPost, "/admin/api/features",
|
|
strings.NewReader(`{"action":"save","expectedRevision":0,"overrides":{"made_up":true}}`))
|
|
rec := httptest.NewRecorder()
|
|
server.handleAdminFeaturePolicy(rec, req)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400", rec.Code)
|
|
}
|
|
}
|