package e2e import ( "encoding/json" "net/http" "os" "path/filepath" "strings" "testing" ) // writeE2EPlugin 落一个插件文件到 e2e 的 plugin_dir(SeedBundled 见空目录才 // 落地内置插件,所以先放自定义插件再启动网关)。 func writeE2EPlugin(t *testing.T, dir, name, code string) { t.Helper() p := filepath.Join(dir, "plugins", name) if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(p, []byte(code), 0o644); err != nil { t.Fatal(err) } } // e2eConfig 返回一份最小 e2e 配置文本(含 plugin_dir)。 func e2eConfig(dir, listen string) string { return "listen: " + listen + "\n" + "gateway_keys:\n - sk-e2e-0001\n" + "default_model: AUTO\n" + "adapter_dir: " + filepath.Join(dir, "adapters") + "\n" + "runtime_file: " + filepath.Join(dir, "runtime.json") + "\n" + "plugin_dir: " + filepath.Join(dir, "plugins") + "\n" + "sources: []\n" } // TestConflictArbitrationE2E(用户要求:服务路径冲突不崩溃不拒启 + WebAPI 裁决) // // 真二进制全链路: // 1. 两个插件声明同一条 /api/plugins/http/ 路径 → 两插件都 loaded,启动正常 // 2. 请求该路径 → 409,错误信息点名两个候选插件(不静默选边) // 3. GET /api/plugins/conflicts → 列出冲突(路径 + 候选 + 未裁决) // 4. POST /api/plugins/conflicts 裁决 → 持久化 // 5. 再请求该路径 → 200,赢家应答({"from":"B"}) // 6. /v1/models 等既有路由全程不受影响(旧通路无回归) func TestConflictArbitrationE2E(t *testing.T) { bin := buildBinary(t) dir := t.TempDir() addr := mustAddr(t) writeE2EPlugin(t, dir, "plugA.lua", ` local P = {} P.name = "plugA" P.version = "1" P.exports = {} P.http = { { path = "contested", method = "POST", handler = "h", roles = { "admin" } } } function P.h(req) return { status = 200, json = { from = "A" } } end return P `) writeE2EPlugin(t, dir, "plugB.lua", ` local P = {} P.name = "plugB" P.version = "1" P.exports = {} P.http = { { path = "contested", method = "POST", handler = "h", roles = { "admin" } } } function P.h(req) return { status = 200, json = { from = "B" } } end return P `) cfgPath := filepath.Join(dir, "config.yaml") if err := os.WriteFile(cfgPath, []byte(e2eConfig(dir, addr)), 0o644); err != nil { t.Fatal(err) } g := startGateway(t, bin, addr, cfgPath) // ① 两个插件都加载成功(冲突不拒载) resp, body := g.do(http.MethodGet, "/api/plugins", "", true) if resp.StatusCode != 200 { t.Fatalf("GET /api/plugins: %d %s", resp.StatusCode, body) } var list struct { Plugins []struct { Name string `json:"name"` Loaded bool `json:"loaded"` } `json:"plugins"` } if err := json.Unmarshal([]byte(body), &list); err != nil { t.Fatal(err) } loaded := map[string]bool{} for _, p := range list.Plugins { loaded[p.Name] = p.Loaded } if !loaded["plugA"] || !loaded["plugB"] { t.Fatalf("冲突的两个插件都应 loaded: %v", loaded) } // ② 请求冲突路径 → 409 + 点名候选(不静默选边) resp, body = g.do(http.MethodPost, "/api/plugins/http/contested", "", true) if resp.StatusCode != http.StatusConflict { t.Fatalf("未裁决冲突路径应 409, 实得 %d (%s)", resp.StatusCode, body) } if !strings.Contains(body, "plugA") || !strings.Contains(body, "plugB") { t.Fatalf("409 应点名两个候选: %s", body) } // ③ 冲突列表可见 resp, body = g.do(http.MethodGet, "/api/plugins/conflicts", "", true) if resp.StatusCode != 200 { t.Fatalf("GET conflicts: %d %s", resp.StatusCode, body) } var cl struct { Routes []struct { Path string `json:"path"` Candidates []string `json:"candidates"` Resolved string `json:"resolved"` } `json:"routes"` } if err := json.Unmarshal([]byte(body), &cl); err != nil { t.Fatal(err) } if len(cl.Routes) != 1 || cl.Routes[0].Path != "contested" || cl.Routes[0].Resolved != "" { t.Fatalf("冲突形态不对: %+v", cl) } // ④ 裁决 plugB 赢 arbit, _ := json.Marshal(map[string]string{ "kind": "route", "path": "contested", "winner": "plugB", }) resp, body = g.do(http.MethodPost, "/api/plugins/conflicts", string(arbit), true) if resp.StatusCode != 200 { t.Fatalf("POST conflicts 裁决: %d %s", resp.StatusCode, body) } // ⑤ 再请求 → 200 且赢家应答 resp, body = g.do(http.MethodPost, "/api/plugins/http/contested", "", true) if resp.StatusCode != 200 { t.Fatalf("裁决后请求冲突路径应 200, 实得 %d (%s)", resp.StatusCode, body) } if !strings.Contains(body, `"from":"B"`) { t.Fatalf("赢家 plugB 应答, 实得 %s", body) } // ⑥ 旧通路无回归:/v1/models 正常 resp, _ = g.do(http.MethodGet, "/v1/models", "", true) if resp.StatusCode != 200 { t.Fatalf("旧通路 /v1/models 应 200, 实得 %d", resp.StatusCode) } } // TestAdapterConflictE2E:插件装与内置同名适配器 → 挂起(现任保留), // /api/plugins/conflicts 列出,裁决 candidate 后插件版生效。 func TestAdapterConflictE2E(t *testing.T) { bin := buildBinary(t) dir := t.TempDir() addr := mustAddr(t) writeE2EPlugin(t, dir, "shadow.lua", ` local P = {} P.name = "shadow" P.version = "1" P.exports = { "install" } function P.install(args) local src = 'local a={} a.name="openai" a.version="SHADOW_V1" a.endpoint="/x" return a' local name, err = __llmsproxy_adapters.install("openai", src) return { name = name, err = err } end return P `) cfgPath := filepath.Join(dir, "config.yaml") if err := os.WriteFile(cfgPath, []byte(e2eConfig(dir, addr)), 0o644); err != nil { t.Fatal(err) } g := startGateway(t, bin, addr, cfgPath) // ① 安装 → 返回 err(pending 语义),内置版本不变 resp, body := g.do(http.MethodPost, "/api/plugins/shadow/call/install", "{}", true) if resp.StatusCode != 200 { t.Fatalf("call install: %d %s", resp.StatusCode, body) } // Call 端点把插件返回值包在 {"result": …} 里;挂起语义的断言在 result 层做。 var wrapped struct { Result struct { Name *string `json:"name"` Err string `json:"err"` } `json:"result"` } if err := json.Unmarshal([]byte(body), &wrapped); err != nil { t.Fatal(err) } out := wrapped.Result if out.Name != nil { t.Fatalf("挂起时不得返回生效名: %s", body) } if !strings.Contains(out.Err, "openai") || !strings.Contains(out.Err, "conflict") { t.Fatalf("错误应点名冲突: %q", out.Err) } // ② 冲突列表可见 resp, body = g.do(http.MethodGet, "/api/plugins/conflicts", "", true) if resp.StatusCode != 200 { t.Fatalf("GET conflicts: %d %s", resp.StatusCode, body) } var cl struct { Adapters []struct { Name string `json:"name"` Candidate string `json:"candidate"` } `json:"adapters"` } if err := json.Unmarshal([]byte(body), &cl); err != nil { t.Fatal(err) } if len(cl.Adapters) != 1 || cl.Adapters[0].Name != "openai" || cl.Adapters[0].Candidate != "shadow" { t.Fatalf("冲突形态不对: %+v", cl) } // ③ 适配器列表里 openai 仍是内置版本(现任继续服务,不被静默覆盖) assertOpenAIVersion := func(want string, msg string) { t.Helper() resp, body := g.do(http.MethodGet, "/api/adapters", "", true) if resp.StatusCode != 200 { t.Fatalf("GET /api/adapters: %d %s", resp.StatusCode, body) } var al struct { Adapters []struct { Name string `json:"name"` Version string `json:"version"` } `json:"adapters"` } if err := json.Unmarshal([]byte(body), &al); err != nil { t.Fatal(err) } for _, a := range al.Adapters { if a.Name == "openai" { if a.Version != want { t.Fatalf("%s: openai 版本应 %q, 实得 %q", msg, want, a.Version) } return } } t.Fatalf("%s: openai 适配器不存在: %s", msg, body) } assertOpenAIVersion("2.0.0", "现任内置必须继续服务(不被静默覆盖)") // ④ 裁决 candidate → 插件版生效 arbit, _ := json.Marshal(map[string]string{ "kind": "adapter", "name": "openai", "winner": "candidate", }) if resp, body = g.do(http.MethodPost, "/api/plugins/conflicts", string(arbit), true); resp.StatusCode != 200 { t.Fatalf("裁决 candidate: %d %s", resp.StatusCode, body) } // ⑤ 适配器版本已切换 + 冲突清空 assertOpenAIVersion("SHADOW_V1", "裁决后插件版应生效") resp, body = g.do(http.MethodGet, "/api/plugins/conflicts", "", true) if resp.StatusCode != 200 { t.Fatal(resp.StatusCode) } if strings.Contains(body, `"name":"openai"`) && strings.Contains(body, `"adapters":[{`) { t.Fatalf("裁决后冲突应清除: %s", body) } }