Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions src/v1/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5646,6 +5646,109 @@ mod test_serialization {
assert!(matches!(deserialized, AuthMethod::Agent(_)));
}

#[test]
fn test_logout_method_name_is_stable() {
// `logout` was recently stabilized (PR #1273); pinning the wire name
// here ensures a future feature-flag or rename can't silently move it.
assert_eq!(AGENT_METHOD_NAMES.logout, "logout");
assert_eq!(
ClientRequest::LogoutRequest(LogoutRequest::new()).method(),
"logout"
);
}

#[test]
fn test_logout_request_round_trip_empty_and_with_meta() {
// Empty request must encode as `{}` so peers that omit params still
// produce a valid wire payload.
let req = LogoutRequest::new();
assert_eq!(serde_json::to_value(&req).unwrap(), json!({}));

// Round-trip with `_meta` populated. The `_meta` extension key MUST
// be serialized with the leading underscore.
let mut meta = serde_json::Map::new();
meta.insert("trace_id".into(), json!("abc"));
let req = LogoutRequest::new().meta(meta.clone());
let value = serde_json::to_value(&req).unwrap();
assert_eq!(value, json!({"_meta": {"trace_id": "abc"}}));

let deserialized: LogoutRequest = serde_json::from_value(value).unwrap();
assert_eq!(deserialized.meta.as_ref().unwrap(), &meta);
}

#[test]
fn test_logout_response_default_round_trip() {
// Response is also conventionally `{}`; the `AgentResponse` enum
// wraps it with `#[serde(default)]` so peers may omit the body.
let resp = LogoutResponse::new();
assert_eq!(serde_json::to_value(&resp).unwrap(), json!({}));

let parsed: LogoutResponse = serde_json::from_value(json!({})).unwrap();
assert_eq!(parsed, LogoutResponse::default());

// Round-trip through the AgentResponse routing enum (untagged), which
// is how the type is actually carried over the wire.
let envelope = AgentResponse::LogoutResponse(LogoutResponse::new());
let wire = serde_json::to_value(&envelope).unwrap();
// Untagged variants serialize as the inner value.
assert_eq!(wire, json!({}));
}

#[test]
fn test_agent_auth_capabilities_default_and_round_trip() {
// Default capabilities advertise nothing: a wire payload of `{}`
// means "no auth-related capabilities supported".
let caps = AgentAuthCapabilities::new();
assert_eq!(serde_json::to_value(&caps).unwrap(), json!({}));

// Setting `logout` with the empty marker capability MUST serialize
// as `{"logout": {}}` per the "supplying `{}` means supported"
// convention documented on the field.
let caps = AgentAuthCapabilities::new().logout(LogoutCapabilities::new());
let v = serde_json::to_value(&caps).unwrap();
assert_eq!(v, json!({"logout": {}}));

let parsed: AgentAuthCapabilities = serde_json::from_value(v).unwrap();
assert!(parsed.logout.is_some());
}

#[test]
fn test_agent_auth_capabilities_tolerates_malformed_logout_field() {
// `logout` uses `DefaultOnError` so an old/misbehaving peer that
// sends garbage for the capability shouldn't crash deserialization
// of the whole `initialize` response — it should fall back to None.
let parsed: AgentAuthCapabilities =
serde_json::from_value(json!({"logout": "not an object"})).unwrap();
assert!(parsed.logout.is_none());

let parsed: AgentAuthCapabilities = serde_json::from_value(json!({"logout": 42})).unwrap();
assert!(parsed.logout.is_none());

// `null` is treated as "absent", giving `None`.
let parsed: AgentAuthCapabilities =
serde_json::from_value(json!({"logout": null})).unwrap();
assert!(parsed.logout.is_none());
}

#[test]
fn test_agent_capabilities_default_omits_auth() {
// `auth` is a required field with a `Default` value; omitting it
// entirely on the wire must be tolerated by deserialization.
let parsed: AgentCapabilities = serde_json::from_value(json!({})).unwrap();
assert!(parsed.auth.logout.is_none());
}

#[test]
fn test_agent_capabilities_threads_auth_through_round_trip() {
let caps = AgentCapabilities::new()
.auth(AgentAuthCapabilities::new().logout(LogoutCapabilities::new()));
let v = serde_json::to_value(&caps).unwrap();
assert_eq!(v["auth"], json!({"logout": {}}));

let parsed: AgentCapabilities = serde_json::from_value(v).unwrap();
assert!(parsed.auth.logout.is_some());
}

#[cfg(feature = "unstable_session_delete")]
#[test]
fn test_session_delete_serialization() {
Expand Down
152 changes: 152 additions & 0 deletions src/v1/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -370,4 +370,156 @@ mod tests {
);
}
}

