use assert_cmd::Command;
use std::net::TcpListener;
use std::path::Path;
use std::process::{Command as StdCommand, Stdio};
use std::time::{Duration, Instant};
fn doido(dir: &Path) -> Command {
let mut cmd = Command::cargo_bin("doido-generators").expect("doido-generators binary");
cmd.current_dir(dir);
cmd
}
fn e2e_tempdir() -> tempfile::TempDir {
let base = Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("workspace root")
.join("target/e2e");
std::fs::create_dir_all(&base).expect("create e2e base dir");
tempfile::Builder::new()
.prefix("doido-e2e-")
.tempdir_in(&base)
.expect("create tempdir")
}
fn free_port() -> u16 {
TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap()
.port()
}
#[test]
#[ignore = "slow: builds the framework + app and boots a server; run via `make example`"]
fn generated_app_serves_crud_over_http() {
let tmp = e2e_tempdir();
doido(tmp.path())
.args(["new", "blog", "--non-interactive", "--database=sqlite"])
.assert()
.success();
let app = tmp.path().join("blog");
doido(&app)
.args([
"generate",
"scaffold",
"Post",
"title:string",
"body:text",
"--api",
])
.assert()
.success();
let port = free_port();
let dev_yml = app.join("config/development.yml");
let cfg = std::fs::read_to_string(&dev_yml).unwrap();
let cfg = cfg
.replace("bind: 0.0.0.0", "bind: 127.0.0.1")
.replace("port: 3000", &format!("port: {port}"));
std::fs::write(&dev_yml, cfg).unwrap();
let status = StdCommand::new(env!("CARGO"))
.args(["build", "--workspace", "--manifest-path"])
.arg(app.join("Cargo.toml"))
.status()
.expect("cargo build");
assert!(status.success(), "generated app failed to build");
let bin = app.join("target/debug/blog");
run_app(&bin, &app, &["db", "create"]);
run_app(&bin, &app, &["db", "migrate"]);
let mut server = StdCommand::new(&bin)
.arg("server")
.current_dir(&app)
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("spawn server");
let result = std::panic::catch_unwind(|| crud_cycle(port));
let _ = server.kill();
let _ = server.wait();
if let Err(e) = result {
std::panic::resume_unwind(e);
}
}
fn run_app(bin: &Path, app: &Path, args: &[&str]) {
let status = StdCommand::new(bin)
.args(args)
.current_dir(app)
.status()
.unwrap_or_else(|e| panic!("run {args:?}: {e}"));
assert!(status.success(), "app command {args:?} failed");
}
fn crud_cycle(port: u16) {
let base = format!("http://127.0.0.1:{port}");
wait_until_ready(&base);
let created: serde_json::Value = ureq::post(&format!("{base}/posts"))
.send_json(serde_json::json!({ "title": "Hello", "body": "world" }))
.expect("POST /posts")
.into_body()
.read_json()
.expect("json body");
let id = created["id"].as_i64().expect("created id");
assert_eq!(created["title"], "Hello");
let index: serde_json::Value = ureq::get(&format!("{base}/posts"))
.call()
.expect("GET /posts")
.into_body()
.read_json()
.unwrap();
assert!(
index
.as_array()
.unwrap()
.iter()
.any(|p| p["title"] == "Hello"),
"index lists the created post"
);
assert_eq!(
ureq::get(&format!("{base}/posts/{id}"))
.call()
.unwrap()
.status()
.as_u16(),
200
);
let updated: serde_json::Value = ureq::patch(&format!("{base}/posts/{id}"))
.send_json(serde_json::json!({ "title": "Updated", "body": "world" }))
.expect("PATCH /posts/:id")
.into_body()
.read_json()
.unwrap();
assert_eq!(updated["title"], "Updated");
assert_eq!(
ureq::delete(&format!("{base}/posts/{id}"))
.call()
.expect("DELETE /posts/:id")
.status()
.as_u16(),
204
);
}
fn wait_until_ready(base: &str) {
let deadline = Instant::now() + Duration::from_secs(30);
while Instant::now() < deadline {
if ureq::get(&format!("{base}/posts")).call().is_ok() {
return;
}
std::thread::sleep(Duration::from_millis(200));
}
panic!("server did not become ready within 30s");
}