use std::collections::{HashMap, VecDeque};
use std::io::{Read, Write};
use std::net::TcpListener;
use std::sync::{Arc, Mutex};
use serde_json::{Value, json};
use crate::config::Configs;
type ResponseQueue = VecDeque<(u16, String)>;
type ResponseTable = Arc<Mutex<HashMap<String, ResponseQueue>>>;
pub struct MockBackboard {
base_url: String,
requests: Arc<Mutex<Vec<Value>>>,
responses: ResponseTable,
}
impl MockBackboard {
pub fn spawn() -> Self {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let requests: Arc<Mutex<Vec<Value>>> = Arc::new(Mutex::new(Vec::new()));
let responses: ResponseTable = Arc::new(Mutex::new(HashMap::new()));
let requests_for_thread = Arc::clone(&requests);
let responses_for_thread = Arc::clone(&responses);
std::thread::spawn(move || {
for stream in listener.incoming() {
let Ok(mut stream) = stream else { break };
let Some(body) = read_http_body(&mut stream) else {
continue;
};
let parsed: Value = serde_json::from_slice(&body).unwrap_or(Value::Null);
let operation = parsed
.get("operationName")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
requests_for_thread.lock().unwrap().push(parsed);
let (status, response_body) = {
let mut table = responses_for_thread.lock().unwrap();
match table.get_mut(&operation) {
Some(queue) if !queue.is_empty() => {
if queue.len() > 1 {
queue.pop_front().unwrap()
} else {
queue.front().unwrap().clone()
}
}
_ => (
200,
json!({
"errors": [{
"message": format!(
"MockBackboard: no scripted response for operation {operation:?}"
)
}],
"data": null,
})
.to_string(),
),
}
};
let reason = match status {
200 => "OK",
400 => "Bad Request",
500 => "Internal Server Error",
502 => "Bad Gateway",
503 => "Service Unavailable",
_ => "Unknown",
};
let response = format!(
"HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{response_body}",
response_body.len()
);
let _ = stream.write_all(response.as_bytes());
let _ = stream.flush();
}
});
Self {
base_url: format!("http://127.0.0.1:{port}/graphql/v2"),
requests,
responses,
}
}
pub fn url(&self) -> String {
self.base_url.clone()
}
pub fn configs(&self, dir: &tempfile::TempDir) -> Configs {
static ENV_LOCK: Mutex<()> = Mutex::new(());
let _guard = ENV_LOCK.lock().unwrap();
let previous = std::env::var("RAILWAY_BACKBOARD_URL").ok();
unsafe { std::env::set_var("RAILWAY_BACKBOARD_URL", self.url()) };
let configs = Configs::for_test(dir.path().join("config.json"));
unsafe {
match previous {
Some(value) => std::env::set_var("RAILWAY_BACKBOARD_URL", value),
None => std::env::remove_var("RAILWAY_BACKBOARD_URL"),
}
}
configs
}
pub fn stub(&self, operation: &str, data: Value) {
self.stub_raw(operation, 200, json!({ "data": data }).to_string());
}
pub fn stub_graphql_error(&self, operation: &str, message: &str) {
self.stub_raw(
operation,
200,
json!({ "errors": [{ "message": message }], "data": null }).to_string(),
);
}
pub fn stub_raw(&self, operation: &str, status: u16, body: String) {
self.responses
.lock()
.unwrap()
.entry(operation.to_string())
.or_default()
.push_back((status, body));
}
pub fn requests(&self) -> Vec<Value> {
self.requests.lock().unwrap().clone()
}
pub fn variables_for(&self, operation: &str) -> Vec<Value> {
self.requests()
.into_iter()
.filter(|r| r.get("operationName").and_then(Value::as_str) == Some(operation))
.map(|r| r.get("variables").cloned().unwrap_or(Value::Null))
.collect()
}
pub fn hits(&self) -> usize {
self.requests.lock().unwrap().len()
}
}
fn read_http_body(stream: &mut std::net::TcpStream) -> Option<Vec<u8>> {
let mut buf = Vec::new();
let mut tmp = [0u8; 1024];
let mut content_length = 0usize;
loop {
let read = stream.read(&mut tmp).ok()?;
if read == 0 {
return None;
}
buf.extend_from_slice(&tmp[..read]);
if let Some(pos) = find_headers_end(&buf) {
let headers = String::from_utf8_lossy(&buf[..pos]).to_lowercase();
for line in headers.lines() {
if let Some(v) = line.strip_prefix("content-length:") {
content_length = v.trim().parse().unwrap_or(0);
}
}
if buf.len() >= pos + 4 + content_length {
let body_start = pos + 4;
return Some(buf[body_start..body_start + content_length].to_vec());
}
}
}
}
fn find_headers_end(buf: &[u8]) -> Option<usize> {
buf.windows(4).position(|w| w == b"\r\n\r\n")
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn routes_by_operation_name_and_records_variables() {
let server = MockBackboard::spawn();
server.stub("OpA", json!({ "a": 1 }));
server.stub("OpB", json!({ "b": 2 }));
let client = reqwest::Client::new();
for (op, var) in [("OpA", "x"), ("OpB", "y"), ("OpA", "z")] {
let response: Value = client
.post(server.url())
.json(&json!({ "operationName": op, "query": "{}", "variables": { "v": var } }))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert!(response.get("data").is_some(), "unexpected: {response}");
}
assert_eq!(server.hits(), 3);
assert_eq!(server.variables_for("OpA").len(), 2);
assert_eq!(server.variables_for("OpB"), vec![json!({ "v": "y" })]);
}
#[tokio::test]
async fn responses_are_served_in_order_and_the_last_repeats() {
let server = MockBackboard::spawn();
server.stub("Poll", json!({ "n": 1 }));
server.stub("Poll", json!({ "n": 2 }));
let client = reqwest::Client::new();
let mut seen = Vec::new();
for _ in 0..4 {
let response: Value = client
.post(server.url())
.json(&json!({ "operationName": "Poll", "query": "{}" }))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
seen.push(response["data"]["n"].as_i64().unwrap());
}
assert_eq!(seen, vec![1, 2, 2, 2]);
}
#[tokio::test]
async fn unscripted_operations_fail_loudly() {
let server = MockBackboard::spawn();
let client = reqwest::Client::new();
let response: Value = client
.post(server.url())
.json(&json!({ "operationName": "Nope", "query": "{}" }))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let message = response["errors"][0]["message"].as_str().unwrap();
assert!(message.contains("no scripted response"));
assert!(message.contains("Nope"));
}
}