v0.2.7-pre.009
This commit is contained in:
202
crates/ksp-onchain-transport-lib/unit_tests/ws_accounts.rs
Normal file
202
crates/ksp-onchain-transport-lib/unit_tests/ws_accounts.rs
Normal file
@@ -0,0 +1,202 @@
|
||||
// file: crates/ksp-onchain-transport-lib/unit_tests/ws_accounts.rs
|
||||
// version: 1
|
||||
|
||||
use futures_util::SinkExt; // rust-rules: trait-import
|
||||
use futures_util::StreamExt; // rust-rules: trait-import
|
||||
|
||||
fn local_endpoint(url: &str) -> crate::WsEndpointSettings {
|
||||
return crate::WsEndpointSettings::new(
|
||||
"local_ws_accounts",
|
||||
true,
|
||||
crate::WsProviderName::new("local-fixture"),
|
||||
crate::WsClusterName::new("local"),
|
||||
crate::WsProtocolKind::SolanaStandard,
|
||||
crate::WsEndpointUrl::parse(url).expect("local test WebSocket URL must parse"),
|
||||
crate::WsSessionSettings::default(),
|
||||
);
|
||||
}
|
||||
|
||||
async fn bind_local_listener() -> (tokio::net::TcpListener, std::string::String) {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("local listener must bind");
|
||||
let address = listener.local_addr().expect("local listener must expose address");
|
||||
return (listener, format!("ws://{address}"));
|
||||
}
|
||||
|
||||
async fn read_request(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>) -> serde_json::Value {
|
||||
let message = websocket.next().await.expect("request message must exist").expect("request message must decode");
|
||||
let text = message.to_text().expect("request must be text");
|
||||
return serde_json::from_str(text).expect("request must contain JSON");
|
||||
}
|
||||
|
||||
async fn send_result(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>, request: &serde_json::Value, result: serde_json::Value) {
|
||||
let id = request.get("id").and_then(serde_json::Value::as_u64).expect("request id must be numeric");
|
||||
let response = serde_json::json!({"jsonrpc":"2.0","id":id,"result":result});
|
||||
websocket.send(tokio_tungstenite::tungstenite::Message::Text(response.to_string().into())).await.expect("local response must send");
|
||||
}
|
||||
|
||||
async fn send_notification(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>, method: &str, remote_id: u64, result: serde_json::Value) {
|
||||
let notification = serde_json::json!({"jsonrpc":"2.0","method":method,"params":{"result":result,"subscription":remote_id}});
|
||||
websocket.send(tokio_tungstenite::tungstenite::Message::Text(notification.to_string().into())).await.expect("local notification must send");
|
||||
}
|
||||
|
||||
async fn wait_for_close_frame(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>) {
|
||||
loop {
|
||||
let message = websocket.next().await;
|
||||
match message {
|
||||
std::option::Option::Some(std::result::Result::Ok(tokio_tungstenite::tungstenite::Message::Close(_))) => return,
|
||||
std::option::Option::Some(std::result::Result::Ok(_)) => {},
|
||||
std::option::Option::Some(std::result::Result::Err(_)) | std::option::Option::None => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn account_wire(lamports: u64) -> serde_json::Value {
|
||||
return serde_json::json!({
|
||||
"lamports": lamports,
|
||||
"data": ["AQID", "base64"],
|
||||
"owner": "11111111111111111111111111111111",
|
||||
"executable": false,
|
||||
"rentEpoch": 7,
|
||||
"space": 3
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_subscribe_config_preserves_effective_websocket_options_without_min_context_slot() {
|
||||
let config = crate::SolanaAccountSubscribeConfig::new(
|
||||
std::option::Option::Some(crate::SolanaAccountEncoding::Base64Zstd),
|
||||
std::option::Option::Some(crate::SolanaDataSliceConfig::new(4, 16)),
|
||||
std::option::Option::Some(crate::SolanaCommitment::Confirmed),
|
||||
);
|
||||
assert_eq!(config.encoding(), std::option::Option::Some(crate::SolanaAccountEncoding::Base64Zstd));
|
||||
assert_eq!(config.data_slice(), std::option::Option::Some(crate::SolanaDataSliceConfig::new(4, 16)));
|
||||
assert_eq!(config.commitment(), std::option::Option::Some(crate::SolanaCommitment::Confirmed));
|
||||
assert_eq!(config.to_json_value(), serde_json::json!({"encoding":"base64+zstd","dataSlice":{"offset":4,"length":16},"commitment":"confirmed"}));
|
||||
assert!(config.to_json_value().get("minContextSlot").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn program_subscribe_config_preserves_filters_with_context_and_deterministic_bounds() {
|
||||
let config = crate::SolanaProgramSubscribeConfig::new(
|
||||
crate::SolanaAccountSubscribeConfig::new(
|
||||
std::option::Option::Some(crate::SolanaAccountEncoding::Base64),
|
||||
std::option::Option::None,
|
||||
std::option::Option::Some(crate::SolanaCommitment::Finalized),
|
||||
),
|
||||
std::vec![
|
||||
crate::SolanaProgramAccountFilter::DataSize(80),
|
||||
crate::SolanaProgramAccountFilter::Memcmp(crate::SolanaMemcmpFilter::new(4, crate::SolanaMemcmpBytes::Bytes(std::vec![1, 2, 3]))),
|
||||
crate::SolanaProgramAccountFilter::TokenAccountState,
|
||||
],
|
||||
std::option::Option::Some(true),
|
||||
);
|
||||
assert_eq!(config.with_context(), std::option::Option::Some(true));
|
||||
assert_eq!(config.filters().len(), 3);
|
||||
assert_eq!(
|
||||
config.to_json_value(),
|
||||
serde_json::json!({
|
||||
"encoding":"base64",
|
||||
"commitment":"finalized",
|
||||
"filters":[{"dataSize":80},{"memcmp":{"offset":4,"bytes":[1,2,3],"encoding":"bytes"}},"tokenAccountState"],
|
||||
"withContext":true
|
||||
})
|
||||
);
|
||||
let too_many = std::vec![
|
||||
crate::SolanaProgramAccountFilter::DataSize(1),
|
||||
crate::SolanaProgramAccountFilter::DataSize(2),
|
||||
crate::SolanaProgramAccountFilter::DataSize(3),
|
||||
crate::SolanaProgramAccountFilter::DataSize(4),
|
||||
crate::SolanaProgramAccountFilter::DataSize(5),
|
||||
];
|
||||
let error = super::validate_program_subscribe_filters(too_many.as_slice()).expect_err("five programSubscribe filters must reject locally");
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RPC_PARAMETERS);
|
||||
let oversized =
|
||||
std::vec![crate::SolanaProgramAccountFilter::Memcmp(crate::SolanaMemcmpFilter::new(0, crate::SolanaMemcmpBytes::Bytes(std::vec![0; 129]),))];
|
||||
let error = super::validate_program_subscribe_filters(oversized.as_slice()).expect_err("oversized raw memcmp bytes must reject locally");
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RPC_PARAMETERS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn program_notification_decoder_accepts_contextual_and_non_contextual_wire_forms() {
|
||||
let keyed = serde_json::json!({"pubkey":"11111111111111111111111111111111","account":account_wire(42)});
|
||||
let bare = super::decode_program_notification("programSubscribe", keyed.clone()).expect("bare program notification must decode");
|
||||
assert!(bare.context().is_none());
|
||||
assert_eq!(bare.account().account().lamports(), 42);
|
||||
let contextual = super::decode_program_notification("programSubscribe", serde_json::json!({"context":{"slot":99,"apiVersion":"4.2.1"},"value":keyed}))
|
||||
.expect("contextual program notification must decode");
|
||||
assert_eq!(contextual.context().expect("context must be retained").slot(), 99);
|
||||
assert_eq!(contextual.account().account().lamports(), 42);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn stable_account_and_program_wrappers_use_exact_methods_decode_notifications_and_unsubscribe_by_handle() {
|
||||
let (listener, url) = bind_local_listener().await;
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.expect("local server must accept client");
|
||||
let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("local WebSocket handshake must succeed");
|
||||
let account_request = read_request(&mut websocket).await;
|
||||
assert_eq!(account_request["method"], serde_json::json!("accountSubscribe"));
|
||||
assert_eq!(
|
||||
account_request["params"],
|
||||
serde_json::json!(["11111111111111111111111111111111", {"encoding":"base64","dataSlice":{"offset":1,"length":2},"commitment":"confirmed"}])
|
||||
);
|
||||
send_result(&mut websocket, &account_request, serde_json::json!(51)).await;
|
||||
send_notification(&mut websocket, "accountNotification", 51, serde_json::json!({"context":{"slot":700},"value":account_wire(123)})).await;
|
||||
let account_unsubscribe = read_request(&mut websocket).await;
|
||||
assert_eq!(account_unsubscribe["method"], serde_json::json!("accountUnsubscribe"));
|
||||
assert_eq!(account_unsubscribe["params"], serde_json::json!([51]));
|
||||
send_result(&mut websocket, &account_unsubscribe, serde_json::json!(true)).await;
|
||||
let program_request = read_request(&mut websocket).await;
|
||||
assert_eq!(program_request["method"], serde_json::json!("programSubscribe"));
|
||||
assert_eq!(
|
||||
program_request["params"],
|
||||
serde_json::json!(["11111111111111111111111111111111", {"encoding":"jsonParsed","filters":[{"dataSize":80}],"withContext":true}])
|
||||
);
|
||||
send_result(&mut websocket, &program_request, serde_json::json!(73)).await;
|
||||
send_notification(
|
||||
&mut websocket,
|
||||
"programNotification",
|
||||
73,
|
||||
serde_json::json!({
|
||||
"context":{"slot":701},
|
||||
"value":{"pubkey":"11111111111111111111111111111111","account":account_wire(456)}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let program_unsubscribe = read_request(&mut websocket).await;
|
||||
assert_eq!(program_unsubscribe["method"], serde_json::json!("programUnsubscribe"));
|
||||
assert_eq!(program_unsubscribe["params"], serde_json::json!([73]));
|
||||
send_result(&mut websocket, &program_unsubscribe, serde_json::json!(true)).await;
|
||||
wait_for_close_frame(&mut websocket).await;
|
||||
});
|
||||
let session = crate::WsSession::connect(local_endpoint(url.as_str())).await.expect("client handshake must succeed");
|
||||
let account_pubkey = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("account pubkey fixture must parse");
|
||||
let account_config = crate::SolanaAccountSubscribeConfig::new(
|
||||
std::option::Option::Some(crate::SolanaAccountEncoding::Base64),
|
||||
std::option::Option::Some(crate::SolanaDataSliceConfig::new(1, 2)),
|
||||
std::option::Option::Some(crate::SolanaCommitment::Confirmed),
|
||||
);
|
||||
let mut account = session.account_subscribe(&account_pubkey, std::option::Option::Some(&account_config)).await.expect("accountSubscribe must register");
|
||||
assert_eq!(account.kind(), crate::WsSubscriptionKind::Account);
|
||||
let notification = account.recv().await.expect("account notification must arrive").expect("account notification must decode");
|
||||
assert_eq!(notification.context().slot(), 700);
|
||||
assert_eq!(notification.value().lamports(), 123);
|
||||
assert!(account.unsubscribe().await.expect("account unsubscribe must complete"));
|
||||
let program_config = crate::SolanaProgramSubscribeConfig::new(
|
||||
crate::SolanaAccountSubscribeConfig::new(
|
||||
std::option::Option::Some(crate::SolanaAccountEncoding::JsonParsed),
|
||||
std::option::Option::None,
|
||||
std::option::Option::None,
|
||||
),
|
||||
std::vec![crate::SolanaProgramAccountFilter::DataSize(80)],
|
||||
std::option::Option::Some(true),
|
||||
);
|
||||
let mut program = session.program_subscribe(&account_pubkey, std::option::Option::Some(&program_config)).await.expect("programSubscribe must register");
|
||||
assert_eq!(program.kind(), crate::WsSubscriptionKind::Program);
|
||||
let notification = program.recv().await.expect("program notification must arrive").expect("program notification must decode");
|
||||
assert_eq!(notification.context().expect("program context must be retained").slot(), 701);
|
||||
assert_eq!(notification.account().account().lamports(), 456);
|
||||
assert!(program.unsubscribe().await.expect("program unsubscribe must complete"));
|
||||
session.close().await.expect("session close must complete");
|
||||
server.await.expect("local server task must complete");
|
||||
}
|
||||
122
crates/ksp-onchain-transport-lib/unit_tests/ws_transactions.rs
Normal file
122
crates/ksp-onchain-transport-lib/unit_tests/ws_transactions.rs
Normal file
@@ -0,0 +1,122 @@
|
||||
// file: crates/ksp-onchain-transport-lib/unit_tests/ws_transactions.rs
|
||||
// version: 1
|
||||
|
||||
use futures_util::SinkExt; // rust-rules: trait-import
|
||||
use futures_util::StreamExt; // rust-rules: trait-import
|
||||
|
||||
fn local_endpoint(url: &str) -> crate::WsEndpointSettings {
|
||||
return crate::WsEndpointSettings::new(
|
||||
"local_ws_transactions",
|
||||
true,
|
||||
crate::WsProviderName::new("local-fixture"),
|
||||
crate::WsClusterName::new("local"),
|
||||
crate::WsProtocolKind::SolanaStandard,
|
||||
crate::WsEndpointUrl::parse(url).expect("local test WebSocket URL must parse"),
|
||||
crate::WsSessionSettings::default(),
|
||||
);
|
||||
}
|
||||
|
||||
async fn bind_local_listener() -> (tokio::net::TcpListener, std::string::String) {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("local listener must bind");
|
||||
let address = listener.local_addr().expect("local listener must expose address");
|
||||
return (listener, format!("ws://{address}"));
|
||||
}
|
||||
|
||||
async fn read_request(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>) -> serde_json::Value {
|
||||
let message = websocket.next().await.expect("request message must exist").expect("request message must decode");
|
||||
let text = message.to_text().expect("request must be text");
|
||||
return serde_json::from_str(text).expect("request must contain JSON");
|
||||
}
|
||||
|
||||
async fn send_result(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>, request: &serde_json::Value, result: serde_json::Value) {
|
||||
let id = request.get("id").and_then(serde_json::Value::as_u64).expect("request id must be numeric");
|
||||
let response = serde_json::json!({"jsonrpc":"2.0","id":id,"result":result});
|
||||
websocket.send(tokio_tungstenite::tungstenite::Message::Text(response.to_string().into())).await.expect("local response must send");
|
||||
}
|
||||
|
||||
async fn wait_for_close_frame(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>) {
|
||||
loop {
|
||||
let message = websocket.next().await;
|
||||
match message {
|
||||
std::option::Option::Some(std::result::Result::Ok(tokio_tungstenite::tungstenite::Message::Close(_))) => return,
|
||||
std::option::Option::Some(std::result::Result::Ok(_)) => {},
|
||||
std::option::Option::Some(std::result::Result::Err(_)) | std::option::Option::None => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logs_subscribe_filters_preserve_all_all_with_votes_and_exactly_one_mention() {
|
||||
let pubkey = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("mention fixture must parse");
|
||||
assert_eq!(crate::SolanaLogsSubscribeFilter::All.to_json_value(), serde_json::json!("all"));
|
||||
assert_eq!(crate::SolanaLogsSubscribeFilter::AllWithVotes.to_json_value(), serde_json::json!("allWithVotes"));
|
||||
assert_eq!(crate::SolanaLogsSubscribeFilter::Mentions(pubkey).to_json_value(), serde_json::json!({"mentions":["11111111111111111111111111111111"]}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logs_notification_decoder_preserves_context_signature_nullable_error_and_ordered_logs() {
|
||||
let success = super::decode_logs_notification(
|
||||
"logsSubscribe",
|
||||
serde_json::json!({
|
||||
"context":{"slot":81,"apiVersion":"4.2.1"},
|
||||
"value":{"signature":"fixture-signature","err":null,"logs":["first","second"]}
|
||||
}),
|
||||
)
|
||||
.expect("successful logs notification must decode");
|
||||
assert_eq!(success.context().slot(), 81);
|
||||
assert_eq!(success.value().signature(), "fixture-signature");
|
||||
assert!(success.value().err().is_none());
|
||||
assert_eq!(success.value().logs(), &["first".to_owned(), "second".to_owned()]);
|
||||
let failed = super::decode_logs_notification(
|
||||
"logsSubscribe",
|
||||
serde_json::json!({"context":{"slot":82},"value":{"signature":"fixture-signature-2","err":{"InstructionError":[0,"Custom"]},"logs":[]}}),
|
||||
)
|
||||
.expect("failed logs notification must preserve transaction error wire value");
|
||||
assert_eq!(failed.context().slot(), 82);
|
||||
assert!(failed.value().err().is_some());
|
||||
let missing_err =
|
||||
super::decode_logs_notification("logsSubscribe", serde_json::json!({"context":{"slot":83},"value":{"signature":"fixture-signature-3","logs":[]}}));
|
||||
assert!(missing_err.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn stable_logs_wrapper_uses_exact_filter_config_notification_and_handle_unsubscribe() {
|
||||
let (listener, url) = bind_local_listener().await;
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.expect("local server must accept client");
|
||||
let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("local WebSocket handshake must succeed");
|
||||
let subscribe = read_request(&mut websocket).await;
|
||||
assert_eq!(subscribe["method"], serde_json::json!("logsSubscribe"));
|
||||
assert_eq!(subscribe["params"], serde_json::json!([{"mentions":["11111111111111111111111111111111"]},{"commitment":"finalized"}]));
|
||||
send_result(&mut websocket, &subscribe, serde_json::json!(88)).await;
|
||||
let id = subscribe.get("id").and_then(serde_json::Value::as_u64).expect("subscribe request id must exist");
|
||||
assert!(id > 0);
|
||||
let notification = serde_json::json!({
|
||||
"jsonrpc":"2.0",
|
||||
"method":"logsNotification",
|
||||
"params":{
|
||||
"result":{"context":{"slot":900},"value":{"signature":"fixture-signature","err":null,"logs":["Program fixture success"]}},
|
||||
"subscription":88
|
||||
}
|
||||
});
|
||||
websocket.send(tokio_tungstenite::tungstenite::Message::Text(notification.to_string().into())).await.expect("logs notification must send");
|
||||
let unsubscribe = read_request(&mut websocket).await;
|
||||
assert_eq!(unsubscribe["method"], serde_json::json!("logsUnsubscribe"));
|
||||
assert_eq!(unsubscribe["params"], serde_json::json!([88]));
|
||||
send_result(&mut websocket, &unsubscribe, serde_json::json!(true)).await;
|
||||
wait_for_close_frame(&mut websocket).await;
|
||||
});
|
||||
let session = crate::WsSession::connect(local_endpoint(url.as_str())).await.expect("client handshake must succeed");
|
||||
let mention = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("mention fixture must parse");
|
||||
let filter = crate::SolanaLogsSubscribeFilter::Mentions(mention);
|
||||
let config = crate::SolanaCommitmentConfig::new(std::option::Option::Some(crate::SolanaCommitment::Finalized));
|
||||
let mut subscription = session.logs_subscribe(&filter, std::option::Option::Some(&config)).await.expect("logsSubscribe must register");
|
||||
assert_eq!(subscription.kind(), crate::WsSubscriptionKind::Logs);
|
||||
let notification = subscription.recv().await.expect("logs notification must arrive").expect("logs notification must decode");
|
||||
assert_eq!(notification.context().slot(), 900);
|
||||
assert_eq!(notification.value().signature(), "fixture-signature");
|
||||
assert_eq!(notification.value().logs(), &["Program fixture success".to_owned()]);
|
||||
assert!(subscription.unsubscribe().await.expect("logs unsubscribe must complete"));
|
||||
session.close().await.expect("session close must complete");
|
||||
server.await.expect("local server task must complete");
|
||||
}
|
||||
Reference in New Issue
Block a user