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 #[serde(default)]
218 pub config_dir: String,
219 #[serde(default)]
220 pub url: String,
221
222 #[serde(default)]
224 pub token: String,
225 #[serde(default)]
226 pub request: String,
227
228 #[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 #[serde(default)]
238 pub login_request: String,
239 #[serde(default)]
240 pub token_jq: String,
241
242 #[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 #[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 #[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}