use super::*;
use std::collections::HashMap;
#[cfg(unix)]
use crate::engine::fake_podman;
#[cfg(unix)]
fn engine_with(client: crate::libpod::Client, project: &str) -> Engine {
Engine::with_base_dir(client, project.into(), std::env::temp_dir())
}
fn entry(status: &str, state: &str) -> ContainerListEntry {
ContainerListEntry {
id: "abc123".into(),
names: vec!["/web".into()],
image: "alpine".into(),
status: status.into(),
state: state.into(),
ports: vec![],
exit_code: None,
labels: HashMap::new(),
}
}
#[test]
fn status_matches_empty_filter_matches_all() {
assert!(status_matches("running", &[]));
assert!(status_matches("exited", &[]));
}
#[test]
fn status_matches_is_case_insensitive_prefix() {
assert!(status_matches("running", &["RUNNING".to_string()]));
assert!(status_matches("exited", &["exit".to_string()]));
assert!(!status_matches("running", &["exited".to_string()]));
assert!(!status_matches("running", &["".to_string()]));
}
#[test]
fn split_ps_filters_buckets_known_keys_and_flags_unknown() {
let (status, names, unknown) = split_ps_filters(&[
"status=running".to_string(),
"name=web".to_string(),
"label=foo".to_string(),
]);
assert_eq!(status, vec!["running".to_string()]);
assert_eq!(names, vec!["web".to_string()]);
assert_eq!(unknown, vec!["label=foo".to_string()]);
}
#[test]
fn display_status_falls_back_to_state_when_status_empty() {
assert_eq!(display_status(&entry("", "running")), "running");
assert_eq!(display_status(&entry("", "exited")), "exited");
}
#[test]
fn display_status_prefers_status_when_present() {
assert_eq!(
display_status(&entry("Up 2 seconds", "running")),
"Up 2 seconds"
);
}
fn entry_exit(state: &str, code: Option<i32>) -> ContainerListEntry {
ContainerListEntry {
exit_code: code,
..entry("", state)
}
}
#[test]
fn table_status_shows_exit_code_for_bare_exited() {
assert_eq!(table_status(&entry_exit("exited", Some(7))), "Exited (7)");
assert_eq!(table_status(&entry_exit("exited", Some(0))), "Exited (0)");
assert_eq!(table_status(&entry_exit("exited", None)), "Exited (0)");
assert_eq!(table_status(&entry_exit("dead", Some(255))), "Exited (255)");
}
#[test]
fn table_status_keeps_running_and_rich_status_text() {
assert_eq!(
table_status(&entry("Up 2 seconds", "running")),
"Up 2 seconds"
);
assert_eq!(table_status(&entry("", "running")), "running");
let c = ContainerListEntry {
exit_code: Some(7),
..entry("Exited (7) 4 seconds ago", "exited")
};
assert_eq!(table_status(&c), "Exited (7) 4 seconds ago");
}
#[test]
fn format_ports_defaults_missing_host_ip_to_all_interfaces() {
let p = ContainerPort {
host_ip: None,
host_port: Some(8080),
container_port: 80,
protocol: Some("tcp".into()),
..Default::default()
};
assert_eq!(
format_ports(std::slice::from_ref(&p)),
"0.0.0.0:8080->80/tcp"
);
}
#[test]
fn format_ports_keeps_explicit_host_ip() {
let p = ContainerPort {
host_ip: Some("127.0.0.1".into()),
host_port: Some(5432),
container_port: 5432,
..Default::default()
};
assert_eq!(
format_ports(std::slice::from_ref(&p)),
"127.0.0.1:5432->5432"
);
}
#[test]
fn format_ports_expands_a_collapsed_range() {
let p = ContainerPort {
host_ip: None,
host_port: Some(51251),
container_port: 8080,
protocol: Some("tcp".into()),
range: Some(3),
};
assert_eq!(
format_ports(std::slice::from_ref(&p)),
"0.0.0.0:51251-51253->8080-8082/tcp"
);
}
#[test]
fn format_port_record_does_not_overflow_u16_on_pathological_range() {
let p = ContainerPort {
host_ip: None,
host_port: Some(65535),
container_port: 65535,
protocol: Some("tcp".into()),
range: Some(2),
};
let rendered = format_ports(std::slice::from_ref(&p));
assert_eq!(rendered, "0.0.0.0:65535-65536->65535-65536/tcp");
}
#[test]
fn publishers_expand_each_port_in_a_range() {
let p = ContainerPort {
host_ip: Some("0.0.0.0".into()),
host_port: Some(51251),
container_port: 8080,
protocol: Some("tcp".into()),
range: Some(3),
};
let pubs = publishers(std::slice::from_ref(&p));
assert_eq!(pubs.len(), 3);
assert_eq!(pubs[0]["TargetPort"], 8080);
assert_eq!(pubs[0]["PublishedPort"], 51251);
assert_eq!(pubs[2]["TargetPort"], 8082);
assert_eq!(pubs[2]["PublishedPort"], 51253);
assert_eq!(pubs[1]["Protocol"], "tcp");
}
#[test]
fn publishers_does_not_overflow_u16_on_pathological_range() {
let p = ContainerPort {
host_ip: Some("0.0.0.0".into()),
host_port: Some(65535),
container_port: 65535,
protocol: Some("tcp".into()),
range: Some(2),
};
let pubs = publishers(std::slice::from_ref(&p));
assert_eq!(pubs.len(), 2);
assert_eq!(pubs[1]["TargetPort"], 65536);
assert_eq!(pubs[1]["PublishedPort"], 65536);
}
#[test]
fn health_is_derived_from_status_text() {
assert_eq!(health_from_status("Up 2 minutes (healthy)"), "healthy");
assert_eq!(health_from_status("Up 1 minute (unhealthy)"), "unhealthy");
assert_eq!(
health_from_status("Up 3 seconds (health: starting)"),
"starting"
);
assert_eq!(health_from_status("Exited (1) 4 seconds ago"), "");
assert_eq!(health_from_status("Restarting (1) 3 seconds ago"), "");
}
#[test]
fn ps_json_row_surfaces_state_exitcode_and_publishers() {
let mut labels = HashMap::new();
labels.insert("podup.project".to_string(), "demo".to_string());
labels.insert("podup.service".to_string(), "web".to_string());
let c = ContainerListEntry {
id: "deadbeef".into(),
names: vec!["/demo-web-1".into()],
image: "nginx:1.25".into(),
status: "Exited (137) 2s ago".into(),
state: "exited".into(),
ports: vec![ContainerPort {
host_ip: None,
host_port: Some(8080),
container_port: 80,
protocol: Some("tcp".into()),
range: None,
}],
exit_code: Some(137),
labels,
};
let row = ps_json_row(&c);
assert_eq!(row["Name"], "demo-web-1");
assert_eq!(row["Service"], "web");
assert_eq!(row["Project"], "demo");
assert_eq!(row["State"], "exited");
assert_eq!(row["ExitCode"], 137);
assert_eq!(row["ID"], "deadbeef");
assert_eq!(row["Publishers"][0]["PublishedPort"], 8080);
}
#[tokio::test]
#[cfg(unix)]
async fn ps_status_filter_forces_all_true_even_when_opts_all_is_false() {
let fake = fake_podman::start(|method, _target| {
if method == "GET" {
(200, "[]".to_string())
} else {
(404, r#"{"message":"not found"}"#.to_string())
}
});
let e = engine_with(fake.client(), "proj");
let file = ComposeFile::default();
e.ps_filtered(
&file,
PsOptions {
all: false,
..Default::default()
},
PsFilterOptions {
status: vec!["exited".into()],
..Default::default()
},
)
.await
.expect("ps should succeed");
let seen = fake.requests.lock().unwrap();
assert!(
seen.iter()
.any(|r| r.contains("/containers/json") && r.contains("all=true")),
"a status filter must force all=true even with opts.all=false: {seen:?}"
);
}
#[tokio::test]
#[cfg(unix)]
async fn ps_without_status_filter_keeps_all_false_by_default() {
let fake = fake_podman::start(|method, _target| {
if method == "GET" {
(200, "[]".to_string())
} else {
(404, r#"{"message":"not found"}"#.to_string())
}
});
let e = engine_with(fake.client(), "proj");
let file = ComposeFile::default();
e.ps_filtered(&file, PsOptions::default(), PsFilterOptions::default())
.await
.expect("ps should succeed");
let seen = fake.requests.lock().unwrap();
assert!(
seen.iter()
.any(|r| r.contains("/containers/json") && r.contains("all=false")),
"no status filter must keep all=false: {seen:?}"
);
}