use axum::{
extract::State,
routing::{get, post},
Json, Router,
};
use serde_json::{json, Value};
use std::collections::HashMap;
use std::net::TcpListener;
use std::path::Path;
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
use tarn::fixtures::{file_path_hash, slugify_name};
use tarn::model::TestFile;
use tarn::report::fixture_writer::{self, FixtureWriteConfig};
use tarn::runner::{self, RunOptions};
use tempfile::TempDir;
struct AppState {
counter: Mutex<u32>,
fail_every: u32,
}
async fn handler_list() -> Json<Value> {
Json(json!({"items": [{"id": 1, "name": "alpha"}]}))
}
async fn handler_create(
State(state): State<Arc<AppState>>,
Json(body): Json<Value>,
) -> (axum::http::StatusCode, Json<Value>) {
let mut count = state.counter.lock().unwrap();
*count += 1;
if state.fail_every > 0 && *count % state.fail_every == 0 {
return (
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "forced failure"})),
);
}
let id = *count as i64;
(
axum::http::StatusCode::CREATED,
Json(json!({"id": id, "echo": body})),
)
}
fn start_server(fail_every: u32) -> String {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
drop(listener);
let state = Arc::new(AppState {
counter: Mutex::new(0),
fail_every,
});
thread::spawn(move || {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
runtime.block_on(async move {
let app = Router::new()
.route("/users", post(handler_create))
.route("/users", get(handler_list))
.with_state(state);
let listener = tokio::net::TcpListener::bind(format!("127.0.0.1:{}", port))
.await
.unwrap();
axum::serve(listener, app).await.unwrap();
});
});
let health = format!("http://127.0.0.1:{}/users", port);
for _ in 0..50 {
if let Ok(client) = reqwest::blocking::Client::builder()
.timeout(Duration::from_millis(200))
.build()
{
if client.get(&health).send().is_ok() {
return format!("http://127.0.0.1:{}", port);
}
}
thread::sleep(Duration::from_millis(50));
}
panic!("test server never came online on port {}", port);
}
fn mk_options(root: &Path, enabled: bool, retention: usize) -> RunOptions {
RunOptions {
fixtures: FixtureWriteConfig {
enabled,
workspace_root: root.to_path_buf(),
retention,
},
..RunOptions::default()
}
}
fn write_test_file(dir: &Path, base_url: &str) -> (std::path::PathBuf, TestFile) {
let file_path = dir.join("tests/users.tarn.yaml");
std::fs::create_dir_all(file_path.parent().unwrap()).unwrap();
let yaml = format!(
r#"
name: Users
env:
base_url: "{base_url}"
tests:
happy:
steps:
- name: list users
request:
method: GET
url: "{{{{ env.base_url }}}}/users"
assert:
status: 200
"#
);
std::fs::write(&file_path, &yaml).unwrap();
let tf: TestFile = serde_yaml::from_str(&yaml).unwrap();
(file_path, tf)
}
#[test]
fn run_writes_fixture_under_dot_tarn_fixtures() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().to_path_buf();
let base = start_server(0);
let (file_path, tf) = write_test_file(&root, &base);
let opts = mk_options(&root, true, 5);
let env = HashMap::from([("base_url".to_string(), base.clone())]);
let file_path_str = file_path.to_string_lossy().into_owned();
let result = runner::run_file(&tf, &file_path_str, &env, &[], &opts).unwrap();
assert!(result.passed, "run must pass: {:?}", result);
let hash = file_path_hash(&root, &file_path);
let step_dir = root
.join(".tarn/fixtures")
.join(&hash)
.join(slugify_name("happy"))
.join("0");
assert!(step_dir.is_dir(), "expected {}", step_dir.display());
let latest = step_dir.join("latest-passed.json");
assert!(latest.is_file(), "latest-passed.json missing");
let raw = std::fs::read_to_string(&latest).unwrap();
let doc: Value = serde_json::from_str(&raw).unwrap();
assert_eq!(doc["passed"], json!(true));
assert_eq!(doc["request"]["method"], json!("GET"));
let resp = &doc["response"];
assert_eq!(resp["status"], json!(200));
let body = &resp["body"];
assert!(body.is_object(), "expected JSON body, got {body}");
}
#[test]
fn run_with_disabled_fixtures_writes_nothing() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().to_path_buf();
let base = start_server(0);
let (file_path, tf) = write_test_file(&root, &base);
let opts = mk_options(&root, false, 5);
let env = HashMap::from([("base_url".to_string(), base.clone())]);
let file_path_str = file_path.to_string_lossy().into_owned();
let result = runner::run_file(&tf, &file_path_str, &env, &[], &opts).unwrap();
assert!(result.passed);
let base_dir = root.join(".tarn").join("fixtures");
assert!(
!base_dir.exists(),
"fixtures directory should not exist when --no-fixtures is set"
);
}
#[test]
fn retention_caps_history_to_configured_value() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().to_path_buf();
let base = start_server(0);
let (file_path, tf) = write_test_file(&root, &base);
let opts = mk_options(&root, true, 2);
let env = HashMap::from([("base_url".to_string(), base.clone())]);
let file_path_str = file_path.to_string_lossy().into_owned();
for _ in 0..5 {
std::thread::sleep(Duration::from_millis(5));
let r = runner::run_file(&tf, &file_path_str, &env, &[], &opts).unwrap();
assert!(r.passed);
}
let hash = file_path_hash(&root, &file_path);
let step_dir = root
.join(".tarn/fixtures")
.join(&hash)
.join(slugify_name("happy"))
.join("0");
let entries: Vec<String> = std::fs::read_dir(&step_dir)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| n != "latest-passed.json" && n != "_index.json" && !n.ends_with(".tmp"))
.collect();
assert_eq!(
entries.len(),
2,
"retention=2 cap should yield 2 rolling entries, got {entries:?}"
);
}
#[test]
fn failing_step_is_readable_via_latest_fixture_reader() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().to_path_buf();
let base = start_server(1);
let yaml = format!(
r#"
name: Fails
env:
base_url: "{base}"
tests:
bad:
steps:
- name: create user
request:
method: POST
url: "{{{{ env.base_url }}}}/users"
body:
name: leaky-secret
assert:
status: 201
"#
);
let file_path = root.join("tests/fails.tarn.yaml");
std::fs::create_dir_all(file_path.parent().unwrap()).unwrap();
std::fs::write(&file_path, &yaml).unwrap();
let tf: TestFile = serde_yaml::from_str(&yaml).unwrap();
let opts = mk_options(&root, true, 5);
let env = HashMap::from([("base_url".to_string(), base.clone())]);
let file_path_str = file_path.to_string_lossy().into_owned();
let r = runner::run_file(&tf, &file_path_str, &env, &[], &opts).unwrap();
assert!(!r.passed);
let fixture = fixture_writer::read_latest_fixture(&root, &file_path, "bad", 0)
.expect("failed step must still persist a fixture");
assert!(!fixture.passed);
assert!(
fixture
.failure_message
.as_deref()
.unwrap_or("")
.to_lowercase()
.contains("status"),
"failure_message should reflect the status assertion: {:?}",
fixture.failure_message
);
assert_eq!(
fixture.response.as_ref().map(|r| r.status),
Some(500),
"fixture must retain the 500 status"
);
}