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