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}