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