use super::*;
use std::io::{Read as _, Write as _};
use std::net::{Shutdown, TcpListener, TcpStream};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use super::compile_matrix::{EVENTS_MAIL, EVENTS_MAIL_KAFKA, Scenario};
use super::worker_e2e::{
Project, build_project, create_product, free_port, http, logged, wait_until,
};
const CONSUMER: &str = "mail_product_live";
struct SmtpSwitch {
port: u16,
up: Arc<AtomicBool>,
open: Arc<Mutex<Vec<TcpStream>>>,
}
impl SmtpSwitch {
fn start(upstream: String) -> Self {
let listener = TcpListener::bind("127.0.0.1:0").expect("no free port");
let port = listener.local_addr().unwrap().port();
let up = Arc::new(AtomicBool::new(true));
let open: Arc<Mutex<Vec<TcpStream>>> = Arc::default();
let (accept_up, accept_open) = (up.clone(), open.clone());
std::thread::spawn(move || {
for client in listener.incoming().flatten() {
if !accept_up.load(Ordering::SeqCst) {
let _ = client.shutdown(Shutdown::Both);
continue;
}
let Ok(server) = TcpStream::connect(&upstream) else {
continue;
};
let mut open = accept_open.lock().unwrap();
open.push(client.try_clone().unwrap());
open.push(server.try_clone().unwrap());
pipe(client.try_clone().unwrap(), server.try_clone().unwrap());
pipe(server, client);
}
});
Self { port, up, open }
}
fn url(&self) -> String {
format!("smtp://127.0.0.1:{}", self.port)
}
fn down(&self) {
self.up.store(false, Ordering::SeqCst);
for stream in self.open.lock().unwrap().drain(..) {
let _ = stream.shutdown(Shutdown::Both);
}
}
fn up(&self) {
self.up.store(true, Ordering::SeqCst);
}
}
fn pipe(mut from: TcpStream, mut to: TcpStream) {
std::thread::spawn(move || {
let _ = std::io::copy(&mut from, &mut to);
let _ = to.shutdown(Shutdown::Write);
});
}
fn mailpit() -> Option<(String, u16)> {
let smtp = std::env::var("PUERTO_TEST_SMTP_URL").ok()?;
let api = std::env::var("PUERTO_TEST_MAILPIT_API").ok()?;
let smtp = smtp.trim_start_matches("smtp://").to_string();
let port = api.rsplit(':').next()?.trim_end_matches('/').parse().ok()?;
Some((smtp, port))
}
fn mailpit_messages(api_port: u16) -> Vec<serde_json::Value> {
let mut stream = TcpStream::connect(("127.0.0.1", api_port)).expect("Mailpit is not up");
stream
.write_all(b"GET /api/v1/messages?limit=1000 HTTP/1.0\r\nHost: localhost\r\n\r\n")
.unwrap();
let mut response = String::new();
stream.read_to_string(&mut response).unwrap();
let body = response.split_once("\r\n\r\n").map_or("", |(_, b)| b);
let json: serde_json::Value = serde_json::from_str(body).unwrap_or_default();
json["messages"].as_array().cloned().unwrap_or_default()
}
fn sent_to(api_port: u16, to: &str) -> Vec<String> {
mailpit_messages(api_port)
.iter()
.filter(|m| {
m["To"]
.as_array()
.is_some_and(|tos| tos.iter().any(|t| t["Address"] == to))
})
.map(|m| m["Subject"].as_str().unwrap_or_default().to_string())
.collect()
}
fn address(tag: &str) -> String {
format!("{tag}-{}@e2e.test", std::process::id())
}
fn redeliver(database_url: &str, consumer: &str) {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(async {
let pool = sqlx::PgPool::connect(database_url).await.unwrap();
let handed_back = sqlx::query(
"UPDATE event_deliveries SET status = 'pending', next_attempt_at = now() \
WHERE consumer = $1",
)
.bind(consumer)
.execute(&pool)
.await
.unwrap()
.rows_affected();
assert!(handed_back > 0, "no delivery of {consumer} to hand back");
pool.close().await;
});
}
fn build_mail_project(
scenario: &Scenario,
name: &str,
database: &str,
switch: &SmtpSwitch,
extra_env: &[(&str, String)],
) -> Option<Project> {
let mut env = vec![
("SMTP_URL", switch.url()),
("MAIL_FROM", "Shop <no-reply@e2e.test>".to_string()),
("MAX_DELIVERIES", "100".to_string()),
("RETRY_MAX_DELAY_MS", "100".to_string()),
];
env.extend(extra_env.iter().cloned());
build_project(scenario, name, database, &[], &env)
}
fn database_url(project: &Project) -> String {
project
.env
.iter()
.find(|(k, _)| k == "DATABASE_URL")
.map(|(_, v)| v.clone())
.expect("no DATABASE_URL")
}
fn assert_mails_are_sent_once(
project: &Project,
api_binary: &str,
switch: &SmtpSwitch,
api_port_mailpit: u16,
redelivery: Option<&dyn Fn()>,
) {
let (api_port, worker_port) = (free_port(), free_port());
let api_log = project.dir.join("api.log");
let worker_log = project.dir.join("worker.log");
let api = project.spawn(
api_binary,
&api_log,
&[("SERVICE_PORT", api_port.to_string())],
);
let worker_env = [("WORKER_PORT", worker_port.to_string())];
let worker = project.spawn("worker", &worker_log, &worker_env);
wait_until("the API to be ready", || {
http(api_port, "GET", "/health/ready", "").is_some_and(|(s, _)| s == 200)
});
wait_until("the worker to be ready", || {
http(worker_port, "GET", "/health/ready", "").is_some_and(|(s, _)| s == 200)
});
let first = address("first");
create_product(api_port, &first);
wait_until("the first mail to reach Mailpit", || {
!sent_to(api_port_mailpit, &first).is_empty()
});
assert_eq!(
sent_to(api_port_mailpit, &first),
["Product live, Desk lamp"]
);
if let Some(redeliver) = redelivery {
redeliver();
wait_until("the redelivered event to be skipped", || {
logged(
&worker_log,
&["mail already sent for this event", "product_live"],
)
});
assert_eq!(sent_to(api_port_mailpit, &first).len(), 1);
}
switch.down();
let delayed = address("delayed");
create_product(api_port, &delayed);
wait_until("the mail to be retried", || {
logged(&worker_log, &["mail: not sent, retrying", "product_live"])
});
assert!(sent_to(api_port_mailpit, &delayed).is_empty());
switch.up();
wait_until("the delayed mail to reach Mailpit", || {
!sent_to(api_port_mailpit, &delayed).is_empty()
});
assert_eq!(sent_to(api_port_mailpit, &delayed).len(), 1);
drop(worker);
let after_restart = address("after-restart");
create_product(api_port, &after_restart);
let restarted_log = project.dir.join("worker-restarted.log");
let restarted = project.spawn("worker", &restarted_log, &worker_env);
wait_until("the restarted worker to send the next mail", || {
!sent_to(api_port_mailpit, &after_restart).is_empty()
});
assert_eq!(sent_to(api_port_mailpit, &first).len(), 1);
assert_eq!(sent_to(api_port_mailpit, &delayed).len(), 1);
drop(restarted);
drop(api);
}
#[test]
#[ignore = "slow: needs Postgres and Mailpit — run via `make test/db`"]
fn postgres_mail_is_sent_once_per_event_and_retried_while_smtp_is_down() {
let Some((smtp, api_port_mailpit)) = mailpit() else {
eprintln!(
"PUERTO_TEST_SMTP_URL / PUERTO_TEST_MAILPIT_API not set — skipping (run `make test/db`)"
);
return;
};
let switch = SmtpSwitch::start(smtp);
let Some(project) = build_mail_project(
&EVENTS_MAIL,
"e2e-mail",
"puerto_matrix_mail_e2e",
&switch,
&[],
) else {
eprintln!("PUERTO_TEST_DATABASE_URL not set — skipping (run `make test/db`)");
return;
};
let url = database_url(&project);
assert_mails_are_sent_once(
&project,
"e2e-mail",
&switch,
api_port_mailpit,
Some(&|| redeliver(&url, CONSUMER)),
);
cleanup(&project.dir);
}
#[test]
#[ignore = "slow: needs Kafka, Postgres and Mailpit — run via `make test/kafka`"]
fn kafka_mail_is_sent_once_per_event_and_retried_while_smtp_is_down() {
let Some((smtp, api_port_mailpit)) = mailpit() else {
eprintln!(
"PUERTO_TEST_SMTP_URL / PUERTO_TEST_MAILPIT_API not set — skipping (run `make test/kafka`)"
);
return;
};
let Some(brokers) = std::env::var("PUERTO_TEST_KAFKA_BROKERS").ok() else {
eprintln!("PUERTO_TEST_KAFKA_BROKERS not set — skipping (run `make test/kafka`)");
return;
};
let switch = SmtpSwitch::start(smtp);
let prefix = format!("e2email{}", std::process::id());
let Some(project) = build_mail_project(
&EVENTS_MAIL_KAFKA,
"e2e-mail-kafka",
"puerto_matrix_mail_kafka_e2e",
&switch,
&[("KAFKA_BROKERS", brokers), ("KAFKA_TOPIC_PREFIX", prefix)],
) else {
eprintln!("PUERTO_TEST_DATABASE_URL not set — skipping (run `make test/kafka`)");
return;
};
assert_mails_are_sent_once(&project, "e2e-mail-kafka", &switch, api_port_mailpit, None);
cleanup(&project.dir);
}