test(kiosk): manifest, identity, version and client unit tests

- TestReadIdentity: literal, const, chained, cross-package, alias,
  other-module and Requires shapes; refusals naming ID()/Requires()
- TestDerive, TestManifestFormat, TestCheck, TestVersions,
  TestRegistryClient (httptest: escaping, 404, envelopes, cap, redirect)
- runnable ExampleMarshal and ExampleNewestCompatible
- party TestDescribe: copy of the plugin list, publish once per app
This commit is contained in:
Jakub Zych
2026-10-29 21:22:47 +01:00
parent b93a6de20e
commit 9109e5b139
7 changed files with 1291 additions and 0 deletions

View File

@@ -0,0 +1,261 @@
package kiosk
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"git.golem15.com/golem15/summercms/modules/compass"
)
// registryServer serves handler under RegistryAPIPath and records the
// request URIs it saw.
func registryServer(t *testing.T, handler http.HandlerFunc) (*httptest.Server, *[]string) {
t.Helper()
var mu sync.Mutex
var seen []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
seen = append(seen, r.URL.RequestURI())
mu.Unlock()
handler(w, r)
}))
t.Cleanup(srv.Close)
return srv, &seen
}
func newTestClient(t *testing.T, base string) *Client {
t.Helper()
c, err := NewClient(base, nil)
if err != nil {
t.Fatalf("NewClient(%s): %v", base, err)
}
return c
}
// TestRegistryClient covers decoding, query escaping, the error mapping
// and the transport limits of kiosk.Client.
func TestRegistryClient(t *testing.T) {
ctx := context.Background()
t.Run("decode", func(t *testing.T) {
srv, seen := registryServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Accept") != "application/json" {
t.Errorf("Accept = %q", r.Header.Get("Accept"))
}
switch r.URL.Path {
case RegistryAPIPath + "/plugins":
_ = json.NewEncoder(w).Encode(ListResponse{Data: []RegistryPlugin{{ID: "acme.shop", LatestVersion: "v0.2.0"}}, Meta: ListMeta{Total: 1}})
case RegistryAPIPath + "/plugins/acme.shop":
_ = json.NewEncoder(w).Encode(DetailResponse{Data: RegistryPluginDetail{RegistryPlugin: RegistryPlugin{ID: "acme.shop"}, Requires: Requires{"acme.user": "v0.1.0"}}})
case RegistryAPIPath + "/plugins/acme.shop/versions":
_, _ = w.Write([]byte(`{"data":[{"version":"v0.2.0","framework":"v0.6.0","requires":{},"future":true},{"version":"v0.1.0","framework":"v0.5.0","requires":{"acme.user":""}}]}`))
default:
http.NotFound(w, r)
}
})
c := newTestClient(t, srv.URL+"/")
if c.BaseURL() != srv.URL {
t.Errorf("BaseURL = %q, want the trailing slash trimmed", c.BaseURL())
}
list, err := c.List(ctx, "", "")
if err != nil || len(list.Data) != 1 || list.Data[0].ID != "acme.shop" || list.Meta.Total != 1 {
t.Fatalf("List = %+v, %v", list, err)
}
detail, err := c.Plugin(ctx, "acme.shop")
if err != nil || detail.ID != "acme.shop" || detail.Requires["acme.user"] != "v0.1.0" {
t.Fatalf("Plugin = %+v, %v", detail, err)
}
versions, err := c.Versions(ctx, "acme.shop")
if err != nil || len(versions) != 2 || versions[0].Version != "v0.2.0" || versions[1].Framework != "v0.5.0" {
t.Fatalf("Versions = %+v, %v", versions, err)
}
if (*seen)[0] != RegistryAPIPath+"/plugins" {
t.Errorf("List without filters asked %q", (*seen)[0])
}
})
t.Run("query escaping", func(t *testing.T) {
var got []string
srv, _ := registryServer(t, func(w http.ResponseWriter, r *http.Request) {
got = append(got, r.URL.Path+"|"+r.URL.Query().Get("q")+"|"+r.URL.Query().Get("category")+"|"+r.URL.RawQuery)
_, _ = w.Write([]byte(`{"data":[],"meta":{"total":0}}`))
})
c := newTestClient(t, srv.URL)
if _, err := c.List(ctx, "100% a&b=c #x", "content"); err != nil {
t.Fatal(err)
}
if _, err := c.List(ctx, "", "seo"); err != nil {
t.Fatal(err)
}
if got[0] != RegistryAPIPath+"/plugins|100% a&b=c #x|content|category=content&q=100%25+a%26b%3Dc+%23x" {
t.Errorf("search request = %q", got[0])
}
if got[1] != RegistryAPIPath+"/plugins||seo|category=seo" {
t.Errorf("category request = %q", got[1])
}
})
t.Run("plugin id is path-escaped", func(t *testing.T) {
srv, seen := registryServer(t, func(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
})
c := newTestClient(t, srv.URL)
_, _ = c.Plugin(ctx, "a/../b?x")
if len(*seen) != 1 || (*seen)[0] != RegistryAPIPath+"/plugins/a%2F..%2Fb%3Fx" {
t.Errorf("requested %q", *seen)
}
})
t.Run("not found", func(t *testing.T) {
srv, _ := registryServer(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"error":{"code":"not_found","message":"no plugin"}}`))
})
c := newTestClient(t, srv.URL)
_, err := c.Plugin(ctx, "acme.gone")
if !errors.Is(err, ErrNotFound) || !strings.Contains(err.Error(), "acme.gone") || !strings.Contains(err.Error(), srv.URL) {
t.Errorf("Plugin 404 = %v, want ErrNotFound naming the ID and the registry", err)
}
_, err = c.Versions(ctx, "acme.gone")
if !errors.Is(err, ErrNotFound) {
t.Errorf("Versions 404 = %v", err)
}
_, err = c.List(ctx, "", "")
if !errors.Is(err, ErrNotFound) || !strings.Contains(err.Error(), srv.URL) {
t.Errorf("List 404 = %v", err)
}
})
t.Run("error envelope", func(t *testing.T) {
srv, _ := registryServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("q") == "plain" {
w.WriteHeader(http.StatusBadGateway)
_, _ = w.Write([]byte("<html>bad gateway</html>"))
return
}
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":{"code":"invalid_query","message":"category must match ^[a-z0-9-]{1,40}$"}}`))
})
c := newTestClient(t, srv.URL)
_, err := c.List(ctx, "", "Bad")
if err == nil || errors.Is(err, ErrNotFound) || !strings.Contains(err.Error(), "HTTP 400 invalid_query") || !strings.Contains(err.Error(), "category must match") {
t.Errorf("400 = %v, want the code and message", err)
}
_, err = c.List(ctx, "plain", "")
if err == nil || !strings.Contains(err.Error(), "HTTP 502") || strings.Contains(err.Error(), "html") {
t.Errorf("502 without an envelope = %v", err)
}
})
t.Run("server error with envelope", func(t *testing.T) {
srv, _ := registryServer(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"error":{"code":"internal","message":"database down"}}`))
})
_, err := newTestClient(t, srv.URL).Versions(ctx, "acme.shop")
if err == nil || !strings.Contains(err.Error(), "HTTP 500 internal: database down") {
t.Errorf("500 = %v", err)
}
})
t.Run("bad json", func(t *testing.T) {
srv, _ := registryServer(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"data":[`))
})
_, err := newTestClient(t, srv.URL).Versions(ctx, "acme.shop")
if err == nil || !strings.Contains(err.Error(), "decode") {
t.Errorf("truncated JSON = %v", err)
}
})
t.Run("oversized body", func(t *testing.T) {
srv, _ := registryServer(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"data":[],"meta":{"total":0},"pad":"`))
_, _ = w.Write([]byte(strings.Repeat("x", registryMaxBytes)))
_, _ = w.Write([]byte(`"}`))
})
if _, err := newTestClient(t, srv.URL).List(ctx, "", ""); err == nil {
t.Error("a body over the cap was accepted")
}
})
t.Run("redirect is not followed", func(t *testing.T) {
target, targetSeen := registryServer(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"data":[],"meta":{"total":0}}`))
})
srv, _ := registryServer(t, func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, target.URL+r.URL.RequestURI(), http.StatusFound)
})
_, err := newTestClient(t, srv.URL).List(ctx, "", "")
if err == nil || !strings.Contains(err.Error(), "redirect") || !strings.Contains(err.Error(), "plugins.registry_url") {
t.Errorf("redirect = %v, want an error naming plugins.registry_url", err)
}
if len(*targetSeen) != 0 {
t.Errorf("the redirect target was called: %v", *targetSeen)
}
})
t.Run("NewClient", func(t *testing.T) {
for _, base := range []string{"", " ", "/"} {
if _, err := NewClient(base, nil); !errors.Is(err, ErrRegistryDisabled) {
t.Errorf("NewClient(%q) = %v, want ErrRegistryDisabled", base, err)
}
}
for base, want := range map[string]string{
"ftp://example.test": "https://",
"example.test": "https://",
"https://": "no host",
"https://user:pw@example.test": "user info",
"https://example.test/?a=b": "query",
"https://example.test/#x": "query",
"https://example.test/?": "query",
"https://exa mple.test": "registry URL",
} {
if _, err := NewClient(base, nil); err == nil || !strings.Contains(err.Error(), want) {
t.Errorf("NewClient(%q) = %v, want an error naming %q", base, err, want)
}
}
c, err := NewClient(" https://registry.example.test/ ", nil)
if err != nil || c.BaseURL() != "https://registry.example.test" {
t.Errorf("NewClient trims: %v, %v", c, err)
}
})
t.Run("RegistryURL", func(t *testing.T) {
if got := RegistryURL(nil); got != DefaultRegistryURL {
t.Errorf("RegistryURL(nil) = %q", got)
}
cfg, err := compass.Open(compass.Options{Dir: t.TempDir(), Environ: []string{"SUMMER_ENV=development"}})
if err != nil {
t.Fatal(err)
}
if got := RegistryURL(cfg); got != DefaultRegistryURL {
t.Errorf("RegistryURL without the key = %q", got)
}
for set, want := range map[string]string{
"https://registry.example.test/": "https://registry.example.test",
" http://127.0.0.1:8080 ": "http://127.0.0.1:8080",
"": "",
} {
if err := cfg.Set("plugins.registry_url", set); err != nil {
t.Fatal(err)
}
if got := RegistryURL(cfg); got != want {
t.Errorf("RegistryURL(%q) = %q, want %q", set, got, want)
}
}
env, err := compass.Open(compass.Options{Dir: t.TempDir(), Environ: []string{"SUMMER_ENV=development", "SUMMER_PLUGINS__REGISTRY_URL=https://env.example.test"}})
if err != nil {
t.Fatal(err)
}
if got := RegistryURL(env); got != "https://env.example.test" {
t.Errorf("RegistryURL from the environment = %q", got)
}
})
}

View File

@@ -0,0 +1,252 @@
package kiosk
import (
"context"
"os"
"path/filepath"
"slices"
"strings"
"testing"
)
// derivePlugin writes a literal-ID plugin acme.shop with the given go.mod
// framework line and README into a temp dir and returns the dir.
func derivePlugin(t *testing.T, require, readme string) string {
t.Helper()
dir := t.TempDir()
gomod := "module example.test/sm-shop-plugin\n\ngo 1.27.0\n"
if require != "" {
gomod += "\nrequire " + FrameworkModule + " " + require + "\n"
}
files := map[string]string{
"go.mod": gomod,
"plugin.go": pluginHead + `func (*Plugin) ID() string { return "acme.shop" }`,
}
if readme != "" {
files["README.md"] = readme
}
writeTree(t, dir, files)
return dir
}
// TestDerive covers README extraction, overrides, framework rules and
// requirement minimums.
func TestDerive(t *testing.T) {
requireGo(t)
ctx := context.Background()
t.Run("readme text is flattened", func(t *testing.T) {
readme := "<!-- badge -->\n\n# The `acme` *Shop*\n\n" +
"Products, [carts](https://example.test/carts) and **orders**\n" +
"for `acme` apps, see <https://example.test>.\n\n" +
"Second paragraph.\n"
m, warnings, err := Derive(ctx, derivePlugin(t, "v0.6.0", readme), DeriveOptions{})
if err != nil {
t.Fatalf("Derive: %v", err)
}
if len(warnings) != 0 {
t.Errorf("warnings = %v", warnings)
}
if m.Name != "The acme Shop" {
t.Errorf("name = %q", m.Name)
}
if want := "Products, carts and orders for acme apps, see https://example.test."; m.Description != want {
t.Errorf("description = %q, want %q", m.Description, want)
}
if m.Package != m.Module || m.Module != "example.test/sm-shop-plugin" || m.Framework != "v0.6.0" || m.Schema != SchemaVersion {
t.Errorf("manifest = %+v", m)
}
})
t.Run("readme errors", func(t *testing.T) {
cases := []struct{ name, readme, want string }{
{"missing", "", "needs a README.md"},
{"no h1", "## Shop\n\nText.\n", "has no H1"},
{"empty h1", "#\n\nText.\n", "H1"},
{"no summary", "# Shop\n\n## Usage\n\nText.\n", "no summary paragraph"},
{"id as h1", "# acme.shop\n\nText.\n", "--name"},
}
for _, c := range cases {
_, _, err := Derive(ctx, derivePlugin(t, "v0.6.0", c.readme), DeriveOptions{})
if err == nil || !strings.Contains(err.Error(), c.want) {
t.Errorf("%s: err = %v, want %q", c.name, err, c.want)
}
}
})
t.Run("framework rules", func(t *testing.T) {
readme := "# Shop\n\nA shop.\n"
if _, _, err := Derive(ctx, derivePlugin(t, "", readme), DeriveOptions{}); err == nil || !strings.Contains(err.Error(), "does not require "+FrameworkModule) {
t.Errorf("no framework require: err = %v", err)
}
if _, _, err := Derive(ctx, derivePlugin(t, "v0.0.0", readme), DeriveOptions{}); err == nil || !strings.Contains(err.Error(), "not a released version") {
t.Errorf("framework v0.0.0: err = %v", err)
}
pseudo := "v0.6.1-0.20261029101500-abcdefabcdef"
m, warnings, err := Derive(ctx, derivePlugin(t, pseudo, readme), DeriveOptions{})
if err != nil {
t.Fatalf("pseudo framework: %v", err)
}
if m.Framework != pseudo || len(warnings) != 1 || !strings.Contains(warnings[0], "pseudo-version") {
t.Errorf("pseudo framework: framework %q, warnings %v", m.Framework, warnings)
}
})
t.Run("overrides", func(t *testing.T) {
dir := derivePlugin(t, "v0.6.0", "# acme.shop\n\nA shop.\n")
set := func(name, desc *string) (Manifest, error) {
t.Helper()
if _, _, err := Write(ctx, dir, DeriveOptions{Name: name, Description: desc}); err != nil {
return Manifest{}, err
}
raw, err := os.ReadFile(filepath.Join(dir, FileName))
if err != nil {
t.Fatal(err)
}
return Parse(raw)
}
str := func(s string) *string { return &s }
m, err := set(str(" Shop "), str("Products and orders."))
if err != nil {
t.Fatalf("set: %v", err)
}
if m.Name != "Shop" || m.Description != "Products and orders." || m.Overrides == nil || m.Overrides.Name != "Shop" {
t.Fatalf("after set = %+v / %+v", m, m.Overrides)
}
if m, err = set(nil, nil); err != nil || m.Name != "Shop" || m.Description != "Products and orders." {
t.Fatalf("keep: %+v, %v", m, err)
}
if m, err = set(nil, str("")); err != nil || m.Description != "A shop." || m.Overrides.Description != "" || m.Overrides.Name != "Shop" {
t.Fatalf("clear description: %+v / %+v, %v", m, m.Overrides, err)
}
if _, err = set(str(""), nil); err == nil || !strings.Contains(err.Error(), "--name") {
t.Fatalf("clearing the name while the H1 is the ID: err = %v", err)
}
})
t.Run("corrupt plugin.yaml", func(t *testing.T) {
dir := derivePlugin(t, "v0.6.0", "# Shop\n\nA shop.\n")
writeTree(t, dir, map[string]string{FileName: "schema: [\n"})
if _, _, err := Derive(ctx, dir, DeriveOptions{}); err == nil || !strings.Contains(err.Error(), "delete") {
t.Errorf("err = %v, want the advice to delete and rerun", err)
}
})
t.Run("not a plugin directory", func(t *testing.T) {
if _, _, err := Derive(ctx, filepath.Join(t.TempDir(), "x"), DeriveOptions{}); err == nil || !strings.Contains(err.Error(), "not a plugin directory") {
t.Errorf("missing dir: err = %v", err)
}
dir := t.TempDir()
writeTree(t, dir, map[string]string{"plugin.go": pluginHead + `func (*Plugin) ID() string { return "acme.x" }`})
if _, err := moduleRoot(dir); err == nil {
t.Skip("a go.mod above the temp dir")
}
if _, _, err := Derive(ctx, dir, DeriveOptions{}); err == nil || !strings.Contains(err.Error(), "no go.mod") {
t.Errorf("no go.mod: err = %v", err)
}
})
t.Run("minimums", func(t *testing.T) {
root := t.TempDir()
fw := "\nrequire " + FrameworkModule + " v0.6.0\n\nreplace " + FrameworkModule + " => ../framework\n"
writeTree(t, root, map[string]string{
"framework/go.mod": "module " + FrameworkModule + "\n\ngo 1.27.0\n",
// A module that requires the framework and holds acme.user.
"user/go.mod": "module example.test/sm-user-plugin\n\ngo 1.27.0\n" + fw,
"user/plugin.go": pluginHead + `func (*Plugin) ID() string { return "acme.user" }`,
"user/README.md": "# User\n\nUsers.\n",
// A module that does not require the framework: never searched.
"lib/go.mod": "module example.test/lib\n\ngo 1.27.0\n",
"lib/plugin.go": pluginHead + `func (*Plugin) ID() string { return "acme.lib" }`,
"shop/go.mod": "module example.test/sm-shop-plugin\n\ngo 1.27.0\n\nrequire (\n\t" + FrameworkModule + " v0.6.0\n\texample.test/sm-user-plugin v0.1.2\n\texample.test/lib v1.0.0\n)\n\n" +
"replace " + FrameworkModule + " => ../framework\n\nreplace example.test/sm-user-plugin => ../user\n\nreplace example.test/lib => ../lib\n",
"shop/plugin.go": pluginHead + `func (*Plugin) ID() string { return "acme.shop" }
func (*Plugin) Requires() []string { return []string{"acme.user", "acme.shopadmin", "acme.lib", "acme.soft"} }`,
"shop/README.md": "# Shop\n\nA shop.\n",
"shop/admin/plugin.go": "package admin\n\ntype Plugin struct{}\n\nfunc (*Plugin) ID() string { return \"acme.shopadmin\" }\n",
"shop/admin/README.md": "# Shop admin\n\nAdmin.\n",
"shop/testdata/plugin.go": pluginHead + `func (*Plugin) ID() string { return "acme.soft" }`,
})
m, warnings, err := Derive(ctx, filepath.Join(root, "shop"), DeriveOptions{})
if err != nil {
t.Fatalf("Derive: %v", err)
}
if len(warnings) != 0 {
t.Errorf("warnings = %v", warnings)
}
want := Requires{"acme.user": "v0.1.2", "acme.shopadmin": "", "acme.lib": "", "acme.soft": ""}
if len(m.Requires) != len(want) {
t.Fatalf("requires = %v, want %v", m.Requires, want)
}
for id, v := range want {
if got, ok := m.Requires[id]; !ok || got != v {
t.Errorf("requires[%s] = %q (present %v), want %q", id, got, ok, v)
}
}
nested, _, err := Derive(ctx, filepath.Join(root, "shop", "admin"), DeriveOptions{})
if err != nil {
t.Fatalf("Derive nested: %v", err)
}
if nested.Package != "example.test/sm-shop-plugin/admin" || nested.Module != "example.test/sm-shop-plugin" {
t.Errorf("nested = %+v", nested)
}
// A required plugin module at v0.0.0 is refused, a pseudo-version warns.
shopMod := filepath.Join(root, "shop", "go.mod")
orig, _ := os.ReadFile(shopMod)
for v, want := range map[string]string{"v0.0.0": "not a released version", "v0.1.3-0.20261029101500-abcdefabcdef": ""} {
data := strings.Replace(string(orig), "example.test/sm-user-plugin v0.1.2", "example.test/sm-user-plugin "+v, 1)
if err := os.WriteFile(shopMod, []byte(data), 0o644); err != nil {
t.Fatal(err)
}
m, warnings, err := Derive(ctx, filepath.Join(root, "shop"), DeriveOptions{})
if want != "" {
if err == nil || !strings.Contains(err.Error(), want) || !strings.Contains(err.Error(), "acme.user") {
t.Errorf("%s: err = %v", v, err)
}
continue
}
if err != nil || m.Requires["acme.user"] != v || len(warnings) != 1 || !strings.Contains(warnings[0], "acme.user") {
t.Errorf("%s: requires %v, warnings %v, err %v", v, m.Requires, warnings, err)
}
}
})
}
func TestPackagePath(t *testing.T) {
root := filepath.FromSlash("/src/m")
for dir, want := range map[string]string{
"/src/m": "example.test/m",
"/src/m/a": "example.test/m/a",
"/src/m/a/b/c": "example.test/m/a/b/c",
} {
got, err := packagePath("example.test/m", root, filepath.FromSlash(dir))
if err != nil || got != want {
t.Errorf("packagePath(%s) = %q, %v; want %q", dir, got, err, want)
}
}
}
func TestPluginDirs(t *testing.T) {
root := t.TempDir()
writeTree(t, root, map[string]string{
"plugin.go": "package p",
"sub/plugin.go": "package sub",
"sub/deeper/plugin.go": "package deeper",
"testdata/plugin.go": "package x",
"vendor/x/plugin.go": "package x",
"node_modules/plugin.go": "package x",
".hidden/plugin.go": "package x",
"_skip/plugin.go": "package x",
"nested/go.mod": "module example.test/nested\n",
"nested/plugin.go": "package nested",
"other/notplugin.go": "package other",
})
got := pluginDirs(root)
slices.Sort(got)
want := []string{root, filepath.Join(root, "sub"), filepath.Join(root, "sub", "deeper")}
if strings.Join(got, "|") != strings.Join(want, "|") {
t.Errorf("pluginDirs = %v, want %v", got, want)
}
}

View File

@@ -0,0 +1,57 @@
package kiosk_test
import (
"fmt"
"git.golem15.com/golem15/summercms/modules/kiosk"
)
// Marshal writes the plugin.yaml bytes: the generated header, the fields
// in a fixed order and the required plugins sorted by ID.
func ExampleMarshal() {
out, err := kiosk.Marshal(kiosk.Manifest{
Schema: kiosk.SchemaVersion,
ID: "acme.shop",
Module: "example.test/sm-shop-plugin",
Package: "example.test/sm-shop-plugin",
Name: "Shop",
Description: "Products, carts and orders.",
Framework: "v0.6.0",
Requires: kiosk.Requires{"acme.user": "v0.1.2", "acme.payment": ""},
})
if err != nil {
fmt.Println(err)
return
}
fmt.Print(string(out))
// Output:
// # Generated by summer plugin:manifest. Do not edit; rerun the command.
// schema: 1
// id: acme.shop
// module: example.test/sm-shop-plugin
// package: example.test/sm-shop-plugin
// name: Shop
// description: Products, carts and orders.
// framework: v0.6.0
// requires:
// acme.payment: ""
// acme.user: v0.1.2
}
// NewestCompatible picks the newest release whose framework minimum the
// application's framework meets; the list order does not matter.
func ExampleNewestCompatible() {
versions := []kiosk.RegistryVersion{
{Version: "v0.3.0", Framework: "v0.7.0"},
{Version: "v0.2.1", Framework: "v0.6.0"},
{Version: "v0.1.0", Framework: "v0.5.0"},
}
for _, framework := range []string{"v0.6.2", "v0.7.0", "v0.4.0"} {
v, ok := kiosk.NewestCompatible(versions, framework)
fmt.Println(framework, v.Version, ok)
}
// Output:
// v0.6.2 v0.2.1 true
// v0.7.0 v0.3.0 true
// v0.4.0 false
}

View File

@@ -0,0 +1,256 @@
package kiosk
import (
"context"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"testing"
)
// writeTree writes files (slash paths relative to root) under root.
func writeTree(t *testing.T, root string, files map[string]string) {
t.Helper()
for rel, body := range files {
path := filepath.Join(root, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
}
// requireGo skips a test that runs the go command when it is missing.
func requireGo(t *testing.T) {
t.Helper()
if _, err := exec.LookPath("go"); err != nil {
t.Skip("go command not found")
}
}
const pluginHead = "package p\n\ntype Plugin struct{}\n\n"
// TestReadIdentity reads ID() and Requires() from fixtures of every
// supported shape and refuses the rest with the function named.
func TestReadIdentity(t *testing.T) {
requireGo(t)
ctx := context.Background()
gomod := "module example.test/p\n\ngo 1.27.0\n"
accepted := []struct {
name string
files map[string]string
want Identity
}{
{"literal", map[string]string{
"plugin.go": pluginHead + `func (*Plugin) ID() string { return "acme.a" }
func (*Plugin) Requires() []string { return []string{"acme.b", "acme.c"} }`,
}, Identity{ID: "acme.a", Requires: []string{"acme.b", "acme.c"}}},
{"parenthesized literal", map[string]string{
"plugin.go": pluginHead + `func (Plugin) ID() string { return ("acme.a") }`,
}, Identity{ID: "acme.a"}},
{"same-file const", map[string]string{
"plugin.go": pluginHead + `const id = "acme.a"
func (*Plugin) ID() string { return id }
func (*Plugin) Requires() []string { return nil }`,
}, Identity{ID: "acme.a"}},
{"const in another file", map[string]string{
"plugin.go": pluginHead + `func (*Plugin) ID() string { return PluginID }`,
"ids.go": "package p\n\nconst PluginID = \"acme.a\"\n",
}, Identity{ID: "acme.a"}},
{"chained and concatenated consts", map[string]string{
"plugin.go": pluginHead + `const (
vendor = "acme"
name = base
base = "a"
full = vendor + "." + name
)
func (*Plugin) ID() string { return full }`,
}, Identity{ID: "acme.a"}},
{"cross-package same module", map[string]string{
"go.mod": gomod,
"ids/ids.go": "package ids\n\nconst A = \"acme.a\"\nconst B = \"acme.b\"\n",
"plugin.go": pluginHead[:len("package p\n\n")] + `import "example.test/p/ids"
type Plugin struct{}
func (*Plugin) ID() string { return ids.A }
func (*Plugin) Requires() []string { return []string{ids.B} }`,
}, Identity{ID: "acme.a", Requires: []string{"acme.b"}}},
{"named import alias", map[string]string{
"go.mod": gomod,
"names/ids.go": "package names\n\nconst A = \"acme.a\"\n",
"plugin.go": `package p
import x "example.test/p/names"
type Plugin struct{}
func (*Plugin) ID() string { return x.A }`,
}, Identity{ID: "acme.a"}},
{"package name differs from path", map[string]string{
"go.mod": gomod,
"go-idents/ids.go": "package idents\n\nconst A = \"acme.a\"\n",
"plugin.go": `package p
import "example.test/p/go-idents"
type Plugin struct{}
func (*Plugin) ID() string { return idents.A }`,
}, Identity{ID: "acme.a"}},
{"requires empty slice", map[string]string{
"plugin.go": pluginHead + `func (*Plugin) ID() string { return "acme.a" }
func (*Plugin) Requires() []string { return []string{} }`,
}, Identity{ID: "acme.a", Requires: []string{}}},
{"requires mixed literals and consts", map[string]string{
"plugin.go": pluginHead + `const user = "acme.user"
func (*Plugin) ID() string { return "acme.a" }
func (*Plugin) Requires() []string { return []string{user, "acme.translate"} }`,
}, Identity{ID: "acme.a", Requires: []string{"acme.user", "acme.translate"}}},
{"no Requires method", map[string]string{
"plugin.go": pluginHead + `func (*Plugin) ID() string { return "acme.a" }`,
}, Identity{ID: "acme.a"}},
{"plugin.go wins over a model's ID", map[string]string{
"a_model.go": "package p\n\ntype Post struct{}\n\nfunc (Post) ID() string { return \"post\" }\nfunc (Post) Requires() []string { return []string{\"x.y\"} }\n",
"plugin.go": pluginHead + `func (*Plugin) ID() string { return "acme.a" }
func (*Plugin) Requires() []string { return []string{"acme.b"} }`,
}, Identity{ID: "acme.a", Requires: []string{"acme.b"}}},
{"test files are ignored", map[string]string{
"plugin.go": pluginHead + `func (*Plugin) ID() string { return PluginID }`,
"ids.go": "package p\n\nconst PluginID = \"acme.a\"\n",
"plugin_test.go": "package p\n\nconst PluginID = \"acme.test\"\n",
}, Identity{ID: "acme.a"}},
}
for _, tc := range accepted {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
files := map[string]string{"go.mod": gomod}
for k, v := range tc.files {
files[k] = v
}
writeTree(t, dir, files)
got, err := ReadIdentity(ctx, dir)
if err != nil {
t.Fatalf("ReadIdentity: %v", err)
}
if got.ID != tc.want.ID || !slices.Equal(got.Requires, tc.want.Requires) || (got.Requires == nil) != (tc.want.Requires == nil) {
t.Errorf("identity = %#v, want %#v", got, tc.want)
}
})
}
t.Run("other module through a replace", func(t *testing.T) {
root := t.TempDir()
writeTree(t, root, map[string]string{
"dep/go.mod": "module example.test/dep\n\ngo 1.27.0\n",
"dep/consts/ids.go": "package consts\n\nconst Shop = \"acme.shop\"\nconst User = \"acme.user\"\n",
"plugin/go.mod": "module example.test/p\n\ngo 1.27.0\n\nrequire example.test/dep v0.3.0\n\nreplace example.test/dep => ../dep\n",
"plugin/plugin.go": `package p
import "example.test/dep/consts"
type Plugin struct{}
func (*Plugin) ID() string { return consts.Shop }
func (*Plugin) Requires() []string { return []string{consts.User} }`,
})
got, err := ReadIdentity(ctx, filepath.Join(root, "plugin"))
if err != nil {
t.Fatalf("ReadIdentity: %v", err)
}
if got.ID != "acme.shop" || !slices.Equal(got.Requires, []string{"acme.user"}) {
t.Errorf("identity = %#v", got)
}
})
refused := []struct {
name, src, want string
extra map[string]string
keepsID bool
}{
{"function call", `func (*Plugin) ID() string { return id() }
func id() string { return "acme.x" }`, "ID()", nil, false},
{"var", `var pluginID = "acme.x"
func (*Plugin) ID() string { return pluginID }`, "ID()", nil, false},
{"concatenation with a call", `func (*Plugin) ID() string { return "acme." + suffix() }
func suffix() string { return "x" }`, "ID()", nil, false},
{"empty ID", `func (*Plugin) ID() string { return "" }`, "empty string", nil, false},
{"no return", `func (*Plugin) ID() string { panic("x") }`, "ID()", nil, false},
{"self-referencing const", `const a = b
const b = a
func (*Plugin) ID() string { return a }`, "refers to itself", nil, false},
{"const without value", `const (
a = "acme.a"
b
)
func (*Plugin) ID() string { return b }`, "no explicit value", nil, false},
{"unknown import", `func (*Plugin) ID() string { return ids.A }`, "no import provides package ids", nil, false},
{"import outside the module", "", "no import provides package ids", map[string]string{"plugin.go": `package p
import "example.test/elsewhere/ids"
type Plugin struct{}
func (*Plugin) ID() string { return ids.A }`}, false},
{"missing ID method", `func (*Plugin) Name() string { return "acme.x" }`, "no ID() method", nil, false},
{"requires var", `var deps = []string{"acme.y"}
func (*Plugin) ID() string { return "acme.x" }
func (*Plugin) Requires() []string { return deps }`, "Requires()", nil, true},
{"requires array", `func (*Plugin) ID() string { return "acme.x" }
func (*Plugin) Requires() []string { s := [1]string{"acme.y"}; return s[:] }`, "Requires()", nil, true},
{"requires element var", `var dep = "acme.y"
func (*Plugin) ID() string { return "acme.x" }
func (*Plugin) Requires() []string { return []string{dep} }`, "Requires()", nil, true},
}
for _, tc := range refused {
t.Run("refuse "+tc.name, func(t *testing.T) {
dir := t.TempDir()
files := map[string]string{"go.mod": gomod}
if tc.src != "" {
files["plugin.go"] = pluginHead + tc.src + "\n"
}
for k, v := range tc.extra {
files[k] = v
}
writeTree(t, dir, files)
got, err := ReadIdentity(ctx, dir)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("err = %v, want one naming %q", err, tc.want)
}
if tc.keepsID != (got.ID == "acme.x") {
t.Errorf("identity = %#v; the ID is kept only when Requires() fails", got)
}
if tc.name != "missing ID method" && !strings.Contains(err.Error(), dir) {
t.Errorf("err = %v, want the plugin directory named", err)
}
})
}
t.Run("not a directory", func(t *testing.T) {
if _, err := ReadIdentity(ctx, filepath.Join(t.TempDir(), "missing")); err == nil {
t.Error("ReadIdentity of a missing directory succeeded")
}
empty := t.TempDir()
if _, err := ReadIdentity(ctx, empty); err == nil || !strings.Contains(err.Error(), "no Go files") {
t.Errorf("empty directory: err = %v", err)
}
})
}
func TestLastElem(t *testing.T) {
for in, want := range map[string]string{
"example.test/a/b": "b",
"example.test/a/v2": "a",
"example.test/go-yaml": "yaml",
"v2": "v2",
} {
if got := lastElem(in); got != want {
t.Errorf("lastElem(%q) = %q, want %q", in, got, want)
}
}
}

View File

@@ -0,0 +1,263 @@
package kiosk
import (
"context"
"errors"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
)
// fullManifest is a valid manifest with every field set.
func fullManifest() Manifest {
return Manifest{
Schema: SchemaVersion,
ID: "acme.shop",
Module: "example.test/sm-shop-plugin",
Package: "example.test/sm-shop-plugin/shop",
Name: "Shop",
Description: "Products, carts and orders.",
Framework: "v0.6.0",
Requires: Requires{"acme.user": "v0.1.2", "acme.payment": "", "acme.audit": "v1.0.0"},
Overrides: &Overrides{Name: "Shop"},
}
}
// TestManifestFormat pins the plugin.yaml bytes, the parse rules and the
// validation table.
func TestManifestFormat(t *testing.T) {
t.Run("marshal is stable and ordered", func(t *testing.T) {
want := Header + "\n" +
"schema: 1\n" +
"id: acme.shop\n" +
"module: example.test/sm-shop-plugin\n" +
"package: example.test/sm-shop-plugin/shop\n" +
"name: Shop\n" +
"description: Products, carts and orders.\n" +
"framework: v0.6.0\n" +
"requires:\n" +
" acme.audit: v1.0.0\n" +
" acme.payment: \"\"\n" +
" acme.user: v0.1.2\n" +
"overrides:\n" +
" name: Shop\n"
for i := 0; i < 20; i++ {
got, err := Marshal(fullManifest())
if err != nil {
t.Fatal(err)
}
if string(got) != want {
t.Fatalf("run %d: Marshal =\n%s\nwant\n%s", i, got, want)
}
}
})
t.Run("empty requires and overrides are left out", func(t *testing.T) {
m := fullManifest()
m.Requires = Requires{}
m.Overrides = &Overrides{}
got, err := Marshal(m)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(got), "requires") || strings.Contains(string(got), "overrides") {
t.Errorf("Marshal kept empty blocks:\n%s", got)
}
m.Requires = nil
m.Overrides = nil
again, err := Marshal(m)
if err != nil {
t.Fatal(err)
}
if string(again) != string(got) {
t.Errorf("nil and empty differ:\n%s\n%s", got, again)
}
})
t.Run("parse round-trips", func(t *testing.T) {
raw, err := Marshal(fullManifest())
if err != nil {
t.Fatal(err)
}
m, err := Parse(raw)
if err != nil {
t.Fatalf("Parse: %v", err)
}
again, err := Marshal(m)
if err != nil {
t.Fatal(err)
}
if string(again) != string(raw) {
t.Errorf("round trip changed the bytes:\n%s\n%s", raw, again)
}
if v, ok := m.Requires["acme.payment"]; !ok || v != "" {
t.Errorf("empty minimum lost: %v", m.Requires)
}
})
t.Run("parse refusals", func(t *testing.T) {
raw, err := Marshal(fullManifest())
if err != nil {
t.Fatal(err)
}
newer := strings.Replace(string(raw), "schema: 1", "schema: 2", 1)
if _, err := Parse([]byte(newer)); err == nil || !strings.Contains(err.Error(), "update summer") {
t.Errorf("newer schema: err = %v, want one saying to update summer", err)
}
if _, err := Parse([]byte("schema: [1\n")); err == nil || !strings.Contains(err.Error(), FileName) {
t.Errorf("broken YAML: err = %v", err)
}
bad := strings.Replace(string(raw), "id: acme.shop", "id: Acme.Shop", 1)
if _, err := Parse([]byte(bad)); err == nil || !strings.Contains(err.Error(), "vendor.plugin") {
t.Errorf("invalid id: err = %v", err)
}
// A field a later schema-1 writer adds is ignored, as documented
// on Parse: older summer binaries keep reading newer files.
extra := string(raw) + "homepage: https://example.test\n"
m, err := Parse([]byte(extra))
if err != nil {
t.Fatalf("unknown field: %v", err)
}
if m.ID != "acme.shop" {
t.Errorf("unknown field changed the manifest: %+v", m)
}
})
t.Run("validate", func(t *testing.T) {
if err := Validate(fullManifest()); err != nil {
t.Fatalf("Validate(full) = %v", err)
}
cases := []struct {
name string
mutate func(*Manifest)
want string
}{
{"schema zero", func(m *Manifest) { m.Schema = 0 }, "schema"},
{"schema newer", func(m *Manifest) { m.Schema = SchemaVersion + 1 }, "schema"},
{"id upper", func(m *Manifest) { m.ID = "Acme.shop" }, "id"},
{"id no vendor", func(m *Manifest) { m.ID = "shop" }, "id"},
{"id three parts", func(m *Manifest) { m.ID = "acme.shop.x" }, "id"},
{"id digit first", func(m *Manifest) { m.ID = "1acme.shop" }, "id"},
{"module empty", func(m *Manifest) { m.Module = "" }, "module"},
{"module dash", func(m *Manifest) { m.Module = "-x/y"; m.Package = m.Module }, "module"},
{"module space", func(m *Manifest) { m.Module = "example.test/a b"; m.Package = m.Module }, "module"},
{"module dotdot", func(m *Manifest) { m.Module = "example.test/../x"; m.Package = m.Module }, "module"},
{"module version", func(m *Manifest) { m.Module = "example.test/x@v1"; m.Package = m.Module }, "module"},
{"package shell", func(m *Manifest) { m.Package = "example.test/sm-shop-plugin/$(id)" }, "package"},
{"package outside", func(m *Manifest) { m.Package = "example.test/sm-shop-plugin-x" }, "not inside"},
{"name", func(m *Manifest) { m.Name = "\t" }, "name"},
{"description", func(m *Manifest) { m.Description = "" }, "description"},
{"framework bare", func(m *Manifest) { m.Framework = "0.6.0" }, "framework"},
{"framework empty", func(m *Manifest) { m.Framework = "" }, "framework"},
{"framework describe", func(m *Manifest) { m.Framework = "v0.6.0-3-gabcdef1" }, "framework"},
{"required id", func(m *Manifest) { m.Requires = Requires{"User": ""} }, "required id"},
{"required minimum", func(m *Manifest) { m.Requires = Requires{"acme.user": "main"} }, "minimum"},
}
for _, tc := range cases {
m := fullManifest()
tc.mutate(&m)
err := Validate(m)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Errorf("%s: Validate = %v, want an error naming %q", tc.name, err, tc.want)
}
}
ok := []func(*Manifest){
func(m *Manifest) { m.Package = m.Module },
func(m *Manifest) { m.Framework = "v0.6.1-0.20261029101500-abcdefabcdef" },
func(m *Manifest) { m.Requires = Requires{"acme.user": "v0.1.3-0.20261029101500-abcdefabcdef"} },
func(m *Manifest) { m.Requires = nil; m.Overrides = nil },
}
for i, mutate := range ok {
m := fullManifest()
mutate(&m)
if err := Validate(m); err != nil {
t.Errorf("accepted case %d: Validate = %v", i, err)
}
}
})
}
// TestCheck covers the drift guard: missing, stale and fresh files, and
// CheckT's single Fatalf.
func TestCheck(t *testing.T) {
ctx := context.Background()
dir := filepath.Join(copyTestdata(t, "literal"), "literal")
path := filepath.Join(dir, FileName)
if err := Check(ctx, dir); err != nil {
t.Fatalf("Check on the committed fixture: %v", err)
}
var fresh fakeTB
CheckT(&fresh, dir)
if len(fresh.fatals) != 0 {
t.Fatalf("CheckT on a fresh file: %q", fresh.fatals)
}
committed, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
stale := strings.Replace(string(committed), "name: Blog", "name: Weblog", 1)
if err := os.WriteFile(path, []byte(stale), 0o644); err != nil {
t.Fatal(err)
}
var se *StaleError
err = Check(ctx, dir)
if !errors.As(err, &se) || se.Missing {
t.Fatalf("Check on an edited file = %v, want a stale *StaleError", err)
}
if se.Path != path || string(se.Got) != stale || string(se.Want) != string(committed) {
t.Errorf("StaleError = path %s got %q want %q", se.Path, se.Got, se.Want)
}
line := strings.Count(string(committed)[:strings.Index(string(committed), "name: Blog")], "\n") + 1
wantMsg := `line ` + strconv.Itoa(line) + ` is "name: Weblog", want "name: Blog"`
if !strings.Contains(err.Error(), wantMsg) {
t.Errorf("stale message = %q, want it to carry %q", err.Error(), wantMsg)
}
var staleTB fakeTB
CheckT(&staleTB, dir)
if len(staleTB.fatals) != 1 || !strings.Contains(staleTB.fatals[0], "summer plugin:manifest "+dir) {
t.Errorf("CheckT on a stale file: %q", staleTB.fatals)
}
if err := os.Remove(path); err != nil {
t.Fatal(err)
}
err = Check(ctx, dir)
if !errors.As(err, &se) || !se.Missing || !strings.Contains(err.Error(), "is missing") {
t.Fatalf("Check without the file = %v, want Missing", err)
}
var missingTB fakeTB
CheckT(&missingTB, dir)
if len(missingTB.fatals) != 1 {
t.Errorf("CheckT on a missing file: %q", missingTB.fatals)
}
var broken fakeTB
CheckT(&broken, t.TempDir())
if len(broken.fatals) != 1 {
t.Errorf("CheckT on a directory without a plugin: %q", broken.fatals)
}
t.Run("stale message", func(t *testing.T) {
cases := []struct {
want, got, msg string
}{
{"a\nb\n", "a\nc\n", `line 2 is "c", want "b"`},
{"a\nb\n", "a\nb\nextra\n", `line 3 is "extra", want ""`},
{"a\nb\nc\n", "a\n", `line 2 is "", want "b"`},
}
for _, c := range cases {
e := &StaleError{Path: "p/plugin.yaml", Want: []byte(c.want), Got: []byte(c.got)}
if !strings.Contains(e.Error(), c.msg) || !strings.HasPrefix(e.Error(), "kiosk: p/plugin.yaml is stale") {
t.Errorf("StaleError(%q, %q) = %q, want %q", c.want, c.got, e.Error(), c.msg)
}
}
same := &StaleError{Path: "p", Want: []byte("x"), Got: []byte("x")}
if same.Error() != "kiosk: p is stale" {
t.Errorf("equal bytes: %q", same.Error())
}
})
}

View File

@@ -0,0 +1,141 @@
package kiosk
import "testing"
// TestVersions pins the version rules the registry, plugin:install,
// plugin:update and the admin share.
func TestVersions(t *testing.T) {
t.Run("ValidVersion", func(t *testing.T) {
for v, want := range map[string]bool{
"v0.0.0": true, "v0.6.0": true, "v1.20.300": true, "v12345678901234567890.0.1": true,
"0.6.0": false, "v0.6": false, "v0.6.0.1": false, "v0.6.0-rc.1": false, "v0.6.0+build": false,
"v00.6.0": false, "v0.06.0": false, "v0.6.00": false, "V0.6.0": false, " v0.6.0": false,
"v0.6.0\n": false, "v-1.0.0": false, "": false, "v0.6.0-1-gabcdef0": false,
} {
if got := ValidVersion(v); got != want {
t.Errorf("ValidVersion(%q) = %v, want %v", v, got, want)
}
}
})
t.Run("CompareVersions", func(t *testing.T) {
cases := []struct {
a, b string
want int
}{
{"v0.0.0", "v0.0.0", 0}, {"v0.0.1", "v0.0.0", 1}, {"v0.1.0", "v0.0.99", 1},
{"v1.0.0", "v0.99.99", 1}, {"v0.9.0", "v0.10.0", -1}, {"v0.10.0", "v0.9.0", 1},
{"v99999999999999999999.0.0", "v99999999999999999998.9.9", 1},
{"v0.5.3", "v0.6.0", -1}, {"v0.6.0", "v0.6.0", 0},
}
for _, c := range cases {
if got := CompareVersions(c.a, c.b); got != c.want {
t.Errorf("CompareVersions(%s, %s) = %d, want %d", c.a, c.b, got, c.want)
}
if got := CompareVersions(c.b, c.a); got != -c.want {
t.Errorf("CompareVersions(%s, %s) = %d, want %d (antisymmetric)", c.b, c.a, got, -c.want)
}
}
})
t.Run("BaseVersion", func(t *testing.T) {
for in, want := range map[string]string{
"v0.6.0": "v0.6.0",
"v0.6.0-12-gdeadbee": "v0.6.0",
"v0.6.0-1-g0123456789ab": "v0.6.0",
"v0.6.0-dirty": "",
"v0.6.0-12-gdeadbee-dirty": "",
"v0.6.0-rc.1": "",
"v0.6.0-rc.1-2-gabcdef0": "",
"deadbee": "",
"0.6.0-1-gabcdef0": "",
"v0.6.0-x-gabcdef0": "",
"": "",
} {
if got := BaseVersion(in); got != want {
t.Errorf("BaseVersion(%q) = %q, want %q", in, got, want)
}
}
})
t.Run("pseudo-versions", func(t *testing.T) {
for v, want := range map[string]bool{
"v0.0.0-20261029101500-abcdefabcdef": true,
"v0.6.1-0.20261029101500-abcdefabcdef": true,
"v0.6.0-rc.1.0.20261029101500-abcdefabcdef": true,
"v0.6.1-0.2026102910150-abcdefabcdef": false,
"v0.6.1-0.20261029101500-abcdefabcde": false,
"v0.6.1": false,
"v0.6.1-12-gabcdef0": false,
} {
if got := isPseudoVersion(v); got != want {
t.Errorf("isPseudoVersion(%q) = %v, want %v", v, got, want)
}
}
})
t.Run("Satisfies", func(t *testing.T) {
cases := []struct {
have, min string
want bool
}{
{"v0.6.0", "", true}, {"", "", true}, {"garbage", "", true},
{"v0.6.0", "v0.6.0", true}, {"v0.6.1", "v0.6.0", true}, {"v1.0.0", "v0.6.0", true},
{"v0.5.9", "v0.6.0", false}, {"", "v0.6.0", false}, {"v0.6.0-3-gabcdef0", "v0.6.0", false},
// A pseudo-version minimum vX.Y.Z-0.<ts>-<hash> sorts just
// below vX.Y.Z: the release itself meets it, the one before
// does not.
{"v0.6.1", "v0.6.1-0.20261029101500-abcdefabcdef", true},
{"v0.7.0", "v0.6.1-0.20261029101500-abcdefabcdef", true},
{"v0.6.0", "v0.6.1-0.20261029101500-abcdefabcdef", false},
{"v0.6.0", "v0.6.0-rc.1.0.20261029101500-abcdefabcdef", true},
{"v0.0.1", "v0.0.0-20261029101500-abcdefabcdef", true},
{"v0.6.0", "latest", false}, {"v0.6.0", "v0.6", false},
}
for _, c := range cases {
if got := Satisfies(c.have, c.min); got != c.want {
t.Errorf("Satisfies(%q, %q) = %v, want %v", c.have, c.min, got, c.want)
}
}
})
t.Run("NewestCompatible", func(t *testing.T) {
if _, ok := NewestCompatible(nil, "v0.6.0"); ok {
t.Error("nil list found a version")
}
if _, ok := NewestCompatible([]RegistryVersion{}, "v0.6.0"); ok {
t.Error("empty list found a version")
}
incompatible := []RegistryVersion{{Version: "v0.2.0", Framework: "v0.7.0"}, {Version: "v0.3.0", Framework: "v1.0.0"}}
if got, ok := NewestCompatible(incompatible, "v0.6.0"); ok {
t.Errorf("all incompatible found %+v", got)
}
unsorted := []RegistryVersion{
{Version: "v0.1.0", Framework: "v0.5.0"},
{Version: "v0.10.0", Framework: "v0.6.0", Commit: "ten"},
{Version: "v0.9.0", Framework: "v0.5.0"},
{Version: "v0.11.0", Framework: "v0.7.0"},
{Version: "v0.12.0-rc.1", Framework: ""},
{Version: "main", Framework: ""},
{Version: "v0.2.0", Framework: ""},
}
got, ok := NewestCompatible(unsorted, "v0.6.0")
if !ok || got.Version != "v0.10.0" || got.Commit != "ten" {
t.Errorf("unsorted = %+v, %v; want v0.10.0", got, ok)
}
if got, ok := NewestCompatible(unsorted, "v0.7.0"); !ok || got.Version != "v0.11.0" {
t.Errorf("framework v0.7.0 = %+v, %v; want v0.11.0", got, ok)
}
// A framework that is not a release only meets empty minimums.
if got, ok := NewestCompatible(unsorted, ""); !ok || got.Version != "v0.2.0" {
t.Errorf("unknown framework = %+v, %v; want v0.2.0", got, ok)
}
pseudo := []RegistryVersion{{Version: "v0.4.0", Framework: "v0.6.1-0.20261029101500-abcdefabcdef"}}
if _, ok := NewestCompatible(pseudo, "v0.6.0"); ok {
t.Error("pseudo-version framework minimum met by the release before it")
}
if _, ok := NewestCompatible(pseudo, "v0.6.1"); !ok {
t.Error("pseudo-version framework minimum not met by its release")
}
})
}

View File

@@ -0,0 +1,61 @@
package party
import (
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/compass"
)
// TestDescribe covers publishing the build-time plugin metadata.
func TestDescribe(t *testing.T) {
if err := Describe(nil, nil, "v0.6.0"); err == nil || !strings.Contains(err.Error(), "app is nil") {
t.Errorf("Describe(nil) = %v", err)
}
app := backpack.New(&compass.Config{})
if _, ok := app.Lookup[Installed](); ok {
t.Fatal("a fresh app already has Installed")
}
plugins := []Info{
{ID: "acme.user", Module: "example.test/sm-user-plugin", Package: "example.test/sm-user-plugin", Name: "User", Version: "v0.1.2", Source: SourceModule},
{ID: "acme.shop", Package: "example.test/app/plugins/acme/shop", Source: SourceLocal},
}
if err := Describe(app, plugins, "v0.6.0"); err != nil {
t.Fatalf("Describe: %v", err)
}
// The caller's slice is copied: changing it later does not change
// what the app published.
plugins[0].Version = "v9.9.9"
got, ok := app.Lookup[Installed]()
if !ok {
t.Fatal("Installed not published")
}
if got.Framework != "v0.6.0" || len(got.Plugins) != 2 || got.Plugins[0].Version != "v0.1.2" || got.Plugins[1].Source != SourceLocal {
t.Errorf("Installed = %+v", got)
}
// Describe publishes once per app (the generated main calls it once,
// before Activate); a second call is refused and the first stays.
if err := Describe(app, nil, "v0.7.0"); err == nil || !strings.Contains(err.Error(), "party:") {
t.Errorf("second Describe = %v, want a duplicate error", err)
}
if again, _ := app.Lookup[Installed](); again.Framework != "v0.6.0" || len(again.Plugins) != 2 {
t.Errorf("after a second Describe: %+v", again)
}
empty := backpack.New(&compass.Config{})
if err := Describe(empty, nil, ""); err != nil {
t.Fatalf("Describe with no plugins: %v", err)
}
if got, ok := empty.Lookup[Installed](); !ok || got.Plugins == nil || len(got.Plugins) != 0 || got.Framework != "" {
t.Errorf("empty Installed = %+v, %v", got, ok)
}
for _, s := range []string{SourceModule, SourceSubmodule, SourceLocal} {
if s == "" || strings.ToLower(s) != s {
t.Errorf("source constant %q", s)
}
}
}