Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/hive-forge/tests/dependency_cycle.rs

165 lines
5.7 KiB
Rust

//! `dependency add` against a mock forge: an edge that would close a cycle
//! must be refused with a message naming the existing edge, before any
//! POST reaches the forge.
use std::io::{BufRead as _, BufReader, Read as _, Write as _};
use std::net::{TcpListener, TcpStream};
use std::path::PathBuf;
use std::process::Command;
use std::sync::{Arc, Mutex};
/// Existing edges `(issue, depends_on)` served by the mock.
const EDGES: &[(u64, u64)] = &[(20, 10), (30, 20)];
fn scratch_state_dir(tag: &str) -> PathBuf {
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos());
let dir = std::env::temp_dir().join(format!("hive-forge-dep-cycle-test-{tag}-{ts}"));
std::fs::create_dir_all(&dir).expect("create scratch state dir");
std::fs::write(dir.join("forge-token"), "test-token\n").expect("seed forge-token");
dir
}
/// `GET .../issues/<n>/dependencies` lists `n`'s dependencies; `POST`
/// answers 500 like the real forge does for a cycle and is recorded.
fn serve(mut stream: TcpStream, posts: &Mutex<u32>) {
let mut reader = BufReader::new(stream.try_clone().expect("clone stream"));
let mut request_line = String::new();
reader.read_line(&mut request_line).expect("request line");
let mut content_length = 0usize;
loop {
let mut line = String::new();
if reader.read_line(&mut line).expect("read header") == 0 || line == "\r\n" {
break;
}
if let Some((name, value)) = line.split_once(':')
&& name.eq_ignore_ascii_case("content-length")
{
content_length = value.trim().parse().expect("numeric content-length");
}
}
let mut raw = vec![0u8; content_length];
reader.read_exact(&mut raw).expect("read body");
let response = if request_line.starts_with("POST") {
*posts.lock().expect("lock") += 1;
"HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
.to_owned()
} else {
let n: u64 = request_line
.split("/issues/")
.nth(1)
.and_then(|r| r.split(['/', '?', ' ']).next())
.and_then(|r| r.parse().ok())
.expect("issue number in path");
let deps: Vec<_> = EDGES
.iter()
.filter(|(a, _)| *a == n)
.map(|(_, b)| serde_json::json!({ "number": b, "title": "t", "state": "open" }))
.collect();
let body = if request_line.contains("/dependencies") {
serde_json::Value::Array(deps).to_string()
} else {
serde_json::json!({ "number": n, "title": "t", "state": "open" }).to_string()
};
format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
)
};
stream
.write_all(response.as_bytes())
.expect("write response");
}
/// Run `hive-forge -r o/r issue dependency <number> add <dep>`; returns
/// `(success, stderr, POST count)`.
fn add(tag: &str, number: &str, dep: &str) -> (bool, String, u32) {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock forge");
let addr = listener.local_addr().expect("mock forge addr");
let posts = Arc::new(Mutex::new(0));
let served = Arc::clone(&posts);
std::thread::spawn(move || {
for stream in listener.incoming() {
serve(stream.expect("accept"), &served);
}
});
let out = Command::new(env!("CARGO_BIN_EXE_hive-forge"))
.args(["-r", "o/r", "issue", "dependency", number, "add", dep])
.env("HIVE_FORGE_URL", format!("http://{addr}"))
.env("HYPERHIVE_STATE_DIR", scratch_state_dir(tag))
.env_remove("HIVE_FORGE_TOKEN_FILE")
.env_remove("HIVE_LABEL")
.output()
.expect("run hive-forge");
let count = *posts.lock().expect("lock");
(
out.status.success(),
String::from_utf8_lossy(&out.stderr).into_owned(),
count,
)
}
#[test]
fn direct_reverse_edge_is_refused_before_any_post() {
// Issue 20 depends on issue 10; making 10 depend on 20 closes a cycle.
let (ok, stderr, posts) = add("direct", "10", "20");
assert!(!ok);
assert!(
stderr.contains(&format!(
"{} already depends on {} (directly)",
tag(20),
tag(10)
)),
"{stderr}"
);
assert!(stderr.contains("would create a cycle"), "{stderr}");
assert!(!stderr.contains("500"), "{stderr}");
assert_eq!(posts, 0);
}
#[test]
fn transitive_cycle_is_refused_before_any_post() {
// Issue 30 depends on 20, which depends on 10; adding 30 under 10 closes a cycle via 20.
let (ok, stderr, posts) = add("transitive", "10", "30");
assert!(!ok);
assert!(
stderr.contains(&format!(
"{} already depends on {} (via {})",
tag(30),
tag(10),
tag(20)
)),
"{stderr}"
);
assert_eq!(posts, 0);
}
#[test]
fn non_cyclic_add_reaches_the_forge() {
// 10 depends on nothing, so making 40 depend on 10 is fine; the mock
// answers the POST with 500, which proves the request was sent.
let (ok, stderr, posts) = add("control", "40", "10");
assert_eq!(posts, 1, "{stderr}");
assert!(!stderr.contains("cycle"), "{stderr}");
assert!(!ok);
}
/// How the CLI writes an issue number in its messages.
fn tag(n: u64) -> String {
format!("{}{n}", '#')
}
#[test]
fn self_dependency_is_refused_before_any_post() {
let (ok, stderr, posts) = add("self", "10", "10");
assert!(!ok);
assert!(
stderr.contains(&format!(
"{} cannot depend on itself, which would create a cycle",
tag(10)
)),
"{stderr}"
);
assert_eq!(posts, 0);
}