Files
navidrome/plugins/manifest_test.go
Deluan Quintão f1e75c40dc feat(plugins): add JSONForms-based plugin configuration UI (#4911)
* feat(plugins): add JSONForms schema for plugin configuration

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

* feat: enhance error handling by formatting validation errors with field names

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

* feat: enforce required fields in config validation and improve error handling

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

* format JS code

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

* feat: add config schema validation and enhance manifest structure

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

* feat: refactor plugin config parsing and add unit tests

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

* feat: add config validation error message in Portuguese

* feat: enhance AlwaysExpandedArrayLayout with description support and improve array control testing

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

* feat: update Discord Rust plugin configuration to use JSONForm for user tokens and enhance schema validation

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

* fix: resolve React Hooks linting issues in plugin UI components

* Apply suggestions from code review

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* format code

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

* feat: migrate schema validation to use santhosh-tekuri/jsonschema and improve error formatting

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

* address PR comments

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

* fix flaky test

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

* feat: enhance array layout and configuration handling with AJV defaults

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

* feat: implement custom tester to exclude enum arrays from AlwaysExpandedArrayLayout

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

* feat: add error boundary for schema rendering and improve error messages

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

* feat: refine non-enum array control logic by utilizing JSONForms schema resolution

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

* feat: add error styling to ToggleEnabledSwitch for disabled state

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

* feat: adjust label positioning and styling in SchemaConfigEditor for improved layout

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

* feat: implement outlined input controls renderers to replace custom fragile CSS

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

* feat: remove margin from last form control inside array items for better spacing

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

* feat: enhance AJV error handling to transform required errors for field-level validation

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

* feat: set default value for User Tokens in manifest.json to improve user experience

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

* format

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

* feat: add margin to outlined input controls for improved spacing

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

* feat: remove redundant margin rule for last form control in array items

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

* feat: adjust font size of label elements in SchemaConfigEditor for improved readability

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-01-19 20:51:00 -05:00

468 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", "*.spotify.com"]
}
}
}`)
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", "*.spotify.com"))
})
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())
})
})
})