diff --git a/Cargo.lock b/Cargo.lock index 804a4e4eb..4fb2ce65c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2689,6 +2689,7 @@ dependencies = [ "chrono", "hex", "nostr", + "schemars", "serde", "serde_json", "thiserror", diff --git a/TESTING.md b/TESTING.md index b25cddbc0..6954aeea4 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1,13 +1,13 @@ # Sprout Testing Guide -This guide enables an AI agent (the **operator**) to run the full Sprout test suite: automated `cargo test` suites and a three-agent multi-agent E2E run that exercises all 40 MCP tools against a live relay. +This guide enables an AI agent (the **operator**) to run the full Sprout test suite: automated `cargo test` suites and a three-agent multi-agent E2E run that exercises all 41 MCP tools against a live relay. ## Two Test Modes | Mode | What It Does | When to Use | |------|-------------|-------------| | **Automated** (`cargo test`) | Unit tests + REST/WebSocket/MCP integration tests | Fast CI check; verify no unit regressions | -| **Multi-Agent E2E** | Three agents (Alice, Bob, Charlie) run via `sprout-acp` harness, exercising all 40 MCP tools via real Nostr identities | Before merging relay/MCP/auth changes; full regression run; exploring new features | +| **Multi-Agent E2E** | Three agents (Alice, Bob, Charlie) run via `sprout-acp` harness, exercising all 41 MCP tools via real Nostr identities | Before merging relay/MCP/auth changes; full regression run; exploring new features | Run both modes for a complete regression check. Run automated-only for a fast sanity check. @@ -27,7 +27,7 @@ Run both modes for a complete regression check. Run automated-only for a fast sa - [3.7 Expected Results](#37-expected-results) 4. [Advanced: ACP Harness Scenarios](#4-advanced-acp-harness-scenarios) 5. [Workflow YAML Reference](#5-workflow-yaml-reference) -6. [The 40 MCP Tools](#6-the-40-mcp-tools) +6. [The 41 MCP Tools](#6-the-41-mcp-tools) 7. [Cleanup](#7-cleanup) 8. [Known Issues / Troubleshooting](#8-known-issues--troubleshooting) @@ -163,7 +163,7 @@ sleep 3 && curl -s http://localhost:3000/health Then run the integration suites: ```bash -# REST API integration tests (35 tests) +# REST API integration tests (40 tests) RELAY_URL=ws://localhost:3000 \ cargo test -p sprout-test-client --test e2e_rest_api -- --ignored @@ -171,7 +171,7 @@ RELAY_URL=ws://localhost:3000 \ RELAY_URL=ws://localhost:3000 \ cargo test -p sprout-test-client --test e2e_relay -- --ignored -# MCP server integration tests (10 tests) +# MCP server integration tests (14 tests) RELAY_URL=ws://localhost:3000 \ cargo test -p sprout-test-client --test e2e_mcp -- --ignored ``` @@ -179,12 +179,12 @@ RELAY_URL=ws://localhost:3000 \ ### Expected Results ``` -test result: ok. 35 passed; 0 failed; 0 ignored ← REST API +test result: ok. 40 passed; 0 failed; 0 ignored ← REST API test result: ok. 14 passed; 0 failed; 0 ignored ← relay -test result: ok. 10 passed; 0 failed; 0 ignored ← MCP +test result: ok. 14 passed; 0 failed; 0 ignored ← MCP ``` -All 59 integration tests pass (across the three suites above). An additional 7 workflow integration tests exist in `e2e_workflows.rs` — run them separately if workflow changes are involved. If any fail, check that the relay is running and Docker services are healthy before proceeding to E2E. +All 68 integration tests pass (across the three suites above). An additional 7 workflow integration tests exist in `e2e_workflows.rs` — run them separately if workflow changes are involved. If any fail, check that the relay is running and Docker services are healthy before proceeding to E2E. --- @@ -202,7 +202,7 @@ Operator (you) Sprout Relay ──WS (NIP-01)──► sprout-acp (harness) ──stdio (ACP)──► goose │ sprout-mcp-server - (40 MCP tools) + (41 MCP tools) │ Sprout Relay (send_message, etc.) @@ -440,7 +440,7 @@ mention "$GENERAL" "$ALICE_PUBKEY" \ ```bash mention "$GENERAL" "$ALICE_PUBKEY" \ - "Set your display name to 'Alice (Test Agent)'. Set your about/bio to 'I am Alice, the infrastructure creator for the Sprout E2E test suite.' Set your NIP-05 handle to 'alice@localhost' using set_profile. Then use get_presence to check your own presence status (pubkey: $ALICE_PUBKEY)." + "Set your display name to 'Alice (Test Agent)'. Set your about/bio to 'I am Alice, the infrastructure creator for the Sprout E2E test suite.' Set your NIP-05 handle to 'alice@localhost' using set_profile. Then use set_presence to set your status to 'online'. Finally, use get_presence to check your own presence status (pubkey: $ALICE_PUBKEY) and confirm it shows 'online'." ``` **A-6: Feed, search, and membership** @@ -502,7 +502,14 @@ mention "$GENERAL" "$BOB_PUBKEY" \ ```bash mention "$GENERAL" "$BOB_PUBKEY" \ - "Get the presence status for Alice (pubkey: $ALICE_PUBKEY). Get your own presence status. Report both." + "Use set_presence to set your status to 'away'. Then get the presence status for Alice (pubkey: $ALICE_PUBKEY) and yourself. Report both statuses — Alice should be 'online' (from A-5) and you should be 'away'." +``` + +**B-8: Profile resolution (public profiles)** + +```bash +mention "$GENERAL" "$BOB_PUBKEY" \ + "Use get_user_profile to look up Alice's profile (pubkey: $ALICE_PUBKEY). Report her display name and about text. Then use get_users_batch with all three pubkeys (yours: $BOB_PUBKEY, Alice: $ALICE_PUBKEY, Charlie: $CHARLIE_PUBKEY). Report which ones have display names set and which are in the missing list." ``` **B-8: Profile resolution (public profiles)** @@ -696,6 +703,7 @@ After all exercises complete, the following should be true: | Profile resolution | Bob can read Alice's profile via `get_user_profile`; `get_users_batch` returns Alice and Bob with display names, Charlie with null display name (all in profiles map) | | NIP-05 verification | Charlie queries `/.well-known/nostr.json?name=alice` and gets Alice's pubkey (Alice set `alice@localhost` in A-5) | | Profile edge cases | Charlie gets appropriate errors for invalid/unknown pubkeys | +| Presence | Alice is 'online' (A-5), Bob is 'away' (B-7); Charlie can read both via get_presence (C-7) | | Error handling | Charlie's C-1, C-2, C-5 exercises report correct errors | | charlie-lifecycle | Unarchived and final message sent successfully | @@ -1003,9 +1011,9 @@ steps: --- -## 6. The 40 MCP Tools +## 6. The 41 MCP Tools -The `sprout-mcp-server` exposes 40 tools covering the full Sprout feature surface. All are available to agents running via the `sprout-acp` harness. +The `sprout-mcp-server` exposes 41 tools covering the full Sprout feature surface. All are available to agents running via the `sprout-acp` harness. ### Channels (8) @@ -1096,6 +1104,7 @@ The `sprout-mcp-server` exposes 40 tools covering the full Sprout feature surfac | Tool | Description | |------|-------------| | `get_presence` | Bulk presence lookup by pubkey | +| `set_presence` | Set presence status (online/away/offline) with TTL | ### Members (3) @@ -1170,9 +1179,9 @@ rm -f /tmp/alice-keys.txt /tmp/agent-b-keys.txt /tmp/agent-c-keys.txt All automated tests pass as of 2026-03-11: -- ✅ 32/32 REST API integration tests -- ✅ 13/13 WebSocket relay integration tests -- ✅ 10/10 MCP server integration tests +- ✅ 40/40 REST API integration tests +- ✅ 14/14 WebSocket relay integration tests +- ✅ 14/14 MCP server integration tests - ✅ Multi-agent E2E (Alice/Bob/Charlie) via sprout-acp harness --- diff --git a/crates/sprout-core/Cargo.toml b/crates/sprout-core/Cargo.toml index 726c3b4e6..c0267e313 100644 --- a/crates/sprout-core/Cargo.toml +++ b/crates/sprout-core/Cargo.toml @@ -9,6 +9,7 @@ description = "Core types, event verification, and filter matching for Sprout" [features] test-utils = [] +mcp-schema = ["schemars"] [dependencies] nostr = { workspace = true } @@ -18,5 +19,6 @@ thiserror = { workspace = true } uuid = { workspace = true } chrono = { workspace = true } hex = { workspace = true } +schemars = { workspace = true, optional = true } # NO tokio, NO sqlx, NO redis, NO axum — zero I/O dependencies diff --git a/crates/sprout-core/src/lib.rs b/crates/sprout-core/src/lib.rs index 44f22fb38..2b03275f2 100644 --- a/crates/sprout-core/src/lib.rs +++ b/crates/sprout-core/src/lib.rs @@ -15,12 +15,15 @@ pub mod filter; pub mod kind; /// Network utilities — SSRF-safe IP classification. pub mod network; +/// Presence status types shared across crates. +pub mod presence; /// Schnorr signature and event ID verification. pub mod verification; pub use error::VerificationError; pub use event::StoredEvent; pub use nostr::{Event, EventId, Filter, Keys, Kind, PublicKey}; +pub use presence::PresenceStatus; pub use verification::verify_event; #[cfg(any(test, feature = "test-utils"))] diff --git a/crates/sprout-core/src/presence.rs b/crates/sprout-core/src/presence.rs new file mode 100644 index 000000000..801446856 --- /dev/null +++ b/crates/sprout-core/src/presence.rs @@ -0,0 +1,74 @@ +//! Presence status types shared across REST, MCP, and WebSocket surfaces. + +use serde::{Deserialize, Serialize}; + +/// Allowed presence statuses for the REST/MCP surface. +/// +/// The WebSocket path (kind:20001) accepts arbitrary status strings for +/// forward-compatibility; this enum is the curated set for structured APIs. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(feature = "mcp-schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "lowercase")] +pub enum PresenceStatus { + /// User is actively online. + Online, + /// User is away / idle. + Away, + /// User is offline; clears the presence entry. + Offline, +} + +impl PresenceStatus { + /// Returns the lowercase string representation stored in Redis. + pub fn as_str(&self) -> &'static str { + match self { + Self::Online => "online", + Self::Away => "away", + Self::Offline => "offline", + } + } +} + +impl std::fmt::Display for PresenceStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn serde_roundtrip() { + let online: PresenceStatus = serde_json::from_str(r#""online""#).unwrap(); + assert_eq!(online, PresenceStatus::Online); + assert_eq!(serde_json::to_string(&online).unwrap(), r#""online""#); + + let away: PresenceStatus = serde_json::from_str(r#""away""#).unwrap(); + assert_eq!(away, PresenceStatus::Away); + + let offline: PresenceStatus = serde_json::from_str(r#""offline""#).unwrap(); + assert_eq!(offline, PresenceStatus::Offline); + } + + #[test] + fn serde_rejects_unknown_variant() { + let result: Result = serde_json::from_str(r#""invisible""#); + assert!(result.is_err()); + } + + #[test] + fn as_str_matches_serde() { + assert_eq!(PresenceStatus::Online.as_str(), "online"); + assert_eq!(PresenceStatus::Away.as_str(), "away"); + assert_eq!(PresenceStatus::Offline.as_str(), "offline"); + } + + #[test] + fn display_matches_as_str() { + assert_eq!(format!("{}", PresenceStatus::Online), "online"); + assert_eq!(format!("{}", PresenceStatus::Away), "away"); + assert_eq!(format!("{}", PresenceStatus::Offline), "offline"); + } +} diff --git a/crates/sprout-mcp/Cargo.toml b/crates/sprout-mcp/Cargo.toml index 1f27ccd3b..21b38923d 100644 --- a/crates/sprout-mcp/Cargo.toml +++ b/crates/sprout-mcp/Cargo.toml @@ -13,7 +13,7 @@ path = "src/main.rs" [dependencies] # Sprout core types -sprout-core = { workspace = true } +sprout-core = { workspace = true, features = ["mcp-schema"] } # MCP SDK rmcp = { workspace = true } diff --git a/crates/sprout-mcp/src/relay_client.rs b/crates/sprout-mcp/src/relay_client.rs index 3ab3e4665..3db7f59a4 100644 --- a/crates/sprout-mcp/src/relay_client.rs +++ b/crates/sprout-mcp/src/relay_client.rs @@ -500,7 +500,9 @@ impl RelayClient { let url = format!("{}{}", self.relay_http_url(), path); let resp = self.apply_auth(self.http.get(&url)).send().await?; if !resp.status().is_success() { - return Err(anyhow::anyhow!("HTTP {}: {}", resp.status(), url)); + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(anyhow::anyhow!("{} {}: {}", status, url, body)); } Ok(resp.text().await?) } @@ -514,7 +516,9 @@ impl RelayClient { .send() .await?; if !resp.status().is_success() { - return Err(anyhow::anyhow!("HTTP {}: {}", resp.status(), url)); + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(anyhow::anyhow!("{} {}: {}", status, url, body)); } Ok(resp.text().await?) } @@ -528,7 +532,9 @@ impl RelayClient { .send() .await?; if !resp.status().is_success() { - return Err(anyhow::anyhow!("HTTP {}: {}", resp.status(), url)); + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(anyhow::anyhow!("{} {}: {}", status, url, body)); } Ok(resp.text().await?) } @@ -538,7 +544,9 @@ impl RelayClient { let url = format!("{}{}", self.relay_http_url(), path); let resp = self.apply_auth(self.http.delete(&url)).send().await?; if !resp.status().is_success() { - return Err(anyhow::anyhow!("HTTP {}: {}", resp.status(), url)); + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(anyhow::anyhow!("{} {}: {}", status, url, body)); } Ok(resp.text().await?) } @@ -560,7 +568,9 @@ impl RelayClient { pub async fn get_api(&self, url: &str) -> anyhow::Result { let resp = self.apply_auth(self.http.get(url)).send().await?; if !resp.status().is_success() { - return Err(anyhow::anyhow!("HTTP {}: {}", resp.status(), url)); + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(anyhow::anyhow!("{} {}: {}", status, url, body)); } Ok(resp.text().await?) } diff --git a/crates/sprout-mcp/src/server.rs b/crates/sprout-mcp/src/server.rs index 3f0e4dd78..375a76de7 100644 --- a/crates/sprout-mcp/src/server.rs +++ b/crates/sprout-mcp/src/server.rs @@ -6,6 +6,7 @@ use rmcp::{ use serde::{Deserialize, Serialize}; use crate::relay_client::RelayClient; +use sprout_core::PresenceStatus; /// Percent-encode a string for safe inclusion in a URL query parameter value. /// Encodes all characters except unreserved ones (A-Z a-z 0-9 - _ . ~). @@ -417,6 +418,13 @@ pub struct GetPresenceParams { pub pubkeys: String, } +/// Parameters for the `set_presence` tool. +#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)] +pub struct SetPresenceParams { + /// Presence status to set. + pub status: PresenceStatus, +} + /// Parameters for the `get_feed` tool. #[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)] pub struct GetFeedParams { @@ -1397,6 +1405,19 @@ impl SproutMcpServer { Err(e) => format!("Error fetching presence: {e}"), } } + + /// Set the agent's presence status. + #[tool( + name = "set_presence", + description = "Set the agent's presence status. Valid values: 'online', 'away', 'offline'. Presence auto-expires after 90 seconds — call periodically to stay online." + )] + pub async fn set_presence(&self, Parameters(p): Parameters) -> String { + let body = serde_json::json!({ "status": p.status }); + match self.client.put("/api/presence", &body).await { + Ok(b) => b, + Err(e) => format!("Error: {e}"), + } + } } #[tool_handler] diff --git a/crates/sprout-relay/src/api/mod.rs b/crates/sprout-relay/src/api/mod.rs index 2cd1110ac..2c75addd6 100644 --- a/crates/sprout-relay/src/api/mod.rs +++ b/crates/sprout-relay/src/api/mod.rs @@ -5,7 +5,7 @@ //! - `events` — GET /api/events/:id //! - `search` — GET /api/search //! - `agents` — GET /api/agents -//! - `presence` — GET /api/presence +//! - `presence` — GET/PUT /api/presence //! - `workflows` — workflow CRUD + trigger + webhook //! - `approvals` — approval grant/deny //! - `feed` — GET /api/feed @@ -59,7 +59,7 @@ pub use events::get_event; pub use feed::feed_handler; pub use members::{add_members, join_channel, leave_channel, list_members, remove_member}; pub use messages::{delete_message, get_thread, list_messages, send_message}; -pub use presence::presence_handler; +pub use presence::{presence_handler, set_presence_handler}; pub use reactions::{add_reaction_handler, list_reactions_handler, remove_reaction_handler}; pub use search::search_handler; pub use users::{get_profile, get_user_profile, get_users_batch, update_profile}; @@ -294,6 +294,29 @@ pub(crate) async fn check_channel_access( } } +// ── Custom JSON extractor ───────────────────────────────────────────────────── + +use axum::extract::{rejection::JsonRejection, FromRequest, Request}; + +/// A JSON extractor that returns our standard `{"error": "..."}` envelope +/// on deserialization failure instead of Axum's default plain-text 422. +pub struct ApiJson(pub T); + +impl FromRequest for ApiJson +where + axum::Json: FromRequest, + S: Send + Sync, +{ + type Rejection = (StatusCode, Json); + + async fn from_request(req: Request, state: &S) -> Result { + match axum::Json::::from_request(req, state).await { + Ok(axum::Json(value)) => Ok(ApiJson(value)), + Err(rejection) => Err(api_error(rejection.status(), &rejection.body_text())), + } + } +} + // ── Tests ───────────────────────────────────────────────────────────────────── #[cfg(test)] diff --git a/crates/sprout-relay/src/api/presence.rs b/crates/sprout-relay/src/api/presence.rs index ad836f00d..3e72853a3 100644 --- a/crates/sprout-relay/src/api/presence.rs +++ b/crates/sprout-relay/src/api/presence.rs @@ -1,4 +1,4 @@ -//! GET /api/presence — bulk presence lookup by pubkey. +//! Presence API — GET /api/presence (bulk lookup) and PUT /api/presence (set status). use std::sync::Arc; @@ -8,6 +8,8 @@ use axum::{ response::Json, }; use serde::Deserialize; +use sprout_core::PresenceStatus; +use sprout_pubsub::presence::PRESENCE_TTL_SECS; use crate::state::AppState; @@ -64,3 +66,52 @@ pub async fn presence_handler( Ok(Json(serde_json::Value::Object(result))) } + +/// Request body for `PUT /api/presence`. +#[derive(Debug, Deserialize)] +pub struct SetPresenceBody { + /// Presence status to set. + pub status: PresenceStatus, +} + +/// Set the authenticated user's presence status. +/// +/// Accepts `{"status": "online" | "away" | "offline"}` (case-sensitive). +/// Serde rejects unknown variants automatically, returning a 422. +/// - `"offline"` clears the presence entry (TTL 0). +/// - `"online"` / `"away"` upsert the entry with a 90-second TTL. +/// +/// Returns `{"status": "...", "ttl_seconds": N}`. +/// +/// **Note:** The WebSocket path (kind:20001) accepts arbitrary status strings +/// for forward-compatibility, but the REST/MCP surface intentionally restricts +/// to the curated enum above. Aligning the WebSocket path is tracked separately. +pub async fn set_presence_handler( + State(state): State>, + headers: HeaderMap, + super::ApiJson(body): super::ApiJson, +) -> Result, (StatusCode, Json)> { + let (pubkey, _pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?; + + match body.status { + PresenceStatus::Online | PresenceStatus::Away => { + state + .pubsub + .set_presence(&pubkey, body.status.as_str()) + .await + .map_err(|e| super::internal_error(&format!("presence error: {e}")))?; + } + PresenceStatus::Offline => { + state + .pubsub + .clear_presence(&pubkey) + .await + .map_err(|e| super::internal_error(&format!("presence error: {e}")))?; + } + } + + Ok(Json(serde_json::json!({ + "status": body.status, + "ttl_seconds": if body.status == PresenceStatus::Offline { 0 } else { PRESENCE_TTL_SECS }, + }))) +} diff --git a/crates/sprout-relay/src/router.rs b/crates/sprout-relay/src/router.rs index c605085a2..eb35d5c79 100644 --- a/crates/sprout-relay/src/router.rs +++ b/crates/sprout-relay/src/router.rs @@ -32,7 +32,10 @@ pub fn build_router(state: Arc) -> Router { .route("/api/events/{id}", get(api::get_event)) .route("/api/search", get(api::search_handler)) .route("/api/agents", get(api::agents_handler)) - .route("/api/presence", get(api::presence_handler)) + .route( + "/api/presence", + get(api::presence_handler).put(api::set_presence_handler), + ) // Workflow routes .route( "/api/channels/{channel_id}/workflows", diff --git a/crates/sprout-test-client/tests/e2e_mcp.rs b/crates/sprout-test-client/tests/e2e_mcp.rs index c47ebf72b..48a4e22a7 100644 --- a/crates/sprout-test-client/tests/e2e_mcp.rs +++ b/crates/sprout-test-client/tests/e2e_mcp.rs @@ -246,7 +246,7 @@ impl McpSession { // ── Tests ───────────────────────────────────────────────────────────────────── /// Spawn the MCP server, complete the initialize handshake, and verify that -/// all 40 expected tools are listed by `tools/list`. +/// all 41 expected tools are listed by `tools/list`. #[tokio::test] #[ignore] async fn test_mcp_initialize_and_list_tools() { @@ -295,8 +295,8 @@ async fn test_mcp_initialize_and_list_tools() { assert_eq!( tools.len(), - 40, - "expected exactly 40 tools, got {}. Tools: {:?}", + 41, + "expected exactly 41 tools, got {}. Tools: {:?}", tools.len(), tools .iter() @@ -348,6 +348,7 @@ async fn test_mcp_initialize_and_list_tools() { "get_users_batch", "search", "get_presence", + "set_presence", ]; for expected in &expected_tools { @@ -1064,3 +1065,166 @@ async fn test_mcp_get_users_batch() { session.stop(); } + +/// Call `set_presence` via MCP and verify it succeeds. +#[tokio::test] +#[ignore] +async fn test_mcp_set_presence() { + let keys = generate_test_keys(); + let mut session = McpSession::start(&keys).await; + session.initialize(); + + // Set presence to "online". + let resp = session.call_tool("set_presence", json!({"status": "online"})); + assert!( + resp.get("error").is_none(), + "set_presence returned an error: {resp}" + ); + let text = McpSession::tool_text(&resp); + let parsed: serde_json::Value = serde_json::from_str(&text).expect("response should be JSON"); + assert_eq!( + parsed["status"].as_str(), + Some("online"), + "set_presence response should have status 'online', got: {text}" + ); + assert_eq!( + parsed["ttl_seconds"].as_u64(), + Some(90), + "online presence should have 90s TTL, got: {text}" + ); + + // Verify via get_presence. + let pubkey_hex = keys.public_key().to_hex(); + let resp = session.call_tool("get_presence", json!({"pubkeys": pubkey_hex})); + assert!( + resp.get("error").is_none(), + "get_presence returned an error: {resp}" + ); + let text = McpSession::tool_text(&resp); + let parsed: serde_json::Value = serde_json::from_str(&text).expect("response should be JSON"); + assert_eq!( + parsed[&pubkey_hex].as_str(), + Some("online"), + "get_presence should show 'online' after set_presence, got: {text}" + ); + + session.stop(); +} + +/// Call `set_presence` with "offline" via MCP and verify presence is cleared. +#[tokio::test] +#[ignore] +async fn test_mcp_set_presence_offline() { + let keys = generate_test_keys(); + let mut session = McpSession::start(&keys).await; + session.initialize(); + + // First set to "online". + let resp = session.call_tool("set_presence", json!({"status": "online"})); + assert!( + resp.get("error").is_none(), + "set_presence(online) returned an error: {resp}" + ); + + // Now set to "offline" — should clear presence. + let resp = session.call_tool("set_presence", json!({"status": "offline"})); + assert!( + resp.get("error").is_none(), + "set_presence(offline) returned an error: {resp}" + ); + let text = McpSession::tool_text(&resp); + let parsed: serde_json::Value = serde_json::from_str(&text).expect("response should be JSON"); + assert_eq!( + parsed["status"].as_str(), + Some("offline"), + "set_presence(offline) response should have status 'offline', got: {text}" + ); + assert_eq!( + parsed["ttl_seconds"].as_u64(), + Some(0), + "offline presence should have 0 TTL, got: {text}" + ); + + // Verify via get_presence — should show "offline". + let pubkey_hex = keys.public_key().to_hex(); + let resp = session.call_tool("get_presence", json!({"pubkeys": pubkey_hex})); + assert!( + resp.get("error").is_none(), + "get_presence returned an error: {resp}" + ); + let text = McpSession::tool_text(&resp); + let parsed: serde_json::Value = serde_json::from_str(&text).expect("response should be JSON"); + assert_eq!( + parsed[&pubkey_hex].as_str(), + Some("offline"), + "get_presence should show 'offline' after clearing, got: {text}" + ); + + session.stop(); +} + +/// Call `set_presence` with "away" via MCP and verify round-trip. +#[tokio::test] +#[ignore] +async fn test_mcp_set_presence_away() { + let keys = generate_test_keys(); + let mut session = McpSession::start(&keys).await; + session.initialize(); + + let resp = session.call_tool("set_presence", json!({"status": "away"})); + assert!( + resp.get("error").is_none(), + "set_presence(away) returned an error: {resp}" + ); + let text = McpSession::tool_text(&resp); + let parsed: serde_json::Value = serde_json::from_str(&text).expect("response should be JSON"); + assert_eq!( + parsed["status"].as_str(), + Some("away"), + "set_presence response should have status 'away', got: {text}" + ); + assert_eq!( + parsed["ttl_seconds"].as_u64(), + Some(90), + "away presence should have 90s TTL, got: {text}" + ); + + // Verify via get_presence. + let pubkey_hex = keys.public_key().to_hex(); + let resp = session.call_tool("get_presence", json!({"pubkeys": pubkey_hex})); + assert!( + resp.get("error").is_none(), + "get_presence returned an error: {resp}" + ); + let text = McpSession::tool_text(&resp); + let parsed: serde_json::Value = serde_json::from_str(&text).expect("response should be JSON"); + assert_eq!( + parsed[&pubkey_hex].as_str(), + Some("away"), + "get_presence should show 'away', got: {text}" + ); + + session.stop(); +} + +/// Call `set_presence` with an invalid status via MCP and verify error. +#[tokio::test] +#[ignore] +async fn test_mcp_set_presence_invalid_status() { + let keys = generate_test_keys(); + let mut session = McpSession::start(&keys).await; + session.initialize(); + + let resp = session.call_tool("set_presence", json!({"status": "invisible"})); + // MCP framework rejects invalid enum variants at the JSON-RPC level (not as a tool result), + // so the response has an "error" key rather than a "result" key. + let has_error = resp.get("error").is_some(); + let text = McpSession::tool_text(&resp); + let has_error_text = text.contains("422") || text.contains("error") || text.contains("Error"); + assert!( + has_error || has_error_text, + "invalid status should return a JSON-RPC error or error text, got: {resp}" + ); + + session.stop(); +} diff --git a/crates/sprout-test-client/tests/e2e_rest_api.rs b/crates/sprout-test-client/tests/e2e_rest_api.rs index b74ffd49e..056089467 100644 --- a/crates/sprout-test-client/tests/e2e_rest_api.rs +++ b/crates/sprout-test-client/tests/e2e_rest_api.rs @@ -631,6 +631,182 @@ async fn test_presence_empty_pubkeys() { ); } +/// PUT /api/presence sets the user's presence and can be read back. +#[tokio::test] +#[ignore] +async fn test_set_presence_online() { + let client = http_client(); + let keys = Keys::generate(); + let pubkey_hex = keys.public_key().to_hex(); + + // Set presence to "online" via REST. + let url = format!("{}/api/presence", relay_http_url()); + let resp = authed_put( + &client, + &url, + &pubkey_hex, + serde_json::json!({"status": "online"}), + ) + .await; + assert_eq!(resp.status(), 200, "PUT /api/presence should return 200"); + let body: serde_json::Value = resp.json().await.expect("JSON"); + assert_eq!(body["status"].as_str(), Some("online")); + assert_eq!( + body["ttl_seconds"].as_u64(), + Some(90), + "online presence should have 90s TTL" + ); + + // Verify via GET. + let get_url = format!("{}/api/presence?pubkeys={pubkey_hex}", relay_http_url()); + let resp = authed_get(&client, &get_url, &pubkey_hex).await; + assert_eq!(resp.status(), 200); + let body: serde_json::Value = resp.json().await.expect("JSON"); + assert_eq!( + body[&pubkey_hex].as_str(), + Some("online"), + "presence should be 'online' after PUT" + ); +} + +/// PUT /api/presence with "away" then "offline" updates and clears presence. +#[tokio::test] +#[ignore] +async fn test_set_presence_away_and_offline() { + let client = http_client(); + let keys = Keys::generate(); + let pubkey_hex = keys.public_key().to_hex(); + let url = format!("{}/api/presence", relay_http_url()); + + // Set to "away". + let resp = authed_put( + &client, + &url, + &pubkey_hex, + serde_json::json!({"status": "away"}), + ) + .await; + assert_eq!(resp.status(), 200); + let away_body: serde_json::Value = resp.json().await.expect("JSON"); + assert_eq!( + away_body["status"].as_str(), + Some("away"), + "PUT response should echo 'away'" + ); + assert_eq!( + away_body["ttl_seconds"].as_u64(), + Some(90), + "away should have 90s TTL" + ); + + // Verify "away". + let get_url = format!("{}/api/presence?pubkeys={pubkey_hex}", relay_http_url()); + let resp = authed_get(&client, &get_url, &pubkey_hex).await; + let body: serde_json::Value = resp.json().await.expect("JSON"); + assert_eq!(body[&pubkey_hex].as_str(), Some("away")); + + // Set to "offline" — should clear presence. + let resp = authed_put( + &client, + &url, + &pubkey_hex, + serde_json::json!({"status": "offline"}), + ) + .await; + assert_eq!(resp.status(), 200); + let offline_body: serde_json::Value = resp.json().await.expect("JSON"); + assert_eq!( + offline_body["status"].as_str(), + Some("offline"), + "PUT response should echo 'offline'" + ); + assert_eq!( + offline_body["ttl_seconds"].as_u64(), + Some(0), + "offline should have 0 TTL" + ); + + // Verify "offline" (key deleted from Redis, defaults to "offline"). + let resp = authed_get(&client, &get_url, &pubkey_hex).await; + let body: serde_json::Value = resp.json().await.expect("JSON"); + assert_eq!(body[&pubkey_hex].as_str(), Some("offline")); +} + +/// PUT /api/presence with an invalid status returns 422 with standard error envelope. +#[tokio::test] +#[ignore] +async fn test_set_presence_invalid_status() { + let client = http_client(); + let keys = Keys::generate(); + let pubkey_hex = keys.public_key().to_hex(); + + let url = format!("{}/api/presence", relay_http_url()); + let resp = authed_put( + &client, + &url, + &pubkey_hex, + serde_json::json!({"status": "invisible"}), + ) + .await; + assert_eq!( + resp.status(), + 422, + "invalid enum variant should return 422 Unprocessable Entity" + ); + let body: serde_json::Value = resp.json().await.expect("JSON"); + assert!( + body["error"].as_str().is_some(), + "422 response should contain standard error envelope, got: {body}" + ); +} + +/// PUT /api/presence without auth returns 401. +#[tokio::test] +#[ignore] +async fn test_set_presence_requires_auth() { + let client = http_client(); + let url = format!("{}/api/presence", relay_http_url()); + let resp = client + .put(&url) + .json(&serde_json::json!({"status": "online"})) + .send() + .await + .expect("request failed"); + assert_eq!( + resp.status(), + 401, + "PUT /api/presence without auth should return 401" + ); + let body: serde_json::Value = resp.json().await.expect("JSON"); + assert_eq!( + body["error"].as_str(), + Some("authentication required"), + "401 response should contain standard error envelope" + ); +} + +/// PUT /api/presence with missing status field returns a structured error. +#[tokio::test] +#[ignore] +async fn test_set_presence_missing_field() { + let client = http_client(); + let keys = Keys::generate(); + let pubkey_hex = keys.public_key().to_hex(); + + let url = format!("{}/api/presence", relay_http_url()); + let resp = authed_put(&client, &url, &pubkey_hex, serde_json::json!({})).await; + assert_eq!( + resp.status(), + 422, + "missing required field should return 422" + ); + let body: serde_json::Value = resp.json().await.expect("JSON"); + assert!( + body["error"].as_str().is_some(), + "422 response should contain standard error envelope, got: {body}" + ); +} + // ── Agents tests ────────────────────────────────────────────────────────────── /// GET /api/agents returns a JSON array with the expected fields.