Files
ai-gateway-go/internal/workbench/marketplace_integration_test.go
T
superidou 5759c1862e AI Gateway Go 0.10.0 源码快照 + 旗舰版需求规划报告
M0-M7 已完成:核心网关(身份/RBAC/TOTP/OIDC/SAML/Provider/配额/路由/内容策略/审计/定价)+ 资源市场(MCP/Skills/数字员工)。
含 22 个 PostgreSQL 迁移、管理端/门户端前端源码、OpenAPI 契约、部署 compose。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-12 11:45:54 +08:00

180 lines
6.8 KiB
Go

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)
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)
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)
}
}