5759c1862e
M0-M7 已完成:核心网关(身份/RBAC/TOTP/OIDC/SAML/Provider/配额/路由/内容策略/审计/定价)+ 资源市场(MCP/Skills/数字员工)。 含 22 个 PostgreSQL 迁移、管理端/门户端前端源码、OpenAPI 契约、部署 compose。 Co-Authored-By: Claude <noreply@anthropic.com>
180 lines
6.8 KiB
Go
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)
|
|
}
|
|
}
|