#[test]
fn error_code_round_trip_to_i32() {
// Standard JSON-RPC numeric codes are part of the protocol contract;
// regressing them would silently break clients that match on numbers.
for error in ErrorCode::iter() {
let n: i32 = error.into();
let back: ErrorCode = n.into();
assert_eq!(error, back);
}
// Unknown numeric codes must round-trip through `Other(_)`.
let other: ErrorCode = 12345i32.into();
assert_eq!(other, ErrorCode::Other(12345));
let back: i32 = other.into();
assert_eq!(back, 12345);
}

#[test]
fn error_constructors_set_expected_codes() {
assert_eq!(Error::parse_error().code, ErrorCode::ParseError);
assert_eq!(Error::invalid_request().code, ErrorCode::InvalidRequest);
assert_eq!(Error::method_not_found().code, ErrorCode::MethodNotFound);
assert_eq!(Error::invalid_params().code, ErrorCode::InvalidParams);
assert_eq!(Error::internal_error().code, ErrorCode::InternalError);
assert_eq!(Error::auth_required().code, ErrorCode::AuthRequired);
}

#[test]
fn error_display_uses_message_when_present() {
let err = Error::new(-32000, "boom");
assert_eq!(err.to_string(), "boom");
}

#[test]
fn error_display_falls_back_to_code_when_message_empty() {
// Empty message should not produce a blank string; the numeric code
// is the next-best identifier for log lines and panics.
let err = Error::new(-32600, "");
assert_eq!(err.to_string(), "-32600");
}

#[test]
fn error_display_appends_data() {
let err = Error::new(-32000, "boom").data(serde_json::json!({"k": "v"}));
let rendered = err.to_string();
assert!(
rendered.starts_with("boom: "),
"unexpected display: {rendered:?}"
);
// Pretty-printed JSON preserves the key/value pair regardless of
// whitespace formatting choices.
assert!(rendered.contains("\"k\""), "missing key in {rendered:?}");
assert!(rendered.contains("\"v\""), "missing value in {rendered:?}");
}

#[test]
fn resource_not_found_attaches_uri_when_given() {
let err = Error::resource_not_found(Some("file:///tmp/missing".into()));
assert_eq!(err.code, ErrorCode::ResourceNotFound);
assert_eq!(
err.data,
Some(serde_json::json!({"uri": "file:///tmp/missing"}))
);
}

#[test]
fn resource_not_found_omits_data_when_uri_missing() {
let err = Error::resource_not_found(None);
assert_eq!(err.code, ErrorCode::ResourceNotFound);
assert!(err.data.is_none());
}

#[test]
fn from_serde_json_error_is_invalid_params_with_message_in_data() {
// Triggering an actual serde error so behavior matches real usage.
let serde_err = serde_json::from_str::<i32>("not a number").unwrap_err();
let original_message = serde_err.to_string();

let err: Error = serde_err.into();
assert_eq!(err.code, ErrorCode::InvalidParams);
// The original parse error message is preserved in `data` so callers
// can debug malformed requests; regressing this would silently lose
// root-cause info on the wire.
let data = err.data.expect("data should carry the serde message");
assert_eq!(data, serde_json::Value::String(original_message));
}

#[test]
fn into_internal_error_attaches_error_string() {
#[derive(Debug)]
struct MyErr;
impl std::fmt::Display for MyErr {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("kaboom")
}
}
impl std::error::Error for MyErr {}

let err = Error::into_internal_error(MyErr);
assert_eq!(err.code, ErrorCode::InternalError);
assert_eq!(err.data, Some(serde_json::Value::String("kaboom".into())));
}

#[test]
fn from_anyhow_downcasts_to_existing_acp_error() {
// anyhow may wrap an existing ACP `Error`; the conversion must
// unwrap it rather than re-wrapping as a generic internal error
// so the original code (e.g. AuthRequired) survives the round-trip.
let original = Error::auth_required().data(serde_json::json!({"hint": "log in"}));
let wrapped: anyhow::Error = anyhow::Error::new(original.clone());

let converted: Error = wrapped.into();
assert_eq!(converted, original);
}

#[test]
fn from_anyhow_falls_back_to_internal_error_for_foreign_types() {
let wrapped = anyhow::anyhow!("something went wrong");
let converted: Error = wrapped.into();
assert_eq!(converted.code, ErrorCode::InternalError);
// The anyhow display is preserved in `data` for debuggability.
assert_eq!(
converted.data,
Some(serde_json::Value::String("something went wrong".into()))
);
}

#[test]
fn error_serializes_to_jsonrpc_object_shape() {
// The wire shape `{code, message}` (and optional `data`) is part of
// the JSON-RPC contract; a regression would break every peer.
let err = Error::new(-32600, "Invalid request");
let v = serde_json::to_value(&err).unwrap();
assert_eq!(
v,
serde_json::json!({
"code": -32600,
"message": "Invalid request",
})
);

let err = Error::new(-32602, "bad").data(serde_json::json!({"field": "id"}));
let v = serde_json::to_value(&err).unwrap();
assert_eq!(
v,
serde_json::json!({
"code": -32602,
"message": "bad",
"data": {"field": "id"},
})
);
}
}
Loading
Loading