iop/apps/edge/internal/input/a2a/server_test.go
toki b4124f0bd6 feat: CLI setup, edge/node transport refactor, and infrastructure updates
- Add CLI core setup for edge and node services
- Refactor edge transport layer (server, integration tests)
- Refactor node transport layer (parser, session, heartbeat, client)
- Add main_test.go files for edge and node commands
- Add input package for edge service
- Add go.work and go.work.sum for workspace support
- Update configs, docs, and project rules
2026-05-20 16:37:42 +09:00

376 lines
11 KiB
Go

package a2a_test
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"go.uber.org/zap"
"iop/apps/edge/internal/input/a2a"
edgeservice "iop/apps/edge/internal/service"
"iop/packages/config"
iop "iop/proto/gen/iop"
)
// fakeService is a minimal runService implementation for testing.
type fakeService struct {
submitFn func(context.Context, edgeservice.SubmitRunRequest) (*edgeservice.RunHandle, error)
cancelFn func(context.Context, edgeservice.CancelRunRequest) (edgeservice.CommandResult, error)
}
func (f *fakeService) SubmitRun(ctx context.Context, req edgeservice.SubmitRunRequest) (*edgeservice.RunHandle, error) {
if f.submitFn != nil {
return f.submitFn(ctx, req)
}
return nil, nil
}
func (f *fakeService) CancelRun(ctx context.Context, req edgeservice.CancelRunRequest) (edgeservice.CommandResult, error) {
if f.cancelFn != nil {
return f.cancelFn(ctx, req)
}
return edgeservice.CommandResult{}, nil
}
// newTestServer builds a disabled-by-default a2a.Server for handler-level testing.
// We call the handlers directly via httptest rather than starting a real listener.
func newTestServer(cfg config.EdgeA2AConf, svc runServiceIface) *a2a.Server {
return a2a.NewServer(cfg, svc, zap.NewNop())
}
type runServiceIface interface {
SubmitRun(context.Context, edgeservice.SubmitRunRequest) (*edgeservice.RunHandle, error)
CancelRun(context.Context, edgeservice.CancelRunRequest) (edgeservice.CommandResult, error)
}
func rpcPost(t *testing.T, handler http.Handler, body any) *httptest.ResponseRecorder {
t.Helper()
b, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal body: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/a2a", bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
return w
}
func decodeRPCResponse(t *testing.T, w *httptest.ResponseRecorder) a2a.JSONRPCResponse {
t.Helper()
var resp a2a.JSONRPCResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
return resp
}
func TestHandleMessageSendDispatchesRun(t *testing.T) {
dispatched := false
svc := &fakeService{
submitFn: func(_ context.Context, req edgeservice.SubmitRunRequest) (*edgeservice.RunHandle, error) {
dispatched = true
if req.Metadata["source"] != "a2a" {
t.Errorf("expected source=a2a, got %q", req.Metadata["source"])
}
return feedHandle("run-1", &iop.RunEvent{RunId: "run-1", Type: "complete"}), nil
},
}
cfg := config.EdgeA2AConf{Enabled: true, Listen: "127.0.0.1:0", Path: "/a2a", TimeoutSec: 5}
srv := a2a.NewServer(cfg, svc, zap.NewNop())
mux := http.NewServeMux()
mux.HandleFunc("/a2a", srv.RPCHandlerForTest())
sendBody := a2a.JSONRPCRequest{
JSONRPC: "2.0",
Method: "message/send",
Params: mustMarshal(t, a2a.MessageSendParams{
Message: a2a.Message{
Role: "user",
Parts: []a2a.Part{{Type: "text", Text: "hi"}},
},
Configuration: &a2a.SendConfig{Blocking: boolPtr(true)},
}),
ID: 1,
}
w := rpcPost(t, mux, sendBody)
if w.Code != http.StatusOK {
t.Fatalf("message/send status: %d body: %s", w.Code, w.Body.String())
}
resp := decodeRPCResponse(t, w)
if resp.Error != nil {
t.Fatalf("message/send error: %+v", resp.Error)
}
if !dispatched {
t.Error("SubmitRun was not called")
}
}
func TestHandleGetTaskReturnsStoredTask(t *testing.T) {
runID := "run-get-1"
svc := &fakeService{
submitFn: func(_ context.Context, _ edgeservice.SubmitRunRequest) (*edgeservice.RunHandle, error) {
return feedHandle(runID,
&iop.RunEvent{RunId: runID, Type: "delta", Delta: "result text"},
&iop.RunEvent{RunId: runID, Type: "complete"},
), nil
},
}
cfg := config.EdgeA2AConf{Enabled: false, Path: "/a2a", TimeoutSec: 5}
srv := a2a.NewServer(cfg, svc, zap.NewNop())
// Inject a task by calling message/send handler directly via httptest.
sendBody := a2a.JSONRPCRequest{
JSONRPC: "2.0",
Method: "message/send",
Params: mustMarshal(t, a2a.MessageSendParams{
Message: a2a.Message{
Role: "user",
Parts: []a2a.Part{{Type: "text", Text: "hello"}},
},
Configuration: &a2a.SendConfig{Blocking: boolPtr(true)},
}),
ID: 1,
}
mux := http.NewServeMux()
mux.HandleFunc("/a2a", srv.RPCHandlerForTest())
w := rpcPost(t, mux, sendBody)
if w.Code != http.StatusOK {
t.Fatalf("message/send status: %d body: %s", w.Code, w.Body.String())
}
sendResp := decodeRPCResponse(t, w)
if sendResp.Error != nil {
t.Fatalf("message/send error: %+v", sendResp.Error)
}
// Extract task ID from result.
taskMap, ok := sendResp.Result.(map[string]any)
if !ok {
t.Fatalf("expected map result, got %T", sendResp.Result)
}
taskID, _ := taskMap["id"].(string)
if taskID == "" {
t.Fatal("task id is empty")
}
// Now tasks/get.
getBody := a2a.JSONRPCRequest{
JSONRPC: "2.0",
Method: "tasks/get",
Params: mustMarshal(t, a2a.TaskQueryParams{ID: taskID}),
ID: 2,
}
w2 := rpcPost(t, mux, getBody)
if w2.Code != http.StatusOK {
t.Fatalf("tasks/get status: %d", w2.Code)
}
getResp := decodeRPCResponse(t, w2)
if getResp.Error != nil {
t.Fatalf("tasks/get error: %+v", getResp.Error)
}
taskMap2, _ := getResp.Result.(map[string]any)
status, _ := taskMap2["status"].(map[string]any)
state, _ := status["state"].(string)
if state != "completed" {
t.Errorf("expected completed, got %q", state)
}
}
func TestHandleCancelTask(t *testing.T) {
runID := "run-cancel-1"
cancelCalled := false
svc := &fakeService{
submitFn: func(_ context.Context, _ edgeservice.SubmitRunRequest) (*edgeservice.RunHandle, error) {
// Return a handle that stays working (never completes) for non-blocking.
events := make(chan *iop.RunEvent, 1)
nodeEvents := make(chan *iop.EdgeNodeEvent, 1)
return &edgeservice.RunHandle{
RunDispatch: edgeservice.RunDispatch{RunID: runID, TimeoutSec: 60},
RunStream: edgeservice.RunStream{Events: events, NodeEvents: nodeEvents},
}, nil
},
cancelFn: func(_ context.Context, req edgeservice.CancelRunRequest) (edgeservice.CommandResult, error) {
cancelCalled = true
if req.RunID != runID {
t.Errorf("expected RunID=%q, got %q", runID, req.RunID)
}
return edgeservice.CommandResult{}, nil
},
}
cfg := config.EdgeA2AConf{Enabled: false, Path: "/a2a", TimeoutSec: 60}
srv := a2a.NewServer(cfg, svc, zap.NewNop())
mux := http.NewServeMux()
mux.HandleFunc("/a2a", srv.RPCHandlerForTest())
// Submit non-blocking so task stays in working state.
sendBody := a2a.JSONRPCRequest{
JSONRPC: "2.0",
Method: "message/send",
Params: mustMarshal(t, a2a.MessageSendParams{
Message: a2a.Message{
Role: "user",
Parts: []a2a.Part{{Type: "text", Text: "work"}},
},
Configuration: &a2a.SendConfig{Blocking: boolPtr(false)},
}),
ID: 1,
}
w := rpcPost(t, mux, sendBody)
if w.Code != http.StatusOK {
t.Fatalf("message/send: %d %s", w.Code, w.Body.String())
}
sendResp := decodeRPCResponse(t, w)
if sendResp.Error != nil {
t.Fatalf("message/send error: %+v", sendResp.Error)
}
taskMap, _ := sendResp.Result.(map[string]any)
taskID, _ := taskMap["id"].(string)
// Cancel it.
cancelBody := a2a.JSONRPCRequest{
JSONRPC: "2.0",
Method: "tasks/cancel",
Params: mustMarshal(t, a2a.TaskIDParams{ID: taskID}),
ID: 2,
}
w2 := rpcPost(t, mux, cancelBody)
if w2.Code != http.StatusOK {
t.Fatalf("tasks/cancel: %d %s", w2.Code, w2.Body.String())
}
cancelResp := decodeRPCResponse(t, w2)
if cancelResp.Error != nil {
t.Fatalf("tasks/cancel error: %+v", cancelResp.Error)
}
if !cancelCalled {
t.Error("CancelRun was not called")
}
}
func TestRejectsBadAuth(t *testing.T) {
svc := &fakeService{}
cfg := config.EdgeA2AConf{Enabled: false, Path: "/a2a", BearerToken: "secret"}
srv := a2a.NewServer(cfg, svc, zap.NewNop())
mux := http.NewServeMux()
mux.HandleFunc("/a2a", srv.RPCHandlerForTest())
body := a2a.JSONRPCRequest{JSONRPC: "2.0", Method: "message/send", ID: 1}
b, _ := json.Marshal(body)
req := httptest.NewRequest(http.MethodPost, "/a2a", bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer wrong")
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", w.Code)
}
}
func TestRejectsUnknownMethod(t *testing.T) {
svc := &fakeService{}
cfg := config.EdgeA2AConf{Enabled: false, Path: "/a2a"}
srv := a2a.NewServer(cfg, svc, zap.NewNop())
mux := http.NewServeMux()
mux.HandleFunc("/a2a", srv.RPCHandlerForTest())
body := a2a.JSONRPCRequest{JSONRPC: "2.0", Method: "unknown/method", ID: 1}
w := rpcPost(t, mux, body)
resp := decodeRPCResponse(t, w)
if resp.Error == nil {
t.Fatal("expected error for unknown method")
}
if resp.Error.Code != a2a.ErrCodeMethodNotFound {
t.Errorf("expected code %d, got %d", a2a.ErrCodeMethodNotFound, resp.Error.Code)
}
}
// TestBlockingDefault verifies the four cases for the blocking field:
// - no configuration → blocking true
// - configuration: {} (empty object, Blocking nil) → blocking true
// - configuration: {"blocking": true} → blocking true
// - configuration: {"blocking": false} → blocking false
func TestBlockingDefault(t *testing.T) {
cases := []struct {
name string
cfg *a2a.SendConfig
wantBlock bool
}{
{"no configuration", nil, true},
{"empty configuration", &a2a.SendConfig{}, true},
{"explicit true", &a2a.SendConfig{Blocking: boolPtr(true)}, true},
{"explicit false", &a2a.SendConfig{Blocking: boolPtr(false)}, false},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
events := make(chan *iop.RunEvent, 2)
nodeEvents := make(chan *iop.EdgeNodeEvent, 1)
var dispatched bool
svc := &fakeService{
submitFn: func(_ context.Context, req edgeservice.SubmitRunRequest) (*edgeservice.RunHandle, error) {
dispatched = true
got := req.Metadata["blocking"]
want := "true"
if !tc.wantBlock {
want = "false"
}
if got != want {
t.Errorf("blocking metadata: got %q want %q", got, want)
}
if tc.wantBlock {
events <- &iop.RunEvent{RunId: "r", Type: "complete"}
}
return &edgeservice.RunHandle{
RunDispatch: edgeservice.RunDispatch{RunID: "r", TimeoutSec: 5},
RunStream: edgeservice.RunStream{Events: events, NodeEvents: nodeEvents},
}, nil
},
}
cfg := config.EdgeA2AConf{Enabled: false, Path: "/a2a", TimeoutSec: 5}
srv := a2a.NewServer(cfg, svc, zap.NewNop())
mux := http.NewServeMux()
mux.HandleFunc("/a2a", srv.RPCHandlerForTest())
body := a2a.JSONRPCRequest{
JSONRPC: "2.0",
Method: "message/send",
Params: mustMarshal(t, a2a.MessageSendParams{
Message: a2a.Message{Role: "user", Parts: []a2a.Part{{Type: "text", Text: "hi"}}},
Configuration: tc.cfg,
}),
ID: 1,
}
w := rpcPost(t, mux, body)
if w.Code != http.StatusOK {
t.Fatalf("status: %d body: %s", w.Code, w.Body.String())
}
resp := decodeRPCResponse(t, w)
if resp.Error != nil {
t.Fatalf("rpc error: %+v", resp.Error)
}
if !dispatched {
t.Error("SubmitRun was not called")
}
})
}
}
func mustMarshal(t *testing.T, v any) json.RawMessage {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatalf("marshal: %v", err)
}
return b
}
func boolPtr(b bool) *bool { return &b }