Skip to main content

api_testing_core/suite/cleanup/
mod.rs

1use std::io::Write;
2use std::path::Path;
3
4use anyhow::Context;
5
6use crate::Result;
7use crate::suite::safety::writes_enabled;
8use crate::suite::schema::SuiteCleanupStep;
9
10mod context;
11mod graphql;
12mod rest;
13mod template;
14
15pub use context::CleanupContext;
16
17fn append_log(path: &Path, line: &str) -> Result<()> {
18    let mut f = std::fs::OpenOptions::new()
19        .create(true)
20        .append(true)
21        .open(path)
22        .with_context(|| format!("open log for append: {}", path.display()))?;
23    writeln!(f, "{line}").context("append log line")?;
24    Ok(())
25}
26
27fn append_log_from_text(path: &Path, text: &str) -> Result<()> {
28    for line in text.lines() {
29        append_log(path, line)?;
30    }
31    Ok(())
32}
33
34fn log_failure_with_stderr_file(path: &Path, header_line: &str, stderr_file: &Path) -> Result<()> {
35    append_log(path, header_line)?;
36    let stderr_text = std::fs::read_to_string(stderr_file).unwrap_or_default();
37    append_log_from_text(path, &stderr_text)?;
38    Ok(())
39}
40
41fn log_failure_with_error(path: &Path, header_line: &str, err: &anyhow::Error) -> Result<()> {
42    append_log(path, header_line)?;
43    append_log(path, &format!("{err:#}"))?;
44    Ok(())
45}
46
47fn read_json_file(path: &Path) -> Result<serde_json::Value> {
48    let bytes =
49        std::fs::read(path).with_context(|| format!("read JSON file: {}", path.display()))?;
50    let v: serde_json::Value = serde_json::from_slice(&bytes)
51        .with_context(|| format!("parse JSON: {}", path.display()))?;
52    Ok(v)
53}
54
55fn cleanup_step_type(raw: &str) -> String {
56    let t = raw.trim().to_ascii_lowercase();
57    if t == "gql" { "graphql".to_string() } else { t }
58}
59
60fn rest_cleanup_step(
61    ctx: &mut CleanupContext<'_>,
62    response_json: &serde_json::Value,
63    step: &SuiteCleanupStep,
64    step_index: usize,
65) -> Result<bool> {
66    rest::rest_cleanup_step(ctx, response_json, step, step_index)
67}
68
69fn graphql_cleanup_step(
70    ctx: &mut CleanupContext<'_>,
71    response_json: &serde_json::Value,
72    step: &SuiteCleanupStep,
73    step_index: usize,
74) -> Result<bool> {
75    graphql::graphql_cleanup_step(ctx, response_json, step, step_index)
76}
77
78pub fn run_case_cleanup(ctx: &mut CleanupContext<'_>) -> Result<bool> {
79    let Some(cleanup) = ctx.cleanup else {
80        return Ok(true);
81    };
82
83    if !writes_enabled(ctx.allow_writes_flag, ctx.effective_env) {
84        append_log(
85            ctx.main_stderr_file,
86            "cleanup skipped (writes disabled): enable with API_TEST_ALLOW_WRITES_ENABLED=true (or --allow-writes)",
87        )?;
88        return Ok(true);
89    }
90
91    let Some(main_response_file) = ctx.main_response_file else {
92        append_log(
93            ctx.main_stderr_file,
94            "cleanup failed: missing main response file",
95        )?;
96        return Ok(false);
97    };
98    if !main_response_file.is_file() {
99        append_log(
100            ctx.main_stderr_file,
101            "cleanup failed: missing main response file",
102        )?;
103        return Ok(false);
104    }
105
106    let response_json = read_json_file(main_response_file)?;
107
108    let mut any_failed = false;
109    for (i, step) in cleanup.steps().iter().enumerate() {
110        let ty = cleanup_step_type(&step.step_type);
111        let ok = match ty.as_str() {
112            "rest" => rest_cleanup_step(ctx, &response_json, step, i)?,
113            "graphql" => graphql_cleanup_step(ctx, &response_json, step, i)?,
114            other => {
115                append_log(
116                    ctx.main_stderr_file,
117                    &format!("cleanup failed: unknown step type: {other}"),
118                )?;
119                false
120            }
121        };
122        if !ok {
123            any_failed = true;
124        }
125    }
126
127    Ok(!any_failed)
128}
129
130#[cfg(test)]
131mod tests {
132    use super::template::{parse_vars_map, render_template};
133    use super::*;
134    use crate::suite::resolve::write_file;
135    use crate::suite::runtime;
136    use crate::suite::schema::{SuiteCleanup, SuiteCleanupStep, SuiteDefaults};
137    use nils_test_support::fixtures::{GraphqlSetupFixture, RestSetupFixture, SuiteFixture};
138    use pretty_assertions::assert_eq;
139    use std::collections::BTreeMap;
140
141    use tempfile::TempDir;
142
143    #[test]
144    fn suite_cleanup_step_type_maps_gql_to_graphql() {
145        assert_eq!(cleanup_step_type("gql"), "graphql");
146        assert_eq!(cleanup_step_type(" GQL "), "graphql");
147        assert_eq!(cleanup_step_type("REST"), "rest");
148    }
149
150    #[test]
151    fn suite_cleanup_parse_vars_map_none_and_null_are_empty() {
152        assert_eq!(parse_vars_map(None).unwrap(), BTreeMap::new());
153
154        let v = serde_json::Value::Null;
155        assert_eq!(parse_vars_map(Some(&v)).unwrap(), BTreeMap::new());
156    }
157
158    #[test]
159    fn suite_cleanup_parse_vars_map_validates_object_and_string_values() {
160        let v = serde_json::json!(["x"]);
161        let err = parse_vars_map(Some(&v)).unwrap_err();
162        assert!(err.to_string().contains("cleanup.vars must be an object"));
163
164        let v = serde_json::json!({"id": 1});
165        let err = parse_vars_map(Some(&v)).unwrap_err();
166        assert!(
167            err.to_string()
168                .contains("cleanup.vars values must be strings")
169        );
170    }
171
172    #[test]
173    fn suite_cleanup_rest_base_url_resolution_precedence() {
174        let tmp = TempDir::new().unwrap();
175        let repo_root = tmp.path();
176        let defaults = SuiteDefaults::default();
177
178        assert_eq!(
179            runtime::resolve_rest_base_url(
180                repo_root,
181                "setup/rest",
182                "https://override.example",
183                "staging",
184                &defaults,
185                "",
186            )
187            .unwrap(),
188            "https://override.example"
189        );
190
191        let mut defaults2 = SuiteDefaults::default();
192        defaults2.rest.url = "https://defaults.example".to_string();
193        assert_eq!(
194            runtime::resolve_rest_base_url(repo_root, "setup/rest", "", "staging", &defaults2, "")
195                .unwrap(),
196            "https://defaults.example"
197        );
198
199        assert_eq!(
200            runtime::resolve_rest_base_url(
201                repo_root,
202                "setup/rest",
203                "",
204                "staging",
205                &defaults,
206                "https://env.example",
207            )
208            .unwrap(),
209            "https://env.example"
210        );
211
212        std::fs::create_dir_all(repo_root.join("setup/rest")).unwrap();
213        std::fs::write(
214            repo_root.join("setup/rest/endpoints.env"),
215            "REST_URL_STAGING=https://fromfile.example\n",
216        )
217        .unwrap();
218        assert_eq!(
219            runtime::resolve_rest_base_url(repo_root, "setup/rest", "", "staging", &defaults, "")
220                .unwrap(),
221            "https://fromfile.example"
222        );
223    }
224
225    #[test]
226    fn suite_cleanup_gql_url_resolution_precedence() {
227        let tmp = TempDir::new().unwrap();
228        let repo_root = tmp.path();
229        let defaults = SuiteDefaults::default();
230
231        assert_eq!(
232            runtime::resolve_gql_url(
233                repo_root,
234                "setup/graphql",
235                "https://override.example/graphql",
236                "staging",
237                &defaults,
238                "",
239            )
240            .unwrap(),
241            "https://override.example/graphql"
242        );
243
244        let mut defaults2 = SuiteDefaults::default();
245        defaults2.graphql.url = "https://defaults.example/graphql".to_string();
246        assert_eq!(
247            runtime::resolve_gql_url(repo_root, "setup/graphql", "", "staging", &defaults2, "")
248                .unwrap(),
249            "https://defaults.example/graphql"
250        );
251
252        assert_eq!(
253            runtime::resolve_gql_url(
254                repo_root,
255                "setup/graphql",
256                "",
257                "staging",
258                &defaults,
259                "https://env.example/graphql",
260            )
261            .unwrap(),
262            "https://env.example/graphql"
263        );
264
265        std::fs::create_dir_all(repo_root.join("setup/graphql")).unwrap();
266        std::fs::write(
267            repo_root.join("setup/graphql/endpoints.env"),
268            "GQL_URL_STAGING=https://fromfile.example/graphql\n",
269        )
270        .unwrap();
271        assert_eq!(
272            runtime::resolve_gql_url(repo_root, "setup/graphql", "", "staging", &defaults, "")
273                .unwrap(),
274            "https://fromfile.example/graphql"
275        );
276    }
277
278    #[test]
279    fn suite_cleanup_rest_url_selection_uses_rest_endpoints_env() {
280        let fixture = RestSetupFixture::new();
281        fixture.write_endpoints_env("REST_URL_STAGING=https://fromfile.example\n");
282        let defaults = SuiteDefaults::default();
283
284        let url = runtime::resolve_rest_base_url(
285            &fixture.root,
286            "setup/rest",
287            "",
288            "staging",
289            &defaults,
290            "",
291        )
292        .unwrap();
293
294        assert_eq!(url, "https://fromfile.example");
295    }
296
297    #[test]
298    fn suite_cleanup_graphql_url_selection_uses_graphql_endpoints_env() {
299        let fixture = GraphqlSetupFixture::new();
300        fixture.write_endpoints_env("GQL_URL_STAGING=https://fromfile.example/graphql\n");
301        let defaults = SuiteDefaults::default();
302
303        let url =
304            runtime::resolve_gql_url(&fixture.root, "setup/graphql", "", "staging", &defaults, "")
305                .unwrap();
306
307        assert_eq!(url, "https://fromfile.example/graphql");
308    }
309
310    #[test]
311    fn suite_cleanup_template_replaces_vars() {
312        let response = serde_json::json!({"data": {"id": "123"}});
313        let mut vars = BTreeMap::new();
314        vars.insert("id".to_string(), ".data.id".to_string());
315        let out = render_template("/items/{{id}}", &response, &vars).unwrap();
316        assert_eq!(out, "/items/123");
317    }
318
319    #[test]
320    fn suite_cleanup_template_missing_var_is_error() {
321        let response = serde_json::json!({"data": {"id": "123"}});
322        let mut vars = BTreeMap::new();
323        vars.insert("id".to_string(), ".data.missing".to_string());
324        let err = render_template("/items/{{id}}", &response, &vars).unwrap_err();
325        assert!(
326            err.to_string()
327                .contains("template var 'id' failed to resolve")
328        );
329    }
330
331    #[test]
332    fn suite_cleanup_disabled_writes_is_noop_with_log() {
333        let tmp = TempDir::new().unwrap();
334        let repo = tmp.path();
335        std::fs::create_dir_all(repo.join(".git")).unwrap();
336        let run_dir = repo.join("out");
337        std::fs::create_dir_all(&run_dir).unwrap();
338        let stderr_file = run_dir.join("case.stderr.log");
339        write_file(&stderr_file, b"").unwrap();
340
341        let cleanup = SuiteCleanup::One(Box::new(SuiteCleanupStep {
342            step_type: "rest".to_string(),
343            config_dir: String::new(),
344            url: String::new(),
345            env: String::new(),
346            no_history: None,
347            method: "DELETE".to_string(),
348            path_template: "/x".to_string(),
349            vars: None,
350            token: String::new(),
351            expect: None,
352            expect_status: None,
353            expect_jq: String::new(),
354            jwt: String::new(),
355            op: String::new(),
356            vars_jq: String::new(),
357            vars_template: String::new(),
358            allow_errors: false,
359        }));
360
361        let defaults = SuiteDefaults::default();
362        let mut ctx = CleanupContext {
363            repo_root: repo,
364            run_dir: &run_dir,
365            case_id: "c",
366            safe_id: "c",
367            main_response_file: None,
368            main_stderr_file: &stderr_file,
369            allow_writes_flag: false,
370            effective_env: "staging",
371            effective_no_history: true,
372            suite_defaults: &defaults,
373            env_rest_url: "",
374            env_gql_url: "",
375            rest_config_dir: "setup/rest",
376            rest_url: "",
377            rest_token: "",
378            gql_config_dir: "setup/graphql",
379            gql_url: "",
380            gql_jwt: "",
381            access_token_for_case: "",
382            auth_manager: None,
383            cleanup: Some(&cleanup),
384        };
385
386        assert!(run_case_cleanup(&mut ctx).unwrap());
387        let content = std::fs::read_to_string(&stderr_file).unwrap();
388        assert!(content.contains("cleanup skipped"));
389    }
390
391    #[test]
392    fn suite_cleanup_rest_step_missing_path_template_is_early_failure_with_log() {
393        let tmp = TempDir::new().unwrap();
394        let repo = tmp.path();
395        let run_dir = repo.join("out");
396        std::fs::create_dir_all(&run_dir).unwrap();
397        let stderr_file = run_dir.join("case.stderr.log");
398        write_file(&stderr_file, b"").unwrap();
399
400        let defaults = SuiteDefaults::default();
401        let mut ctx = CleanupContext {
402            repo_root: repo,
403            run_dir: &run_dir,
404            case_id: "c",
405            safe_id: "c",
406            main_response_file: None,
407            main_stderr_file: &stderr_file,
408            allow_writes_flag: true,
409            effective_env: "staging",
410            effective_no_history: true,
411            suite_defaults: &defaults,
412            env_rest_url: "",
413            env_gql_url: "",
414            rest_config_dir: "setup/rest",
415            rest_url: "",
416            rest_token: "",
417            gql_config_dir: "setup/graphql",
418            gql_url: "",
419            gql_jwt: "",
420            access_token_for_case: "",
421            auth_manager: None,
422            cleanup: None,
423        };
424
425        let response_json = serde_json::json!({});
426        let step = SuiteCleanupStep {
427            step_type: "rest".to_string(),
428            config_dir: String::new(),
429            url: String::new(),
430            env: String::new(),
431            no_history: None,
432            method: "DELETE".to_string(),
433            path_template: String::new(),
434            vars: None,
435            token: String::new(),
436            expect: None,
437            expect_status: None,
438            expect_jq: String::new(),
439            jwt: String::new(),
440            op: String::new(),
441            vars_jq: String::new(),
442            vars_template: String::new(),
443            allow_errors: false,
444        };
445
446        let ok = rest_cleanup_step(&mut ctx, &response_json, &step, 0).unwrap();
447        assert!(!ok);
448        let content = std::fs::read_to_string(&stderr_file).unwrap();
449        assert!(content.contains("pathTemplate=<missing>"));
450    }
451
452    #[test]
453    fn suite_cleanup_rest_step_invalid_path_is_early_failure_with_log() {
454        let tmp = TempDir::new().unwrap();
455        let repo = tmp.path();
456        let run_dir = repo.join("out");
457        std::fs::create_dir_all(&run_dir).unwrap();
458        let stderr_file = run_dir.join("case.stderr.log");
459        write_file(&stderr_file, b"").unwrap();
460
461        let defaults = SuiteDefaults::default();
462        let mut ctx = CleanupContext {
463            repo_root: repo,
464            run_dir: &run_dir,
465            case_id: "c",
466            safe_id: "c",
467            main_response_file: None,
468            main_stderr_file: &stderr_file,
469            allow_writes_flag: true,
470            effective_env: "staging",
471            effective_no_history: true,
472            suite_defaults: &defaults,
473            env_rest_url: "",
474            env_gql_url: "",
475            rest_config_dir: "setup/rest",
476            rest_url: "",
477            rest_token: "",
478            gql_config_dir: "setup/graphql",
479            gql_url: "",
480            gql_jwt: "",
481            access_token_for_case: "",
482            auth_manager: None,
483            cleanup: None,
484        };
485
486        let response_json = serde_json::json!({"data": {"id": "123"}});
487        let step = SuiteCleanupStep {
488            step_type: "rest".to_string(),
489            config_dir: String::new(),
490            url: "https://override.example".to_string(),
491            env: String::new(),
492            no_history: None,
493            method: "DELETE".to_string(),
494            path_template: "invalid".to_string(),
495            vars: None,
496            token: String::new(),
497            expect: None,
498            expect_status: None,
499            expect_jq: String::new(),
500            jwt: String::new(),
501            op: String::new(),
502            vars_jq: String::new(),
503            vars_template: String::new(),
504            allow_errors: false,
505        };
506
507        let ok = rest_cleanup_step(&mut ctx, &response_json, &step, 1).unwrap();
508        assert!(!ok);
509        let content = std::fs::read_to_string(&stderr_file).unwrap();
510        assert!(content.contains("invalid path"));
511    }
512
513    #[test]
514    fn suite_cleanup_graphql_step_missing_op_is_early_failure_with_log() {
515        let tmp = TempDir::new().unwrap();
516        let repo = tmp.path();
517        let run_dir = repo.join("out");
518        std::fs::create_dir_all(&run_dir).unwrap();
519        let stderr_file = run_dir.join("case.stderr.log");
520        write_file(&stderr_file, b"").unwrap();
521
522        let defaults = SuiteDefaults::default();
523        let mut ctx = CleanupContext {
524            repo_root: repo,
525            run_dir: &run_dir,
526            case_id: "c",
527            safe_id: "c",
528            main_response_file: None,
529            main_stderr_file: &stderr_file,
530            allow_writes_flag: true,
531            effective_env: "staging",
532            effective_no_history: true,
533            suite_defaults: &defaults,
534            env_rest_url: "",
535            env_gql_url: "",
536            rest_config_dir: "setup/rest",
537            rest_url: "",
538            rest_token: "",
539            gql_config_dir: "setup/graphql",
540            gql_url: "",
541            gql_jwt: "",
542            access_token_for_case: "",
543            auth_manager: None,
544            cleanup: None,
545        };
546
547        let response_json = serde_json::json!({});
548        let step = SuiteCleanupStep {
549            step_type: "graphql".to_string(),
550            config_dir: String::new(),
551            url: "https://override.example/graphql".to_string(),
552            env: String::new(),
553            no_history: None,
554            method: String::new(),
555            path_template: String::new(),
556            vars: None,
557            token: String::new(),
558            expect: None,
559            expect_status: None,
560            expect_jq: String::new(),
561            jwt: String::new(),
562            op: String::new(),
563            vars_jq: String::new(),
564            vars_template: String::new(),
565            allow_errors: false,
566        };
567
568        let ok = graphql_cleanup_step(&mut ctx, &response_json, &step, 0).unwrap();
569        assert!(!ok);
570        let content = std::fs::read_to_string(&stderr_file).unwrap();
571        assert!(content.contains("op=<missing>"));
572    }
573
574    #[test]
575    fn suite_cleanup_graphql_step_vars_jq_failure_is_logged() {
576        let tmp = TempDir::new().unwrap();
577        let repo = tmp.path();
578        let run_dir = repo.join("out");
579        std::fs::create_dir_all(&run_dir).unwrap();
580        let stderr_file = run_dir.join("case.stderr.log");
581        write_file(&stderr_file, b"").unwrap();
582
583        let op_dir = repo.join("ops");
584        std::fs::create_dir_all(&op_dir).unwrap();
585        let op_path = op_dir.join("cleanup.graphql");
586        std::fs::write(&op_path, "query Q { ok }\n").unwrap();
587
588        let defaults = SuiteDefaults::default();
589        let mut ctx = CleanupContext {
590            repo_root: repo,
591            run_dir: &run_dir,
592            case_id: "c",
593            safe_id: "c",
594            main_response_file: None,
595            main_stderr_file: &stderr_file,
596            allow_writes_flag: true,
597            effective_env: "staging",
598            effective_no_history: true,
599            suite_defaults: &defaults,
600            env_rest_url: "",
601            env_gql_url: "",
602            rest_config_dir: "setup/rest",
603            rest_url: "",
604            rest_token: "",
605            gql_config_dir: "setup/graphql",
606            gql_url: "",
607            gql_jwt: "",
608            access_token_for_case: "",
609            auth_manager: None,
610            cleanup: None,
611        };
612
613        let response_json = serde_json::json!({"data": {"id": "123"}});
614        let step = SuiteCleanupStep {
615            step_type: "graphql".to_string(),
616            config_dir: String::new(),
617            url: "https://override.example/graphql".to_string(),
618            env: String::new(),
619            no_history: None,
620            method: String::new(),
621            path_template: String::new(),
622            vars: None,
623            token: String::new(),
624            expect: None,
625            expect_status: None,
626            expect_jq: String::new(),
627            jwt: String::new(),
628            op: op_path.to_string_lossy().to_string(),
629            vars_jq: "???".to_string(),
630            vars_template: String::new(),
631            allow_errors: false,
632        };
633
634        let ok = graphql_cleanup_step(&mut ctx, &response_json, &step, 1).unwrap();
635        assert!(!ok);
636        let content = std::fs::read_to_string(&stderr_file).unwrap();
637        assert!(content.contains("varsJq failed"));
638    }
639
640    #[test]
641    fn suite_cleanup_graphql_step_invalid_vars_template_vars_map_is_error() {
642        let fixture = GraphqlSetupFixture::new();
643        let run_dir = fixture.root.join("out");
644        std::fs::create_dir_all(&run_dir).unwrap();
645        let stderr_file = run_dir.join("case.stderr.log");
646        write_file(&stderr_file, b"").unwrap();
647
648        let op_path = fixture.root.join("ops/cleanup.graphql");
649        std::fs::create_dir_all(op_path.parent().unwrap()).unwrap();
650        std::fs::write(&op_path, "query Q { ok }\n").unwrap();
651
652        let template_path = fixture.root.join("templates/vars.json");
653        std::fs::create_dir_all(template_path.parent().unwrap()).unwrap();
654        std::fs::write(&template_path, r#"{"id":"{{id}}"}"#).unwrap();
655
656        let defaults = SuiteDefaults::default();
657        let mut ctx = CleanupContext {
658            repo_root: &fixture.root,
659            run_dir: &run_dir,
660            case_id: "c",
661            safe_id: "c",
662            main_response_file: None,
663            main_stderr_file: &stderr_file,
664            allow_writes_flag: true,
665            effective_env: "staging",
666            effective_no_history: true,
667            suite_defaults: &defaults,
668            env_rest_url: "",
669            env_gql_url: "",
670            rest_config_dir: "setup/rest",
671            rest_url: "",
672            rest_token: "",
673            gql_config_dir: "setup/graphql",
674            gql_url: "",
675            gql_jwt: "",
676            access_token_for_case: "",
677            auth_manager: None,
678            cleanup: None,
679        };
680
681        let response_json = serde_json::json!({"data": {"id": "123"}});
682        let step = SuiteCleanupStep {
683            step_type: "graphql".to_string(),
684            config_dir: String::new(),
685            url: "https://override.example/graphql".to_string(),
686            env: String::new(),
687            no_history: None,
688            method: String::new(),
689            path_template: String::new(),
690            vars: Some(serde_json::json!(["bad"])),
691            token: String::new(),
692            expect: None,
693            expect_status: None,
694            expect_jq: String::new(),
695            jwt: String::new(),
696            op: op_path
697                .strip_prefix(&fixture.root)
698                .unwrap()
699                .to_string_lossy()
700                .to_string(),
701            vars_jq: String::new(),
702            vars_template: template_path
703                .strip_prefix(&fixture.root)
704                .unwrap()
705                .to_string_lossy()
706                .to_string(),
707            allow_errors: false,
708        };
709
710        let err = graphql_cleanup_step(&mut ctx, &response_json, &step, 2).unwrap_err();
711        assert!(err.to_string().contains("cleanup.vars must be an object"));
712    }
713
714    #[test]
715    fn suite_cleanup_graphql_step_vars_template_render_failure_is_logged() {
716        let fixture = SuiteFixture::new();
717        let run_dir = fixture.root.join("out");
718        std::fs::create_dir_all(&run_dir).unwrap();
719        let stderr_file = run_dir.join("case.stderr.log");
720        write_file(&stderr_file, b"").unwrap();
721
722        let op_path = fixture.root.join("ops/cleanup.graphql");
723        std::fs::create_dir_all(op_path.parent().unwrap()).unwrap();
724        std::fs::write(&op_path, "query Q { ok }\n").unwrap();
725
726        let template_path = fixture.root.join("templates/vars.json");
727        std::fs::create_dir_all(template_path.parent().unwrap()).unwrap();
728        std::fs::write(&template_path, r#"{"id":"{{id}}"}"#).unwrap();
729
730        let defaults = SuiteDefaults::default();
731        let mut ctx = CleanupContext {
732            repo_root: &fixture.root,
733            run_dir: &run_dir,
734            case_id: "c",
735            safe_id: "c",
736            main_response_file: None,
737            main_stderr_file: &stderr_file,
738            allow_writes_flag: true,
739            effective_env: "staging",
740            effective_no_history: true,
741            suite_defaults: &defaults,
742            env_rest_url: "",
743            env_gql_url: "",
744            rest_config_dir: "setup/rest",
745            rest_url: "",
746            rest_token: "",
747            gql_config_dir: "setup/graphql",
748            gql_url: "",
749            gql_jwt: "",
750            access_token_for_case: "",
751            auth_manager: None,
752            cleanup: None,
753        };
754
755        let response_json = serde_json::json!({"data": {"other": "x"}});
756        let step = SuiteCleanupStep {
757            step_type: "graphql".to_string(),
758            config_dir: String::new(),
759            url: "https://override.example/graphql".to_string(),
760            env: String::new(),
761            no_history: None,
762            method: String::new(),
763            path_template: String::new(),
764            vars: Some(serde_json::json!({"id": ".data.id"})),
765            token: String::new(),
766            expect: None,
767            expect_status: None,
768            expect_jq: String::new(),
769            jwt: String::new(),
770            op: op_path
771                .strip_prefix(&fixture.root)
772                .unwrap()
773                .to_string_lossy()
774                .to_string(),
775            vars_jq: String::new(),
776            vars_template: template_path
777                .strip_prefix(&fixture.root)
778                .unwrap()
779                .to_string_lossy()
780                .to_string(),
781            allow_errors: false,
782        };
783
784        let ok = graphql_cleanup_step(&mut ctx, &response_json, &step, 3).unwrap();
785        assert!(!ok);
786        let content = std::fs::read_to_string(&stderr_file).unwrap();
787        assert!(content.contains("varsTemplate render failed"));
788    }
789
790    #[test]
791    fn suite_cleanup_graphql_step_allow_errors_requires_expect_jq() {
792        let fixture = GraphqlSetupFixture::new();
793        let run_dir = fixture.root.join("out");
794        std::fs::create_dir_all(&run_dir).unwrap();
795        let stderr_file = run_dir.join("case.stderr.log");
796        write_file(&stderr_file, b"").unwrap();
797
798        let op_path = fixture.root.join("ops/cleanup.graphql");
799        std::fs::create_dir_all(op_path.parent().unwrap()).unwrap();
800        std::fs::write(&op_path, "query Q { ok }\n").unwrap();
801
802        let defaults = SuiteDefaults::default();
803        let mut ctx = CleanupContext {
804            repo_root: &fixture.root,
805            run_dir: &run_dir,
806            case_id: "c",
807            safe_id: "c",
808            main_response_file: None,
809            main_stderr_file: &stderr_file,
810            allow_writes_flag: true,
811            effective_env: "staging",
812            effective_no_history: true,
813            suite_defaults: &defaults,
814            env_rest_url: "",
815            env_gql_url: "",
816            rest_config_dir: "setup/rest",
817            rest_url: "",
818            rest_token: "",
819            gql_config_dir: "setup/graphql",
820            gql_url: "",
821            gql_jwt: "",
822            access_token_for_case: "",
823            auth_manager: None,
824            cleanup: None,
825        };
826
827        let response_json = serde_json::json!({});
828        let step = SuiteCleanupStep {
829            step_type: "graphql".to_string(),
830            config_dir: String::new(),
831            url: "https://override.example/graphql".to_string(),
832            env: String::new(),
833            no_history: None,
834            method: String::new(),
835            path_template: String::new(),
836            vars: None,
837            token: String::new(),
838            expect: None,
839            expect_status: None,
840            expect_jq: String::new(),
841            jwt: String::new(),
842            op: op_path
843                .strip_prefix(&fixture.root)
844                .unwrap()
845                .to_string_lossy()
846                .to_string(),
847            vars_jq: String::new(),
848            vars_template: String::new(),
849            allow_errors: true,
850        };
851
852        let ok = graphql_cleanup_step(&mut ctx, &response_json, &step, 4).unwrap();
853        assert!(!ok);
854        let content = std::fs::read_to_string(&stderr_file).unwrap();
855        assert!(content.contains("expect.jq missing"));
856    }
857
858    #[test]
859    fn suite_cleanup_graphql_step_invalid_vars_template_is_logged() {
860        let tmp = TempDir::new().unwrap();
861        let repo = tmp.path();
862        let run_dir = repo.join("out");
863        std::fs::create_dir_all(&run_dir).unwrap();
864        let stderr_file = run_dir.join("case.stderr.log");
865        write_file(&stderr_file, b"").unwrap();
866
867        let op_dir = repo.join("ops");
868        std::fs::create_dir_all(&op_dir).unwrap();
869        let op_path = op_dir.join("cleanup.graphql");
870        std::fs::write(&op_path, "query Q { ok }\n").unwrap();
871
872        let template_path = repo.join("templates/vars.json");
873        std::fs::create_dir_all(template_path.parent().unwrap()).unwrap();
874        std::fs::write(&template_path, r#"{"id": {{id}}"#).unwrap();
875
876        let defaults = SuiteDefaults::default();
877        let mut ctx = CleanupContext {
878            repo_root: repo,
879            run_dir: &run_dir,
880            case_id: "c",
881            safe_id: "c",
882            main_response_file: None,
883            main_stderr_file: &stderr_file,
884            allow_writes_flag: true,
885            effective_env: "staging",
886            effective_no_history: true,
887            suite_defaults: &defaults,
888            env_rest_url: "",
889            env_gql_url: "",
890            rest_config_dir: "setup/rest",
891            rest_url: "",
892            rest_token: "",
893            gql_config_dir: "setup/graphql",
894            gql_url: "",
895            gql_jwt: "",
896            access_token_for_case: "",
897            auth_manager: None,
898            cleanup: None,
899        };
900
901        let response_json = serde_json::json!({"data": {"id": "123"}});
902        let step = SuiteCleanupStep {
903            step_type: "graphql".to_string(),
904            config_dir: String::new(),
905            url: "https://override.example/graphql".to_string(),
906            env: String::new(),
907            no_history: None,
908            method: String::new(),
909            path_template: String::new(),
910            vars: Some(serde_json::json!({"id": ".data.id"})),
911            token: String::new(),
912            expect: None,
913            expect_status: None,
914            expect_jq: String::new(),
915            jwt: String::new(),
916            op: op_path.to_string_lossy().to_string(),
917            vars_jq: String::new(),
918            vars_template: template_path.to_string_lossy().to_string(),
919            allow_errors: false,
920        };
921
922        let err = graphql_cleanup_step(&mut ctx, &response_json, &step, 2).unwrap_err();
923        assert!(
924            err.to_string()
925                .contains("varsTemplate rendered invalid JSON")
926        );
927    }
928
929    #[test]
930    fn suite_cleanup_run_case_missing_response_file_is_error() {
931        let tmp = TempDir::new().unwrap();
932        let repo = tmp.path();
933        std::fs::create_dir_all(repo.join(".git")).unwrap();
934        let run_dir = repo.join("out");
935        std::fs::create_dir_all(&run_dir).unwrap();
936        let stderr_file = run_dir.join("case.stderr.log");
937        write_file(&stderr_file, b"").unwrap();
938
939        let cleanup = SuiteCleanup::One(Box::new(SuiteCleanupStep {
940            step_type: "rest".to_string(),
941            config_dir: String::new(),
942            url: String::new(),
943            env: String::new(),
944            no_history: None,
945            method: "DELETE".to_string(),
946            path_template: "/x".to_string(),
947            vars: None,
948            token: String::new(),
949            expect: None,
950            expect_status: None,
951            expect_jq: String::new(),
952            jwt: String::new(),
953            op: String::new(),
954            vars_jq: String::new(),
955            vars_template: String::new(),
956            allow_errors: false,
957        }));
958
959        let defaults = SuiteDefaults::default();
960        let missing_response = run_dir.join("missing.json");
961        let mut ctx = CleanupContext {
962            repo_root: repo,
963            run_dir: &run_dir,
964            case_id: "c",
965            safe_id: "c",
966            main_response_file: Some(&missing_response),
967            main_stderr_file: &stderr_file,
968            allow_writes_flag: true,
969            effective_env: "staging",
970            effective_no_history: true,
971            suite_defaults: &defaults,
972            env_rest_url: "",
973            env_gql_url: "",
974            rest_config_dir: "setup/rest",
975            rest_url: "",
976            rest_token: "",
977            gql_config_dir: "setup/graphql",
978            gql_url: "",
979            gql_jwt: "",
980            access_token_for_case: "",
981            auth_manager: None,
982            cleanup: Some(&cleanup),
983        };
984
985        let ok = run_case_cleanup(&mut ctx).unwrap();
986        assert!(!ok);
987        let content = std::fs::read_to_string(&stderr_file).unwrap();
988        assert!(content.contains("missing main response file"));
989    }
990
991    #[test]
992    fn suite_cleanup_run_case_unknown_step_type_is_error() {
993        let tmp = TempDir::new().unwrap();
994        let repo = tmp.path();
995        std::fs::create_dir_all(repo.join(".git")).unwrap();
996        let run_dir = repo.join("out");
997        std::fs::create_dir_all(&run_dir).unwrap();
998        let stderr_file = run_dir.join("case.stderr.log");
999        write_file(&stderr_file, b"").unwrap();
1000
1001        let response_file = run_dir.join("response.json");
1002        std::fs::write(&response_file, br#"{"ok":true}"#).unwrap();
1003
1004        let cleanup = SuiteCleanup::One(Box::new(SuiteCleanupStep {
1005            step_type: "mystery".to_string(),
1006            config_dir: String::new(),
1007            url: String::new(),
1008            env: String::new(),
1009            no_history: None,
1010            method: "DELETE".to_string(),
1011            path_template: "/x".to_string(),
1012            vars: None,
1013            token: String::new(),
1014            expect: None,
1015            expect_status: None,
1016            expect_jq: String::new(),
1017            jwt: String::new(),
1018            op: String::new(),
1019            vars_jq: String::new(),
1020            vars_template: String::new(),
1021            allow_errors: false,
1022        }));
1023
1024        let defaults = SuiteDefaults::default();
1025        let mut ctx = CleanupContext {
1026            repo_root: repo,
1027            run_dir: &run_dir,
1028            case_id: "c",
1029            safe_id: "c",
1030            main_response_file: Some(&response_file),
1031            main_stderr_file: &stderr_file,
1032            allow_writes_flag: true,
1033            effective_env: "staging",
1034            effective_no_history: true,
1035            suite_defaults: &defaults,
1036            env_rest_url: "",
1037            env_gql_url: "",
1038            rest_config_dir: "setup/rest",
1039            rest_url: "",
1040            rest_token: "",
1041            gql_config_dir: "setup/graphql",
1042            gql_url: "",
1043            gql_jwt: "",
1044            access_token_for_case: "",
1045            auth_manager: None,
1046            cleanup: Some(&cleanup),
1047        };
1048
1049        let ok = run_case_cleanup(&mut ctx).unwrap();
1050        assert!(!ok);
1051        let content = std::fs::read_to_string(&stderr_file).unwrap();
1052        assert!(content.contains("unknown step type"));
1053    }
1054}