//! `view` and `comments` against a mock forge whose issue-comments //! endpoint behaves like Forgejo's: it takes no page or limit and returns //! the whole thread on every request. Drives the compiled binary, since //! the request code is inline in each verb's `run()`. use std::io::{BufRead as _, BufReader, Write as _}; use std::net::{TcpListener, TcpStream}; use std::path::PathBuf; use std::process::Command; /// More than one 50-row page, so a loop that pages until a short page /// never sees one. const THREAD_LEN: usize = 60; /// The issue the mock forge serves; any other route gets an empty list. const NUMBER: &str = "1"; /// A unique scratch `HYPERHIVE_STATE_DIR` holding a `forge-token` file, /// same manual-tmpdir pattern as `credential_helper.rs`. 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-comments-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 } /// Answer one request. The comments route ignores the query string, as /// the real endpoint does; any route the verbs only read best-effort /// (notifications, reactions, PR reviews) gets an empty list. fn serve(mut stream: TcpStream) { let mut reader = BufReader::new(stream.try_clone().expect("clone stream")); let mut request_line = String::new(); reader .read_line(&mut request_line) .expect("read request line"); loop { let mut line = String::new(); if reader.read_line(&mut line).expect("read header") == 0 || line == "\r\n" { break; } } let target = request_line.split_whitespace().nth(1).unwrap_or(""); let path = target.split('?').next().unwrap_or(""); let issue = format!("/api/v1/repos/o/r/issues/{NUMBER}"); let body = match path { p if p == format!("{issue}/comments") => serde_json::Value::Array( (0..THREAD_LEN) .map(|i| { serde_json::json!({ "id": i + 1, "body": format!("comment {i}"), "user": {"login": "someone"}, "created_at": format!("2026-10-09T00:{:02}:00Z", i % 60), }) }) .collect(), ), p if p == issue => serde_json::json!({ "number": NUMBER.parse::().expect("numeric NUMBER"), "title": "a long thread", "body": "", "state": "open", "user": {"login": "someone"}, }), _ => serde_json::json!([]), } .to_string(); let response = format!( "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nX-Total-Count: {THREAD_LEN}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len() ); stream .write_all(response.as_bytes()) .expect("write response"); } /// Start the mock forge on a loopback port; returns its base URL. fn mock_forge() -> String { let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock forge"); let addr = listener.local_addr().expect("mock forge addr"); std::thread::spawn(move || { for stream in listener.incoming() { serve(stream.expect("accept")); } }); format!("http://{addr}") } /// Run `hive-forge -r o/r ` against a fresh mock forge; returns /// stdout, asserting the run succeeded. fn hive_forge(tag: &str, args: &[&str]) -> String { let out = Command::new(env!("CARGO_BIN_EXE_hive-forge")) .args(["-r", "o/r"]) .args(args) .env("HIVE_FORGE_URL", mock_forge()) .env("HYPERHIVE_STATE_DIR", scratch_state_dir(tag)) .env_remove("HIVE_FORGE_TOKEN_FILE") .env_remove("HIVE_LABEL") .output() .expect("run hive-forge"); let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); assert!( out.status.success(), "hive-forge {args:?} failed\nstdout:\n{stdout}\nstderr:\n{}", String::from_utf8_lossy(&out.stderr) ); stdout } /// Rendered comments: both verbs start each one with a bold `**` author. fn comment_count(stdout: &str) -> usize { stdout.lines().filter(|l| l.starts_with("**")).count() } #[test] fn view_prints_each_comment_once() { let stdout = hive_forge("view", &["view", NUMBER]); assert_eq!(comment_count(&stdout), THREAD_LEN, "{stdout}"); assert!(stdout.contains(&format!("## Comments ({THREAD_LEN})"))); } #[test] fn comments_limit_returns_exactly_limit() { let stdout = hive_forge("limit", &["comments", NUMBER, "--limit", "5"]); assert_eq!(comment_count(&stdout), 5, "{stdout}"); assert!(stdout.contains(&format!("({} more comment(s)", THREAD_LEN - 5))); } #[test] fn comments_tail_returns_the_last_n() { let stdout = hive_forge("tail", &["comments", NUMBER, "--tail", "3"]); assert_eq!(comment_count(&stdout), 3, "{stdout}"); assert!(stdout.contains(&format!("comment {}", THREAD_LEN - 1))); assert!(!stdout.contains(&format!("comment {}", THREAD_LEN - 4))); assert!(stdout.contains(&format!("({} earlier comment(s)", THREAD_LEN - 3))); } #[test] fn comments_since_limit_above_a_page_returns_exactly_limit() { let stdout = hive_forge( "since-limit", &[ "--json", "comments", NUMBER, "--since", "2026-10-09T00:00:00Z", "--limit", "55", ], ); let out: serde_json::Value = serde_json::from_str(&stdout).expect("comments --json output"); assert_eq!(out["comments"].as_array().map(Vec::len), Some(55)); assert_eq!(out["since_more"], true); assert_eq!(out["since_limit_clamped"], false); }