use std::{
collections::HashMap,
sync::{Arc, Mutex},
time::Duration,
};
use eventsourcingdb::Client;
use serde_json::Value;
use tokio::{
io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader},
net::{TcpListener, TcpStream},
};
use url::Url;
#[derive(Clone, Copy, Debug, PartialEq)]
pub(super) enum Api {
V1,
V2,
}
#[derive(Clone, Debug)]
pub(super) struct Reply {
pub(super) status: u16,
pub(super) body: String,
pub(super) headers: Vec<(String, String)>,
pub(super) is_from_eventsourcingdb: bool,
}
impl Reply {
pub(super) fn status(status: u16, body: &str) -> Self {
Self {
status,
body: body.to_owned(),
headers: Vec::new(),
is_from_eventsourcingdb: true,
}
}
pub(super) fn foreign(status: u16) -> Self {
Self {
is_from_eventsourcingdb: false,
..Self::status(status, "bad gateway")
}
}
pub(super) fn redirect(status: u16, location: &str) -> Self {
Self {
headers: vec![("Location".to_owned(), location.to_owned())],
..Self::foreign(status)
}
}
pub(super) fn error_with_code(code: &str, description: &str) -> Self {
Self::error_with_details(code, description, &serde_json::json!({}))
}
pub(super) fn error_with_details(code: &str, description: &str, details: &Value) -> Self {
let item = serde_json::json!({
"type": "error",
"data": { "code": code, "description": description, "details": details },
});
Self::status(200, &format!("{item}\n"))
}
}
#[derive(Clone, Debug)]
pub(super) enum Answer {
Reply(Reply),
ToPost(Reply),
HangUp,
HangUpOnHeaders,
CutOff(Reply),
Stall(Duration),
InTurn(Vec<Answer>),
}
#[derive(Debug)]
pub(super) struct Fake {
api: Api,
routes: Vec<(&'static str, Answer)>,
turns: Mutex<HashMap<String, usize>>,
vanishes_after: Option<&'static str>,
}
impl Fake {
pub(super) fn new(api: Api) -> Self {
Self {
api,
routes: Vec::new(),
vanishes_after: None,
turns: Mutex::default(),
}
}
pub(super) fn route(mut self, path: &'static str, answer: Answer) -> Self {
self.routes.push((path, answer));
self
}
pub(super) fn vanishing_after(mut self, path: &'static str) -> Self {
self.vanishes_after = Some(path);
self
}
pub(super) async fn start(self) -> (Url, Requests) {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("the fake binds");
let address = listener.local_addr().expect("the fake has an address");
let requests = Requests::default();
let recorded = requests.clone();
drop(tokio::spawn(async move {
while let Ok((connection, _)) = listener.accept().await {
let path = self.answer(connection, &recorded).await;
if self
.vanishes_after
.is_some_and(|vanishing| path.as_deref() == Some(vanishing))
{
break;
}
}
}));
let url = format!("http://{address}")
.parse()
.expect("the address is a URL");
(url, requests)
}
pub(super) async fn client(self) -> Client {
Client::new(self.start().await.0, "secret")
}
async fn answer(&self, connection: TcpStream, requests: &Requests) -> Option<String> {
let mut reader = BufReader::new(connection);
let mut request_line = String::new();
if reader.read_line(&mut request_line).await.unwrap_or(0) == 0 {
return None;
}
let mut parts = request_line.split_whitespace();
let method = parts.next().unwrap_or_default().to_owned();
let path = parts.next().unwrap_or_default().to_owned();
requests.record(&path);
let mut content_length = 0;
loop {
let mut header = String::new();
if reader.read_line(&mut header).await.unwrap_or(0) == 0 || header == "\r\n" {
break;
}
if let Some((name, value)) = header.split_once(':')
&& name.eq_ignore_ascii_case("content-length")
{
content_length = value.trim().parse().unwrap_or(0);
}
}
let answer = self.answer_to(&path);
if matches!(answer, Answer::HangUpOnHeaders) {
return Some(path);
}
let mut body = vec![0; content_length];
let _ = reader.read_exact(&mut body).await;
let mut connection = reader.into_inner();
let reply = match answer {
Answer::Reply(reply) => reply,
Answer::ToPost(reply) if method == "POST" => reply,
Answer::ToPost(_) => Reply::status(405, "method not allowed"),
Answer::HangUp | Answer::HangUpOnHeaders => return Some(path),
Answer::CutOff(reply) => {
let half = &reply.body.as_bytes()[..reply.body.len() / 2];
let _ = connection.write_all(head_of(&reply).as_bytes()).await;
let _ = connection.write_all(half).await;
return Some(path);
}
Answer::Stall(duration) => {
tokio::time::sleep(duration).await;
return Some(path);
}
Answer::InTurn(_) => unreachable!("answer_to resolves the turn"),
};
let _ = connection.write_all(head_of(&reply).as_bytes()).await;
let _ = connection.write_all(reply.body.as_bytes()).await;
let _ = connection.shutdown().await;
Some(path)
}
fn answer_to(&self, path: &str) -> Answer {
if path == "/api/v2/system/ping" {
return Answer::Reply(match self.api {
Api::V2 => Reply::status(200, "{\"type\":\"event\",\"data\":{}}\n"),
Api::V1 => Reply::status(404, "not found"),
});
}
if let Some((_, answer)) = self.routes.iter().find(|(route, _)| *route == path) {
let Answer::InTurn(answers) = answer else {
return answer.clone();
};
let mut turns = self.turns.lock().expect("the turns are not poisoned");
let turn = turns.entry(path.to_owned()).or_default();
let answer = answers[(*turn).min(answers.len() - 1)].clone();
*turn += 1;
return answer;
}
Answer::Reply(match self.api {
Api::V2 => Reply::status(200, "{\"type\":\"end\",\"data\":{}}\n"),
Api::V1 => Reply::status(200, ""),
})
}
}
fn head_of(reply: &Reply) -> String {
let server = if reply.is_from_eventsourcingdb {
"Server: EventSourcingDB/test\r\n"
} else {
"Server: proxy\r\n"
};
let mut head = format!(
"HTTP/1.1 {} Fake\r\nContent-Type: text/plain\r\nContent-Length: {}\r\n{server}",
reply.status,
reply.body.len(),
);
for (name, value) in &reply.headers {
for part in [name, ": ", value, "\r\n"] {
head.push_str(part);
}
}
head.push_str("Connection: close\r\n\r\n");
head
}
#[derive(Clone, Debug, Default)]
pub(super) struct Requests(Arc<Mutex<Vec<String>>>);
impl Requests {
fn record(&self, path: &str) {
self.0
.lock()
.expect("no test panics while recording")
.push(path.to_owned());
}
pub(super) fn count(&self) -> usize {
self.0.lock().expect("no test panics while recording").len()
}
pub(super) fn to(&self, path: &str) -> usize {
self.0
.lock()
.expect("no test panics while recording")
.iter()
.filter(|recorded| *recorded == path)
.count()
}
}
pub(super) async fn dead_client() -> Client {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("the address binds");
let address = listener.local_addr().expect("the address is known");
drop(listener);
let url = format!("http://{address}")
.parse()
.expect("the address is a URL");
Client::new(url, "secret")
}
pub(super) async fn refusing(api: Api, path: &'static str, reply: Reply) -> Client {
Fake::new(api)
.route(path, Answer::Reply(reply))
.client()
.await
}