cli/cmd/ninja/cmd/plugin_gallery_test.go
Alex Dunmow b5e39121d2 feat(cli): ninja plugin gallery commands (Phase 6)
Add `ninja plugin gallery list|add|remove|reorder|set-featured` for
author-side curation of a plugin/theme's registry screenshot gallery,
feeding the browse/detail surfaces shipped in Phases 4-5.

- Re-vendor plugin_registry.proto from orchestrator (adds
  PluginGalleryService) and regenerate the connect client.
- Wire the Gallery client into orchclient.Client.
- Target plugin defaults from plugin.mod with --scope/--name overrides;
  device-flow auth via resolveClient. list is a public read; add/remove/
  reorder/set-featured are RoleUser (scope/account membership enforced
  server-side). add derives content_type from the extension against the
  png/jpg/jpeg/webp allowlist and rejects >8MiB client-side.

Scope handling trims a leading @ client-side because ListScreenshots does
not trim server-side (GetPlugin does). Live-verified against the dev
orchestrator; go build + go test ./... + check-safety all green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 12:35:09 +08:00

192 lines
5.7 KiB
Go

package cmd
import (
"os"
"path/filepath"
"testing"
"github.com/spf13/cobra"
core "git.dev.alexdunmow.com/block/core/plugin"
"git.dev.alexdunmow.com/block/cli/internal/creds"
)
func TestImageContentType(t *testing.T) {
cases := []struct {
path string
want string
wantErr bool
}{
{path: "preview.png", want: "image/png"},
{path: "shot.jpg", want: "image/jpeg"},
{path: "shot.jpeg", want: "image/jpeg"},
{path: "shot.webp", want: "image/webp"},
{path: "SHOT.PNG", want: "image/png"}, // case-insensitive
{path: "dir/nested/x.jpg", want: "image/jpeg"},
{path: "shot.gif", wantErr: true},
{path: "shot.svg", wantErr: true},
{path: "noext", wantErr: true},
}
for _, c := range cases {
got, err := imageContentType(c.path)
if c.wantErr {
if err == nil {
t.Errorf("imageContentType(%q) = %q, want error", c.path, got)
}
continue
}
if err != nil {
t.Errorf("imageContentType(%q) err: %v", c.path, err)
continue
}
if got != c.want {
t.Errorf("imageContentType(%q) = %q, want %q", c.path, got, c.want)
}
}
}
// galleryTestCmd builds a command carrying the --scope/--name flags exactly as
// the gallery parent registers them, so resolveGalleryTarget can read them.
func galleryTestCmd(scope, name string) *cobra.Command {
c := &cobra.Command{Use: "gallery"}
c.Flags().String("scope", "", "")
c.Flags().String("name", "", "")
if scope != "" {
_ = c.Flags().Set("scope", scope)
}
if name != "" {
_ = c.Flags().Set("name", name)
}
return c
}
func writeGalleryMod(t *testing.T, dir, scope, name string, private bool) {
t.Helper()
m := &core.ModFile{Plugin: core.ModPlugin{
Name: name,
Scope: scope,
Version: "0.1.0",
Private: private,
}}
if err := writeMod(filepath.Join(dir, "plugin.mod"), m); err != nil {
t.Fatalf("writeMod: %v", err)
}
}
func TestResolveGalleryTarget_FromMod(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
writeGalleryMod(t, dir, "@themes", "gotham", false)
got, err := resolveGalleryTarget(galleryTestCmd("", ""), creds.HostCreds{})
if err != nil {
t.Fatalf("resolveGalleryTarget: %v", err)
}
// Leading @ must be trimmed (ListScreenshots does not trim server-side).
if got.scopeSlug != "themes" || got.name != "gotham" || got.private {
t.Errorf("got %+v, want scopeSlug=themes name=gotham private=false", got)
}
}
func TestResolveGalleryTarget_FlagOverride(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
writeGalleryMod(t, dir, "@themes", "gotham", false)
got, err := resolveGalleryTarget(galleryTestCmd("acme", "widget"), creds.HostCreds{})
if err != nil {
t.Fatalf("resolveGalleryTarget: %v", err)
}
if got.scopeSlug != "acme" || got.name != "widget" {
t.Errorf("got %+v, want scopeSlug=acme name=widget", got)
}
}
func TestResolveGalleryTarget_NoModNeedsFlags(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir) // no plugin.mod written
// Flags supply both coordinates → resolves without a plugin.mod.
got, err := resolveGalleryTarget(galleryTestCmd("acme", "widget"), creds.HostCreds{})
if err != nil {
t.Fatalf("resolveGalleryTarget with flags: %v", err)
}
if got.scopeSlug != "acme" || got.name != "widget" {
t.Errorf("got %+v, want scopeSlug=acme name=widget", got)
}
// No plugin.mod and no flags → an actionable error.
if _, err := resolveGalleryTarget(galleryTestCmd("", ""), creds.HostCreds{}); err == nil {
t.Error("expected error with no plugin.mod and no flags")
}
}
func TestResolveGalleryTarget_MissingScope(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
// Name but no scope (public) → error naming the missing scope.
_, err := resolveGalleryTarget(galleryTestCmd("", "widget"), creds.HostCreds{})
if err == nil {
t.Fatal("expected error for name without scope")
}
}
func TestResolveGalleryTarget_PrivateFromMod(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
writeGalleryMod(t, dir, "", "widget", true)
// Private plugin without an active account → error.
if _, err := resolveGalleryTarget(galleryTestCmd("", ""), creds.HostCreds{}); err == nil {
t.Error("expected error: private gallery needs an active account")
}
// With an active account, the scope resolves to the @private namespace.
got, err := resolveGalleryTarget(galleryTestCmd("", ""), creds.HostCreds{ActiveAccountID: "acct-1"})
if err != nil {
t.Fatalf("resolveGalleryTarget private: %v", err)
}
if !got.private || got.scopeSlug != core.PrivateScopeSlug || got.accountID != "acct-1" {
t.Errorf("got %+v, want private=true scopeSlug=%s accountID=acct-1", got, core.PrivateScopeSlug)
}
}
func TestResolveGalleryTarget_PrivateScopeNameImpliesPrivate(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
// Naming the private scope directly flips private on (and still needs an account).
got, err := resolveGalleryTarget(galleryTestCmd("@"+core.PrivateScopeSlug, "widget"), creds.HostCreds{ActiveAccountID: "acct-1"})
if err != nil {
t.Fatalf("resolveGalleryTarget: %v", err)
}
if !got.private || got.scopeSlug != core.PrivateScopeSlug {
t.Errorf("got %+v, want private=true scopeSlug=%s", got, core.PrivateScopeSlug)
}
}
func TestResolveGalleryTarget_ReadFileHelper(t *testing.T) {
// Guard that a valid image on disk round-trips through the same os.ReadFile
// path `add` uses (content-type derived from the extension).
dir := t.TempDir()
path := filepath.Join(dir, "preview.png")
if err := os.WriteFile(path, []byte("\x89PNG\r\n\x1a\nfake"), 0o644); err != nil {
t.Fatal(err)
}
ct, err := imageContentType(path)
if err != nil {
t.Fatalf("imageContentType: %v", err)
}
if ct != "image/png" {
t.Errorf("content type = %q, want image/png", ct)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read: %v", err)
}
if len(data) == 0 || len(data) > maxScreenshotBytes {
t.Errorf("unexpected size %d", len(data))
}
}