feat: set_presence REST endpoint, MCP tool, and shared PresenceStatus enum (#33)

This commit is contained in:
tlongwell-block
2026-03-11 16:05:31 -04:00
committed by GitHub
parent f21c6e41eb
commit 397564b434
13 changed files with 566 additions and 29 deletions
Generated
+1
View File
@@ -2689,6 +2689,7 @@ dependencies = [
"chrono",
"hex",
"nostr",
"schemars",
"serde",
"serde_json",
"thiserror",
+25 -16
View File
@@ -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
---
+2
View File
@@ -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
+3
View File
@@ -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"))]
+74
View File
@@ -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<PresenceStatus, _> = 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");
}
}
+1 -1
View File
@@ -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 }
+15 -5
View File
@@ -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<String> {
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?)
}
+21
View File
@@ -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<SetPresenceParams>) -> 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]
+25 -2
View File
@@ -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<T>(pub T);
impl<S, T> FromRequest<S> for ApiJson<T>
where
axum::Json<T>: FromRequest<S, Rejection = JsonRejection>,
S: Send + Sync,
{
type Rejection = (StatusCode, Json<serde_json::Value>);
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
match axum::Json::<T>::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)]
+52 -1
View File
@@ -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<Arc<AppState>>,
headers: HeaderMap,
super::ApiJson(body): super::ApiJson<SetPresenceBody>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
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 },
})))
}
+4 -1
View File
@@ -32,7 +32,10 @@ pub fn build_router(state: Arc<AppState>) -> 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",
+167 -3
View File
@@ -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();
}
@@ -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.