package tools import ( "context" "encoding/json" "testing" "github.com/modelcontextprotocol/go-sdk/mcp" "isphere-ai-bridge/internal/isphere" ) func TestISphereReceiveMessagesToolReturnsSourceMessages(t *testing.T) { fake := &fakeReceiveMessagesSource{ result: isphere.ReceiveMessagesResult{Messages: []isphere.Message{{ ID: "msg-1", ConversationID: "sender@imopenfire1-lanzhou|receiver@imopenfire1-lanzhou", ConversationType: "chat", SenderID: "sender@imopenfire1-lanzhou", ReceiverID: "receiver@imopenfire1-lanzhou", Text: "redacted-report.docx", Timestamp: "1783423807000", Subject: "FILE_TRANSFER_CANCEL", ReceiptID: "receipt-1", Read: true, }}}, } session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) { RegisterISphereReadTools(server, fake) }) defer cleanup() listResult, err := session.ListTools(context.Background(), &mcp.ListToolsParams{}) if err != nil { t.Fatalf("list tools: %v", err) } gotNames := make([]string, 0, len(listResult.Tools)) for _, tool := range listResult.Tools { gotNames = append(gotNames, tool.Name) } assertSameStrings(t, gotNames, []string{ToolNameReceiveMessages}) callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{ Name: ToolNameReceiveMessages, Arguments: map[string]any{"conversation_id": "sender@imopenfire1-lanzhou|receiver@imopenfire1-lanzhou", "query": "REPORT", "since": "2026-07-07T11:30:00Z", "limit": 5}, }) if err != nil { t.Fatalf("call %s: %v", ToolNameReceiveMessages, err) } if callResult.IsError { t.Fatalf("call result is error: %+v", callResult) } if len(fake.queries) != 1 || fake.queries[0].Limit != 5 || fake.queries[0].ConversationID != "sender@imopenfire1-lanzhou|receiver@imopenfire1-lanzhou" || fake.queries[0].Query != "REPORT" || fake.queries[0].Since != "2026-07-07T11:30:00Z" { t.Fatalf("queries = %+v, want conversation_id/query/since/limit", fake.queries) } var decoded struct { OK bool `json:"ok"` NextCursor *string `json:"next_cursor"` Conversation *struct { ConversationID string `json:"conversation_id"` ConversationType string `json:"conversation_type"` DisplayName string `json:"display_name"` } `json:"conversation"` Messages []struct { ID string `json:"id"` MessageID string `json:"message_id"` ConversationID string `json:"conversation_id"` ConversationType string `json:"conversation_type"` SenderID string `json:"sender_id"` SenderName *string `json:"sender_name"` ReceiverID string `json:"receiver_id"` Text string `json:"text"` ContentType string `json:"content_type"` ContentText string `json:"content_text"` Attachments []struct { FileID string `json:"file_id"` FileName string `json:"file_name"` DownloadRef any `json:"download_ref"` } `json:"attachments"` Timestamp string `json:"timestamp"` CreatedAt *string `json:"created_at"` ReceivedAt any `json:"received_at"` Subject string `json:"subject"` ReceiptID string `json:"receipt_id"` Read bool `json:"read"` Source string `json:"source"` RawRef string `json:"raw_ref"` } `json:"messages"` Audit struct { Tool string `json:"tool"` Source string `json:"source"` ExecutionMode string `json:"execution_mode"` Result string `json:"result"` StartedAt string `json:"started_at"` FinishedAt string `json:"finished_at"` } `json:"audit"` } payload, err := json.Marshal(callResult.StructuredContent) if err != nil { t.Fatalf("marshal structured content: %v", err) } if err := json.Unmarshal(payload, &decoded); err != nil { t.Fatalf("decode structured content %s: %v", payload, err) } if len(decoded.Messages) != 1 { t.Fatalf("messages = %+v, want one", decoded.Messages) } if !decoded.OK || decoded.NextCursor != nil { t.Fatalf("ok/next_cursor = %v/%v", decoded.OK, decoded.NextCursor) } if decoded.Conversation == nil { t.Fatalf("conversation missing") } if decoded.Conversation.ConversationID != "sender@imopenfire1-lanzhou|receiver@imopenfire1-lanzhou" || decoded.Conversation.DisplayName == "" { t.Fatalf("unexpected conversation: %+v", decoded.Conversation) } msg := decoded.Messages[0] if msg.ID != "msg-1" || msg.MessageID != "msg-1" || msg.Text != "redacted-report.docx" || msg.ContentText != "redacted-report.docx" || msg.Subject != "FILE_TRANSFER_CANCEL" { t.Fatalf("unexpected message content: %+v", msg) } if msg.ContentType != "file" { t.Fatalf("content_type = %q, want file", msg.ContentType) } if len(msg.Attachments) != 1 || msg.Attachments[0].FileID != "msg-1:redacted-report.docx" || msg.Attachments[0].FileName != "redacted-report.docx" || msg.Attachments[0].DownloadRef != nil { t.Fatalf("attachments = %+v", msg.Attachments) } if msg.ConversationID != "sender@imopenfire1-lanzhou|receiver@imopenfire1-lanzhou" { t.Fatalf("conversation_id = %q", msg.ConversationID) } if msg.SenderID != "sender@imopenfire1-lanzhou" || msg.ReceiverID != "receiver@imopenfire1-lanzhou" { t.Fatalf("sender/receiver = %q/%q", msg.SenderID, msg.ReceiverID) } if msg.Timestamp != "1783423807000" || msg.CreatedAt == nil || *msg.CreatedAt != "2026-07-07T11:30:07Z" || msg.ReceiptID != "receipt-1" || !msg.Read { t.Fatalf("metadata = %+v", msg) } if msg.SenderName != nil || msg.ReceivedAt != nil || msg.Source != "local_readonly" || msg.RawRef != "packetlog_message" { t.Fatalf("contract metadata = %+v", msg) } if decoded.Audit.Tool != ToolNameReceiveMessages || decoded.Audit.Source != "local_readonly" || decoded.Audit.ExecutionMode != "read" || decoded.Audit.Result != "ok" || decoded.Audit.StartedAt == "" || decoded.Audit.FinishedAt == "" { t.Fatalf("unexpected audit: %+v", decoded.Audit) } } func TestISphereReceiveMessagesToolValidatesRemainingContractArgs(t *testing.T) { fake := &fakeReceiveMessagesSource{ result: isphere.ReceiveMessagesResult{Messages: []isphere.Message{{ ID: "msg-1", ConversationID: "sender@imopenfire1-lanzhou|receiver@imopenfire1-lanzhou", ConversationType: "chat", SenderID: "sender@imopenfire1-lanzhou", ReceiverID: "receiver@imopenfire1-lanzhou", Text: "redacted-report.docx", Timestamp: "1783423807000", Subject: "FILE_TRANSFER_CANCEL", }}}, } session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) { RegisterISphereReadTools(server, fake) }) defer cleanup() callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{ Name: ToolNameReceiveMessages, Arguments: map[string]any{ "include_attachment_metadata": false, "source_preference": "local_readonly", "preview": true, "cursor": "", "limit": 5, }, }) if err != nil { t.Fatalf("call with safe contract args: %v", err) } if callResult.IsError { t.Fatalf("call result is error: %+v", callResult) } var decoded struct { Messages []struct { Attachments []struct { FileID string `json:"file_id"` } `json:"attachments"` } `json:"messages"` } payload, err := json.Marshal(callResult.StructuredContent) if err != nil { t.Fatalf("marshal structured content: %v", err) } if err := json.Unmarshal(payload, &decoded); err != nil { t.Fatalf("decode structured content %s: %v", payload, err) } if len(decoded.Messages) != 1 || len(decoded.Messages[0].Attachments) != 0 { t.Fatalf("attachments = %+v, want suppressed", decoded.Messages) } bridgeResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{ Name: ToolNameReceiveMessages, Arguments: map[string]any{"source_preference": "bridge", "limit": 5}, }) if err == nil && !bridgeResult.IsError { t.Fatalf("bridge source_preference was accepted before connector exists") } cursorResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{ Name: ToolNameReceiveMessages, Arguments: map[string]any{"cursor": "next-page", "limit": 5}, }) if err == nil && !cursorResult.IsError { t.Fatalf("non-empty cursor was accepted before pagination exists") } } type fakeReceiveMessagesSource struct { queries []isphere.ReceiveMessagesQuery result isphere.ReceiveMessagesResult } func (f *fakeReceiveMessagesSource) ReceiveMessages(_ context.Context, query isphere.ReceiveMessagesQuery) (isphere.ReceiveMessagesResult, error) { f.queries = append(f.queries, query) return f.result, nil }