Autoscan setup asked operators to hold five concepts — plugin, capability, source, connection, delivery mode — before anything scanned, spread across four tabs. Sources never stated which library they fed, so a misconfigured one ran cleanly and silently did nothing. The admin UI also hardcoded two plugin identities: SourcesPanel.tsx carried 33 references to silo.autoscan.cephfs / silo.autoscan.arr-webhook plus a bespoke CephFS config editor. A third-party scan-source plugin could render no configuration UI at all without patching silo-server. Host side, add a ScanSourceDescriptor read from capability manifest metadata: delivery modes, connection requirement, connection kinds, and the per-source config form. Capabilities that declare nothing resolve to poll + optional connection — exactly the pre-descriptor behavior, so existing installs are unaffected. Compatibility descriptors for the two first-party plugins live in one file with a clear exit: the manifest always wins, so a plugin takes ownership by publishing its own. UI side, the Add-source flow builds its steps from the descriptor rather than from plugin ids, so a single-mode source is never asked how changes arrive and a credential-free one never sees the connection step. Connections are created inline (previously a dead end that forced cancelling out of the dialog), and webhook setup finishes in one place: mappings, then the URL with the exact Sonarr/Radarr triggers the host actually parses. Mapping rows seed from library paths collapsed to their common ancestor per mount point. Verified against a real install: 96 library paths become 2 rows. Rewrites match by longest prefix at a segment boundary, so one row per mount covers everything beneath it. Fold the Connections and Settings tabs into an Advanced section (4 tabs -> 2); old ?tab= links land on Sources with it expanded. Source rows now name the libraries they feed and warn when a source can never resolve one. API changes are additive within /api/v1: new optional fields on /autoscan/scan-source-plugins only.
106 lines
3.5 KiB
Go
106 lines
3.5 KiB
Go
package autoscan
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
)
|
|
|
|
// fakeLister returns a fixed set of discovered scan sources.
|
|
type fakeLister struct{ sources []DiscoveredSource }
|
|
|
|
func (f fakeLister) ListScanSources(context.Context) ([]DiscoveredSource, error) {
|
|
return f.sources, nil
|
|
}
|
|
|
|
func TestListAvailableScanSourcesEnumeratesInstalled(t *testing.T) {
|
|
lister := fakeLister{sources: []DiscoveredSource{
|
|
{PluginID: "sonarr", CapabilityID: "arr-a", DisplayName: "Sonarr"},
|
|
{PluginID: "radarr", CapabilityID: "arr-b", DisplayName: "Radarr"},
|
|
}}
|
|
svc := &Service{lister: lister}
|
|
|
|
available, err := svc.ListAvailableScanSources(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("ListAvailableScanSources: %v", err)
|
|
}
|
|
if len(available) != 2 {
|
|
t.Fatalf("expected 2 available, got %d: %+v", len(available), available)
|
|
}
|
|
got := available[0]
|
|
if got.PluginID != "sonarr" || got.CapabilityID != "arr-a" || got.DisplayName != "Sonarr" {
|
|
t.Fatalf("unexpected first available: %+v", got)
|
|
}
|
|
// A lister that supplies no descriptor must still yield the default one, so
|
|
// every consumer can rely on the field being populated.
|
|
if got.Descriptor.Connection != ConnectionOptional {
|
|
t.Fatalf("expected default connection requirement, got %q", got.Descriptor.Connection)
|
|
}
|
|
if !got.Descriptor.SupportsDeliveryMode(DeliveryModePoll) {
|
|
t.Fatalf("expected default poll delivery mode, got %+v", got.Descriptor.DeliveryModes)
|
|
}
|
|
}
|
|
|
|
func TestWithBuiltinSourcesAppendsToInner(t *testing.T) {
|
|
inner := fakeLister{sources: []DiscoveredSource{
|
|
{PluginID: "sonarr", CapabilityID: "arr-a", DisplayName: "Sonarr"},
|
|
}}
|
|
lister := WithBuiltinSources(inner, BuiltinArrWebhookSource())
|
|
|
|
discovered, err := lister.ListScanSources(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("ListScanSources: %v", err)
|
|
}
|
|
if len(discovered) != 2 {
|
|
t.Fatalf("expected 2 discovered, got %d: %+v", len(discovered), discovered)
|
|
}
|
|
if discovered[0].PluginID != "sonarr" {
|
|
t.Fatalf("plugin entries must pass through first, got %+v", discovered[0])
|
|
}
|
|
if !isBuiltinArrWebhookSource(discovered[1]) {
|
|
t.Fatalf("expected builtin appended, got %+v", discovered[1])
|
|
}
|
|
}
|
|
|
|
func TestWithBuiltinSourcesNilInner(t *testing.T) {
|
|
lister := WithBuiltinSources(nil, BuiltinArrWebhookSource())
|
|
|
|
discovered, err := lister.ListScanSources(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("ListScanSources: %v", err)
|
|
}
|
|
if len(discovered) != 1 || !isBuiltinArrWebhookSource(discovered[0]) {
|
|
t.Fatalf("expected only builtin, got %+v", discovered)
|
|
}
|
|
}
|
|
|
|
// isBuiltinArrWebhookSource compares the identifying fields of a discovered
|
|
// source. DiscoveredSource carries slices (delivery modes, connection kinds) so
|
|
// it is no longer comparable with ==, and these tests only care that the
|
|
// builtin identity came through.
|
|
func isBuiltinArrWebhookSource(got DiscoveredSource) bool {
|
|
want := BuiltinArrWebhookSource()
|
|
return got.PluginID == want.PluginID &&
|
|
got.CapabilityID == want.CapabilityID &&
|
|
got.DisplayName == want.DisplayName
|
|
}
|
|
|
|
func TestIsBuiltinArrWebhookIdentity(t *testing.T) {
|
|
if !IsBuiltinArrWebhookIdentity(BuiltinArrWebhookPluginID, BuiltinArrWebhookCapabilityID) {
|
|
t.Fatal("builtin identity must match")
|
|
}
|
|
if IsBuiltinArrWebhookIdentity("sonarr", "arr") {
|
|
t.Fatal("plugin identity must not match builtin")
|
|
}
|
|
}
|
|
|
|
func TestListAvailableScanSourcesNilListerEmpty(t *testing.T) {
|
|
svc := &Service{lister: nil}
|
|
available, err := svc.ListAvailableScanSources(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("ListAvailableScanSources: %v", err)
|
|
}
|
|
if len(available) != 0 {
|
|
t.Fatalf("nil lister must return empty, got %+v", available)
|
|
}
|
|
}
|