package cabana import ( "context" "encoding/json" "errors" "fmt" "net/http" "net/url" "strings" "sync" "testing" "time" "git.golem15.com/golem15/summercms/modules/bouncer" "git.golem15.com/golem15/summercms/modules/kiosk" "git.golem15.com/golem15/summercms/modules/party" ) // memPluginRegistry is an in-process pluginRegistry: versions per ID, // a list, call counters, an in-flight gauge and a failure switch. type memPluginRegistry struct { mu sync.Mutex versions map[string][]kiosk.RegistryVersion list []kiosk.RegistryPlugin fail error hold time.Duration calls []string inFlight int maxFlight int } func (m *memPluginRegistry) List(_ context.Context, q, category string) (kiosk.ListResponse, error) { m.mu.Lock() defer m.mu.Unlock() m.calls = append(m.calls, "list "+q+"|"+category) if m.fail != nil { return kiosk.ListResponse{}, m.fail } var out []kiosk.RegistryPlugin for _, p := range m.list { if (category == "" || p.Category == category) && (q == "" || strings.Contains(p.ID, q)) { out = append(out, p) } } return kiosk.ListResponse{Data: out, Meta: kiosk.ListMeta{Total: len(out)}}, nil } func (m *memPluginRegistry) Versions(ctx context.Context, id string) ([]kiosk.RegistryVersion, error) { m.mu.Lock() m.calls = append(m.calls, "versions "+id) m.inFlight++ if m.inFlight > m.maxFlight { m.maxFlight = m.inFlight } hold, fail := m.hold, m.fail v, ok := m.versions[id] m.mu.Unlock() defer func() { m.mu.Lock() m.inFlight-- m.mu.Unlock() }() if hold > 0 { select { case <-time.After(hold): case <-ctx.Done(): return nil, ctx.Err() } } if fail != nil { return nil, fail } if !ok { return nil, fmt.Errorf("%s is %w at mem", id, kiosk.ErrNotFound) } return v, nil } func (m *memPluginRegistry) count(prefix string) int { m.mu.Lock() defer m.mu.Unlock() n := 0 for _, c := range m.calls { if strings.HasPrefix(c, prefix) { n++ } } return n } // testClock is a settable clock for the catalog's cache. type testClock struct { mu sync.Mutex now time.Time } func (c *testClock) Now() time.Time { c.mu.Lock() defer c.mu.Unlock() return c.now } func (c *testClock) advance(d time.Duration) { c.mu.Lock() c.now = c.now.Add(d) c.mu.Unlock() } // unitPluginsService is pluginsService with its catalog replaced by one // over reg (nil: disabled) and clock. func unitPluginsService(t *testing.T, installed *party.Installed, reg *memPluginRegistry, clock *testClock) *service { t.Helper() svc := pluginsService(t, pluginsConfig(t, ""), installed, nil) catalog := &pluginCatalog{ timeout: time.Second, now: clock.Now, lists: map[string]cachedList{}, versions: map[string]cachedVersions{}, } if reg == nil { catalog.disabled = true } else { catalog.registry = reg } svc.catalog = catalog return svc } func rel(version, framework string) kiosk.RegistryVersion { return kiosk.RegistryVersion{Version: version, Framework: framework} } func byPluginID(overview PluginsOverview) map[string]InstalledPlugin { out := map[string]InstalledPlugin{} for _, p := range overview.Plugins { out[p.ID] = p } return out } // TestPluginsEndpoints covers the Plugins screen endpoints at unit level: // access, the settings entry, the update rule, the registry cache and // concurrency bound, and the marketplace query rules. func TestPluginsEndpoints(t *testing.T) { clock := &testClock{now: time.Date(2026, 10, 29, 12, 0, 0, 0, time.UTC)} t.Run("access", func(t *testing.T) { svc := unitPluginsService(t, testInstalled(), nil, clock) notBackend := &bouncer.Principal{ID: 9, Backend: false, IsSuperuser: true} for _, target := range []string{"/plugins", "/plugins/marketplace"} { handler := (*service).pluginsInstalled if target != "/plugins" { handler = (*service).pluginsMarketplace } for _, c := range []struct { name string principal *bouncer.Principal want int }{ {"anonymous", nil, http.StatusUnauthorized}, {"frontend principal", notBackend, http.StatusUnauthorized}, {"no permission", pluginsDenied, http.StatusForbidden}, {"permission", pluginsViewer, http.StatusOK}, {"superuser", pluginsSuperuser, http.StatusOK}, } { rec := pluginsCall(svc, handler, c.principal, target) if rec.Code != c.want { t.Errorf("%s %s: status %d, want %d (%s)", target, c.name, rec.Code, c.want, rec.Body.String()) } } } }) t.Run("settings entry", func(t *testing.T) { entry := pluginsSettingsEntry("Plugins", "Installed plugins", "system") if entry.Path != "/system/plugins" || entry.Code != "plugins" || entry.Controller != "" || entry.Keywords == nil { t.Errorf("entry = %+v", entry) } raw, _ := json.Marshal(SettingsEntry{Code: "other"}) if strings.Contains(string(raw), `"path"`) { t.Errorf("an entry without a path serializes one: %s", raw) } }) t.Run("update rule", func(t *testing.T) { cases := []struct { name, installed, framework string versions []kiosk.RegistryVersion latest, latestFramework string update bool }{ {"exact tag, newer compatible", "v0.1.0", "v0.6.0", []kiosk.RegistryVersion{rel("v0.2.0", "v0.6.0"), rel("v0.1.0", "v0.5.0")}, "v0.2.0", "", true}, {"exact tag, current", "v0.2.0", "v0.6.0", []kiosk.RegistryVersion{rel("v0.2.0", "v0.6.0")}, "v0.2.0", "", false}, {"describe base equal to latest", "v0.1.1-3-gabc1234", "v0.6.0", []kiosk.RegistryVersion{rel("v0.1.1", "v0.6.0")}, "v0.1.1", "", false}, {"describe base below latest", "v0.1.1-3-gabc1234", "v0.6.0", []kiosk.RegistryVersion{rel("v0.1.2", "v0.6.0"), rel("v0.1.1", "v0.6.0")}, "v0.1.2", "", true}, {"untagged commit", "abc1234", "v0.6.0", []kiosk.RegistryVersion{rel("v0.1.2", "v0.6.0")}, "v0.1.2", "", false}, // A module at a commit after v0.4.0 (go get M@main): v0.4.0 is // no update, v0.4.1 is. {"pseudo-version after the latest", "v0.4.1-0.20261001120000-abcdef123456", "v0.6.0", []kiosk.RegistryVersion{rel("v0.4.0", "v0.6.0")}, "v0.4.0", "", false}, {"pseudo-version before the latest", "v0.4.1-0.20261001120000-abcdef123456", "v0.6.0", []kiosk.RegistryVersion{rel("v0.4.1", "v0.6.0"), rel("v0.4.0", "v0.6.0")}, "v0.4.1", "", true}, {"pseudo-version without a tag before it", "v0.0.0-20261001120000-abcdef123456", "v0.6.0", []kiosk.RegistryVersion{rel("v0.1.0", "v0.6.0")}, "v0.1.0", "", false}, {"pseudo-version, newer incompatible", "v0.4.1-0.20261001120000-abcdef123456", "v0.6.0", []kiosk.RegistryVersion{rel("v0.5.0", "v0.7.0"), rel("v0.4.0", "v0.6.0")}, "v0.4.0", "v0.7.0", false}, {"pseudo-version, older incompatible", "v0.4.1-0.20261001120000-abcdef123456", "v0.6.0", []kiosk.RegistryVersion{rel("v0.4.0", "v0.7.0")}, "", "", false}, {"newer incompatible only", "v0.1.0", "v0.6.0", []kiosk.RegistryVersion{rel("v0.3.0", "v0.7.0"), rel("v0.1.0", "v0.5.0")}, "v0.1.0", "v0.7.0", false}, {"compatible update and newer incompatible", "v0.1.0", "v0.6.0", []kiosk.RegistryVersion{rel("v0.3.0", "v0.8.0"), rel("v0.2.0", "v0.6.0")}, "v0.2.0", "v0.8.0", true}, {"incompatible but not newer than installed", "v0.3.0", "v0.6.0", []kiosk.RegistryVersion{rel("v0.3.0", "v0.7.0")}, "", "", false}, {"framework is a describe string", "v0.1.0", "v0.6.0-4-gdeadbee", []kiosk.RegistryVersion{rel("v0.2.0", "v0.6.0")}, "v0.2.0", "", true}, } for _, c := range cases { p := InstalledPlugin{ID: "acme.blog", Version: c.installed} applyUpdate(&p, c.versions, frameworkRelease(c.framework)) wantCmd := "" if c.update { wantCmd = "summer plugin:update acme.blog" } if !p.Listed || p.Latest != c.latest || p.LatestFramework != c.latestFramework || p.UpdateAvailable != c.update || p.UpdateCommand != wantCmd { t.Errorf("%s: %+v", c.name, p) } } }) t.Run("update badges through the endpoint", func(t *testing.T) { reg := &memPluginRegistry{versions: map[string][]kiosk.RegistryVersion{ "acme.blog": {rel("v0.2.0", "v0.6.0"), rel("v0.1.0", "v0.5.0")}, "acme.translate": {rel("v0.1.1", "v0.6.0")}, }} installed := testInstalled() installed.Plugins = append(installed.Plugins, party.Info{ID: "acme.unlisted", Version: "v1.0.0", Source: party.SourceModule}) svc := unitPluginsService(t, installed, reg, clock) overview := decodeOverview(t, pluginsCall(svc, (*service).pluginsInstalled, pluginsViewer, "/plugins")) if overview.Registry != (RegistryState{Enabled: true, Status: registryStatusOK}) { t.Errorf("registry = %+v", overview.Registry) } got := byPluginID(overview) if b := got["acme.blog"]; !b.UpdateAvailable || b.Latest != "v0.2.0" || b.UpdateCommand != "summer plugin:update acme.blog" { t.Errorf("blog = %+v", b) } if tr := got["acme.translate"]; tr.UpdateAvailable || tr.Latest != "v0.1.1" || !tr.Listed { t.Errorf("translate = %+v", tr) } if u := got["acme.unlisted"]; u.Listed || u.UpdateAvailable || u.Latest != "" { t.Errorf("unlisted = %+v", u) } if l := got["acme.local"]; l.Listed || l.UpdateAvailable { t.Errorf("local = %+v", l) } if n := reg.count("versions acme.local"); n != 0 { t.Errorf("the local plugin was looked up %d times", n) } }) t.Run("registry error keeps the list", func(t *testing.T) { reg := &memPluginRegistry{fail: errors.New("connection refused")} svc := unitPluginsService(t, testInstalled(), reg, clock) overview := decodeOverview(t, pluginsCall(svc, (*service).pluginsInstalled, pluginsSuperuser, "/plugins")) if overview.Registry != (RegistryState{Enabled: true, Status: registryStatusUnavailable}) || len(overview.Plugins) != 3 { t.Errorf("overview = %+v", overview) } for _, p := range overview.Plugins { if p.UpdateAvailable || p.Listed { t.Errorf("plugin with a failed lookup = %+v", p) } } }) t.Run("search cache is bounded", func(t *testing.T) { reg := &memPluginRegistry{} clk := &testClock{now: clock.Now()} c := &pluginCatalog{registry: reg, timeout: time.Second, now: clk.Now, lists: map[string]cachedList{}, versions: map[string]cachedVersions{}} for i := 0; i < marketplaceCacheSize+50; i++ { clk.advance(time.Millisecond) if _, err := c.list(t.Context(), fmt.Sprintf("q%d", i), ""); err != nil { t.Fatal(err) } } if n := len(c.lists); n != marketplaceCacheSize { t.Fatalf("cached searches = %d, want at most %d", n, marketplaceCacheSize) } // The oldest went first; the newest is still a hit. if _, ok := c.lists["q0\x00"]; ok { t.Error("the oldest search survived") } before := reg.count(fmt.Sprintf("list q%d|", marketplaceCacheSize+49)) if _, err := c.list(t.Context(), fmt.Sprintf("q%d", marketplaceCacheSize+49), ""); err != nil || reg.count(fmt.Sprintf("list q%d|", marketplaceCacheSize+49)) != before { t.Errorf("the newest search was not cached: %v", err) } // Expired searches are dropped when a new one needs the room. clk.advance(marketplaceTTL + time.Second) if _, err := c.list(t.Context(), "fresh", ""); err != nil { t.Fatal(err) } if n := len(c.lists); n != 1 { t.Errorf("cached searches after expiry = %d, want 1", n) } }) t.Run("cache hit and expiry", func(t *testing.T) { reg := &memPluginRegistry{versions: map[string][]kiosk.RegistryVersion{"acme.blog": {rel("v0.2.0", "v0.6.0")}, "acme.translate": {rel("v0.1.1", "v0.6.0")}}} clk := &testClock{now: clock.Now()} svc := unitPluginsService(t, testInstalled(), reg, clk) call := func() { decodeOverview(t, pluginsCall(svc, (*service).pluginsInstalled, pluginsSuperuser, "/plugins")) } call() call() clk.advance(versionsTTL - time.Second) call() if n := reg.count("versions acme.blog"); n != 1 { t.Fatalf("lookups within the TTL = %d, want 1", n) } clk.advance(2 * time.Second) call() if n := reg.count("versions acme.blog"); n != 2 { t.Fatalf("lookups after the TTL = %d, want 2", n) } // A failure is cached for registryFailureTTL only. reg.mu.Lock() reg.fail = errors.New("down") reg.mu.Unlock() clk.advance(versionsTTL + time.Second) call() before := reg.count("versions acme.blog") clk.advance(registryFailureTTL / 2) call() if n := reg.count("versions acme.blog"); n != before { t.Fatalf("a cached failure was retried within %s", registryFailureTTL) } clk.advance(registryFailureTTL) call() if n := reg.count("versions acme.blog"); n != before+1 { t.Fatalf("a failure was not retried after %s", registryFailureTTL) } // Searches are cached per query and category. reg.mu.Lock() reg.fail = nil reg.mu.Unlock() for i := 0; i < 3; i++ { pluginsCall(svc, (*service).pluginsMarketplace, pluginsSuperuser, "/plugins/marketplace?q=blog") } if n := reg.count("list blog|"); n != 1 { t.Fatalf("search lookups = %d, want 1", n) } clk.advance(marketplaceTTL + time.Second) pluginsCall(svc, (*service).pluginsMarketplace, pluginsSuperuser, "/plugins/marketplace?q=blog") if n := reg.count("list blog|"); n != 2 { t.Fatalf("search lookups after the TTL = %d, want 2", n) } }) t.Run("concurrency bound", func(t *testing.T) { installed := &party.Installed{Framework: "v0.6.0"} reg := &memPluginRegistry{versions: map[string][]kiosk.RegistryVersion{}, hold: 30 * time.Millisecond} for i := 0; i < 12; i++ { id := fmt.Sprintf("acme.p%d", i) installed.Plugins = append(installed.Plugins, party.Info{ID: id, Version: "v0.1.0", Source: party.SourceModule}) reg.versions[id] = []kiosk.RegistryVersion{rel("v0.1.0", "v0.6.0")} } svc := unitPluginsService(t, installed, reg, clock) overview := decodeOverview(t, pluginsCall(svc, (*service).pluginsInstalled, pluginsSuperuser, "/plugins")) if overview.Registry.Status != registryStatusOK || reg.count("versions ") != 12 { t.Fatalf("registry %+v, lookups %d", overview.Registry, reg.count("versions ")) } if reg.maxFlight > registryLookups || reg.maxFlight < 2 { t.Errorf("at most %d lookups ran at once, want between 2 and %d", reg.maxFlight, registryLookups) } }) t.Run("slow registry times out", func(t *testing.T) { reg := &memPluginRegistry{versions: map[string][]kiosk.RegistryVersion{"acme.blog": {rel("v0.2.0", "v0.6.0")}}, hold: time.Second} svc := unitPluginsService(t, testInstalled(), reg, clock) svc.catalog.timeout = 20 * time.Millisecond start := time.Now() overview := decodeOverview(t, pluginsCall(svc, (*service).pluginsInstalled, pluginsSuperuser, "/plugins")) if overview.Registry.Status != registryStatusUnavailable || time.Since(start) > 500*time.Millisecond { t.Errorf("registry %+v after %s", overview.Registry, time.Since(start)) } }) t.Run("marketplace compatibility", func(t *testing.T) { list := []kiosk.RegistryPlugin{ {ID: "acme.shop", Name: "Shop", LatestVersion: "v1.0.0", LatestFramework: "v0.9.0"}, {ID: "acme.old", Name: "Old", LatestVersion: "v0.3.0", LatestFramework: "v0.9.0"}, {ID: "acme.blog", Name: "Blog", LatestVersion: "v0.2.0", LatestFramework: "v0.6.0"}, } versions := map[string][]kiosk.RegistryVersion{ "acme.shop": {rel("v1.0.0", "v0.9.0"), rel("v0.9.0", "v0.8.0")}, "acme.old": {rel("v0.3.0", "v0.9.0"), rel("v0.1.0", "v0.5.0")}, } compat := func(t *testing.T, framework string) (map[string]bool, *memPluginRegistry) { t.Helper() reg := &memPluginRegistry{list: list, versions: versions} installed := testInstalled() installed.Framework = framework svc := unitPluginsService(t, installed, reg, clock) got := map[string]bool{} for _, p := range decodeMarketplace(t, pluginsCall(svc, (*service).pluginsMarketplace, pluginsViewer, "/plugins/marketplace")).Data { got[p.ID] = p.Compatible } return got, reg } // A release framework: an older compatible release counts, as it // does for plugin:install; only the incompatible are looked up. got, reg := compat(t, "v0.6.0") if got["acme.shop"] || !got["acme.old"] || !got["acme.blog"] { t.Errorf("v0.6.0: %v", got) } if reg.count("versions acme.blog") != 0 { t.Error("a compatible plugin was looked up") } // A framework that is not a release cannot be judged: no marker. for _, fw := range []string{"(devel)", "", "abc1234"} { got, reg := compat(t, fw) if !got["acme.shop"] || !got["acme.old"] || !got["acme.blog"] || reg.count("versions") != 0 { t.Errorf("%q: %v", fw, got) } } p := InstalledPlugin{ID: "acme.blog", Version: "v0.1.0"} applyUpdate(&p, []kiosk.RegistryVersion{rel("v0.3.0", "v0.7.0")}, frameworkRelease("(devel)")) if p.LatestFramework != "" || p.UpdateAvailable { t.Errorf("devel framework marks the installed plugin: %+v", p) } }) t.Run("marketplace", func(t *testing.T) { reg := &memPluginRegistry{list: []kiosk.RegistryPlugin{ {ID: "acme.blog", Name: "Blog", Category: "content", LatestVersion: "v0.2.0", LatestFramework: "v0.6.0", IconURL: "https://cdn.example.test/blog.png"}, {ID: "acme.shop", Name: "Shop", Category: "commerce", Featured: true, LatestVersion: "v1.0.0", LatestFramework: "v0.9.0", IconURL: "http://cdn.example.test/shop.png"}, {ID: "acme.forum", Name: "Forum", Category: "communication", LatestVersion: "v0.1.0", IconURL: "https://user:pw@cdn.example.test/x.png"}, {ID: "Acme.Evil;rm", Name: "Evil", Category: "tools"}, {ID: "acme.odd", Name: "Odd", Category: "Not Valid"}, }} svc := unitPluginsService(t, testInstalled(), reg, clock) rec := pluginsCall(svc, (*service).pluginsMarketplace, pluginsViewer, "/plugins/marketplace") if rec.Code != http.StatusOK { t.Fatalf("status %d: %s", rec.Code, rec.Body.String()) } out := decodeMarketplace(t, rec) if out.Meta.Total != 4 || len(out.Data) != 4 || out.Meta.Registry.Status != registryStatusOK { t.Fatalf("marketplace = %+v", out) } if strings.Join(out.Meta.Categories, ",") != "commerce,communication,content" { t.Errorf("categories = %v", out.Meta.Categories) } got := map[string]MarketplacePlugin{} for _, p := range out.Data { got[p.ID] = p if p.InstallCommand != "summer plugin:install "+p.ID { t.Errorf("%s command = %q", p.ID, p.InstallCommand) } } if _, ok := got["Acme.Evil;rm"]; ok { t.Error("an ID outside the plugin ID shape was offered") } if b := got["acme.blog"]; !b.Installed || !b.Compatible || b.IconURL != "https://cdn.example.test/blog.png" { t.Errorf("blog = %+v", b) } if s := got["acme.shop"]; s.Installed || s.Compatible || s.IconURL != "" || !s.Featured { t.Errorf("shop = %+v", s) } if f := got["acme.forum"]; !f.Compatible || f.IconURL != "" { t.Errorf("forum = %+v", f) } // A filtered search still gets every category for the select. rec = pluginsCall(svc, (*service).pluginsMarketplace, pluginsViewer, "/plugins/marketplace?category=content&q=blog") filtered := decodeMarketplace(t, rec) if len(filtered.Data) != 1 || filtered.Data[0].ID != "acme.blog" || len(filtered.Meta.Categories) != 3 { t.Errorf("filtered = %+v", filtered) } if n := reg.count("list blog|content"); n != 1 { t.Errorf("filtered search lookups = %d", n) } for target, field := range map[string]string{ "/plugins/marketplace?q=" + strings.Repeat("x", 101): "q", "/plugins/marketplace?category=Content": "category", "/plugins/marketplace?category=" + strings.Repeat("a", 41): "category", "/plugins/marketplace?category=" + url.QueryEscape("a b"): "category", } { before := reg.count("list ") rec := pluginsCall(svc, (*service).pluginsMarketplace, pluginsViewer, target) if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "invalid_query") || !strings.Contains(rec.Body.String(), `"`+field+`"`) { t.Errorf("%s: status %d body %s", target, rec.Code, rec.Body.String()) } if reg.count("list ") != before { t.Errorf("%s reached the registry", target) } } if rec := pluginsCall(svc, (*service).pluginsMarketplace, pluginsViewer, "/plugins/marketplace?q="+strings.Repeat("ż", 100)); rec.Code != http.StatusOK { t.Errorf("100 runes: status %d", rec.Code) } reg.mu.Lock() reg.fail = errors.New("down") reg.mu.Unlock() rec = pluginsCall(svc, (*service).pluginsMarketplace, pluginsViewer, "/plugins/marketplace?q=new") down := decodeMarketplace(t, rec) if rec.Code != http.StatusOK || down.Meta.Registry.Status != registryStatusUnavailable || len(down.Data) != 0 || down.Data == nil { t.Errorf("registry down: %d %+v", rec.Code, down) } }) t.Run("disabled registry makes no call", func(t *testing.T) { reg := &memPluginRegistry{versions: map[string][]kiosk.RegistryVersion{"acme.blog": {rel("v0.2.0", "v0.6.0")}}} svc := unitPluginsService(t, testInstalled(), reg, clock) svc.catalog.disabled = true overview := decodeOverview(t, pluginsCall(svc, (*service).pluginsInstalled, pluginsSuperuser, "/plugins")) rec := pluginsCall(svc, (*service).pluginsMarketplace, pluginsSuperuser, "/plugins/marketplace?q=x") market := decodeMarketplace(t, rec) if overview.Registry != (RegistryState{Enabled: false, Status: registryStatusDisabled}) || market.Meta.Registry.Status != registryStatusDisabled { t.Errorf("overview %+v, marketplace %+v", overview.Registry, market.Meta) } if len(reg.calls) != 0 { t.Errorf("a disabled registry was called: %v", reg.calls) } }) t.Run("unusable registry URL", func(t *testing.T) { c := newPluginCatalog(pluginsConfig(t, "ftp://registry.example.test")) if !c.enabled() || c.registry != nil { t.Fatalf("catalog = %+v", c) } if _, _, err := c.releases(context.Background(), "acme.blog"); !errors.Is(err, errRegistryUnusable) { t.Errorf("releases = %v", err) } if _, err := c.list(context.Background(), "", ""); !errors.Is(err, errRegistryUnusable) { t.Errorf("list = %v", err) } }) t.Run("helpers", func(t *testing.T) { for in, want := range map[string]string{ "https://cdn.example.test/a.png": "https://cdn.example.test/a.png", "http://cdn.example.test/a.png": "", "//cdn.example.test/a.png": "", "javascript:alert(1)": "", "https:///a.png": "", "": "", } { if got := httpsURL(in); got != want { t.Errorf("httpsURL(%q) = %q", in, got) } } for in, want := range map[string]string{"v0.6.0": "v0.6.0", "v0.6.0-2-gabc1234": "v0.6.0", "devel": "devel", "": ""} { if got := frameworkRelease(in); got != want { t.Errorf("frameworkRelease(%q) = %q", in, got) } } got := categoriesOf([]kiosk.RegistryPlugin{{ID: "a.seo", Category: "seo"}, {ID: "a.blog", Category: "content"}, {ID: "b.seo", Category: "seo"}, {ID: "a.none", Category: ""}, {ID: "a.bad", Category: "Bad Cat"}, {ID: "Bad;id", Category: "tools"}}) if strings.Join(got, ",") != "content,seo" { t.Errorf("categoriesOf = %v", got) } }) }