Skip to main content

api_testing_core/suite/
schema.rs

1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3
4use anyhow::Context;
5use serde::Deserialize;
6
7use crate::Result;
8
9fn default_auth_required() -> bool {
10    true
11}
12
13fn default_auth_secret_env() -> String {
14    "API_TEST_AUTH_JSON".to_string()
15}
16
17fn default_auth_token_jq() -> String {
18    ".. | objects | (.accessToken? // .access_token? // .token? // .jwt? // empty) | select(type==\"string\" and length>0) | .".to_string()
19}
20
21fn default_rest_config_dir() -> String {
22    "setup/rest".to_string()
23}
24
25fn default_graphql_config_dir() -> String {
26    "setup/graphql".to_string()
27}
28
29fn default_grpc_config_dir() -> String {
30    "setup/grpc".to_string()
31}
32
33#[derive(Debug, Clone, Deserialize)]
34#[serde(rename_all = "camelCase")]
35pub struct SuiteManifest {
36    pub version: u32,
37    #[serde(default)]
38    pub name: String,
39    #[serde(default)]
40    pub defaults: SuiteDefaults,
41    #[serde(default)]
42    pub auth: Option<SuiteAuth>,
43    pub cases: Vec<SuiteCase>,
44}
45
46#[derive(Debug, Clone, Deserialize, Default)]
47#[serde(rename_all = "camelCase")]
48pub struct SuiteDefaults {
49    #[serde(default)]
50    pub env: String,
51    #[serde(default)]
52    pub no_history: bool,
53    #[serde(default)]
54    pub rest: SuiteDefaultsRest,
55    #[serde(default)]
56    pub graphql: SuiteDefaultsGraphql,
57    #[serde(default)]
58    pub grpc: SuiteDefaultsGrpc,
59}
60
61#[derive(Debug, Clone, Deserialize)]
62#[serde(rename_all = "camelCase")]
63pub struct SuiteDefaultsRest {
64    #[serde(default = "default_rest_config_dir")]
65    pub config_dir: String,
66    #[serde(default)]
67    pub url: String,
68    #[serde(default)]
69    pub token: String,
70}
71
72impl Default for SuiteDefaultsRest {
73    fn default() -> Self {
74        Self {
75            config_dir: default_rest_config_dir(),
76            url: String::new(),
77            token: String::new(),
78        }
79    }
80}
81
82#[derive(Debug, Clone, Deserialize)]
83#[serde(rename_all = "camelCase")]
84pub struct SuiteDefaultsGraphql {
85    #[serde(default = "default_graphql_config_dir")]
86    pub config_dir: String,
87    #[serde(default)]
88    pub url: String,
89    #[serde(default)]
90    pub jwt: String,
91}
92
93impl Default for SuiteDefaultsGraphql {
94    fn default() -> Self {
95        Self {
96            config_dir: default_graphql_config_dir(),
97            url: String::new(),
98            jwt: String::new(),
99        }
100    }
101}
102
103#[derive(Debug, Clone, Deserialize)]
104#[serde(rename_all = "camelCase")]
105pub struct SuiteDefaultsGrpc {
106    #[serde(default = "default_grpc_config_dir")]
107    pub config_dir: String,
108    #[serde(default)]
109    pub url: String,
110    #[serde(default)]
111    pub token: String,
112}
113
114impl Default for SuiteDefaultsGrpc {
115    fn default() -> Self {
116        Self {
117            config_dir: default_grpc_config_dir(),
118            url: String::new(),
119            token: String::new(),
120        }
121    }
122}
123
124#[derive(Debug, Clone, Deserialize)]
125#[serde(rename_all = "camelCase")]
126pub struct SuiteAuth {
127    #[serde(default)]
128    pub provider: String,
129    #[serde(default = "default_auth_required")]
130    pub required: bool,
131    #[serde(default = "default_auth_secret_env")]
132    pub secret_env: String,
133    #[serde(default)]
134    pub rest: Option<SuiteAuthRest>,
135    #[serde(default)]
136    pub graphql: Option<SuiteAuthGraphql>,
137}
138
139#[derive(Debug, Clone, Deserialize)]
140#[serde(rename_all = "camelCase")]
141pub struct SuiteAuthRest {
142    pub login_request_template: String,
143    pub credentials_jq: String,
144    #[serde(default = "default_auth_token_jq")]
145    pub token_jq: String,
146    #[serde(default)]
147    pub config_dir: String,
148    #[serde(default)]
149    pub url: String,
150    #[serde(default)]
151    pub env: String,
152}
153
154#[derive(Debug, Clone, Deserialize)]
155#[serde(rename_all = "camelCase")]
156pub struct SuiteAuthGraphql {
157    pub login_op: String,
158    pub login_vars_template: String,
159    pub credentials_jq: String,
160    #[serde(default = "default_auth_token_jq")]
161    pub token_jq: String,
162    #[serde(default)]
163    pub config_dir: String,
164    #[serde(default)]
165    pub url: String,
166    #[serde(default)]
167    pub env: String,
168}
169
170#[derive(Debug, Clone, Deserialize)]
171#[serde(rename_all = "camelCase")]
172pub struct SuiteCase {
173    pub id: String,
174    #[serde(rename = "type")]
175    pub case_type: String,
176
177    #[serde(default)]
178    pub tags: Vec<String>,
179
180    #[serde(default)]
181    pub env: String,
182
183    #[serde(default)]
184    pub no_history: Option<bool>,
185
186    #[serde(default)]
187    pub allow_write: bool,
188
189    // Shared config overrides (meaning depends on type).
190    #[serde(default)]
191    pub config_dir: String,
192    #[serde(default)]
193    pub url: String,
194
195    // REST case fields
196    #[serde(default)]
197    pub token: String,
198    #[serde(default)]
199    pub request: String,
200
201    // gRPC case fields
202    #[serde(default)]
203    pub grpc_proto: Option<String>,
204    #[serde(default)]
205    pub grpc_import_paths: Option<Vec<String>>,
206    #[serde(default)]
207    pub grpc_plaintext: Option<bool>,
208
209    // REST-flow case fields
210    #[serde(default)]
211    pub login_request: String,
212    #[serde(default)]
213    pub token_jq: String,
214
215    // GraphQL case fields
216    #[serde(default)]
217    pub jwt: String,
218    #[serde(default)]
219    pub op: String,
220    #[serde(default)]
221    pub vars: Option<String>,
222    #[serde(default)]
223    pub allow_errors: bool,
224    #[serde(default)]
225    pub expect: Option<SuiteGraphqlExpect>,
226
227    #[serde(default)]
228    pub cleanup: Option<SuiteCleanup>,
229}
230
231#[derive(Debug, Clone, Deserialize)]
232#[serde(rename_all = "camelCase")]
233pub struct SuiteGraphqlExpect {
234    #[serde(default)]
235    pub jq: String,
236}
237
238#[derive(Debug, Clone, Deserialize)]
239#[serde(untagged)]
240pub enum SuiteCleanup {
241    One(Box<SuiteCleanupStep>),
242    Many(Vec<SuiteCleanupStep>),
243}
244
245impl SuiteCleanup {
246    pub fn steps(&self) -> Vec<SuiteCleanupStep> {
247        match self {
248            Self::One(step) => vec![step.as_ref().clone()],
249            Self::Many(steps) => steps.clone(),
250        }
251    }
252}
253
254#[derive(Debug, Clone, Deserialize)]
255#[serde(rename_all = "camelCase")]
256pub struct SuiteCleanupStep {
257    #[serde(rename = "type")]
258    pub step_type: String,
259
260    #[serde(default)]
261    pub config_dir: String,
262    #[serde(default)]
263    pub url: String,
264    #[serde(default)]
265    pub env: String,
266    #[serde(default)]
267    pub no_history: Option<bool>,
268
269    // REST cleanup fields
270    #[serde(default)]
271    pub method: String,
272    #[serde(default)]
273    pub path_template: String,
274    #[serde(default)]
275    pub vars: Option<serde_json::Value>,
276    #[serde(default)]
277    pub token: String,
278    #[serde(default)]
279    pub expect: Option<SuiteCleanupExpect>,
280    #[serde(default)]
281    pub expect_status: Option<u16>,
282    #[serde(default)]
283    pub expect_jq: String,
284
285    // GraphQL cleanup fields
286    #[serde(default)]
287    pub jwt: String,
288    #[serde(default)]
289    pub op: String,
290    #[serde(default)]
291    pub vars_jq: String,
292    #[serde(default)]
293    pub vars_template: String,
294    #[serde(default)]
295    pub allow_errors: bool,
296}
297
298#[derive(Debug, Clone, Deserialize)]
299#[serde(rename_all = "camelCase")]
300pub struct SuiteCleanupExpect {
301    #[serde(default)]
302    pub status: Option<u16>,
303    #[serde(default)]
304    pub jq: String,
305}
306
307fn is_valid_env_var_name(raw: &str) -> bool {
308    let mut chars = raw.chars();
309    let Some(first) = chars.next() else {
310        return false;
311    };
312    if !(first == '_' || first.is_ascii_alphabetic()) {
313        return false;
314    }
315    chars.all(|c| c == '_' || c.is_ascii_alphanumeric())
316}
317
318fn schema_error(
319    path: &str,
320    case_id: Option<&str>,
321    message: impl std::fmt::Display,
322) -> anyhow::Error {
323    match case_id {
324        Some(id) if !id.trim().is_empty() => {
325            anyhow::anyhow!("Suite schema error at {path} (case {id}): {message}")
326        }
327        _ => anyhow::anyhow!("Suite schema error at {path}: {message}"),
328    }
329}
330
331fn canonical_case_type(raw: &str) -> String {
332    raw.trim().to_ascii_lowercase()
333}
334
335pub fn load_suite_manifest(path: impl AsRef<Path>) -> Result<SuiteManifest> {
336    let path = path.as_ref();
337    let bytes =
338        std::fs::read(path).with_context(|| format!("read suite file: {}", path.display()))?;
339
340    let manifest: SuiteManifest = serde_json::from_slice(&bytes)
341        .with_context(|| format!("Suite file is not valid JSON: {}", path.display()))?;
342    Ok(manifest)
343}
344
345pub fn validate_suite_manifest(manifest: &SuiteManifest, suite_path: &Path) -> Result<()> {
346    if manifest.version != 1 {
347        anyhow::bail!(
348            "Unsupported suite version: {} (expected 1): {}",
349            manifest.version,
350            suite_path.display()
351        );
352    }
353
354    if let Some(auth) = &manifest.auth {
355        let secret_env = auth.secret_env.trim();
356        if secret_env.is_empty() {
357            return Err(schema_error("auth.secretEnv", None, "must not be empty"));
358        }
359        if !is_valid_env_var_name(secret_env) {
360            return Err(schema_error(
361                "auth.secretEnv",
362                None,
363                "must be a valid env var name",
364            ));
365        }
366
367        let provider_raw = auth.provider.trim().to_ascii_lowercase();
368        let provider = if provider_raw.is_empty() {
369            match (&auth.rest, &auth.graphql) {
370                (Some(_), None) => "rest".to_string(),
371                (None, Some(_)) => "graphql".to_string(),
372                (Some(_), Some(_)) => {
373                    return Err(schema_error(
374                        "auth.provider",
375                        None,
376                        "is required when both auth.rest and auth.graphql are present",
377                    ));
378                }
379                (None, None) => {
380                    return Err(schema_error(
381                        "auth",
382                        None,
383                        "must include either auth.rest or auth.graphql",
384                    ));
385                }
386            }
387        } else if provider_raw == "gql" {
388            "graphql".to_string()
389        } else {
390            provider_raw
391        };
392
393        match provider.as_str() {
394            "rest" => {
395                let Some(rest) = &auth.rest else {
396                    return Err(schema_error(
397                        "auth.rest",
398                        None,
399                        "is required for provider=rest",
400                    ));
401                };
402                if rest.login_request_template.trim().is_empty() {
403                    return Err(schema_error(
404                        "auth.rest.loginRequestTemplate",
405                        None,
406                        "is required",
407                    ));
408                }
409                if rest.credentials_jq.trim().is_empty() {
410                    return Err(schema_error("auth.rest.credentialsJq", None, "is required"));
411                }
412            }
413            "graphql" => {
414                let Some(graphql) = &auth.graphql else {
415                    return Err(schema_error(
416                        "auth.graphql",
417                        None,
418                        "is required for provider=graphql",
419                    ));
420                };
421                if graphql.login_op.trim().is_empty() {
422                    return Err(schema_error("auth.graphql.loginOp", None, "is required"));
423                }
424                if graphql.login_vars_template.trim().is_empty() {
425                    return Err(schema_error(
426                        "auth.graphql.loginVarsTemplate",
427                        None,
428                        "is required",
429                    ));
430                }
431                if graphql.credentials_jq.trim().is_empty() {
432                    return Err(schema_error(
433                        "auth.graphql.credentialsJq",
434                        None,
435                        "is required",
436                    ));
437                }
438            }
439            _ => {
440                return Err(schema_error(
441                    "auth.provider",
442                    None,
443                    "must be one of: rest, graphql",
444                ));
445            }
446        }
447    }
448
449    let mut seen_ids: HashSet<String> = HashSet::new();
450    for (i, c) in manifest.cases.iter().enumerate() {
451        let id = c.id.trim();
452        if id.is_empty() {
453            return Err(schema_error(&format!("cases[{i}].id"), None, "is required"));
454        }
455        if !seen_ids.insert(id.to_string()) {
456            return Err(schema_error(
457                &format!("cases[{i}].id"),
458                Some(id),
459                "must be unique",
460            ));
461        }
462
463        let ty = canonical_case_type(&c.case_type);
464        if ty.is_empty() {
465            return Err(schema_error(
466                &format!("cases[{i}].type"),
467                Some(id),
468                "is required",
469            ));
470        }
471
472        match ty.as_str() {
473            "rest" => {
474                if c.request.trim().is_empty() {
475                    return Err(schema_error(
476                        &format!("cases[{i}].request"),
477                        Some(id),
478                        "is required for type=rest",
479                    ));
480                }
481            }
482            "rest-flow" | "rest_flow" => {
483                if c.login_request.trim().is_empty() {
484                    return Err(schema_error(
485                        &format!("cases[{i}].loginRequest"),
486                        Some(id),
487                        "is required for type=rest-flow",
488                    ));
489                }
490                if c.request.trim().is_empty() {
491                    return Err(schema_error(
492                        &format!("cases[{i}].request"),
493                        Some(id),
494                        "is required for type=rest-flow",
495                    ));
496                }
497            }
498            "graphql" => {
499                if c.op.trim().is_empty() {
500                    return Err(schema_error(
501                        &format!("cases[{i}].op"),
502                        Some(id),
503                        "is required for type=graphql",
504                    ));
505                }
506
507                if c.allow_errors {
508                    let expect_jq = c.expect.as_ref().map(|e| e.jq.trim()).unwrap_or_default();
509                    if expect_jq.is_empty() {
510                        return Err(schema_error(
511                            &format!("cases[{i}].expect.jq"),
512                            Some(id),
513                            "allowErrors=true requires expect.jq",
514                        ));
515                    }
516                }
517            }
518            "grpc" => {
519                if c.request.trim().is_empty() {
520                    return Err(schema_error(
521                        &format!("cases[{i}].request"),
522                        Some(id),
523                        "is required for type=grpc",
524                    ));
525                }
526            }
527            other => {
528                return Err(schema_error(
529                    &format!("cases[{i}].type"),
530                    Some(id),
531                    format!("unknown case type: {other}"),
532                ));
533            }
534        }
535    }
536
537    Ok(())
538}
539
540#[derive(Debug, Clone)]
541pub struct LoadedSuite {
542    pub suite_path: PathBuf,
543    pub manifest: SuiteManifest,
544}
545
546pub fn load_and_validate_suite(path: impl AsRef<Path>) -> Result<LoadedSuite> {
547    let path = path.as_ref();
548    let manifest = load_suite_manifest(path)?;
549    let suite_path = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
550    validate_suite_manifest(&manifest, &suite_path)?;
551    Ok(LoadedSuite {
552        suite_path,
553        manifest,
554    })
555}
556
557#[cfg(test)]
558mod tests {
559    use super::*;
560
561    use tempfile::TempDir;
562
563    fn write_suite(tmp: &TempDir, value: &serde_json::Value) -> PathBuf {
564        let path = tmp.path().join("suite.json");
565        std::fs::write(&path, serde_json::to_vec_pretty(value).unwrap()).unwrap();
566        path
567    }
568
569    fn base_rest_case() -> serde_json::Value {
570        serde_json::json!({
571          "id": "rest.health",
572          "type": "rest",
573          "request": "setup/rest/requests/health.request.json"
574        })
575    }
576
577    fn validate_err(value: serde_json::Value) -> String {
578        let tmp = TempDir::new().unwrap();
579        let path = write_suite(&tmp, &value);
580        let err = load_and_validate_suite(&path).unwrap_err();
581        format!("{err:#}")
582    }
583
584    #[test]
585    fn suite_schema_rejects_unsupported_version() {
586        let err = validate_err(serde_json::json!({
587          "version": 2,
588          "cases": [base_rest_case()]
589        }));
590        assert!(err.contains("Unsupported suite version"));
591    }
592
593    #[test]
594    fn suite_schema_rejects_empty_auth_secret_env() {
595        let err = validate_err(serde_json::json!({
596          "version": 1,
597          "auth": { "secretEnv": "   " },
598          "cases": [base_rest_case()]
599        }));
600        assert!(err.contains("auth.secretEnv"));
601        assert!(err.contains("must not be empty"));
602    }
603
604    #[test]
605    fn suite_schema_rejects_invalid_auth_secret_env() {
606        let err = validate_err(serde_json::json!({
607          "version": 1,
608          "auth": { "secretEnv": "123" },
609          "cases": [base_rest_case()]
610        }));
611        assert!(err.contains("auth.secretEnv"));
612        assert!(err.contains("valid env var name"));
613    }
614
615    #[test]
616    fn suite_schema_requires_provider_when_both_auth_blocks_present() {
617        let err = validate_err(serde_json::json!({
618          "version": 1,
619          "auth": {
620            "rest": {
621              "loginRequestTemplate": "setup/rest/requests/login.request.json",
622              "credentialsJq": ".profiles[$profile]"
623            },
624            "graphql": {
625              "loginOp": "setup/graphql/operations/login.graphql",
626              "loginVarsTemplate": "setup/graphql/vars/login.json",
627              "credentialsJq": ".profiles[$profile]"
628            }
629          },
630          "cases": [base_rest_case()]
631        }));
632        assert!(err.contains("auth.provider"));
633        assert!(err.contains("both auth.rest and auth.graphql"));
634    }
635
636    #[test]
637    fn suite_schema_rejects_rest_auth_missing_login_request_template() {
638        let err = validate_err(serde_json::json!({
639          "version": 1,
640          "auth": {
641            "provider": "rest",
642            "rest": {
643              "loginRequestTemplate": " ",
644              "credentialsJq": ".profiles[$profile]"
645            }
646          },
647          "cases": [base_rest_case()]
648        }));
649        assert!(err.contains("auth.rest.loginRequestTemplate"));
650    }
651
652    #[test]
653    fn suite_schema_rejects_rest_auth_missing_credentials_jq() {
654        let err = validate_err(serde_json::json!({
655          "version": 1,
656          "auth": {
657            "provider": "rest",
658            "rest": {
659              "loginRequestTemplate": "setup/rest/requests/login.request.json",
660              "credentialsJq": " "
661            }
662          },
663          "cases": [base_rest_case()]
664        }));
665        assert!(err.contains("auth.rest.credentialsJq"));
666    }
667
668    #[test]
669    fn suite_schema_rejects_graphql_auth_missing_login_op() {
670        let err = validate_err(serde_json::json!({
671          "version": 1,
672          "auth": {
673            "provider": "graphql",
674            "graphql": {
675              "loginOp": " ",
676              "loginVarsTemplate": "setup/graphql/vars/login.json",
677              "credentialsJq": ".profiles[$profile]"
678            }
679          },
680          "cases": [base_rest_case()]
681        }));
682        assert!(err.contains("auth.graphql.loginOp"));
683    }
684
685    #[test]
686    fn suite_schema_rejects_graphql_auth_missing_login_vars_template() {
687        let err = validate_err(serde_json::json!({
688          "version": 1,
689          "auth": {
690            "provider": "graphql",
691            "graphql": {
692              "loginOp": "setup/graphql/operations/login.graphql",
693              "loginVarsTemplate": " ",
694              "credentialsJq": ".profiles[$profile]"
695            }
696          },
697          "cases": [base_rest_case()]
698        }));
699        assert!(err.contains("auth.graphql.loginVarsTemplate"));
700    }
701
702    #[test]
703    fn suite_schema_rejects_graphql_auth_missing_credentials_jq() {
704        let err = validate_err(serde_json::json!({
705          "version": 1,
706          "auth": {
707            "provider": "graphql",
708            "graphql": {
709              "loginOp": "setup/graphql/operations/login.graphql",
710              "loginVarsTemplate": "setup/graphql/vars/login.json",
711              "credentialsJq": " "
712            }
713          },
714          "cases": [base_rest_case()]
715        }));
716        assert!(err.contains("auth.graphql.credentialsJq"));
717    }
718
719    #[test]
720    fn suite_schema_rejects_unknown_auth_provider() {
721        let err = validate_err(serde_json::json!({
722          "version": 1,
723          "auth": { "provider": "soap" },
724          "cases": [base_rest_case()]
725        }));
726        assert!(err.contains("auth.provider"));
727        assert!(err.contains("rest, graphql"));
728    }
729
730    #[test]
731    fn suite_schema_rejects_empty_case_id() {
732        let err = validate_err(serde_json::json!({
733          "version": 1,
734          "cases": [
735            { "id": " ", "type": "rest", "request": "setup/rest/requests/health.request.json" }
736          ]
737        }));
738        assert!(err.contains("cases[0].id"));
739        assert!(err.contains("is required"));
740    }
741
742    #[test]
743    fn suite_schema_rejects_duplicate_case_ids() {
744        let err = validate_err(serde_json::json!({
745          "version": 1,
746          "cases": [
747            { "id": "dup", "type": "rest", "request": "setup/rest/requests/health.request.json" },
748            { "id": "dup", "type": "rest", "request": "setup/rest/requests/health.request.json" }
749          ]
750        }));
751        assert!(err.contains("cases[1].id"));
752        assert!(err.contains("must be unique"));
753    }
754
755    #[test]
756    fn suite_schema_rejects_empty_case_type() {
757        let err = validate_err(serde_json::json!({
758          "version": 1,
759          "cases": [
760            { "id": "x", "type": " ", "request": "setup/rest/requests/health.request.json" }
761          ]
762        }));
763        assert!(err.contains("cases[0].type"));
764        assert!(err.contains("is required"));
765    }
766
767    #[test]
768    fn suite_schema_rejects_rest_case_missing_request() {
769        let err = validate_err(serde_json::json!({
770          "version": 1,
771          "cases": [
772            { "id": "rest.missing", "type": "rest" }
773          ]
774        }));
775        assert!(err.contains("cases[0].request"));
776        assert!(err.contains("type=rest"));
777    }
778
779    #[test]
780    fn suite_schema_rejects_rest_flow_missing_login_request() {
781        let err = validate_err(serde_json::json!({
782          "version": 1,
783          "cases": [
784            { "id": "rest.flow", "type": "rest-flow", "request": "setup/rest/requests/health.request.json" }
785          ]
786        }));
787        assert!(err.contains("cases[0].loginRequest"));
788        assert!(err.contains("type=rest-flow"));
789    }
790
791    #[test]
792    fn suite_schema_rejects_rest_flow_missing_request() {
793        let err = validate_err(serde_json::json!({
794          "version": 1,
795          "cases": [
796            { "id": "rest.flow", "type": "rest-flow", "loginRequest": "setup/rest/requests/login.request.json" }
797          ]
798        }));
799        assert!(err.contains("cases[0].request"));
800        assert!(err.contains("type=rest-flow"));
801    }
802
803    #[test]
804    fn suite_schema_rejects_graphql_case_missing_op() {
805        let err = validate_err(serde_json::json!({
806          "version": 1,
807          "cases": [
808            { "id": "graphql.missing", "type": "graphql" }
809          ]
810        }));
811        assert!(err.contains("cases[0].op"));
812        assert!(err.contains("type=graphql"));
813    }
814
815    #[test]
816    fn suite_schema_rejects_grpc_case_missing_request() {
817        let err = validate_err(serde_json::json!({
818          "version": 1,
819          "cases": [
820            { "id": "grpc.missing", "type": "grpc" }
821          ]
822        }));
823        assert!(err.contains("cases[0].request"));
824        assert!(err.contains("type=grpc"));
825    }
826
827    #[test]
828    fn suite_cleanup_steps_supports_single_and_many() {
829        let one = SuiteCleanup::One(Box::new(SuiteCleanupStep {
830            step_type: "rest".to_string(),
831            config_dir: String::new(),
832            url: String::new(),
833            env: String::new(),
834            no_history: None,
835            method: "DELETE".to_string(),
836            path_template: "/health".to_string(),
837            vars: None,
838            token: String::new(),
839            expect: None,
840            expect_status: None,
841            expect_jq: String::new(),
842            jwt: String::new(),
843            op: String::new(),
844            vars_jq: String::new(),
845            vars_template: String::new(),
846            allow_errors: false,
847        }));
848        let many = SuiteCleanup::Many(vec![one.steps()[0].clone()]);
849
850        assert_eq!(one.steps().len(), 1);
851        assert_eq!(many.steps().len(), 1);
852    }
853
854    #[test]
855    fn suite_schema_rejects_allow_errors_true_without_expect_jq() {
856        let tmp = TempDir::new().unwrap();
857        let path = write_suite(
858            &tmp,
859            &serde_json::json!({
860              "version": 1,
861              "name": "smoke",
862              "cases": [
863                {
864                  "id": "graphql.countries",
865                  "type": "graphql",
866                  "allowErrors": true,
867                  "op": "setup/graphql/ops/countries.graphql"
868                }
869              ]
870            }),
871        );
872
873        let err = load_and_validate_suite(&path).unwrap_err();
874        assert!(format!("{err:#}").contains("graphql.countries"));
875        assert!(format!("{err:#}").contains("allowErrors=true requires expect.jq"));
876    }
877
878    #[test]
879    fn suite_schema_unknown_type_includes_case_id() {
880        let tmp = TempDir::new().unwrap();
881        let path = write_suite(
882            &tmp,
883            &serde_json::json!({
884              "version": 1,
885              "cases": [
886                { "id": "x", "type": "nope" }
887              ]
888            }),
889        );
890        let err = load_and_validate_suite(&path).unwrap_err();
891        assert!(format!("{err:#}").contains("case x"));
892        assert!(format!("{err:#}").contains("unknown case type"));
893    }
894}