navidrome/plugins/manifest_test.go
Deluan Quintão 69e7d163fc
remove built-in Spotify integration (#5197)
* refactor: remove built-in Spotify integration

Remove the Spotify adapter and all related configuration, replacing
the built-in integration with the plugin system. This deletes the
adapters/spotify package, removes Spotify config options (ID/Secret),
updates the default agents list from "deezer,lastfm,spotify" to
"deezer,lastfm", and cleans up all references across configuration,
metrics, logging, artwork caching, and documentation. Users with
Spotify config options will now see a warning that the options are
no longer available.

* feat: add ListenBrainz to list of default agents

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-03-15 13:18:54 -04:00

467 lines
12 KiB
Go

package plugins
import (
"encoding/json"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Manifest", func() {
Describe("UnmarshalJSON", func() {
It("parses a valid manifest", func() {
data := []byte(`{
"name": "Test Plugin",
"author": "Test Author",
"version": "1.0.0",
"description": "A test plugin",
"website": "https://example.com",
"permissions": {
"http": {
"reason": "Fetch metadata",
"requiredHosts": ["api.example.com", "*.musicbrainz.org"]
}
}
}`)
var m Manifest
err := json.Unmarshal(data, &m)
Expect(err).ToNot(HaveOccurred())
Expect(m.Name).To(Equal("Test Plugin"))
Expect(m.Author).To(Equal("Test Author"))
Expect(m.Version).To(Equal("1.0.0"))
Expect(*m.Description).To(Equal("A test plugin"))
Expect(*m.Website).To(Equal("https://example.com"))
Expect(m.Permissions.Http).ToNot(BeNil())
Expect(*m.Permissions.Http.Reason).To(Equal("Fetch metadata"))
Expect(m.Permissions.Http.RequiredHosts).To(ContainElements("api.example.com", "*.musicbrainz.org"))
})
It("parses a minimal manifest", func() {
data := []byte(`{
"name": "Minimal Plugin",
"author": "Author",
"version": "1.0.0"
}`)
var m Manifest
err := json.Unmarshal(data, &m)
Expect(err).ToNot(HaveOccurred())
Expect(m.Name).To(Equal("Minimal Plugin"))
Expect(m.Author).To(Equal("Author"))
Expect(m.Version).To(Equal("1.0.0"))
Expect(m.Description).To(BeNil())
Expect(m.Permissions).To(BeNil())
})
It("returns an error for invalid JSON", func() {
data := []byte(`{invalid json}`)
var m Manifest
err := json.Unmarshal(data, &m)
Expect(err).To(HaveOccurred())
})
It("returns an error when name is missing", func() {
data := []byte(`{"author": "Test Author", "version": "1.0.0"}`)
var m Manifest
err := json.Unmarshal(data, &m)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("name"))
})
It("returns an error when author is missing", func() {
data := []byte(`{"name": "Test Plugin", "version": "1.0.0"}`)
var m Manifest
err := json.Unmarshal(data, &m)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("author"))
})
It("returns an error when version is missing", func() {
data := []byte(`{"name": "Test Plugin", "author": "Test Author"}`)
var m Manifest
err := json.Unmarshal(data, &m)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("version"))
})
It("returns an error when name is empty", func() {
data := []byte(`{"name": "", "author": "Test Author", "version": "1.0.0"}`)
var m Manifest
err := json.Unmarshal(data, &m)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("name"))
})
It("returns an error when author is empty", func() {
data := []byte(`{"name": "Test Plugin", "author": "", "version": "1.0.0"}`)
var m Manifest
err := json.Unmarshal(data, &m)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("author"))
})
It("returns an error when version is empty", func() {
data := []byte(`{"name": "Test Plugin", "author": "Test Author", "version": ""}`)
var m Manifest
err := json.Unmarshal(data, &m)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("version"))
})
})
Describe("HasExperimentalThreads", func() {
It("returns false when no experimental section", func() {
m := &Manifest{}
Expect(m.HasExperimentalThreads()).To(BeFalse())
})
It("returns false when experimental section has no threads", func() {
m := &Manifest{
Experimental: &Experimental{},
}
Expect(m.HasExperimentalThreads()).To(BeFalse())
})
It("returns true when threads feature is present", func() {
m := &Manifest{
Experimental: &Experimental{
Threads: &ThreadsFeature{},
},
}
Expect(m.HasExperimentalThreads()).To(BeTrue())
})
It("returns true when threads feature has a reason", func() {
reason := "Required for concurrent processing"
m := &Manifest{
Experimental: &Experimental{
Threads: &ThreadsFeature{
Reason: &reason,
},
},
}
Expect(m.HasExperimentalThreads()).To(BeTrue())
})
It("parses experimental.threads from JSON", func() {
data := []byte(`{
"name": "Threaded Plugin",
"author": "Test Author",
"version": "1.0.0",
"experimental": {
"threads": {
"reason": "To use multi-threaded WASM module"
}
}
}`)
var m Manifest
err := json.Unmarshal(data, &m)
Expect(err).ToNot(HaveOccurred())
Expect(m.HasExperimentalThreads()).To(BeTrue())
Expect(m.Experimental.Threads.Reason).ToNot(BeNil())
Expect(*m.Experimental.Threads.Reason).To(Equal("To use multi-threaded WASM module"))
})
It("parses experimental.threads without reason from JSON", func() {
data := []byte(`{
"name": "Threaded Plugin",
"author": "Test Author",
"version": "1.0.0",
"experimental": {
"threads": {}
}
}`)
var m Manifest
err := json.Unmarshal(data, &m)
Expect(err).ToNot(HaveOccurred())
Expect(m.HasExperimentalThreads()).To(BeTrue())
})
})
Describe("ParseManifest", func() {
It("parses a valid manifest with users permission", func() {
data := []byte(`{
"name": "Test Plugin",
"author": "Test Author",
"version": "1.0.0",
"permissions": {
"subsonicapi": {},
"users": {}
}
}`)
m, err := ParseManifest(data)
Expect(err).ToNot(HaveOccurred())
Expect(m.Name).To(Equal("Test Plugin"))
Expect(m.Permissions.Subsonicapi).ToNot(BeNil())
Expect(m.Permissions.Users).ToNot(BeNil())
})
It("returns error for invalid JSON", func() {
data := []byte(`{invalid}`)
_, err := ParseManifest(data)
Expect(err).To(HaveOccurred())
})
It("returns error when subsonicapi is requested without users permission", func() {
data := []byte(`{
"name": "Test Plugin",
"author": "Test Author",
"version": "1.0.0",
"permissions": {
"subsonicapi": {}
}
}`)
_, err := ParseManifest(data)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("subsonicapi"))
Expect(err.Error()).To(ContainSubstring("users"))
})
})
Describe("Validate", func() {
It("validates manifest with subsonicapi and users permissions", func() {
m := &Manifest{
Name: "Test",
Author: "Author",
Version: "1.0.0",
Permissions: &Permissions{
Subsonicapi: &SubsonicAPIPermission{},
Users: &UsersPermission{},
},
}
err := m.Validate()
Expect(err).ToNot(HaveOccurred())
})
It("returns error when subsonicapi without users permission", func() {
m := &Manifest{
Name: "Test",
Author: "Author",
Version: "1.0.0",
Permissions: &Permissions{
Subsonicapi: &SubsonicAPIPermission{},
},
}
err := m.Validate()
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("subsonicapi"))
})
It("validates manifest without subsonicapi", func() {
m := &Manifest{
Name: "Test",
Author: "Author",
Version: "1.0.0",
Permissions: &Permissions{
Http: &HTTPPermission{},
},
}
err := m.Validate()
Expect(err).ToNot(HaveOccurred())
})
It("validates manifest without any permissions", func() {
m := &Manifest{
Name: "Test",
Author: "Author",
Version: "1.0.0",
}
err := m.Validate()
Expect(err).ToNot(HaveOccurred())
})
It("validates manifest with valid config schema", func() {
m := &Manifest{
Name: "Test",
Author: "Author",
Version: "1.0.0",
Config: &ConfigDefinition{
Schema: map[string]any{
"type": "object",
"properties": map[string]any{
"api_key": map[string]any{
"type": "string",
},
},
},
},
}
err := m.Validate()
Expect(err).ToNot(HaveOccurred())
})
It("validates manifest with complex config schema", func() {
m := &Manifest{
Name: "Test",
Author: "Author",
Version: "1.0.0",
Config: &ConfigDefinition{
Schema: map[string]any{
"type": "object",
"properties": map[string]any{
"users": map[string]any{
"type": "array",
"items": map[string]any{
"type": "object",
"properties": map[string]any{
"username": map[string]any{"type": "string"},
"token": map[string]any{"type": "string"},
},
"required": []any{"username", "token"},
},
},
},
},
},
}
err := m.Validate()
Expect(err).ToNot(HaveOccurred())
})
It("returns error for invalid config schema - bad type", func() {
m := &Manifest{
Name: "Test",
Author: "Author",
Version: "1.0.0",
Config: &ConfigDefinition{
Schema: map[string]any{
"type": "invalid_type",
},
},
}
err := m.Validate()
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("config schema"))
})
It("returns error for invalid config schema - bad minLength", func() {
m := &Manifest{
Name: "Test",
Author: "Author",
Version: "1.0.0",
Config: &ConfigDefinition{
Schema: map[string]any{
"type": "object",
"properties": map[string]any{
"name": map[string]any{
"type": "string",
"minLength": "not_a_number",
},
},
},
},
}
err := m.Validate()
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("config schema"))
})
It("validates manifest without config", func() {
m := &Manifest{
Name: "Test",
Author: "Author",
Version: "1.0.0",
}
err := m.Validate()
Expect(err).ToNot(HaveOccurred())
})
})
Describe("ValidateWithCapabilities", func() {
It("validates scrobbler capability with users permission", func() {
m := &Manifest{
Name: "Test",
Author: "Author",
Version: "1.0.0",
Permissions: &Permissions{
Users: &UsersPermission{},
},
}
err := ValidateWithCapabilities(m, []Capability{CapabilityScrobbler})
Expect(err).ToNot(HaveOccurred())
})
It("returns error when scrobbler capability without users permission", func() {
m := &Manifest{
Name: "Test",
Author: "Author",
Version: "1.0.0",
}
err := ValidateWithCapabilities(m, []Capability{CapabilityScrobbler})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("scrobbler"))
Expect(err.Error()).To(ContainSubstring("users"))
})
It("validates non-scrobbler capability without users permission", func() {
m := &Manifest{
Name: "Test",
Author: "Author",
Version: "1.0.0",
}
err := ValidateWithCapabilities(m, []Capability{CapabilityMetadataAgent})
Expect(err).ToNot(HaveOccurred())
})
It("validates multiple capabilities including scrobbler", func() {
m := &Manifest{
Name: "Test",
Author: "Author",
Version: "1.0.0",
Permissions: &Permissions{
Users: &UsersPermission{},
},
}
err := ValidateWithCapabilities(m, []Capability{CapabilityMetadataAgent, CapabilityScrobbler})
Expect(err).ToNot(HaveOccurred())
})
It("validates with nil capabilities", func() {
m := &Manifest{
Name: "Test",
Author: "Author",
Version: "1.0.0",
}
err := ValidateWithCapabilities(m, nil)
Expect(err).ToNot(HaveOccurred())
})
It("validates with empty capabilities", func() {
m := &Manifest{
Name: "Test",
Author: "Author",
Version: "1.0.0",
}
err := ValidateWithCapabilities(m, []Capability{})
Expect(err).ToNot(HaveOccurred())
})
})
})