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 #[serde(default)]
191 pub config_dir: String,
192 #[serde(default)]
193 pub url: String,
194
195 #[serde(default)]
197 pub token: String,
198 #[serde(default)]
199 pub request: String,
200
201 #[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 #[serde(default)]
211 pub login_request: String,
212 #[serde(default)]
213 pub token_jq: String,
214
215 #[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 #[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 #[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}