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