#![allow(clippy::unwrap_used)]
#![allow(clippy::expect_used)]
#![allow(clippy::panic)]
#![allow(clippy::indexing_slicing)]
use anyhow::Result;
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::sync::OnceLock;
use std::time::Duration;
mod common;
static BINARY_PATH: OnceLock<PathBuf> = OnceLock::new();
fn get_binary_path() -> &'static PathBuf {
BINARY_PATH.get_or_init(|| {
let output = Command::new("cargo")
.args(["build", "--bin", "pg_exporter"])
.output()
.expect("Failed to build binary");
assert!(
output.status.success(),
"Failed to build binary:\n{}",
String::from_utf8_lossy(&output.stderr)
);
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("target")
.join("debug")
.join("pg_exporter")
})
}
fn run_binary_with_args(args: &[&str]) -> std::io::Result<std::process::Output> {
Command::new(get_binary_path()).args(args).output()
}
fn start_binary(port: u16, dsn: &str) -> std::io::Result<Child> {
Command::new(get_binary_path())
.args(["--port", &port.to_string(), "--dsn", dsn])
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
}
fn start_binary_with_env(port: u16, dsn: &str) -> std::io::Result<Child> {
Command::new(get_binary_path())
.env("PG_EXPORTER_PORT", port.to_string())
.env("PG_EXPORTER_DSN", dsn)
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
}
fn cleanup_child(child: &mut Child) {
let _ = child.kill();
let _ = child.wait();
}
struct ChildGuard(Child);
impl ChildGuard {
fn new(child: Child) -> Self {
Self(child)
}
fn as_mut(&mut self) -> &mut Child {
&mut self.0
}
}
impl Drop for ChildGuard {
fn drop(&mut self) {
cleanup_child(&mut self.0);
}
}
async fn start_and_wait(port: u16, dsn: &str) -> Result<ChildGuard> {
let child = start_binary(port, dsn)?;
let guard = ChildGuard::new(child);
if !common::wait_for_server(port, 100).await {
anyhow::bail!("Server failed to start on port {port}");
}
Ok(guard)
}
async fn http_get(port: u16, endpoint: &str) -> Result<String> {
let client = reqwest::Client::new();
let response = client
.get(format!("http://localhost:{port}{endpoint}"))
.timeout(Duration::from_secs(10))
.send()
.await?;
if !response.status().is_success() {
anyhow::bail!("HTTP request failed with status: {}", response.status());
}
Ok(response.text().await?)
}
#[test]
fn test_binary_help_flag() {
let output = run_binary_with_args(&["--help"]).expect("Failed to execute binary");
assert!(output.status.success(), "Binary should exit successfully");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("PostgreSQL metric exporter"),
"Help output should contain description"
);
assert!(stdout.contains("--port"), "Help should show port option");
assert!(stdout.contains("--dsn"), "Help should show dsn option");
}
#[test]
fn test_binary_version_flag() {
let output = run_binary_with_args(&["--version"]).expect("Failed to execute binary");
assert!(output.status.success(), "Binary should exit successfully");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("pg_exporter"),
"Version output should contain binary name"
);
}
#[test]
fn test_binary_invalid_port() {
let output = run_binary_with_args(&["--port", "70000"]).expect("Failed to execute binary");
assert!(
!output.status.success(),
"Binary should fail with invalid port"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("70000") || stderr.contains("port") || stderr.contains("range"),
"Error should mention port validation"
);
}
#[tokio::test]
async fn test_binary_starts_and_stops() -> Result<()> {
let port = common::get_available_port();
let dsn = common::get_test_dsn();
let mut guard = start_and_wait(port, &dsn).await?;
cleanup_child(guard.as_mut());
tokio::time::sleep(Duration::from_millis(200)).await;
let result = tokio::net::TcpStream::connect(format!("localhost:{port}")).await;
assert!(result.is_err(), "Server should be stopped");
Ok(())
}
#[tokio::test]
#[cfg(unix)]
async fn test_binary_handles_graceful_shutdown() -> Result<()> {
let port = common::get_available_port();
let dsn = common::get_test_dsn();
let mut guard = start_and_wait(port, &dsn).await?;
let child = guard.as_mut();
child.kill().expect("Failed to kill process");
let status = child.wait().expect("Failed to wait for process");
assert!(!status.success(), "Process was killed");
tokio::time::sleep(Duration::from_millis(200)).await;
let result = tokio::net::TcpStream::connect(format!("localhost:{port}")).await;
assert!(result.is_err(), "Server should be stopped");
Ok(())
}
#[tokio::test]
async fn test_binary_uses_environment_variables() -> Result<()> {
let port = common::get_available_port();
let dsn = common::get_test_dsn();
let child = start_binary_with_env(port, &dsn)?;
let _guard = ChildGuard::new(child);
if !common::wait_for_server(port, 100).await {
anyhow::bail!("Server should start using env vars");
}
Ok(())
}
#[test]
fn test_binary_disable_collector() {
let output = run_binary_with_args(&["--help"]).expect("Failed to execute binary");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("--no-collector"),
"Help should show collector disable options"
);
}
#[test]
fn test_binary_validates_dsn_format() {
let output = Command::new(get_binary_path())
.args(["--dsn", "not-a-valid-dsn", "--port", "9999"])
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.expect("Failed to start binary")
.wait_with_output()
.expect("Failed to wait for output");
assert!(
!output.status.success() || !output.stderr.is_empty(),
"Binary should handle invalid DSN gracefully"
);
}
#[tokio::test]
async fn test_binary_exposes_metrics_endpoint() -> Result<()> {
let port = common::get_available_port();
let dsn = common::get_test_dsn();
let _guard = start_and_wait(port, &dsn).await?;
let body = http_get(port, "/metrics").await?;
assert!(body.contains("pg_up"), "Metrics should include pg_up");
assert!(
body.contains("pg_exporter_build_info"),
"Metrics should include build info"
);
Ok(())
}
#[tokio::test]
async fn test_binary_exposes_health_endpoint() -> Result<()> {
let port = common::get_available_port();
let dsn = common::get_test_dsn();
let _guard = start_and_wait(port, &dsn).await?;
let _body = http_get(port, "/health").await?;
Ok(())
}
#[tokio::test]
async fn test_binary_uses_dsn_file() -> Result<()> {
use std::io::Write;
let port = common::get_available_port();
let dsn = common::get_test_dsn();
let mut temp_file = tempfile::NamedTempFile::new()?;
writeln!(temp_file, "{dsn}")?;
temp_file.flush()?;
let child = Command::new(get_binary_path())
.env("PG_EXPORTER_DSN_FILE", temp_file.path())
.args(["--port", &port.to_string()])
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()?;
let _guard = ChildGuard(child);
let mut attempts = 0;
let max_attempts = 20; loop {
tokio::time::sleep(Duration::from_millis(100)).await;
match http_get(port, "/health").await {
Ok(body) => {
assert!(!body.is_empty(), "Health endpoint should return content");
break;
}
Err(_) if attempts < max_attempts => {
attempts += 1;
}
Err(e) => return Err(e),
}
}
Ok(())
}