package caps import ( "context" "errors" "flag" "io" "net/http" "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/egress" "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_published_block_configs", wantMethod: "content.published_block_configs", resp: &abiv1.ContentPublishedBlockConfigsResponse{Configs: [][]byte{[]byte(`{"captchaEnabled":true,"eventTypeSlug":"30min","username":"alice"}`)}}, run: func(t *testing.T, cs plugin.CoreServices) { got, err := cs.Content.PublishedBlockConfigs(ctx, "calcom:booking") if err != nil { t.Fatal(err) } if len(got) != 1 || got[0]["username"] != "alice" || got[0]["captchaEnabled"] != true { t.Errorf("configs = %+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: "

hi

"}, run: func(t *testing.T, cs plugin.CoreServices) { if got := cs.Content.BlockNoteToHTML(ctx, map[string]any{"type": "doc"}); got != "

hi

" { 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("

long text

", 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("plain"); 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) } }, }, // --- datatables --- { name: "datatables_get_table_by_key", wantMethod: "datatables.get_table_by_key", resp: &abiv1.DatatablesGetTableByKeyResponse{Table: &abiv1.DataTableInfo{ Id: idTable.String(), TableKey: "playgrounds", Name: "Playgrounds", RowCount: 678, }}, run: func(t *testing.T, cs plugin.CoreServices) { got, err := cs.DataTables.GetTableByKey(ctx, "playgrounds") if err != nil || got.ID != idTable || got.TableKey != "playgrounds" || got.RowCount != 678 { t.Errorf("table = %+v err = %v", got, err) } }, }, { name: "datatables_get_row", wantMethod: "datatables.get_row", resp: &abiv1.DatatablesGetRowResponse{Row: &abiv1.DataTableRowInfo{ Id: idRow.String(), TableId: idTable.String(), DataJson: []byte(`{"name":"Whiteman Park"}`), SortOrder: 3, }}, run: func(t *testing.T, cs plugin.CoreServices) { got, err := cs.DataTables.GetRow(ctx, idRow) if err != nil || got.ID != idRow || got.TableID != idTable || got.SortOrder != 3 { t.Errorf("row = %+v err = %v", got, err) } }, }, { name: "datatables_list_rows", wantMethod: "datatables.list_rows", resp: &abiv1.DatatablesListRowsResponse{Rows: []*abiv1.DataTableRowInfo{ {Id: idRow.String(), TableId: idTable.String(), DataJson: []byte(`{"name":"A"}`)}, {Id: idItem.String(), TableId: idTable.String(), DataJson: []byte(`{"name":"B"}`), SortOrder: 1}, }}, run: func(t *testing.T, cs plugin.CoreServices) { got, err := cs.DataTables.ListRows(ctx, idTable, 100, 0) if err != nil || len(got) != 2 || got[0].ID != idRow || got[1].SortOrder != 1 { t.Errorf("rows = %+v err = %v", got, err) } }, }, { name: "datatables_create_row", wantMethod: "datatables.create_row", resp: &abiv1.DatatablesCreateRowResponse{Row: &abiv1.DataTableRowInfo{ Id: idRow.String(), TableId: idTable.String(), DataJson: []byte(`{"name":"New"}`), }}, run: func(t *testing.T, cs plugin.CoreServices) { got, err := cs.DataTables.CreateRow(ctx, idTable, []byte(`{"name":"New"}`)) if err != nil || got.ID != idRow || string(got.DataJSON) != `{"name":"New"}` { t.Errorf("row = %+v err = %v", got, err) } }, }, { name: "datatables_update_row_data", wantMethod: "datatables.update_row_data", resp: &abiv1.DatatablesUpdateRowDataResponse{Row: &abiv1.DataTableRowInfo{ Id: idRow.String(), TableId: idTable.String(), DataJson: []byte(`{"name":"Updated"}`), SortOrder: 7, }}, run: func(t *testing.T, cs plugin.CoreServices) { got, err := cs.DataTables.UpdateRowData(ctx, idRow, []byte(`{"name":"Updated"}`)) if err != nil || got.SortOrder != 7 || string(got.DataJSON) != `{"name":"Updated"}` { t.Errorf("row = %+v err = %v", got, err) } }, }, { name: "datatables_find_row_by_field", wantMethod: "datatables.find_row_by_field", resp: &abiv1.DatatablesFindRowByFieldResponse{Row: &abiv1.DataTableRowInfo{ Id: idRow.String(), TableId: idTable.String(), DataJson: []byte(`{"google_place_id":"abc"}`), }}, run: func(t *testing.T, cs plugin.CoreServices) { got, err := cs.DataTables.FindRowByField(ctx, idTable, "google_place_id", "abc") if err != nil || got == nil || got.ID != idRow { t.Errorf("row = %+v err = %v", got, err) } }, }, { name: "datatables_find_row_by_field_miss", wantMethod: "datatables.find_row_by_field", resp: &abiv1.DatatablesFindRowByFieldResponse{}, run: func(t *testing.T, cs plugin.CoreServices) { got, err := cs.DataTables.FindRowByField(ctx, idTable, "google_place_id", "missing") if err != nil || got != nil { t.Errorf("want nil, nil on miss; row = %+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 := "

Hi

" 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: "
{{name}}
", 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) } }, }, // --- http.request (ADR 0023; RoundTripper, not a value-returning stub) --- { name: "http_request", wantMethod: "http.request", resp: &abiv1.HttpRequestResponse{ Status: 200, Headers: map[string]*abiv1.HeaderValues{"Content-Type": {Values: []string{"application/json"}}}, Body: []byte(`{"ok":true}`), FinalUrl: "https://api.cal.com/v1/slots", }, run: func(t *testing.T, cs plugin.CoreServices) { req, err := http.NewRequest(http.MethodGet, "https://api.cal.com/v1/slots", nil) if err != nil { t.Fatal(err) } req.Header.Set("Authorization", "Bearer test") res, err := cs.OutboundHTTP.RoundTrip(req) if err != nil { t.Fatal(err) } defer func() { _ = res.Body.Close() }() if res.StatusCode != 200 || res.Header.Get("Content-Type") != "application/json" { t.Errorf("response = %+v", res) } body, _ := io.ReadAll(res.Body) if string(body) != `{"ok":true}` { t.Errorf("body = %q", body) } }, }, // --- 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 TestEgressDeniedMapsToErrDenied(t *testing.T) { f := &fakeTransport{t: t, err: &fakeAbiErr{code: abiv1.AbiErrorCode_ABI_ERROR_CODE_EGRESS_DENIED, msg: `host "evil.example" not in allowlist`}} cs := NewCoreServices(f.call) f.name = "http_request" // reuse the request golden for the compare req, _ := http.NewRequest(http.MethodGet, "https://api.cal.com/v1/slots", nil) req.Header.Set("Authorization", "Bearer test") // match the http_request_req golden _, err := cs.OutboundHTTP.RoundTrip(req) if err == nil { t.Fatal("want error") } if !errors.Is(err, egress.ErrDenied) { t.Errorf("error %q does not wrap egress.ErrDenied", 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") } }