mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-10-15 20:19:39 +08:00
226 lines
7.0 KiB
Go
226 lines
7.0 KiB
Go
package pluginhost
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"testing"
|
|
|
|
"github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginabi"
|
|
"github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi"
|
|
)
|
|
|
|
type staticEnvelopePluginClient struct {
|
|
raw []byte
|
|
}
|
|
|
|
func (c staticEnvelopePluginClient) Call(context.Context, string, []byte) ([]byte, error) {
|
|
return c.raw, nil
|
|
}
|
|
|
|
func (c staticEnvelopePluginClient) Shutdown() {}
|
|
|
|
func TestDecodeEnvelopeResultPreservesPluginHTTPStatus(t *testing.T) {
|
|
_, errDecode := decodeEnvelopeResult[rpcEmptyResponse](pluginabi.Envelope{
|
|
OK: false,
|
|
Error: &pluginabi.Error{
|
|
Code: "plugin_error",
|
|
Message: "license required",
|
|
HTTPStatus: http.StatusForbidden,
|
|
},
|
|
})
|
|
if errDecode == nil {
|
|
t.Fatal("decodeEnvelopeResult returned nil error")
|
|
}
|
|
if got := errDecode.Error(); got != "license required" {
|
|
t.Fatalf("error = %q, want license required", got)
|
|
}
|
|
statusProvider, ok := errDecode.(interface{ StatusCode() int })
|
|
if !ok {
|
|
t.Fatalf("error %T does not expose StatusCode", errDecode)
|
|
}
|
|
if got := statusProvider.StatusCode(); got != http.StatusForbidden {
|
|
t.Fatalf("status = %d, want %d", got, http.StatusForbidden)
|
|
}
|
|
}
|
|
|
|
func TestCallPluginReturnsPluginErrorWithoutMethodWrapper(t *testing.T) {
|
|
raw, errMarshal := json.Marshal(pluginabi.Envelope{
|
|
OK: false,
|
|
Error: &pluginabi.Error{
|
|
Code: "plugin_error",
|
|
Message: "license required",
|
|
HTTPStatus: http.StatusForbidden,
|
|
},
|
|
})
|
|
if errMarshal != nil {
|
|
t.Fatalf("marshal envelope: %v", errMarshal)
|
|
}
|
|
_, errCall := callPlugin[rpcEmptyResponse](context.Background(), staticEnvelopePluginClient{raw: raw}, pluginabi.MethodExecutorExecuteStream, rpcEmptyResponse{})
|
|
if errCall == nil {
|
|
t.Fatal("callPlugin returned nil error")
|
|
}
|
|
if got := errCall.Error(); got != "license required" {
|
|
t.Fatalf("error = %q, want license required", got)
|
|
}
|
|
statusProvider, ok := errCall.(interface{ StatusCode() int })
|
|
if !ok {
|
|
t.Fatalf("error %T does not expose StatusCode", errCall)
|
|
}
|
|
if got := statusProvider.StatusCode(); got != http.StatusForbidden {
|
|
t.Fatalf("status = %d, want %d", got, http.StatusForbidden)
|
|
}
|
|
}
|
|
|
|
func TestIsPluginErrorEnvelopeAcceptsNonzeroReturnEnvelope(t *testing.T) {
|
|
raw := marshalRPCError("plugin_error", "upstream failed")
|
|
if !isPluginErrorEnvelope(raw) {
|
|
t.Fatalf("isPluginErrorEnvelope(%s) = false, want true", raw)
|
|
}
|
|
if isPluginErrorEnvelope([]byte(`not json`)) {
|
|
t.Fatal("isPluginErrorEnvelope accepted invalid JSON")
|
|
}
|
|
}
|
|
|
|
func TestCallPluginPreservesStatusFromNewErrorEnvelope(t *testing.T) {
|
|
raw, errMarshal := pluginabi.NewErrorEnvelope("insufficient_quota", "plan limit reached", http.StatusForbidden)
|
|
if errMarshal != nil {
|
|
t.Fatalf("NewErrorEnvelope() error = %v", errMarshal)
|
|
}
|
|
_, errCall := callPlugin[rpcEmptyResponse](context.Background(), staticEnvelopePluginClient{raw: raw}, pluginabi.MethodExecutorExecute, rpcEmptyResponse{})
|
|
if errCall == nil {
|
|
t.Fatal("callPlugin returned nil error")
|
|
}
|
|
if got := errCall.Error(); got != "plan limit reached" {
|
|
t.Fatalf("error = %q, want plan limit reached", got)
|
|
}
|
|
statusProvider, ok := errCall.(interface{ StatusCode() int })
|
|
if !ok {
|
|
t.Fatalf("error %T does not expose StatusCode", errCall)
|
|
}
|
|
if got := statusProvider.StatusCode(); got != http.StatusForbidden {
|
|
t.Fatalf("status = %d, want %d", got, http.StatusForbidden)
|
|
}
|
|
}
|
|
|
|
func TestMarshalRPCErrorPreservesHTTPStatus(t *testing.T) {
|
|
for _, status := range []int{http.StatusTooManyRequests, http.StatusServiceUnavailable} {
|
|
raw := marshalRPCError("host_call_failed", "synthetic", status)
|
|
var env pluginabi.Envelope
|
|
if errUnmarshal := json.Unmarshal(raw, &env); errUnmarshal != nil {
|
|
t.Fatalf("unmarshal envelope: %v", errUnmarshal)
|
|
}
|
|
if env.OK {
|
|
t.Fatal("expected envelope OK=false")
|
|
}
|
|
if env.Error == nil {
|
|
t.Fatal("expected non-nil Error in envelope")
|
|
}
|
|
if env.Error.HTTPStatus != status {
|
|
t.Fatalf("HTTPStatus = %d, want %d", env.Error.HTTPStatus, status)
|
|
}
|
|
_, errDecode := decodeEnvelopeResult[rpcEmptyResponse](env)
|
|
if errDecode == nil {
|
|
t.Fatal("expected decode error")
|
|
}
|
|
statusProvider, ok := errDecode.(interface{ StatusCode() int })
|
|
if !ok {
|
|
t.Fatalf("decoded error does not expose StatusCode: %T", errDecode)
|
|
}
|
|
if got := statusProvider.StatusCode(); got != status {
|
|
t.Fatalf("StatusCode = %d, want %d", got, status)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCallPluginSchedulerPickCompatibility(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
resultJSON string
|
|
wantAuthID string
|
|
wantDelegate string
|
|
wantHandled bool
|
|
wantReject bool
|
|
wantCode string
|
|
wantReason string
|
|
}{
|
|
{
|
|
name: "legacy pascal case auth id",
|
|
resultJSON: `{"AuthID":"auth-1","DelegateBuiltin":"","Handled":true}`,
|
|
wantAuthID: "auth-1",
|
|
wantDelegate: "",
|
|
wantHandled: true,
|
|
wantReject: false,
|
|
},
|
|
{
|
|
name: "legacy pascal case delegate",
|
|
resultJSON: `{"AuthID":"","DelegateBuiltin":"round-robin","Handled":true}`,
|
|
wantAuthID: "",
|
|
wantDelegate: "round-robin",
|
|
wantHandled: true,
|
|
wantReject: false,
|
|
},
|
|
{
|
|
name: "snake case auth id",
|
|
resultJSON: `{"auth_id":"auth-2","delegate_builtin":"","handled":true}`,
|
|
wantAuthID: "auth-2",
|
|
wantDelegate: "",
|
|
wantHandled: true,
|
|
wantReject: false,
|
|
},
|
|
{
|
|
name: "snake case delegate",
|
|
resultJSON: `{"auth_id":"","delegate_builtin":"fill-first","handled":true}`,
|
|
wantAuthID: "",
|
|
wantDelegate: "fill-first",
|
|
wantHandled: true,
|
|
wantReject: false,
|
|
},
|
|
{
|
|
name: "snake case terminal rejection",
|
|
resultJSON: `{"handled":true,"reject":true,"reject_code":"quota_exceeded","reject_reason":"quota exhausted"}`,
|
|
wantHandled: true,
|
|
wantReject: true,
|
|
wantCode: "quota_exceeded",
|
|
wantReason: "quota exhausted",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
env := pluginabi.Envelope{
|
|
OK: true,
|
|
Result: json.RawMessage(tc.resultJSON),
|
|
}
|
|
raw, errMarshal := json.Marshal(env)
|
|
if errMarshal != nil {
|
|
t.Fatalf("marshal envelope: %v", errMarshal)
|
|
}
|
|
|
|
client := staticEnvelopePluginClient{raw: raw}
|
|
resp, errCall := callPlugin[pluginapi.SchedulerPickResponse](context.Background(), client, pluginabi.MethodSchedulerPick, pluginapi.SchedulerPickRequest{})
|
|
if errCall != nil {
|
|
t.Fatalf("callPlugin() error = %v", errCall)
|
|
}
|
|
if resp.AuthID != tc.wantAuthID {
|
|
t.Fatalf("AuthID = %q, want %q", resp.AuthID, tc.wantAuthID)
|
|
}
|
|
if resp.DelegateBuiltin != tc.wantDelegate {
|
|
t.Fatalf("DelegateBuiltin = %q, want %q", resp.DelegateBuiltin, tc.wantDelegate)
|
|
}
|
|
if resp.Handled != tc.wantHandled {
|
|
t.Fatalf("Handled = %v, want %v", resp.Handled, tc.wantHandled)
|
|
}
|
|
if resp.Reject != tc.wantReject {
|
|
t.Fatalf("Reject = %v, want %v", resp.Reject, tc.wantReject)
|
|
}
|
|
if resp.RejectCode != tc.wantCode {
|
|
t.Fatalf("RejectCode = %q, want %q", resp.RejectCode, tc.wantCode)
|
|
}
|
|
if resp.RejectReason != tc.wantReason {
|
|
t.Fatalf("RejectReason = %q, want %q", resp.RejectReason, tc.wantReason)
|
|
}
|
|
})
|
|
}
|
|
}
|