package workbench import ( "context" "encoding/base64" "os" "testing" "aigateway.local/core/internal/platform/config" "aigateway.local/core/internal/platform/cryptox" "aigateway.local/core/internal/platform/database" ) func TestMarketplaceLifecycle(t *testing.T) { databaseURL := os.Getenv("WORKBENCH_TEST_DATABASE_URL") if databaseURL == "" { t.Skip("WORKBENCH_TEST_DATABASE_URL is not set") } ctx := context.Background() pool, err := database.Open(ctx, config.Database{URL: databaseURL, MaxConns: 8, MinConns: 0}) if err != nil { t.Fatal(err) } defer pool.Close() actorID := "22222222-2222-4222-8222-222222222222" portalUserID := "33333333-3333-4333-8333-333333333333" cleanup := func() { // pgx v5 Exec uses the extended protocol, which rejects multi-command // strings, so each statement must run in its own Exec. for _, q := range []string{ `DELETE FROM gateway.marketplace_installations WHERE portal_user_id='` + portalUserID + `'`, `DELETE FROM gateway.digital_employees WHERE code='mkt_employee'`, `DELETE FROM gateway.skills WHERE code='mkt_skill'`, `DELETE FROM gateway.mcp_servers WHERE code='mkt_mcp'`, `DELETE FROM gateway.marketplace_categories WHERE name IN ('mkt-分类','mkt-技能分类')`, `DELETE FROM gateway.portal_users WHERE id='` + portalUserID + `'`, } { if _, err := pool.Exec(ctx, q); err != nil { t.Fatalf("cleanup: %v", err) } } } cleanup() defer cleanup() // Inserts must come after the initial cleanup, which would otherwise // delete the portal user we just created (it is referenced by the // marketplace_installations FK). if _, err := pool.Exec(ctx, `INSERT INTO gateway.admin_accounts(id,username,password_hash,role) VALUES($1,'market-test','test','superadmin') ON CONFLICT(id) DO NOTHING`, actorID); err != nil { t.Fatal(err) } if _, err := pool.Exec(ctx, `INSERT INTO gateway.portal_users(id,account,name) VALUES($1,'market-user','市场测试') ON CONFLICT(id) DO NOTHING`, portalUserID); err != nil { t.Fatal(err) } assets := NewService(pool) key := base64.StdEncoding.EncodeToString(make([]byte, 32)) cipher, err := cryptox.NewKeyring(key, 1, "", "mkt-test-headers") if err != nil { t.Fatal(err) } mcpSvc := NewMCPServerService(assets, cipher, true) toolSvc := NewToolService(assets, cipher, true) skillSvc := NewSkillService(assets) deSvc := NewDigitalEmployeeService(assets, skillSvc, toolSvc, mcpSvc) market := NewMarketplaceService(assets, mcpSvc, skillSvc, deSvc) globalCat, err := market.CreateCategory(ctx, Category{Name: "mkt-分类", Description: "全局", ResourceType: ""}, actorID) if err != nil { t.Fatal(err) } skillCat, err := market.CreateCategory(ctx, Category{Name: "mkt-技能分类", Description: "技能专用", ResourceType: "skill"}, actorID) if err != nil { t.Fatal(err) } // A skill category must reject binding to an MCP server. if _, err := market.CreateCategory(ctx, Category{Name: "mkt-冲突分类", Description: "", ResourceType: "mcp_server"}, actorID); err != nil { t.Fatalf("mcp_server category should be creatable: %v", err) } cleanupConflict := func() { _, _ = pool.Exec(ctx, `DELETE FROM gateway.marketplace_categories WHERE name='mkt-冲突分类'`) } cleanupConflict() defer cleanupConflict() mcp, err := mcpSvc.Save(ctx, "", MCPServerInput{ Code: "mkt_mcp", Name: "测试 MCP", Transport: "streamable-http", EndpointURL: "https://mcp.example.com/mcp", Status: "published", CategoryID: &globalCat.ID, Enabled: true, Headers: map[string]string{"Authorization": "Bearer test"}, }, actorID, true) if err != nil { t.Fatal(err) } skill, err := skillSvc.Save(ctx, "", SkillInput{ Code: "mkt_skill", Name: "测试技能", Content: "你是客服,请回答 {{question}}", Variables: []Variable{{Name: "question", Required: true}}, Status: "published", CategoryID: &skillCat.ID, Enabled: true, MCPServerIDs: []string{mcp.ID}, }, actorID, true) if err != nil { t.Fatal(err) } rendered, err := skillSvc.Render(skill, map[string]any{"question": "你好"}) if err != nil || rendered != "你是客服,请回答 你好" { t.Fatalf("render=%q err=%v", rendered, err) } employee, err := deSvc.Save(ctx, "", DigitalEmployeeInput{ Code: "mkt_employee", Name: "客服机器人", Persona: "你是一位耐心的客服", Model: "deepseek-chat", SkillIDs: []string{skill.ID}, Status: "draft", Enabled: true, }, actorID, true) if err != nil { t.Fatal(err) } if employee.Status != "draft" { t.Fatalf("expected draft, got %s", employee.Status) } published, err := deSvc.Publish(ctx, employee.ID, actorID) if err != nil { t.Fatalf("publish failed: %v", err) } if published.Status != "published" { t.Fatalf("expected published, got %s", published.Status) } // Unified catalog merges all three published resource types. items, err := market.Catalog(ctx, "", "", "", "", 50) if err != nil { t.Fatal(err) } found := map[string]bool{} for _, item := range items { found[item.Code] = true } for _, code := range []string{"mkt_mcp", "mkt_skill", "mkt_employee"} { if !found[code] { t.Fatalf("catalog missing %s (got %#v)", code, found) } } // Category filter narrows to the skill category. skillItems, err := market.Catalog(ctx, "skill", skillCat.ID, "", "", 50) if err != nil || len(skillItems) != 1 || skillItems[0].Code != "mkt_skill" { t.Fatalf("category filter failed: %#v err=%v", skillItems, err) } detailItem, raw, err := market.Detail(ctx, "digital_employee", "mkt_employee") if err != nil || detailItem.Code != "mkt_employee" || len(raw) == 0 { t.Fatalf("detail=%#v err=%v", detailItem, err) } // Install/uninstall is idempotent and gated by published status. created, err := market.Install(ctx, "skill", "mkt_skill", portalUserID, "use") if err != nil || !created { t.Fatalf("install created=%v err=%v", created, err) } installed, err := market.Installed(ctx, "skill", skill.ID, portalUserID) if err != nil || !installed { t.Fatalf("installed=%v err=%v", installed, err) } createdAgain, err := market.Install(ctx, "skill", "mkt_skill", portalUserID, "use") if err != nil || createdAgain { t.Fatalf("re-install should be a no-op: created=%v err=%v", createdAgain, err) } installedList, err := market.ListInstalled(ctx, portalUserID) if err != nil || len(installedList) != 1 || installedList[0].Code != "mkt_skill" { t.Fatalf("installed list=%#v err=%v", installedList, err) } if err := market.Uninstall(ctx, "skill", "mkt_skill", portalUserID); err != nil { t.Fatal(err) } installed, err = market.Installed(ctx, "skill", skill.ID, portalUserID) if err != nil || installed { t.Fatalf("expected uninstalled, got installed=%v err=%v", installed, err) } // Runs mirroring: publishing a digital employee whose skill is bound to an // MCP server must still revalidate cleanly (strict bindings). if _, err := deSvc.Publish(ctx, employee.ID, actorID); err != nil { t.Fatalf("re-publish after install should pass: %v", err) } }