use mockforge_graphql::create_graphql_router;
use serde_json::json;
use std::time::Duration;
async fn spawn_server() -> String {
let (http_url, _ws_url) = spawn_server_pair().await;
http_url
}
async fn spawn_server_pair() -> (String, String) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let router = create_graphql_router(None).await.expect("router builds cleanly");
tokio::spawn(async move {
axum::serve(listener, router).await.unwrap();
});
tokio::time::sleep(Duration::from_millis(50)).await;
(format!("http://{addr}/graphql"), format!("ws://{addr}/graphql/ws"))
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn graphql_real_client_users_query_returns_structured_list() {
let url = spawn_server().await;
let client = reqwest::Client::new();
let body = json!({
"query": "query($limit: Int!) { users(limit: $limit) { id name email } }",
"variables": { "limit": 3 },
});
let response =
tokio::time::timeout(Duration::from_secs(5), client.post(&url).json(&body).send())
.await
.expect("request should complete within 5s")
.expect("reqwest transport ok");
assert!(response.status().is_success(), "HTTP {}", response.status());
let value: serde_json::Value = response.json().await.expect("response must be JSON");
if let Some(errs) = value.get("errors") {
assert!(
errs.as_array().map(|a| a.is_empty()).unwrap_or(false),
"expected no GraphQL errors, got: {errs}"
);
}
let users = value
.pointer("/data/users")
.and_then(|v| v.as_array())
.expect("response.data.users must be an array");
assert_eq!(users.len(), 3, "limit=3 was requested; got {} users", users.len());
for (i, u) in users.iter().enumerate() {
let id = u.pointer("/id").and_then(|v| v.as_str()).unwrap();
let name = u.pointer("/name").and_then(|v| v.as_str()).unwrap();
let email = u.pointer("/email").and_then(|v| v.as_str()).unwrap();
assert_eq!(id, format!("user-{i}"));
assert_eq!(name, format!("User {i}"));
assert!(email.ends_with("@example.com"), "unexpected email shape: {email}");
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn graphql_real_client_user_by_id_returns_single_record() {
let url = spawn_server().await;
let client = reqwest::Client::new();
let body = json!({
"query": r#"{ user(id: "42") { id name email } }"#,
});
let response = client.post(&url).json(&body).send().await.expect("reqwest transport ok");
assert!(response.status().is_success());
let value: serde_json::Value = response.json().await.unwrap();
let user = value.pointer("/data/user").expect("data.user present");
assert_eq!(user.pointer("/id").and_then(|v| v.as_str()), Some("42"));
assert!(user.pointer("/name").and_then(|v| v.as_str()).is_some());
}
const INTROSPECTION_QUERY: &str = r#"
query IntrospectionQuery {
__schema {
queryType { name }
mutationType { name }
subscriptionType { name }
types {
name
kind
fields(includeDeprecated: true) { name }
}
}
}
"#;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn graphql_real_client_introspection_query_returns_schema() {
let url = spawn_server().await;
let client = reqwest::Client::new();
let body = json!({ "query": INTROSPECTION_QUERY });
let response =
tokio::time::timeout(Duration::from_secs(5), client.post(&url).json(&body).send())
.await
.expect("request should complete within 5s")
.expect("reqwest transport ok");
assert!(response.status().is_success(), "HTTP {}", response.status());
let value: serde_json::Value = response.json().await.expect("response must be JSON");
if let Some(errs) = value.get("errors") {
assert!(
errs.as_array().map(|a| a.is_empty()).unwrap_or(false),
"expected no GraphQL errors, got: {errs}"
);
}
let schema = value.pointer("/data/__schema").expect("response.data.__schema must be present");
assert_eq!(
schema.pointer("/queryType/name").and_then(|v| v.as_str()),
Some("QueryRoot"),
"queryType name should be QueryRoot"
);
assert_eq!(
schema.pointer("/mutationType/name").and_then(|v| v.as_str()),
Some("MutationRoot"),
"default schema has a mutation root named MutationRoot"
);
assert_eq!(
schema.pointer("/subscriptionType/name").and_then(|v| v.as_str()),
Some("SubscriptionRoot"),
"default schema has a subscription root named SubscriptionRoot"
);
let types = schema
.pointer("/types")
.and_then(|v| v.as_array())
.expect("__schema.types must be an array");
let names: std::collections::HashSet<&str> = types
.iter()
.filter_map(|t| t.pointer("/name").and_then(|v| v.as_str()))
.collect();
for required in ["QueryRoot", "User", "Post"] {
assert!(
names.contains(required),
"introspection types missing `{required}`; returned: {names:?}"
);
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn graphql_real_client_create_user_mutation_returns_user() {
let url = spawn_server().await;
let client = reqwest::Client::new();
let body = json!({
"query": r#"
mutation CreateUser($name: String!, $email: String!) {
createUser(name: $name, email: $email) { id name email }
}
"#,
"variables": { "name": "Alice", "email": "alice@example.com" },
});
let response =
tokio::time::timeout(Duration::from_secs(5), client.post(&url).json(&body).send())
.await
.expect("request should complete within 5s")
.expect("reqwest transport ok");
assert!(response.status().is_success(), "HTTP {}", response.status());
let value: serde_json::Value = response.json().await.expect("response must be JSON");
if let Some(errs) = value.get("errors") {
assert!(
errs.as_array().map(|a| a.is_empty()).unwrap_or(false),
"expected no GraphQL errors, got: {errs}"
);
}
let user = value.pointer("/data/createUser").expect("data.createUser must be present");
assert_eq!(user.pointer("/name").and_then(|v| v.as_str()), Some("Alice"));
assert_eq!(user.pointer("/email").and_then(|v| v.as_str()), Some("alice@example.com"));
let id = user.pointer("/id").and_then(|v| v.as_str()).expect("id must be a string");
assert!(id.starts_with("user-"), "expected derived id `user-<hex>`, got {id}");
assert_eq!(id.len(), "user-".len() + 12, "expected 12-hex-char suffix, got {id}");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn graphql_syntactically_invalid_query_surfaces_error_field() {
let url = spawn_server().await;
let client = reqwest::Client::new();
let body = json!({ "query": "{ this is not graphql" });
let response = client.post(&url).json(&body).send().await.expect("reqwest transport ok");
let value: serde_json::Value = response.json().await.unwrap();
let errors = value.pointer("/errors").and_then(|v| v.as_array());
assert!(
errors.is_some_and(|e| !e.is_empty()),
"expected non-empty errors array for invalid query, got: {value}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn graphql_real_client_subscription_ticks_over_websocket() {
use futures::{SinkExt, StreamExt};
use tokio_tungstenite::tungstenite::handshake::client::generate_key;
use tokio_tungstenite::tungstenite::http::Request;
use tokio_tungstenite::tungstenite::Message;
let (_http_url, ws_url) = spawn_server_pair().await;
let parsed = url::Url::parse(&ws_url).expect("valid ws url");
let host = parsed.host_str().expect("host");
let port = parsed.port().expect("port");
let req = Request::builder()
.uri(&ws_url)
.header("Host", format!("{host}:{port}"))
.header("Upgrade", "websocket")
.header("Connection", "Upgrade")
.header("Sec-WebSocket-Key", generate_key())
.header("Sec-WebSocket-Version", "13")
.header("Sec-WebSocket-Protocol", "graphql-transport-ws")
.body(())
.expect("request builds");
let (mut ws, _resp) =
tokio::time::timeout(Duration::from_secs(5), tokio_tungstenite::connect_async(req))
.await
.expect("ws connect should complete within 5s")
.expect("ws handshake ok");
ws.send(Message::Text(r#"{"type":"connection_init"}"#.into()))
.await
.expect("send connection_init");
let ack = tokio::time::timeout(Duration::from_secs(5), ws.next())
.await
.expect("ack arrives within 5s")
.expect("ack is present")
.expect("no transport error");
let ack_text = ack.to_text().expect("ack is text");
assert!(ack_text.contains("connection_ack"), "expected connection_ack, got: {ack_text}");
ws.send(Message::Text(
r#"{"type":"subscribe","id":"1","payload":{"query":"subscription { tick(count: 3) }"}}"#
.into(),
))
.await
.expect("send subscribe");
let mut ticks: Vec<i64> = Vec::new();
let mut saw_complete = false;
loop {
let msg = tokio::time::timeout(Duration::from_secs(5), ws.next())
.await
.expect("frame arrives within 5s")
.expect("frame present")
.expect("no transport error");
let text = match msg {
Message::Text(t) => t,
Message::Close(_) => break,
_ => continue, };
let v: serde_json::Value = serde_json::from_str(&text).expect("valid json frame");
match v.pointer("/type").and_then(|t| t.as_str()) {
Some("next") => {
let n = v
.pointer("/payload/data/tick")
.and_then(|x| x.as_i64())
.expect("tick value present in frame");
ticks.push(n);
}
Some("complete") => {
saw_complete = true;
break;
}
Some("error") => panic!("subscription error frame: {v}"),
_ => {}
}
}
assert!(saw_complete, "expected a terminal `complete` frame; got {} ticks", ticks.len());
assert_eq!(ticks, vec![1, 2, 3], "expected 3 monotonically increasing ticks");
}