diff --git a/internal/api/handlers/libraries.go b/internal/api/handlers/libraries.go
index 65dd81c6..e6e8d985 100644
--- a/internal/api/handlers/libraries.go
+++ b/internal/api/handlers/libraries.go
@@ -2005,6 +2005,44 @@ func (h *LibraryHandler) HandleGetLibraryProviders(w http.ResponseWriter, r *htt
writeJSON(w, http.StatusOK, map[string]any{"levels": levels})
}
+// HandleGetLibraryProviderDefaults handles GET /libraries/provider-defaults.
+// It returns the provider chain that would be seeded for a new library of the
+// given type, grouped by content level and in seeded order. The admin UI
+// renders this while creating a library instead of re-deriving the chain from
+// plugin manifests client-side, so the displayed defaults and the chain the
+// server seeds on create can never disagree.
+func (h *LibraryHandler) HandleGetLibraryProviderDefaults(w http.ResponseWriter, r *http.Request) {
+ libraryType := r.URL.Query().Get("library_type")
+ levels := metadataContentLevelsForLibraryType(libraryType)
+ if len(levels) == 0 {
+ // A type the server doesn't seed chains for (unknown, or one like
+ // podcasts with no metadata content levels) simply has no defaults.
+ writeJSON(w, http.StatusOK, map[string]any{"levels": map[string][]chainLevelEntry{}})
+ return
+ }
+
+ if h.ChainRepo == nil {
+ writeError(w, http.StatusServiceUnavailable, "unavailable", "Provider chain management is not configured")
+ return
+ }
+
+ out := make(map[string][]chainLevelEntry, len(levels))
+ for _, level := range levels {
+ out[level] = []chainLevelEntry{}
+ }
+ for _, e := range h.seedDefaultChain(r.Context(), libraryType) {
+ out[e.ContentLevel] = append(out[e.ContentLevel], chainLevelEntry{
+ PluginInstallationID: e.PluginInstallationID,
+ CapabilityID: e.CapabilityID,
+ ProviderSlug: e.CapabilityID,
+ Priority: e.Priority,
+ Enabled: e.Enabled,
+ })
+ }
+
+ writeJSON(w, http.StatusOK, map[string]any{"levels": out})
+}
+
// HandleSetLibraryProviders handles PUT /libraries/{id}/providers.
// It replaces the entire provider chain for the given library.
func (h *LibraryHandler) HandleSetLibraryProviders(w http.ResponseWriter, r *http.Request) {
@@ -2082,50 +2120,73 @@ func (h *LibraryHandler) seedDefaultChain(ctx context.Context, libraryType strin
var entries []metadata.ChainEntry
for _, level := range levels {
- type candidate struct {
- installationID int
- capabilityID string
- priority int
- enabled bool
- }
- var candidates []candidate
-
+ candidates := make([]seedCandidate, 0, len(caps))
for _, c := range caps {
- defaultPriority := metadata.LookupDefaultPriority(ctx, h.ChainRepo.Pool(), c.PluginInstallationID, c.CapabilityID, level)
- if defaultPriority > 0 {
- candidates = append(candidates, candidate{
- installationID: c.PluginInstallationID,
- capabilityID: c.CapabilityID,
- priority: defaultPriority,
- enabled: true,
- })
- } else {
- // Plugin doesn't declare this level — include but disabled.
- candidates = append(candidates, candidate{
- installationID: c.PluginInstallationID,
- capabilityID: c.CapabilityID,
- priority: 999,
- enabled: false,
- })
- }
- }
-
- sort.Slice(candidates, func(i, j int) bool {
- return candidates[i].priority < candidates[j].priority
- })
-
- for i, cand := range candidates {
- entries = append(entries, metadata.ChainEntry{
- PluginInstallationID: cand.installationID,
- CapabilityID: cand.capabilityID,
- CapabilityType: "metadata_provider.v1",
- ContentLevel: level,
- Priority: i,
- Enabled: cand.enabled,
+ p := metadata.LookupSeedPlacement(ctx, h.ChainRepo.Pool(), c.PluginInstallationID, c.CapabilityID, level)
+ candidates = append(candidates, seedCandidate{
+ installationID: c.PluginInstallationID,
+ capabilityID: c.CapabilityID,
+ supportsLevel: p.SupportsLevel,
+ declaredPriority: p.DefaultPriority,
+ defaultEnabled: p.DefaultEnabled,
})
}
+ entries = append(entries, buildSeededChainEntries(level, candidates)...)
+ }
+
+ return entries
+}
+
+// seedCandidate is a metadata provider under consideration for a freshly seeded
+// chain at one content level, carrying the manifest-declared values that decide
+// its placement.
+type seedCandidate struct {
+ installationID int
+ capabilityID string
+ supportsLevel bool // provider handles this content level (declared it, or is a legacy catch-all)
+ declaredPriority int // manifest default_priority for this level; 0 = level not declared
+ defaultEnabled bool // manifest default_enabled; false = specialist opts out of auto-enable
+}
+
+// buildSeededChainEntries orders the providers for one content level and assigns
+// positional priorities. Providers that do not handle this level are dropped
+// outright — a single-purpose provider (e.g. audiobook/ebook/manga metadata)
+// never clutters a library type it cannot serve. Of the remaining providers, one
+// that declares this level (declaredPriority>0) is placed by that priority and
+// seeded enabled unless it opted out via default_enabled; a legacy provider that
+// declares no levels at all is parked last and disabled. Keeping an opted-out
+// provider at its declared priority (rather than forcing it last) means that when
+// a user does enable it, it slots in where the manifest intends instead of
+// jumping to the top of the chain.
+func buildSeededChainEntries(level string, candidates []seedCandidate) []metadata.ChainEntry {
+ ranked := make([]seedCandidate, 0, len(candidates))
+ for _, c := range candidates {
+ if !c.supportsLevel {
+ continue
+ }
+ if c.declaredPriority > 0 {
+ ranked = append(ranked, c)
+ } else {
+ // Legacy provider that declares no levels — park last, disabled.
+ ranked = append(ranked, seedCandidate{installationID: c.installationID, capabilityID: c.capabilityID, supportsLevel: true, declaredPriority: 999, defaultEnabled: false})
+ }
}
+ sort.SliceStable(ranked, func(i, j int) bool {
+ return ranked[i].declaredPriority < ranked[j].declaredPriority
+ })
+
+ entries := make([]metadata.ChainEntry, len(ranked))
+ for i, c := range ranked {
+ entries[i] = metadata.ChainEntry{
+ PluginInstallationID: c.installationID,
+ CapabilityID: c.capabilityID,
+ CapabilityType: "metadata_provider.v1",
+ ContentLevel: level,
+ Priority: i,
+ Enabled: c.declaredPriority > 0 && c.defaultEnabled,
+ }
+ }
return entries
}
diff --git a/internal/api/handlers/libraries_provider_defaults_test.go b/internal/api/handlers/libraries_provider_defaults_test.go
new file mode 100644
index 00000000..4ad011e8
--- /dev/null
+++ b/internal/api/handlers/libraries_provider_defaults_test.go
@@ -0,0 +1,49 @@
+package handlers
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+// A library type the server seeds no metadata levels for (unknown types, or
+// ones like podcasts without metadata content levels) has no defaults — the
+// endpoint answers with an empty levels map rather than an error, so the UI
+// can treat "no defaults" and "defaults" uniformly.
+func TestHandleGetLibraryProviderDefaults_NoLevelsForType(t *testing.T) {
+ h := &LibraryHandler{}
+
+ for _, libraryType := range []string{"podcasts", "bogus", ""} {
+ req := httptest.NewRequest(http.MethodGet, "/libraries/provider-defaults?library_type="+libraryType, nil)
+ rec := httptest.NewRecorder()
+ h.HandleGetLibraryProviderDefaults(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("type %q: expected 200, got %d", libraryType, rec.Code)
+ }
+ var body struct {
+ Levels map[string][]chainLevelEntry `json:"levels"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+ t.Fatalf("type %q: decoding body: %v", libraryType, err)
+ }
+ if len(body.Levels) != 0 {
+ t.Errorf("type %q: expected empty levels, got %v", libraryType, body.Levels)
+ }
+ }
+}
+
+// A seedable type still needs the chain repository; without one the endpoint
+// reports unavailable like the other provider-chain handlers.
+func TestHandleGetLibraryProviderDefaults_NoChainRepo(t *testing.T) {
+ h := &LibraryHandler{}
+
+ req := httptest.NewRequest(http.MethodGet, "/libraries/provider-defaults?library_type=series", nil)
+ rec := httptest.NewRecorder()
+ h.HandleGetLibraryProviderDefaults(rec, req)
+
+ if rec.Code != http.StatusServiceUnavailable {
+ t.Fatalf("expected 503, got %d", rec.Code)
+ }
+}
diff --git a/internal/api/handlers/libraries_seed_chain_test.go b/internal/api/handlers/libraries_seed_chain_test.go
new file mode 100644
index 00000000..f051c765
--- /dev/null
+++ b/internal/api/handlers/libraries_seed_chain_test.go
@@ -0,0 +1,100 @@
+package handlers
+
+import (
+ "testing"
+)
+
+// A specialist provider that opts out of default-enable (default_enabled=false)
+// must be seeded installed-but-off and ranked by its declared priority — never
+// jumping ahead of the general providers just because a user might enable it.
+// A provider that does not declare the content level at all must be excluded
+// entirely, not shown as a disabled row. Together these are the regressions that
+// let silo.sportarr land at position 1 enabled, and let the audiobook/ebook/manga
+// providers clutter every TV series and movie library's provider chain.
+func TestBuildSeededChainEntries_OptOutAndLevelScoping(t *testing.T) {
+ candidates := []seedCandidate{
+ {installationID: 1, capabilityID: "tmdb", supportsLevel: true, declaredPriority: 3, defaultEnabled: true},
+ {installationID: 2, capabilityID: "tvdb", supportsLevel: true, declaredPriority: 2, defaultEnabled: true},
+ {installationID: 22, capabilityID: "sportarr", supportsLevel: true, declaredPriority: 50, defaultEnabled: false},
+ // Declares only {audiobook} — does not support the series level, so it
+ // must not appear in a series library's chain at all.
+ {installationID: 3, capabilityID: "audiobook-metadata", supportsLevel: false, declaredPriority: 0, defaultEnabled: true},
+ {installationID: 8, capabilityID: "ebook-metadata", supportsLevel: false, declaredPriority: 0, defaultEnabled: true},
+ }
+
+ entries := buildSeededChainEntries("series", candidates)
+
+ if len(entries) != 3 {
+ t.Fatalf("expected 3 entries (unsupported providers excluded), got %d", len(entries))
+ }
+
+ // Ordering: general providers by declared priority (tvdb 2, tmdb 3), then
+ // the opted-out specialist by its declared priority (50).
+ type want struct {
+ installationID int
+ enabled bool
+ }
+ wants := []want{
+ {2, true}, // tvdb
+ {1, true}, // tmdb
+ {22, false}, // sportarr — declared but opted out
+ }
+ for i, w := range wants {
+ e := entries[i]
+ if e.PluginInstallationID != w.installationID {
+ t.Errorf("position %d: got installation %d, want %d", i, e.PluginInstallationID, w.installationID)
+ }
+ if e.Enabled != w.enabled {
+ t.Errorf("position %d (installation %d): enabled=%v, want %v", i, e.PluginInstallationID, e.Enabled, w.enabled)
+ }
+ if e.Priority != i {
+ t.Errorf("position %d: got priority %d, want %d", i, e.Priority, i)
+ }
+ if e.ContentLevel != "series" {
+ t.Errorf("position %d: got content level %q, want series", i, e.ContentLevel)
+ }
+ if e.CapabilityType != "metadata_provider.v1" {
+ t.Errorf("position %d: got capability type %q", i, e.CapabilityType)
+ }
+ }
+
+ // The audiobook/ebook providers must be entirely absent.
+ for _, e := range entries {
+ if e.PluginInstallationID == 3 || e.PluginInstallationID == 8 {
+ t.Errorf("provider %d does not declare series and must not be seeded", e.PluginInstallationID)
+ }
+ }
+}
+
+// A provider with default_enabled=true (the default) that declares the level is
+// seeded enabled, unchanged from the pre-flag behavior.
+func TestBuildSeededChainEntries_DefaultsEnabled(t *testing.T) {
+ entries := buildSeededChainEntries("movie", []seedCandidate{
+ {installationID: 1, capabilityID: "tmdb", supportsLevel: true, declaredPriority: 2, defaultEnabled: true},
+ })
+ if len(entries) != 1 {
+ t.Fatalf("expected 1 entry, got %d", len(entries))
+ }
+ if !entries[0].Enabled {
+ t.Errorf("provider with default_enabled=true should be seeded enabled")
+ }
+}
+
+// A legacy provider that declares no default_priority map at all makes no claim
+// and stays eligible for every level (parked last, disabled) — preserving the
+// pre-existing catch-all behavior for providers that never enumerated levels.
+func TestBuildSeededChainEntries_LegacyCatchAllParkedLast(t *testing.T) {
+ entries := buildSeededChainEntries("series", []seedCandidate{
+ {installationID: 1, capabilityID: "tmdb", supportsLevel: true, declaredPriority: 3, defaultEnabled: true},
+ {installationID: 9, capabilityID: "legacy", supportsLevel: true, declaredPriority: 0, defaultEnabled: true},
+ })
+ if len(entries) != 2 {
+ t.Fatalf("expected 2 entries, got %d", len(entries))
+ }
+ if entries[0].PluginInstallationID != 1 || !entries[0].Enabled {
+ t.Errorf("tmdb should be first and enabled, got %+v", entries[0])
+ }
+ if entries[1].PluginInstallationID != 9 || entries[1].Enabled {
+ t.Errorf("legacy catch-all should be parked last and disabled, got %+v", entries[1])
+ }
+}
diff --git a/internal/api/handlers/plugins.go b/internal/api/handlers/plugins.go
index e6dc79fe..47c749f1 100644
--- a/internal/api/handlers/plugins.go
+++ b/internal/api/handlers/plugins.go
@@ -690,8 +690,8 @@ func (h *PluginHandler) syncMetadataProviders(ctx context.Context, installation
if cap.Type != "metadata_provider.v1" {
continue
}
- if err := h.chainRepo.AppendProviderToAllChains(ctx, installation.ID, cap.ID, func(level string) int {
- return metadata.LookupDefaultPriority(ctx, h.chainRepo.Pool(), installation.ID, cap.ID, level)
+ if err := h.chainRepo.AppendProviderToAllChains(ctx, installation.ID, cap.ID, func(level string) metadata.SeedPlacement {
+ return metadata.LookupSeedPlacement(ctx, h.chainRepo.Pool(), installation.ID, cap.ID, level)
}); err != nil {
slog.Warn("failed to append provider to library chains",
"installation_id", installation.ID,
diff --git a/internal/api/router.go b/internal/api/router.go
index c5ba8588..969ed2f6 100644
--- a/internal/api/router.go
+++ b/internal/api/router.go
@@ -1851,6 +1851,7 @@ func NewRouter(deps Dependencies) chi.Router {
r.Post("/{id}/metadata-match-queue/retry", libraryHandler.HandleRetryMetadataMatchQueue)
r.Post("/{id}/metadata-match-queue/cancel", libraryHandler.HandleCancelMetadataMatchQueue)
r.Post("/{id}/refresh-metadata", libraryHandler.HandleRefreshLibraryMetadata)
+ r.Get("/provider-defaults", libraryHandler.HandleGetLibraryProviderDefaults)
r.Get("/{id}/providers", libraryHandler.HandleGetLibraryProviders)
r.Put("/{id}/providers", libraryHandler.HandleSetLibraryProviders)
r.Put("/{id}/poster", libraryHandler.HandleUploadPoster)
diff --git a/internal/metadata/chain.go b/internal/metadata/chain.go
index fc437daa..79e4cf2d 100644
--- a/internal/metadata/chain.go
+++ b/internal/metadata/chain.go
@@ -175,14 +175,15 @@ func (r *ChainRepository) DeleteChain(ctx context.Context, folderID int) error {
}
// AppendProviderToAllChains adds a provider to every existing library chain
-// (per content level) that doesn't already include it. The defaultPriority
-// callback returns the plugin's declared priority for a content level (0 means
-// the plugin doesn't declare that level — the entry is still added but disabled).
+// (per content level) that it serves and doesn't already include it. The
+// placement callback resolves the plugin's manifest intent for a content level:
+// levels it does not support are skipped entirely, and it is appended enabled
+// only when it declares the level and opts into default_enabled.
func (r *ChainRepository) AppendProviderToAllChains(
ctx context.Context,
pluginInstallationID int,
capabilityID string,
- defaultPriority func(contentLevel string) int,
+ placement func(contentLevel string) SeedPlacement,
) error {
// Find every distinct (folder, level) pair that has chain entries.
rows, err := r.pool.Query(ctx,
@@ -210,6 +211,15 @@ func (r *ChainRepository) AppendProviderToAllChains(
}
for _, g := range groups {
+ // Only attach the provider to levels it actually serves. A single-purpose
+ // provider (e.g. audiobook/ebook/manga metadata, or a sports provider that
+ // only declares series levels) must not clutter every library's chain with
+ // a disabled row for content it cannot handle.
+ p := placement(g.contentLevel)
+ if !p.SupportsLevel {
+ continue
+ }
+
// Check if the provider is already in this chain.
var exists bool
err := r.pool.QueryRow(ctx,
@@ -238,8 +248,11 @@ func (r *ChainRepository) AppendProviderToAllChains(
return fmt.Errorf("getting max priority: %w", err)
}
- dp := defaultPriority(g.contentLevel)
- enabled := dp > 0
+ // A provider joins an existing library disabled unless it declares this
+ // level and opts into being enabled by default. This keeps a specialist
+ // (default_enabled=false) one click away instead of silently taking over
+ // established libraries the moment it is installed.
+ enabled := p.DefaultPriority > 0 && p.DefaultEnabled
_, err = r.pool.Exec(ctx,
`INSERT INTO library_provider_chains (media_folder_id, plugin_installation_id, capability_id, capability_type, content_level, priority, enabled)
@@ -442,6 +455,61 @@ func extractDefaultPriority(metadataJSON []byte, contentLevel string) int {
return 0
}
+// extractDefaultEnabled reports whether a provider should be enabled by default
+// when a chain is first seeded for a new library. It defaults to true when the
+// flag is absent or unparseable, so every plugin predating this flag keeps its
+// original seeded-enabled behavior. A specialist provider (e.g. a sports
+// metadata source that should not compete with general providers on every
+// library) sets metadata.default_enabled=false to be seeded installed-but-off,
+// leaving it one click away for users who want it. The flag lives in the same
+// "metadata" envelope as default_priority.
+func extractDefaultEnabled(metadataJSON []byte) bool {
+ var meta map[string]json.RawMessage
+ if err := json.Unmarshal(metadataJSON, &meta); err != nil {
+ return true
+ }
+ raw, ok := meta["default_enabled"]
+ if !ok {
+ if innerRaw, innerOK := meta["metadata"]; innerOK {
+ var inner map[string]json.RawMessage
+ if err := json.Unmarshal(innerRaw, &inner); err == nil {
+ raw, ok = inner["default_enabled"]
+ }
+ }
+ }
+ if !ok {
+ return true
+ }
+ var enabled bool
+ if err := json.Unmarshal(raw, &enabled); err != nil {
+ return true
+ }
+ return enabled
+}
+
+// SeedPlacement captures how a provider's manifest wants it placed when a chain
+// is first seeded for a content level: whether it handles the level at all, its
+// declared priority, and whether it should be enabled by default.
+type SeedPlacement struct {
+ SupportsLevel bool
+ DefaultPriority int
+ DefaultEnabled bool
+}
+
+// LookupSeedPlacement resolves a provider's seed placement for one content level
+// from its capability manifest with a single metadata fetch. SupportsLevel uses
+// the same rule as the chain-less fallback (providerSupportsLevel): a provider
+// that enumerates levels is eligible only for the ones it lists; a provider that
+// declares none stays eligible everywhere.
+func LookupSeedPlacement(ctx context.Context, pool *pgxpool.Pool, pluginInstallationID int, capabilityID, contentLevel string) SeedPlacement {
+ md := lookupCapabilityMetadata(ctx, pool, pluginInstallationID, capabilityID)
+ return SeedPlacement{
+ SupportsLevel: providerSupportsLevel(md, contentLevel),
+ DefaultPriority: extractDefaultPriority(md, contentLevel),
+ DefaultEnabled: extractDefaultEnabled(md),
+ }
+}
+
// declaredPriorityLevels parses a capability's default_priority map. The map may
// sit at the top level or inside a "metadata" envelope (plugin capability
// metadata wraps plugin-declared fields in a "metadata" sub-object). The second
diff --git a/internal/metadata/chain_test.go b/internal/metadata/chain_test.go
index 2f439723..7b674073 100644
--- a/internal/metadata/chain_test.go
+++ b/internal/metadata/chain_test.go
@@ -57,3 +57,39 @@ func TestProviderSupportsLevel(t *testing.T) {
})
}
}
+
+func TestExtractDefaultEnabled(t *testing.T) {
+ cases := []struct {
+ name string
+ metadataJSON string
+ want bool
+ }{
+ // Absent flag defaults to true so every existing plugin is seeded
+ // enabled exactly as before this flag existed.
+ {"no metadata defaults enabled", ``, true},
+ {"empty object defaults enabled", `{}`, true},
+ {"tmdb without flag defaults enabled", tmdbCapMetadata, true},
+ {"malformed json defaults enabled (fail-open)", `{not json`, true},
+
+ // A specialist provider opts out of being auto-enabled. The flag lives
+ // in the same "metadata" envelope as default_priority.
+ {"opt-out inside envelope", `{"metadata":{"default_priority":{"series":50},"default_enabled":false}}`, false},
+ {"opt-out at top level", `{"default_enabled":false}`, false},
+
+ // An explicit true is honored (and is the default anyway).
+ {"explicit true inside envelope", `{"metadata":{"default_enabled":true}}`, true},
+
+ // A non-boolean value is ignored, falling back to the enabled default.
+ {"non-boolean value defaults enabled", `{"metadata":{"default_enabled":"nope"}}`, true},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got := extractDefaultEnabled([]byte(tc.metadataJSON))
+ if got != tc.want {
+ t.Fatalf("extractDefaultEnabled(%q) = %v, want %v",
+ tc.metadataJSON, got, tc.want)
+ }
+ })
+ }
+}
diff --git a/web/src/components/admin/libraries/LibraryForm.test.tsx b/web/src/components/admin/libraries/LibraryForm.test.tsx
index 328454bd..155eb450 100644
--- a/web/src/components/admin/libraries/LibraryForm.test.tsx
+++ b/web/src/components/admin/libraries/LibraryForm.test.tsx
@@ -1,6 +1,14 @@
import { describe, expect, it } from "vitest";
-import { contentLevelsForType } from "./useLibraryForm";
+import type { PluginInstallation } from "@/api/types";
+
+import type { LevelChainItem } from "./useLibraryForm";
+import {
+ contentLevelsForType,
+ hasMetadataProviderCapability,
+ levelChainsFromResponse,
+ mergeChainWithDefaults,
+} from "./useLibraryForm";
describe("contentLevelsForType", () => {
it("maps ebook libraries to the ebook metadata content level", () => {
@@ -19,3 +27,121 @@ describe("contentLevelsForType", () => {
]);
});
});
+
+describe("levelChainsFromResponse", () => {
+ it("orders entries by server priority and keeps the server's slug and enabled state", () => {
+ // The same converter handles a saved chain and the provider-defaults
+ // response — including a specialist the server seeded disabled.
+ const chains = levelChainsFromResponse({
+ levels: {
+ series: [
+ {
+ plugin_installation_id: 22,
+ capability_id: "sportarr",
+ provider_slug: "sportarr",
+ priority: 2,
+ enabled: false,
+ },
+ {
+ plugin_installation_id: 2,
+ capability_id: "tvdb",
+ provider_slug: "tvdb",
+ priority: 0,
+ enabled: true,
+ },
+ {
+ plugin_installation_id: 1,
+ capability_id: "tmdb",
+ provider_slug: "tmdb",
+ priority: 1,
+ enabled: true,
+ },
+ ],
+ },
+ });
+
+ expect(chains["series"]!.map((e) => [e.provider_slug, e.enabled])).toEqual([
+ ["tvdb", true],
+ ["tmdb", true],
+ ["sportarr", false],
+ ]);
+ });
+
+ it("returns an empty record for a missing or empty response", () => {
+ expect(levelChainsFromResponse(undefined)).toEqual({});
+ expect(levelChainsFromResponse({ levels: {} })).toEqual({});
+ });
+});
+
+describe("mergeChainWithDefaults", () => {
+ const item = (slug: string, over: Partial