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
17pub(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 #[serde(default)]
231 pub config_dir: String,
232 #[serde(default)]
233 pub url: String,
234
235 #[serde(default)]
237 pub token: String,
238 #[serde(default)]
239 pub request: String,
240
241 #[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 #[serde(default)]
251 pub login_request: String,
252 #[serde(default)]
253 pub token_jq: String,
254
255 #[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 #[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 #[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}