Files
silo-server/internal/autoscan/discovery_test.go
Quick 251282186e feat(autoscan): make source setup descriptor-driven and self-contained
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.
2026-07-29 14:52:16 -04:00

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)
}
}