use crate::core::types::{ExecutionPlan, ForjarConfig, PlanAction, PlannedChange, ResourceType};
fn cfg(yaml: &str) -> ForjarConfig {
serde_yaml_ng::from_str(yaml).expect("fixture parses")
}
fn three_resource_config() -> ForjarConfig {
cfg("version: \"1.0\"\n\
name: repro\n\
params:\n\
\x20 sandbox: /tmp/fj-unit\n\
machines:\n\
\x20 local:\n\
\x20 hostname: localhost\n\
\x20 addr: 127.0.0.1\n\
\x20 user: nobody\n\
\x20 arch: x86_64\n\
resources:\n\
\x20 a-file:\n\
\x20 type: file\n\
\x20 machine: local\n\
\x20 resource_group: alpha\n\
\x20 path: \"{{params.sandbox}}/a.txt\"\n\
\x20 content: \"aaa\\n\"\n\
\x20 b-file:\n\
\x20 type: file\n\
\x20 machine: local\n\
\x20 path: \"{{params.sandbox}}/b.txt\"\n\
\x20 content: \"bbb\\n\"\n\
\x20 c-file:\n\
\x20 type: file\n\
\x20 machine: local\n\
\x20 path: \"{{params.sandbox}}/c.txt\"\n\
\x20 content: \"ccc\\n\"\n")
}
fn change(id: &str, action: PlanAction) -> PlannedChange {
PlannedChange {
resource_id: id.to_string(),
machine: "local".to_string(),
resource_type: ResourceType::File,
action,
description: format!("{id}: create /tmp/fj-unit/{id}"),
}
}
fn three_change_plan() -> ExecutionPlan {
ExecutionPlan {
name: "repro".to_string(),
changes: vec![
change("a-file", PlanAction::Create),
change("b-file", PlanAction::Create),
change("c-file", PlanAction::Create),
],
execution_order: vec![
"a-file".to_string(),
"b-file".to_string(),
"c-file".to_string(),
],
to_create: 3,
to_update: 0,
to_destroy: 0,
unchanged: 0,
}
}
#[test]
fn plan_resource_filter_keeps_only_its_own_resource() {
let mut plan = three_change_plan();
super::plan_selector::apply_resource_filter(&mut plan, &three_resource_config(), Some("a-file"))
.expect("a-file exists");
assert_eq!(plan.changes.len(), 1, "-r must drop the other two");
assert_eq!(plan.changes[0].resource_id, "a-file");
assert_eq!(plan.to_create, 1, "the summary counters must agree with the body");
assert_eq!(plan.execution_order, vec!["a-file".to_string()]);
}
#[test]
fn plan_resource_filter_with_no_match_is_an_error() {
let mut plan = three_change_plan();
let err =
super::plan_selector::apply_resource_filter(&mut plan, &three_resource_config(), Some("nope"))
.expect_err("a typo must not print an empty successful plan");
assert!(err.contains("a-file"), "the error names what IS available: {err}");
}
#[test]
fn plan_without_a_resource_filter_keeps_everything() {
let mut plan = three_change_plan();
super::plan_selector::apply_resource_filter(&mut plan, &three_resource_config(), None)
.expect("no filter");
assert_eq!(plan.changes.len(), 3);
assert_eq!(plan.to_create, 3);
}
#[test]
fn plan_group_filter_keeps_only_that_group() {
let mut plan = three_change_plan();
super::plan_selector::apply_group_filter(&mut plan, &three_resource_config(), Some("alpha"))
.expect("group alpha exists");
assert_eq!(plan.changes.len(), 1, "only a-file is in group alpha");
assert_eq!(plan.changes[0].resource_id, "a-file");
assert_eq!(plan.to_create, 1);
}
#[test]
fn plan_group_filter_with_no_match_is_an_error() {
let mut plan = three_change_plan();
assert!(
super::plan_selector::apply_group_filter(&mut plan, &three_resource_config(), Some("ghost"))
.is_err(),
"1.12.3 printed the WHOLE plan for a group that does not exist"
);
}
#[test]
fn plan_descriptions_name_the_path_apply_will_write() {
let config = three_resource_config();
let order = crate::core::resolver::build_execution_order(&config).expect("order");
let plan = crate::core::planner::plan(&config, &order, &Default::default(), None);
let desc = &plan
.changes
.iter()
.find(|c| c.resource_id == "a-file")
.expect("a-file planned")
.description;
assert!(
!desc.contains("{{"),
"plan is the pre-flight review surface; it must not show a template: {desc}"
);
assert!(desc.contains("/tmp/fj-unit/a.txt"), "{desc}");
}
#[test]
fn a_drifted_resource_is_not_a_forced_noop() {
let changes = vec![
change("a-file", PlanAction::NoOp),
change("b-file", PlanAction::NoOp),
change("c-file", PlanAction::NoOp),
];
let drifted: std::collections::HashSet<String> = ["a-file".to_string()].into_iter().collect();
assert_eq!(
crate::core::executor::count_forced_noops(&changes, &drifted),
2,
"the resource the machine disagrees about was NOT a no-op"
);
}
#[test]
fn a_fully_converged_stack_is_all_forced_noops() {
let changes = vec![
change("a-file", PlanAction::NoOp),
change("b-file", PlanAction::NoOp),
];
assert_eq!(
crate::core::executor::count_forced_noops(&changes, &Default::default()),
2
);
}
#[test]
fn a_created_resource_is_never_a_forced_noop() {
let changes = vec![change("a-file", PlanAction::Create)];
assert_eq!(
crate::core::executor::count_forced_noops(&changes, &Default::default()),
0,
"a creation is an actual change"
);
}
#[test]
fn dry_run_shows_what_would_run() {
let out = super::apply_dry_run::render_dry_run_actions(&three_change_plan());
for id in ["a-file", "b-file", "c-file"] {
assert!(out.contains(id), "dry run must name {id}: {out}");
}
assert!(out.contains("No changes applied."), "{out}");
}
#[test]
fn an_empty_dry_run_says_so_rather_than_looking_broken() {
let empty = ExecutionPlan {
changes: vec![],
execution_order: vec![],
to_create: 0,
..three_change_plan()
};
let out = super::apply_dry_run::render_dry_run_actions(&empty);
assert!(out.contains("nothing selected"), "{out}");
}
#[test]
fn a_malformed_header_json_is_refused_before_the_apply() {
let args = super::commands::ApplyArgs {
notify_webhook_headers: Some("X-Auth: nope".to_string()),
..Default::default()
};
let err = super::dispatch_apply_b::validate_notify_headers(&args)
.expect_err("a header value that cannot be sent must not reach a live apply");
assert!(err.contains("--notify-webhook-headers"), "{err}");
}
#[test]
fn a_well_formed_header_json_is_accepted() {
let args = super::commands::ApplyArgs {
notify_webhook_headers: Some(r#"{"X-Auth":"SECRET123"}"#.to_string()),
..Default::default()
};
assert!(super::dispatch_apply_b::validate_notify_headers(&args).is_ok());
assert!(
super::dispatch_apply_b::validate_notify_headers(&super::commands::ApplyArgs::default())
.is_ok()
);
}