pluginsdk/plugin/wasmguest/caps/caps_roundtrip_test.go
Alex Dunmow b6d40ed8ac feat: bootstrap block/pluginsdk — the proto-first plugin SDK (P1)
Bootstrap the plugin-facing SDK module so the fleet migration (P2) becomes a
mechanical block/core/X -> block/pluginsdk/X import rewrite.

- abi/proto/v1: the single source-of-truth ABI proto tree, copied from the cms
  authoring source (cms/backend/abi/proto/v1) with go_package retargeted to
  git.dev.alexdunmow.com/block/pluginsdk/abi/v1;abiv1. Kills the hand-kept
  cms/core proto duplication (audit gap 4).
- abi/v1: generated Go bindings (buf generate); byte-identical to core's abi/v1
  save the embedded go_package path.
- plugin/ (registration + DI surface), plugin/wasmguest/** (transport shim,
  caps stubs, bnwasm db driver, testdata fixtures + golden .pb), and the
  guest-facing type packages: blocks (+builtin/shared/tags), templates
  (+pongo/bn), auth, settings, content, gating, crypto, rbac, video, ai,
  subscriptions, menus, datasources. Internal imports rewritten core ->
  pluginsdk; zero block/core references remain.
- README/AGENTS(+CLAUDE symlink)/Makefile: proto is the contract, Go is one
  binding; no replace directives; templates/bn is a synced copy authored in cms.

Spec: cms docs/superpowers/specs/2026-07-07-proto-first-plugin-sdk-design.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 10:51:00 +08:00

958 lines
34 KiB
Go

package caps
import (
"context"
"errors"
"flag"
"os"
"path/filepath"
"strings"
"testing"
"time"
abiv1 "git.dev.alexdunmow.com/block/pluginsdk/abi/v1"
"git.dev.alexdunmow.com/block/pluginsdk/ai"
"git.dev.alexdunmow.com/block/pluginsdk/content"
"git.dev.alexdunmow.com/block/pluginsdk/gating"
"git.dev.alexdunmow.com/block/pluginsdk/plugin"
"github.com/google/uuid"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/timestamppb"
)
// -update regenerates the golden capability payloads. WO-WZ-006 (cms host)
// replays these SAME goldens to prove the host and guest agree on the wire, so
// they must stay deterministic (proto marshaled with Deterministic=true).
var update = flag.Bool("update", false, "regenerate golden capability payloads")
const goldenDir = "testdata/golden"
// Fixed IDs keep the goldens stable across runs.
var (
idUser = uuid.MustParse("11111111-1111-1111-1111-111111111111")
idAuthor = uuid.MustParse("22222222-2222-2222-2222-222222222222")
idMenu = uuid.MustParse("33333333-3333-3333-3333-333333333333")
idItem = uuid.MustParse("44444444-4444-4444-4444-444444444444")
idParent = uuid.MustParse("55555555-5555-5555-5555-555555555555")
idBucket = uuid.MustParse("66666666-6666-6666-6666-666666666666")
idTier = uuid.MustParse("77777777-7777-7777-7777-777777777777")
idPlan = uuid.MustParse("88888888-8888-8888-8888-888888888888")
idMedia = uuid.MustParse("99999999-9999-9999-9999-999999999999")
idTable = uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
idRow = uuid.MustParse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")
planTime = time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC)
)
func goldenPath(name string) string { return filepath.Join(goldenDir, name+".pb") }
func detMarshal(t *testing.T, m proto.Message) []byte {
t.Helper()
b, err := proto.MarshalOptions{Deterministic: true}.Marshal(m)
if err != nil {
t.Fatalf("marshal: %v", err)
}
return b
}
// fakeTransport is the swapped-in CallFunc: it checks the request the stub
// produced against a golden, then feeds back a canned response golden.
type fakeTransport struct {
t *testing.T
name string // golden basename for the current call
cannedResp proto.Message // synthesizes the resp golden under -update
err error // returned instead of a response (error-mapping cases)
gotMethod string
}
func (f *fakeTransport) call(method string, req, resp proto.Message) error {
f.gotMethod = method
reqBytes := detMarshal(f.t, req)
reqPath := goldenPath(f.name + "_req")
if *update {
writeGolden(f.t, reqPath, reqBytes)
} else {
want, err := os.ReadFile(reqPath)
if err != nil {
f.t.Fatalf("%s: read request golden (run -update?): %v", f.name, err)
}
if !equalProto(f.t, want, reqBytes, req) {
f.t.Errorf("%s: request payload drifted from golden %s", f.name, reqPath)
}
}
if f.err != nil {
return f.err
}
respPath := goldenPath(f.name + "_resp")
if *update {
respBytes := detMarshal(f.t, f.cannedResp)
writeGolden(f.t, respPath, respBytes)
if err := proto.Unmarshal(respBytes, resp); err != nil {
f.t.Fatalf("%s: unmarshal canned resp: %v", f.name, err)
}
return nil
}
respBytes, err := os.ReadFile(respPath)
if err != nil {
f.t.Fatalf("%s: read response golden (run -update?): %v", f.name, err)
}
if err := proto.Unmarshal(respBytes, resp); err != nil {
f.t.Fatalf("%s: unmarshal response golden: %v", f.name, err)
}
return nil
}
func writeGolden(t *testing.T, path string, b []byte) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir golden: %v", err)
}
if err := os.WriteFile(path, b, 0o644); err != nil {
t.Fatalf("write golden: %v", err)
}
}
// equalProto compares two serializations by decoding both into fresh messages
// of the same type, so semantically-equal encodings still match.
func equalProto(t *testing.T, want, got []byte, sample proto.Message) bool {
t.Helper()
a := sample.ProtoReflect().New().Interface()
b := sample.ProtoReflect().New().Interface()
if err := proto.Unmarshal(want, a); err != nil {
t.Fatalf("unmarshal want: %v", err)
}
if err := proto.Unmarshal(got, b); err != nil {
t.Fatalf("unmarshal got: %v", err)
}
return proto.Equal(a, b)
}
// capCase is one capability round trip: set up the fake, call the stub through
// the assembled CoreServices, and assert both the wire method and the decoded
// Go result.
type capCase struct {
name string
wantMethod string
resp proto.Message
run func(t *testing.T, cs plugin.CoreServices)
}
func TestCapabilityRoundTrip(t *testing.T) {
ctx := context.Background()
f := &fakeTransport{t: t}
cs := NewCoreServices(f.call)
cases := []capCase{
// --- content ---
{
name: "content_get_author_profile", wantMethod: "content.get_author_profile",
resp: &abiv1.ContentGetAuthorProfileResponse{Author: &abiv1.AuthorProfile{
Id: idAuthor.String(), Name: "Ada", Slug: "ada", Bio: "hi",
AvatarUrl: "https://x/a.png", Website: "https://ada.dev",
SocialLinks: map[string]string{"x": "@ada", "gh": "ada"},
}},
run: func(t *testing.T, cs plugin.CoreServices) {
got, err := cs.Content.GetAuthorProfile(ctx, idAuthor)
if err != nil {
t.Fatal(err)
}
if got.ID != idAuthor || got.Name != "Ada" || got.SocialLinks["x"] != "@ada" {
t.Errorf("author = %+v", got)
}
},
},
{
name: "content_get_page", wantMethod: "content.get_page",
resp: &abiv1.ContentGetPageResponse{Page: &abiv1.PageInfo{Id: idAuthor.String(), Slug: "about", Title: "About Us"}},
run: func(t *testing.T, cs plugin.CoreServices) {
got, err := cs.Content.GetPage(ctx, "about")
if err != nil {
t.Fatal(err)
}
if got.Slug != "about" || got.Title != "About Us" {
t.Errorf("page = %+v", got)
}
},
},
{
name: "content_get_post", wantMethod: "content.get_post",
resp: &abiv1.ContentGetPostResponse{Post: &abiv1.PostInfo{
Id: idAuthor.String(), Slug: "hello", Title: "Hello", Excerpt: "hi",
FeaturedImageUrl: "media:x", AuthorId: idAuthor.String(),
}},
run: func(t *testing.T, cs plugin.CoreServices) {
got, err := cs.Content.GetPost(ctx, "hello")
if err != nil {
t.Fatal(err)
}
if got.Title != "Hello" || got.AuthorID != idAuthor {
t.Errorf("post = %+v", got)
}
},
},
{
name: "content_list_posts", wantMethod: "content.list_posts",
resp: &abiv1.ContentListPostsResponse{Posts: []*abiv1.PostInfo{
{
Id: idAuthor.String(), Slug: "first", Title: "First", Excerpt: "one",
FeaturedImageUrl: "media:1", AuthorId: idAuthor.String(),
AuthorName: "Ada", AuthorSlug: "ada", PublishedAt: timestamppb.New(planTime),
},
{
Id: idPlan.String(), Slug: "second", Title: "Second", Excerpt: "two",
AuthorId: idAuthor.String(), AuthorName: "Ada", AuthorSlug: "ada",
PublishedAt: timestamppb.New(planTime),
},
}},
run: func(t *testing.T, cs plugin.CoreServices) {
got, err := cs.Content.ListPosts(ctx, content.ListPostsParams{Limit: 10, IncludeExcerpt: true})
if err != nil {
t.Fatal(err)
}
if len(got) != 2 || got[0].Slug != "first" || got[0].AuthorName != "Ada" {
t.Fatalf("posts = %+v", got)
}
if got[1].Slug != "second" || !got[1].PublishedAt.Equal(planTime) {
t.Errorf("post[1] = %+v", got[1])
}
},
},
{
name: "content_slugify", wantMethod: "content.slugify",
resp: &abiv1.ContentSlugifyResponse{Slug: "hello-world"},
run: func(t *testing.T, cs plugin.CoreServices) {
if got := cs.Content.Slugify("Hello World"); got != "hello-world" {
t.Errorf("slug = %q", got)
}
},
},
{
name: "content_block_note_to_html", wantMethod: "content.block_note_to_html",
resp: &abiv1.ContentBlockNoteToHtmlResponse{Html: "<p>hi</p>"},
run: func(t *testing.T, cs plugin.CoreServices) {
if got := cs.Content.BlockNoteToHTML(ctx, map[string]any{"type": "doc"}); got != "<p>hi</p>" {
t.Errorf("html = %q", got)
}
},
},
{
name: "content_generate_excerpt", wantMethod: "content.generate_excerpt",
resp: &abiv1.ContentGenerateExcerptResponse{Excerpt: "short"},
run: func(t *testing.T, cs plugin.CoreServices) {
if got := cs.Content.GenerateExcerpt("<p>long text</p>", 5); got != "short" {
t.Errorf("excerpt = %q", got)
}
},
},
{
name: "content_strip_html", wantMethod: "content.strip_html",
resp: &abiv1.ContentStripHtmlResponse{Text: "plain"},
run: func(t *testing.T, cs plugin.CoreServices) {
if got := cs.Content.StripHTML("<b>plain</b>"); got != "plain" {
t.Errorf("text = %q", got)
}
},
},
// --- settings ---
{
name: "settings_get_site_settings", wantMethod: "settings.get_site_settings",
resp: &abiv1.SettingsGetSiteSettingsResponse{SettingsJson: []byte(`{"site_name":"Fixture","theme":"dark"}`)},
run: func(t *testing.T, cs plugin.CoreServices) {
m, err := cs.Settings.GetSiteSettings(ctx)
if err != nil {
t.Fatal(err)
}
if m["site_name"] != "Fixture" {
t.Errorf("settings = %+v", m)
}
},
},
{
name: "settings_get_plugin_settings", wantMethod: "settings.get_plugin_settings",
resp: &abiv1.SettingsGetPluginSettingsResponse{SettingsJson: []byte(`{"enabled":true}`)},
run: func(t *testing.T, cs plugin.CoreServices) {
m, err := cs.Settings.GetPluginSettings(ctx, "symposium")
if err != nil {
t.Fatal(err)
}
if m["enabled"] != true {
t.Errorf("plugin settings = %+v", m)
}
},
},
{
name: "settings_update_site_setting", wantMethod: "settings.update_site_setting",
resp: &abiv1.SettingsUpdateSiteSettingResponse{},
run: func(t *testing.T, cs plugin.CoreServices) {
if err := cs.SettingsUpdater.UpdateSiteSetting(ctx, "theme", "light"); err != nil {
t.Fatal(err)
}
},
},
// --- gating ---
{
name: "gating_get_subscriber_tier_level", wantMethod: "gating.get_subscriber_tier_level",
resp: &abiv1.GatingGetSubscriberTierLevelResponse{Level: 3},
run: func(t *testing.T, cs plugin.CoreServices) {
got, err := cs.Gating.GetSubscriberTierLevel(ctx, idUser)
if err != nil {
t.Fatal(err)
}
if got != 3 {
t.Errorf("level = %d", got)
}
},
},
{
name: "gating_evaluate_access", wantMethod: "gating.evaluate_access",
resp: &abiv1.GatingEvaluateAccessResponse{Result: &abiv1.AccessResult{
HasAccess: false, TeaserMode: "soft", TeaserPercent: 20, RequiredLevel: 2,
}},
run: func(t *testing.T, cs plugin.CoreServices) {
got := cs.Gating.EvaluateAccess(1, &gating.AccessRule{MinTierLevel: 2, TeaserMode: "soft", TeaserPercent: 20})
if got.HasAccess || got.TeaserMode != "soft" || got.RequiredLevel != 2 {
t.Errorf("access = %+v", got)
}
},
},
// --- crypto ---
{
name: "crypto_encrypt_secret", wantMethod: "crypto.encrypt_secret",
resp: &abiv1.CryptoEncryptSecretResponse{Ciphertext: "enc:abc"},
run: func(t *testing.T, cs plugin.CoreServices) {
got, err := cs.Crypto.EncryptSecret("plain")
if err != nil || got != "enc:abc" {
t.Errorf("ciphertext = %q err = %v", got, err)
}
},
},
{
name: "crypto_decrypt_secret", wantMethod: "crypto.decrypt_secret",
resp: &abiv1.CryptoDecryptSecretResponse{Plaintext: "plain"},
run: func(t *testing.T, cs plugin.CoreServices) {
got, err := cs.Crypto.DecryptSecret("enc:abc")
if err != nil || got != "plain" {
t.Errorf("plaintext = %q err = %v", got, err)
}
},
},
// --- menus ---
{
name: "menus_get_menu_by_name", wantMethod: "menus.get_menu_by_name",
resp: &abiv1.MenusGetMenuByNameResponse{Menu: &abiv1.Menu{Id: idMenu.String(), Name: "main"}},
run: func(t *testing.T, cs plugin.CoreServices) {
got, err := cs.Menus.GetMenuByName(ctx, "main")
if err != nil || got.ID != idMenu || got.Name != "main" {
t.Errorf("menu = %+v err = %v", got, err)
}
},
},
{
name: "menus_get_menu_items", wantMethod: "menus.get_menu_items",
resp: &abiv1.MenusGetMenuItemsResponse{Items: []*abiv1.MenuItem{{
Id: idItem.String(), MenuId: idMenu.String(), Label: "Home", Url: "/",
PageSlug: "home", ParentId: new(idParent.String()), SortOrder: 1,
OpenInNewTab: true, CssClass: "nav", ItemType: "link", Icon: "home",
}}},
run: func(t *testing.T, cs plugin.CoreServices) {
got, err := cs.Menus.GetMenuItems(ctx, idMenu)
if err != nil || len(got) != 1 {
t.Fatalf("items = %+v err = %v", got, err)
}
it := got[0]
if it.ID != idItem || it.ParentID == nil || *it.ParentID != idParent || !it.OpenInNewTab {
t.Errorf("item = %+v", it)
}
},
},
// --- datasources ---
{
name: "datasources_resolve_bucket", wantMethod: "datasources.resolve_bucket",
resp: &abiv1.DatasourcesResolveBucketResponse{Result: &abiv1.DatasourceResult{
ItemsJson: []byte(`[{"id":1},{"id":2}]`), Total: 2, MetaJson: []byte(`{"page":1}`),
}},
run: func(t *testing.T, cs plugin.CoreServices) {
got, err := cs.Datasources.ResolveBucket(ctx, idBucket)
if err != nil || got.Total != 2 || len(got.Items) != 2 || got.Meta["page"] != float64(1) {
t.Errorf("result = %+v err = %v", got, err)
}
},
},
{
name: "datasources_resolve_bucket_by_key", wantMethod: "datasources.resolve_bucket_by_key",
resp: &abiv1.DatasourcesResolveBucketByKeyResponse{Result: &abiv1.DatasourceResult{
ItemsJson: []byte(`[]`), Total: 0,
}},
run: func(t *testing.T, cs plugin.CoreServices) {
got, err := cs.Datasources.ResolveBucketByKey(ctx, "featured")
if err != nil || got.Total != 0 {
t.Errorf("result = %+v err = %v", got, err)
}
},
},
// --- users ---
{
name: "users_get_by_username", wantMethod: "users.get_by_username",
resp: &abiv1.UsersGetByUsernameResponse{User: &abiv1.PublicUserProfile{
Id: idUser.String(), Email: "u@x.com", Username: "u", DisplayName: "U",
AvatarUrl: "a", Bio: "b", EmailVerified: true, Role: "member",
}},
run: func(t *testing.T, cs plugin.CoreServices) {
got, err := cs.PublicUsers.GetByUsername(ctx, "u")
if err != nil || got.ID != idUser || !got.EmailVerified {
t.Errorf("user = %+v err = %v", got, err)
}
},
},
{
name: "users_get_by_id", wantMethod: "users.get_by_id",
resp: &abiv1.UsersGetByIdResponse{User: &abiv1.PublicUserProfile{Id: idUser.String(), Username: "u"}},
run: func(t *testing.T, cs plugin.CoreServices) {
got, err := cs.PublicUsers.GetByID(ctx, idUser)
if err != nil || got.Username != "u" {
t.Errorf("user = %+v err = %v", got, err)
}
},
},
// --- subscriptions ---
{
name: "subscriptions_get_user_tier_level", wantMethod: "subscriptions.get_user_tier_level",
resp: &abiv1.SubscriptionsGetUserTierLevelResponse{TierLevel: &abiv1.TierLevel{Level: 5, Features: []byte(`{"a":1}`)}},
run: func(t *testing.T, cs plugin.CoreServices) {
got, err := cs.Subscriptions.GetUserTierLevel(ctx, idUser)
if err != nil || got.Level != 5 || string(got.Features) != `{"a":1}` {
t.Errorf("tier level = %+v err = %v", got, err)
}
},
},
{
name: "subscriptions_get_tier_by_slug", wantMethod: "subscriptions.get_tier_by_slug",
resp: &abiv1.SubscriptionsGetTierBySlugResponse{Tier: &abiv1.Tier{
Id: idTier.String(), Name: "Gold", Slug: "gold", Level: 3, Description: "d",
Features: []byte(`{}`), IsDefault: true, Position: 2,
}},
run: func(t *testing.T, cs plugin.CoreServices) {
got, err := cs.Subscriptions.GetTierBySlug(ctx, "gold")
if err != nil || got.ID != idTier || got.Level != 3 || !got.IsDefault {
t.Errorf("tier = %+v err = %v", got, err)
}
},
},
{
name: "subscriptions_list_tiers", wantMethod: "subscriptions.list_tiers",
resp: &abiv1.SubscriptionsListTiersResponse{Tiers: []*abiv1.Tier{
{Id: idTier.String(), Name: "Gold", Slug: "gold", Level: 3},
}},
run: func(t *testing.T, cs plugin.CoreServices) {
got, err := cs.Subscriptions.ListTiers(ctx)
if err != nil || len(got) != 1 || got[0].Slug != "gold" {
t.Errorf("tiers = %+v err = %v", got, err)
}
},
},
{
name: "subscriptions_list_active_plans", wantMethod: "subscriptions.list_active_plans",
resp: &abiv1.SubscriptionsListActivePlansResponse{Plans: []*abiv1.Plan{{
Id: idPlan.String(), TierId: idTier.String(), BillingInterval: "month",
Amount: 4900, Currency: "usd", IsActive: true, CreatedAt: timestamppb.New(planTime),
}}},
run: func(t *testing.T, cs plugin.CoreServices) {
got, err := cs.Subscriptions.ListActivePlans(ctx, idTier)
if err != nil || len(got) != 1 {
t.Fatalf("plans = %+v err = %v", got, err)
}
p := got[0]
if p.ID != idPlan || p.Amount != 4900 || !p.CreatedAt.Equal(planTime) {
t.Errorf("plan = %+v", p)
}
},
},
// --- media ---
{
name: "media_deposit", wantMethod: "media.deposit",
resp: &abiv1.MediaDepositResponse{Id: idMedia.String(), Ref: "media:" + idMedia.String(), Created: true},
run: func(t *testing.T, cs plugin.CoreServices) {
got, err := cs.Media.Deposit(ctx, plugin.MediaDeposit{
ID: idMedia, Filename: "hero.jpg", Data: []byte("bytes"), AltText: "alt", Folder: "f", Source: "plugin",
})
if err != nil || got.ID != idMedia || !got.Created {
t.Errorf("media = %+v err = %v", got, err)
}
},
},
// --- email ---
{
name: "email_send", wantMethod: "email.send",
resp: &abiv1.EmailSendResponse{},
run: func(t *testing.T, cs plugin.CoreServices) {
if err := cs.EmailSender.Send("to@x.com", "Subj", "Body"); err != nil {
t.Fatal(err)
}
},
},
// --- ai ---
{
name: "ai_text_call", wantMethod: "ai.text_call",
resp: &abiv1.AiTextCallResponse{Text: "generated"},
run: func(t *testing.T, cs plugin.CoreServices) {
got, err := cs.AITextCall(ctx, "summarize", "sys", "user")
if err != nil || got != "generated" {
t.Errorf("text = %q err = %v", got, err)
}
},
},
{
name: "ai_tools_register", wantMethod: "ai.tools.register",
resp: &abiv1.AiToolRegisterResponse{},
run: func(t *testing.T, cs plugin.CoreServices) {
cs.ToolRegistry.Register(&ai.ToolDefinition{
Slug: "lookup", Name: "Lookup", Description: "d",
ParameterSchema: map[string]any{"type": "object"},
})
},
},
// --- bridge ---
{
name: "bridge_register_service", wantMethod: "bridge.register_service",
resp: &abiv1.BridgeRegisterServiceResponse{},
run: func(t *testing.T, cs plugin.CoreServices) {
cs.Bridge.RegisterService("symposium", "search", struct{}{})
},
},
{
name: "bridge_get_service", wantMethod: "bridge.get_service",
resp: &abiv1.BridgeGetServiceResponse{Available: true},
run: func(t *testing.T, cs plugin.CoreServices) {
if got := cs.Bridge.GetService("symposium", "search"); got != nil {
t.Errorf("get_service crossed a value: %v (want nil)", got)
}
},
},
// --- jobs ---
{
name: "jobs_submit", wantMethod: "jobs.submit",
resp: &abiv1.JobsSubmitResponse{},
run: func(t *testing.T, cs plugin.CoreServices) {
if err := cs.JobRunner.Submit(ctx, "reindex", []byte(`{"full":true}`)); err != nil {
t.Fatal(err)
}
},
},
// --- embeddings ---
{
name: "embeddings_generate_embedding", wantMethod: "embeddings.generate_embedding",
resp: &abiv1.EmbeddingsGenerateEmbeddingResponse{Embedding: []float32{0.1, 0.2, 0.3}},
run: func(t *testing.T, cs plugin.CoreServices) {
got, err := cs.EmbeddingService.GenerateEmbedding(ctx, "text")
if err != nil || len(got) != 3 || got[0] != 0.1 {
t.Errorf("embedding = %v err = %v", got, err)
}
},
},
{
name: "embeddings_embed_content", wantMethod: "embeddings.embed_content",
resp: &abiv1.EmbeddingsEmbedContentResponse{Embedded: true},
run: func(t *testing.T, cs plugin.CoreServices) {
got, err := cs.EmbeddingService.EmbedContent(ctx, "post", idRow, "text")
if err != nil || !got {
t.Errorf("embedded = %v err = %v", got, err)
}
},
},
{
name: "embeddings_is_available", wantMethod: "embeddings.is_available",
resp: &abiv1.EmbeddingsIsAvailableResponse{Available: true},
run: func(t *testing.T, cs plugin.CoreServices) {
if !cs.EmbeddingService.IsAvailable() {
t.Errorf("is_available = false")
}
},
},
// --- rag ---
{
name: "rag_query", wantMethod: "rag.query",
resp: &abiv1.RagQueryResponse{Results: []*abiv1.RagResult{
{Content: "chunk", Score: 0.9, Metadata: map[string]string{"src": "post"}},
}},
run: func(t *testing.T, cs plugin.CoreServices) {
got, err := cs.RAGService.Query(ctx, "q", 5)
if err != nil || len(got) != 1 || got[0].Score != 0.9 || got[0].Metadata["src"] != "post" {
t.Errorf("rag = %+v err = %v", got, err)
}
},
},
{
name: "rag_on_content_changed", wantMethod: "rag.on_content_changed",
resp: &abiv1.RagOnContentChangedResponse{},
run: func(t *testing.T, cs plugin.CoreServices) {
cs.RAGService.OnContentChanged(ctx, "post", idRow)
},
},
// --- reviews ---
{
name: "reviews_submit_review", wantMethod: "reviews.submit_review",
resp: &abiv1.ReviewsSubmitReviewResponse{ReviewId: "rev-1"},
run: func(t *testing.T, cs plugin.CoreServices) {
got, err := cs.ReviewSubmitter.SubmitReview(ctx, plugin.SubmitReviewParams{
TableID: idTable, RowID: idRow, OverallRating: 4, ReviewText: "good",
Ratings: map[string]any{"food": 5}, Photos: []string{"media:1"},
})
if err != nil || got != "rev-1" {
t.Errorf("review id = %q err = %v", got, err)
}
},
},
// --- badges ---
{
name: "badges_refresh_badges", wantMethod: "badges.refresh_badges",
resp: &abiv1.BadgesRefreshBadgesResponse{},
run: func(t *testing.T, cs plugin.CoreServices) {
if err := cs.BadgeRefresher.RefreshBadges(ctx, idTable, idRow); err != nil {
t.Fatal(err)
}
},
},
// --- content writes (WO-WZ-019) ---
{
name: "content_create_page", wantMethod: "content.create_page",
resp: &abiv1.ContentCreatePageResponse{PageId: idItem.String(), Created: true},
run: func(t *testing.T, cs plugin.CoreServices) {
got, err := cs.ContentAuthor.CreatePage(ctx, content.CreatePageParams{
Slug: "/pricing", ParentSlug: "", Title: "Pricing", TemplateKey: "landing",
})
if err != nil || got.PageID != idItem || !got.Created {
t.Errorf("create_page = %+v err = %v", got, err)
}
},
},
{
name: "content_set_page_blocks", wantMethod: "content.set_page_blocks",
resp: &abiv1.ContentSetPageBlocksResponse{},
run: func(t *testing.T, cs plugin.CoreServices) {
html := "<h1>Hi</h1>"
err := cs.ContentAuthor.SetPageBlocks(ctx, idItem, []content.PageBlock{
{BlockKey: "html", Title: "Hero", Content: map[string]any{"align": "center"}, HTMLContent: &html, Slot: "main", SortOrder: 1},
{BlockKey: "cta", Title: "CTA", Content: map[string]any{"label": "Go"}, SortOrder: 2},
})
if err != nil {
t.Fatal(err)
}
},
},
{
name: "content_publish_page", wantMethod: "content.publish_page",
resp: &abiv1.ContentPublishPageResponse{},
run: func(t *testing.T, cs plugin.CoreServices) {
if err := cs.ContentAuthor.PublishPage(ctx, idItem); err != nil {
t.Fatal(err)
}
},
},
{
name: "content_set_page_seo", wantMethod: "content.set_page_seo",
resp: &abiv1.ContentSetPageSeoResponse{},
run: func(t *testing.T, cs plugin.CoreServices) {
mt, md := "Pricing — Acme", "Plans and pricing."
err := cs.ContentAuthor.SetPageSEO(ctx, idItem, content.PageSEO{MetaTitle: &mt, MetaDescription: &md})
if err != nil {
t.Fatal(err)
}
},
},
{
name: "content_upsert_post", wantMethod: "content.upsert_post",
resp: &abiv1.ContentUpsertPostResponse{PostId: idRow.String(), Created: false},
run: func(t *testing.T, cs plugin.CoreServices) {
excerpt := "hello"
got, err := cs.ContentAuthor.UpsertPost(ctx, content.UpsertPostParams{
Slug: "hello", Title: "Hello", Document: map[string]any{"type": "doc"},
Excerpt: &excerpt, AuthorProfileID: &idAuthor, FeaturedImageID: &idMedia,
Publish: true,
})
if err != nil || got.PostID != idRow || got.Created {
t.Errorf("upsert_post = %+v err = %v", got, err)
}
},
},
// --- settings.update_plugin_settings (WO-WZ-019) ---
{
name: "settings_update_plugin_settings", wantMethod: "settings.update_plugin_settings",
resp: &abiv1.SettingsUpdatePluginSettingsResponse{},
run: func(t *testing.T, cs plugin.CoreServices) {
err := cs.SettingsUpdater.UpdatePluginSettings(ctx, "myplugin", map[string]any{"enabled": true})
if err != nil {
t.Fatal(err)
}
},
},
// --- provisioner (WO-WZ-019) ---
{
name: "provisioner_ensure_page", wantMethod: "provisioner.ensure_page",
resp: &abiv1.ProvisionerEnsurePageResponse{},
run: func(t *testing.T, cs plugin.CoreServices) {
err := cs.Provisioner.EnsurePage(plugin.PageConfig{
Slug: "/", Title: "Home", TemplateKey: "homepage", ReconcileBlocks: true,
Blocks: []plugin.PageBlockConfig{
{BlockKey: "html", Title: "Hero", Content: map[string]any{"x": float64(1)}, Slot: "main", SortOrder: 1},
},
})
if err != nil {
t.Fatal(err)
}
},
},
{
name: "provisioner_merge_site_settings", wantMethod: "provisioner.merge_site_settings",
resp: &abiv1.ProvisionerMergeSiteSettingsResponse{},
run: func(t *testing.T, cs plugin.CoreServices) {
if err := cs.Provisioner.MergeSiteSettings(map[string]any{"site_name": "Acme"}); err != nil {
t.Fatal(err)
}
},
},
{
name: "provisioner_ensure_menu_item", wantMethod: "provisioner.ensure_menu_item",
resp: &abiv1.ProvisionerEnsureMenuItemResponse{},
run: func(t *testing.T, cs plugin.CoreServices) {
err := cs.Provisioner.EnsureMenuItem("main", plugin.MenuItemConfig{Label: "Docs", PageSlug: "/docs", SortOrder: 3})
if err != nil {
t.Fatal(err)
}
},
},
{
name: "provisioner_ensure_media", wantMethod: "provisioner.ensure_media",
resp: &abiv1.ProvisionerEnsureMediaResponse{},
run: func(t *testing.T, cs plugin.CoreServices) {
err := cs.Provisioner.EnsureMedia(plugin.MediaDeposit{
ID: idMedia, Filename: "hero.jpg", Data: []byte{1, 2, 3}, AltText: "hero", Folder: "seed", Source: "myplugin",
})
if err != nil {
t.Fatal(err)
}
},
},
// --- bridge.invoke (WO-WZ-019) ---
{
name: "bridge_invoke", wantMethod: "bridge.invoke",
resp: &abiv1.BridgeInvokeResponse{Payload: []byte(`{"ok":true}`)},
run: func(t *testing.T, cs plugin.CoreServices) {
got, err := cs.Bridge.Invoke(ctx, "symposium", "seeder", "seed_forum", []byte(`{"n":1}`))
if err != nil || string(got) != `{"ok":true}` {
t.Errorf("bridge.invoke = %q err = %v", got, err)
}
},
},
// --- remaining provisioner methods (WO-WZ-019) ---
{
name: "provisioner_ensure_data_table", wantMethod: "provisioner.ensure_data_table",
resp: &abiv1.ProvisionerEnsureDataTableResponse{},
run: func(t *testing.T, cs plugin.CoreServices) {
err := cs.Provisioner.EnsureDataTable(plugin.DataTableConfig{
Key: "playgrounds", Name: "Playgrounds", Description: "d",
Schema: []byte(`{"fields":[]}`), PrimaryKey: "id",
})
if err != nil {
t.Fatal(err)
}
},
},
{
name: "provisioner_ensure_setting", wantMethod: "provisioner.ensure_setting",
resp: &abiv1.ProvisionerEnsureSettingResponse{},
run: func(t *testing.T, cs plugin.CoreServices) {
if err := cs.Provisioner.EnsureSetting("review_dimensions", map[string]any{"max": float64(5)}); err != nil {
t.Fatal(err)
}
},
},
{
name: "provisioner_override_site_settings", wantMethod: "provisioner.override_site_settings",
resp: &abiv1.ProvisionerOverrideSiteSettingsResponse{},
run: func(t *testing.T, cs plugin.CoreServices) {
if err := cs.Provisioner.OverrideSiteSettings(map[string]any{"default_template": "homepage"}); err != nil {
t.Fatal(err)
}
},
},
{
name: "provisioner_register_embedding_config", wantMethod: "provisioner.register_embedding_config",
resp: &abiv1.ProvisionerRegisterEmbeddingConfigResponse{},
run: func(t *testing.T, cs plugin.CoreServices) {
err := cs.Provisioner.RegisterEmbeddingConfig(plugin.EmbeddingConfigDef{
TableKey: "playgrounds", TextTemplate: "{{name}}", Enabled: true,
})
if err != nil {
t.Fatal(err)
}
},
},
{
name: "provisioner_ensure_embed", wantMethod: "provisioner.ensure_embed",
resp: &abiv1.ProvisionerEnsureEmbedResponse{},
run: func(t *testing.T, cs plugin.CoreServices) {
err := cs.Provisioner.EnsureEmbed(plugin.EmbedConfig{
Key: "playground-card", Title: "Playground Card", Description: "d",
Icon: "📍", LabelField: "name", Template: "<div>{{name}}</div>",
DataSource: plugin.EmbedDataSource{Type: "row", TableKey: "playgrounds"},
})
if err != nil {
t.Fatal(err)
}
},
},
{
name: "provisioner_ensure_job_schedule", wantMethod: "provisioner.ensure_job_schedule",
resp: &abiv1.ProvisionerEnsureJobScheduleResponse{},
run: func(t *testing.T, cs plugin.CoreServices) {
err := cs.Provisioner.EnsureJobSchedule(plugin.JobScheduleConfig{
JobType: "bounty_rotation", CronExpression: "0 3 * * *", Config: []byte(`{"n":3}`),
})
if err != nil {
t.Fatal(err)
}
},
},
{
name: "provisioner_update_data_table_row_field", wantMethod: "provisioner.update_data_table_row_field",
resp: &abiv1.ProvisionerUpdateDataTableRowFieldResponse{},
run: func(t *testing.T, cs plugin.CoreServices) {
if err := cs.Provisioner.UpdateDataTableRowField(ctx, idRow, "status", "active"); err != nil {
t.Fatal(err)
}
},
},
{
name: "provisioner_disable_orphaned_job_schedules", wantMethod: "provisioner.disable_orphaned_job_schedules",
resp: &abiv1.ProvisionerDisableOrphanedJobSchedulesResponse{},
run: func(t *testing.T, cs plugin.CoreServices) {
if err := cs.Provisioner.DisableOrphanedJobSchedules([]string{"bounty_rotation", "reindex"}); err != nil {
t.Fatal(err)
}
},
},
{
name: "provisioner_ensure_plugin", wantMethod: "provisioner.ensure_plugin",
resp: &abiv1.ProvisionerEnsurePluginResponse{},
run: func(t *testing.T, cs plugin.CoreServices) {
if err := cs.Provisioner.EnsurePlugin("symposium"); err != nil {
t.Fatal(err)
}
},
},
{
name: "provisioner_ensure_custom_color", wantMethod: "provisioner.ensure_custom_color",
resp: &abiv1.ProvisionerEnsureCustomColorResponse{},
run: func(t *testing.T, cs plugin.CoreServices) {
err := cs.Provisioner.EnsureCustomColor(plugin.CustomColorConfig{
Name: "brand", LightValue: "oklch(0.7 0.1 200)", DarkValue: "oklch(0.4 0.1 200)", Source: "myplugin",
})
if err != nil {
t.Fatal(err)
}
},
},
// --- jobs.progress (WO-WZ-019; sent by the job dispatch path, not a
// CoreServices stub, so the case drives the transport directly) ---
{
name: "jobs_progress", wantMethod: "jobs.progress",
resp: &abiv1.JobsProgressResponse{},
run: func(t *testing.T, _ plugin.CoreServices) {
req := &abiv1.JobsProgressRequest{Current: 2, Total: 5, Message: "halfway"}
if err := f.call("jobs.progress", req, &abiv1.JobsProgressResponse{}); err != nil {
t.Fatal(err)
}
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f.name = tc.name
f.cannedResp = tc.resp
f.err = nil
f.gotMethod = ""
tc.run(t, cs)
if f.gotMethod != tc.wantMethod {
t.Errorf("method = %q, want %q", f.gotMethod, tc.wantMethod)
}
})
}
}
// fakeAbiErr is a transport error carrying an ABI code, like wasmguest.HostError.
type fakeAbiErr struct {
code abiv1.AbiErrorCode
msg string
}
func (e *fakeAbiErr) Error() string { return e.msg }
func (e *fakeAbiErr) AbiErrorCode() abiv1.AbiErrorCode { return e.code }
func TestErrorMappingCarriesFamilyMethod(t *testing.T) {
f := &fakeTransport{t: t, err: &fakeAbiErr{code: abiv1.AbiErrorCode_ABI_ERROR_CODE_PERMISSION_DENIED, msg: "denied"}}
cs := NewCoreServices(f.call)
f.name = "content_get_page" // reuse the request golden for the compare
_, err := cs.Content.GetPage(context.Background(), "about")
if err == nil {
t.Fatal("want error")
}
if got := err.Error(); !strings.Contains(got, "content.get_page") || !strings.Contains(got, "denied") {
t.Errorf("error %q lacks family/method or message", got)
}
}
func TestErrorMappingDeadlineBecomesContextDeadline(t *testing.T) {
f := &fakeTransport{t: t, err: &fakeAbiErr{code: abiv1.AbiErrorCode_ABI_ERROR_CODE_DEADLINE_EXCEEDED, msg: "deadline"}}
cs := NewCoreServices(f.call)
f.name = "crypto_encrypt_secret"
_, err := cs.Crypto.EncryptSecret("plain")
if err == nil {
t.Fatal("want error")
}
if !errors.Is(err, context.DeadlineExceeded) {
t.Errorf("error %q does not wrap context.DeadlineExceeded", err)
}
if !strings.Contains(err.Error(), "crypto.encrypt_secret") {
t.Errorf("error %q lacks family/method", err)
}
}
func TestNilTransportFailsCleanly(t *testing.T) {
cs := NewCoreServices(nil)
_, err := cs.Content.GetPage(context.Background(), "about")
if err == nil || !strings.Contains(err.Error(), "no host transport") {
t.Errorf("nil transport error = %v", err)
}
}
// TestRAGRegisterContentFetcherStaysGuestSide verifies RegisterContentFetcher
// records fetchers locally (no host call) for HOOK_RAG_FETCH dispatch.
func TestRAGRegisterContentFetcherStaysGuestSide(t *testing.T) {
f := &fakeTransport{t: t}
cs := NewCoreServices(f.call)
rag, ok := cs.RAGService.(*RAGStub)
if !ok {
t.Fatalf("RAGService is %T, want *RAGStub", cs.RAGService)
}
called := false
rag.RegisterContentFetcher("post", func(context.Context, uuid.UUID) (string, string, error) {
called = true
return "T", "body", nil
})
if f.gotMethod != "" {
t.Errorf("RegisterContentFetcher made a host call %q", f.gotMethod)
}
if types := rag.FetcherTypes(); len(types) != 1 || types[0] != "post" {
t.Errorf("fetcher types = %v", types)
}
fetcher, ok := rag.Fetcher("post")
if !ok {
t.Fatal("fetcher not found")
}
if _, _, _ = fetcher(context.Background(), idRow); !called {
t.Error("registered fetcher not invoked")
}
}