Wasm plugins' isolated Postgres role is deliberately denied on core tables, which left no sanctioned path to data_tables/data_table_rows. Adds the datatables.* capability family (get_table_by_key, get_row, list_rows, create_row, update_row_data, find_row_by_field) following the datasources pattern: interface package, ABI proto messages, guest stub, CoreServices wiring, golden round-trip coverage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1103 lines
39 KiB
Go
1103 lines
39 KiB
Go
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: "<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)
|
|
}
|
|
},
|
|
},
|
|
// --- 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 := "<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)
|
|
}
|
|
},
|
|
},
|
|
// --- 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")
|
|
}
|
|
}
|