use crate::parse_str;
#[test]
fn extends_unions_sequence_fields() {
let yaml = r#"
services:
base:
image: alpine
ports:
- "80:80"
- "81:81"
app:
extends: base
ports:
- "90:90"
"#;
let file = parse_str(yaml).unwrap();
assert_eq!(file.services["app"].ports.len(), 3);
}
#[test]
fn extends_dedups_identical_sequence_entries() {
let yaml = r#"
services:
base:
image: alpine
ports:
- "80:80"
app:
extends: base
ports:
- "80:80"
- "90:90"
"#;
let file = parse_str(yaml).unwrap();
assert_eq!(file.services["app"].ports.len(), 2);
}
#[test]
fn absent_list_field_falls_back_to_base() {
let yaml = r#"
services:
base:
image: alpine
ports:
- "80:80"
app:
extends: base
"#;
let file = parse_str(yaml).unwrap();
assert_eq!(file.services["app"].ports.len(), 1);
}
#[test]
fn labels_are_merged_with_override_winning() {
let yaml = r#"
services:
base:
image: alpine
labels:
a: base
keep: base
app:
extends: base
labels:
a: over
b: over
"#;
let file = parse_str(yaml).unwrap();
let labels = file.services["app"].labels.to_map();
assert_eq!(labels.get("a").map(|s| s.as_str()), Some("over"));
assert_eq!(labels.get("keep").map(|s| s.as_str()), Some("base"));
assert_eq!(labels.get("b").map(|s| s.as_str()), Some("over"));
}
#[test]
fn empty_override_keeps_base_depends_on() {
let yaml = r#"
services:
db:
image: postgres
base:
image: alpine
depends_on:
- db
app:
extends: base
"#;
let file = parse_str(yaml).unwrap();
assert_eq!(
file.services["app"].depends_on.service_names(),
vec!["db".to_string()]
);
}
#[test]
fn extends_unions_depends_on() {
let yaml = r#"
services:
db:
image: postgres
cache:
image: redis
base:
image: alpine
depends_on:
- db
app:
extends: base
depends_on:
- cache
"#;
let file = parse_str(yaml).unwrap();
let mut names = file.services["app"].depends_on.service_names();
names.sort();
assert_eq!(names, vec!["cache".to_string(), "db".to_string()]);
}
#[test]
fn extends_depends_on_override_wins_on_conflict() {
let yaml = r#"
services:
db:
image: postgres
base:
image: alpine
depends_on:
db:
condition: service_started
app:
extends: base
depends_on:
db:
condition: service_healthy
"#;
let file = parse_str(yaml).unwrap();
assert_eq!(
file.services["app"].depends_on.condition_for("db"),
crate::compose::types::ServiceCondition::ServiceHealthy
);
}
#[test]
fn absent_override_keeps_base_environment() {
let yaml = r#"
services:
base:
image: alpine
environment:
A: "1"
app:
extends: base
"#;
let file = parse_str(yaml).unwrap();
let env = file.services["app"].environment.to_map();
assert_eq!(env.get("A").and_then(|v| v.clone()).as_deref(), Some("1"));
}
#[test]
fn scalar_or_list_field_override_appends_to_base() {
let yaml = r#"
services:
base:
image: alpine
dns:
- 1.1.1.1
app:
extends: base
dns:
- 9.9.9.9
"#;
let file = parse_str(yaml).unwrap();
assert_eq!(
file.services["app"].dns.to_list(),
vec!["1.1.1.1", "9.9.9.9"],
"the base's nameserver must survive an override that adds one"
);
}
#[test]
fn env_file_override_appends_to_base() {
let yaml = r#"
services:
base:
image: alpine
env_file:
- base.env
app:
extends: base
env_file:
- app.env
"#;
let file = parse_str(yaml).unwrap();
let entries = file.services["app"].env_file.to_entries();
assert_eq!(entries.len(), 2, "both env files must be read");
assert_eq!(entries[0].path(), "base.env");
assert_eq!(entries[1].path(), "app.env");
}
#[test]
fn depends_on_unions_when_base_has_none() {
let yaml = r#"
services:
base:
image: alpine
db:
image: postgres
app:
extends: base
depends_on:
- db
"#;
let file = parse_str(yaml).unwrap();
assert!(file.services["app"]
.depends_on
.service_names()
.contains(&"db".to_string()));
}
#[test]
fn networks_are_unioned_not_replaced() {
let yaml = r#"
services:
base:
image: alpine
networks:
- backend
app:
extends: base
networks:
- monitoring
networks:
backend:
monitoring:
"#;
let file = parse_str(yaml).unwrap();
let names = file.services["app"].networks.names();
assert!(
names.contains(&"backend".to_string()),
"the base's network must survive: {names:?}"
);
assert!(
names.contains(&"monitoring".to_string()),
"the override's network must be added: {names:?}"
);
}
#[test]
fn network_union_keeps_base_config_for_a_bare_override_entry() {
let yaml = r#"
services:
base:
image: alpine
networks:
backend:
aliases:
- db
app:
extends: base
networks:
- backend
networks:
backend:
"#;
let file = parse_str(yaml).unwrap();
let cfg = file.services["app"].networks.config_for("backend");
assert!(
cfg.is_some_and(|c| c
.aliases
.as_ref()
.is_some_and(|a| a.contains(&"db".to_string()))),
"a bare override entry must not wipe the base's aliases"
);
}
#[test]
fn sysctls_merge_per_key() {
let yaml = r#"
services:
base:
image: alpine
sysctls:
net.core.somaxconn: "1024"
net.ipv4.tcp_syncookies: "1"
app:
extends: base
sysctls:
net.core.somaxconn: "4096"
"#;
let file = parse_str(yaml).unwrap();
let m = file.services["app"].sysctls.to_map();
assert_eq!(
m.get("net.core.somaxconn").map(String::as_str),
Some("4096"),
"the override wins for a key both set"
);
assert_eq!(
m.get("net.ipv4.tcp_syncookies").map(String::as_str),
Some("1"),
"a key only the base sets must survive"
);
}
#[test]
fn unioned_networks_serialize_back_to_their_names() {
let yaml = r#"
services:
base:
image: alpine
networks:
- backend
app:
extends: base
networks:
- monitoring
networks:
backend:
monitoring:
"#;
let file = parse_str(yaml).unwrap();
let rendered = serde_yaml::to_string(&file.services["app"].networks).unwrap();
assert!(
rendered.contains("backend") && rendered.contains("monitoring"),
"serialized form lost the network names: {rendered}"
);
}
#[test]
fn volumes_at_the_same_target_are_replaced_not_duplicated() {
let yaml = r#"
services:
base:
image: alpine
volumes:
- ./a:/data
- ./logs:/var/log
app:
extends: base
volumes:
- ./b:/data
"#;
let file = parse_str(yaml).unwrap();
let mounts = &file.services["app"].volumes;
let at_data: Vec<_> = mounts.iter().filter(|m| m.target() == "/data").collect();
assert_eq!(at_data.len(), 1, "one mount per target: {mounts:?}");
assert!(
format!("{:?}", at_data[0]).contains("./b"),
"the override's source must win: {:?}",
at_data[0]
);
assert!(
mounts.iter().any(|m| m.target() == "/var/log"),
"an unrelated base mount must survive: {mounts:?}"
);
}
#[test]
fn override_tag_replaces_instead_of_appending() {
let base =
parse_str("services:\n web:\n image: alpine\n ports: [\"8080:80\"]\n").unwrap();
let over = parse_str("services:\n web:\n ports: !override [\"9090:80\"]\n").unwrap();
let raw: serde_yaml::Value =
serde_yaml::from_str("services:\n web:\n ports: !override [\"9090:80\"]\n").unwrap();
let directives = crate::compose::tags::collect(&raw);
let merged = crate::compose::extends::merge_service_tagged(
base.services["web"].clone(),
over.services["web"].clone(),
directives.get("web"),
);
assert_eq!(
merged.ports.len(),
1,
"!override must replace, not append: {:?}",
merged.ports
);
}
#[test]
fn reset_tag_clears_the_base_value() {
let base = parse_str("services:\n web:\n image: alpine\n dns: [\"1.1.1.1\"]\n").unwrap();
let over = parse_str("services:\n web:\n dns: !reset []\n").unwrap();
let raw: serde_yaml::Value =
serde_yaml::from_str("services:\n web:\n dns: !reset []\n").unwrap();
let directives = crate::compose::tags::collect(&raw);
let merged = crate::compose::extends::merge_service_tagged(
base.services["web"].clone(),
over.services["web"].clone(),
directives.get("web"),
);
assert!(
merged.dns.to_list().is_empty(),
"!reset must clear the base: {:?}",
merged.dns
);
}
#[test]
fn an_untagged_key_still_merges_normally() {
let base = parse_str("services:\n web:\n image: alpine\n dns: [\"1.1.1.1\"]\n").unwrap();
let over = parse_str("services:\n web:\n dns: [\"9.9.9.9\"]\n").unwrap();
let merged = crate::compose::extends::merge_service_tagged(
base.services["web"].clone(),
over.services["web"].clone(),
None,
);
assert_eq!(merged.dns.to_list(), vec!["1.1.1.1", "9.9.9.9"]);
}