Skip to main content

api_test/
suite_schema.rs

1use std::fmt;
2
3use serde::Deserialize;
4
5pub const SUITE_SCHEMA_VERSION_V1: u32 = 1;
6
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct RawText(pub String);
9
10impl RawText {
11    pub fn trimmed_lower(&self) -> String {
12        self.0.trim().to_ascii_lowercase()
13    }
14}
15
16impl<'de> Deserialize<'de> for RawText {
17    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
18    where
19        D: serde::Deserializer<'de>,
20    {
21        let v = serde_json::Value::deserialize(deserializer)?;
22        let s = match v {
23            serde_json::Value::String(s) => s,
24            serde_json::Value::Number(n) => n.to_string(),
25            serde_json::Value::Bool(b) => b.to_string(),
26            serde_json::Value::Null => String::new(),
27            other => other.to_string(),
28        };
29        Ok(Self(s))
30    }
31}
32
33#[derive(Debug, Clone, PartialEq, Deserialize)]
34pub struct SuiteManifestV1 {
35    pub version: u32,
36    #[serde(default)]
37    pub name: Option<String>,
38    #[serde(default)]
39    pub defaults: Option<SuiteDefaultsV1>,
40    #[serde(default)]
41    pub auth: Option<SuiteAuthV1>,
42    pub cases: Vec<SuiteCaseV1>,
43}
44
45#[derive(Debug, Clone, PartialEq, Deserialize)]
46pub struct SuiteDefaultsV1 {
47    #[serde(default)]
48    pub env: Option<String>,
49    #[serde(default, rename = "noHistory")]
50    pub no_history: Option<RawText>,
51    #[serde(default)]
52    pub rest: Option<SuiteDefaultsRestV1>,
53    #[serde(default)]
54    pub graphql: Option<SuiteDefaultsGraphqlV1>,
55    #[serde(default)]
56    pub grpc: Option<SuiteDefaultsGrpcV1>,
57    #[serde(default)]
58    pub websocket: Option<SuiteDefaultsWebsocketV1>,
59}
60
61#[derive(Debug, Clone, PartialEq, Deserialize)]
62pub struct SuiteDefaultsRestV1 {
63    #[serde(default, rename = "configDir")]
64    pub config_dir: Option<String>,
65    #[serde(default)]
66    pub url: Option<String>,
67    #[serde(default)]
68    pub token: Option<String>,
69}
70
71#[derive(Debug, Clone, PartialEq, Deserialize)]
72pub struct SuiteDefaultsGraphqlV1 {
73    #[serde(default, rename = "configDir")]
74    pub config_dir: Option<String>,
75    #[serde(default)]
76    pub url: Option<String>,
77    #[serde(default)]
78    pub jwt: Option<String>,
79}
80
81#[derive(Debug, Clone, PartialEq, Deserialize)]
82pub struct SuiteDefaultsGrpcV1 {
83    #[serde(default, rename = "configDir")]
84    pub config_dir: Option<String>,
85    #[serde(default)]
86    pub url: Option<String>,
87    #[serde(default)]
88    pub token: Option<String>,
89}
90
91#[derive(Debug, Clone, PartialEq, Deserialize)]
92pub struct SuiteDefaultsWebsocketV1 {
93    #[serde(default, rename = "configDir")]
94    pub config_dir: Option<String>,
95    #[serde(default)]
96    pub url: Option<String>,
97    #[serde(default)]
98    pub token: Option<String>,
99}
100
101#[derive(Debug, Clone, PartialEq, Deserialize)]
102pub struct SuiteAuthV1 {
103    #[serde(default)]
104    pub provider: Option<String>,
105    #[serde(default)]
106    pub required: Option<RawText>,
107    #[serde(default, rename = "secretEnv")]
108    pub secret_env: Option<String>,
109    #[serde(default)]
110    pub rest: Option<SuiteAuthRestV1>,
111    #[serde(default)]
112    pub graphql: Option<SuiteAuthGraphqlV1>,
113}
114
115#[derive(Debug, Clone, PartialEq, Deserialize)]
116pub struct SuiteAuthRestV1 {
117    #[serde(default, rename = "loginRequestTemplate")]
118    pub login_request_template: Option<String>,
119    #[serde(default, rename = "credentialsJq")]
120    pub credentials_jq: Option<String>,
121    #[serde(default, rename = "tokenJq")]
122    pub token_jq: Option<String>,
123    #[serde(default, rename = "configDir")]
124    pub config_dir: Option<String>,
125    #[serde(default)]
126    pub url: Option<String>,
127    #[serde(default)]
128    pub env: Option<String>,
129}
130
131#[derive(Debug, Clone, PartialEq, Deserialize)]
132pub struct SuiteAuthGraphqlV1 {
133    #[serde(default, rename = "loginOp")]
134    pub login_op: Option<String>,
135    #[serde(default, rename = "loginVarsTemplate")]
136    pub login_vars_template: Option<String>,
137    #[serde(default, rename = "credentialsJq")]
138    pub credentials_jq: Option<String>,
139    #[serde(default, rename = "tokenJq")]
140    pub token_jq: Option<String>,
141    #[serde(default, rename = "configDir")]
142    pub config_dir: Option<String>,
143    #[serde(default)]
144    pub url: Option<String>,
145    #[serde(default)]
146    pub env: Option<String>,
147}
148
149#[derive(Debug, Clone, PartialEq, Deserialize)]
150pub struct SuiteCaseV1 {
151    #[serde(default)]
152    pub id: Option<String>,
153    #[serde(default, rename = "type")]
154    pub case_type: Option<String>,
155    #[serde(default)]
156    pub tags: Vec<String>,
157    #[serde(default)]
158    pub env: Option<String>,
159    #[serde(default, rename = "noHistory")]
160    pub no_history: Option<RawText>,
161    #[serde(default, rename = "allowWrite")]
162    pub allow_write: Option<RawText>,
163    #[serde(default, rename = "configDir")]
164    pub config_dir: Option<String>,
165    #[serde(default)]
166    pub url: Option<String>,
167    #[serde(default)]
168    pub token: Option<String>,
169    #[serde(default)]
170    pub jwt: Option<String>,
171
172    // REST
173    #[serde(default)]
174    pub request: Option<String>,
175
176    // REST flow
177    #[serde(default, rename = "loginRequest")]
178    pub login_request: Option<String>,
179    #[serde(default, rename = "tokenJq")]
180    pub token_jq: Option<String>,
181
182    // GraphQL
183    #[serde(default)]
184    pub op: Option<String>,
185    #[serde(default)]
186    pub vars: Option<String>,
187    #[serde(default, rename = "expect")]
188    pub graphql_expect: Option<SuiteGraphqlExpectV1>,
189    #[serde(default, rename = "allowErrors")]
190    pub allow_errors: Option<RawText>,
191
192    // TODO(sprint>6): model cleanup steps once runner implementation lands.
193    #[serde(default)]
194    pub cleanup: Option<serde_json::Value>,
195}
196
197#[derive(Debug, Clone, PartialEq, Deserialize)]
198pub struct SuiteGraphqlExpectV1 {
199    #[serde(default)]
200    pub jq: Option<String>,
201}
202
203#[derive(Debug, Clone, PartialEq, Eq)]
204pub enum SuiteSchemaValidationError {
205    UnsupportedSuiteVersion { got: u32 },
206
207    InvalidSuiteAuthSecretEnvEmpty,
208    InvalidSuiteAuthSecretEnvNotEnvVarName { value: String },
209    InvalidSuiteAuthRequiredNotBoolean,
210    InvalidSuiteAuthProviderRequiredWhenBothPresent,
211    InvalidSuiteAuthProviderValue { value: String },
212
213    InvalidSuiteAuthRestMissingLoginRequestTemplate,
214    InvalidSuiteAuthRestMissingCredentialsJq,
215
216    InvalidSuiteAuthGraphqlMissingLoginOp,
217    InvalidSuiteAuthGraphqlMissingLoginVarsTemplate,
218    InvalidSuiteAuthGraphqlMissingCredentialsJq,
219
220    CaseMissingId { index: usize },
221    CaseMissingType { id: String },
222
223    RestCaseMissingRequest { id: String },
224    GrpcCaseMissingRequest { id: String },
225    WebsocketCaseMissingRequest { id: String },
226    RestFlowCaseMissingLoginRequest { id: String },
227    RestFlowCaseMissingRequest { id: String },
228
229    GraphqlCaseMissingOp { id: String },
230    GraphqlCaseAllowErrorsInvalid { id: String },
231    GraphqlCaseAllowErrorsTrueRequiresExpectJq { id: String },
232
233    UnknownCaseType { id: String, case_type: String },
234}
235
236impl fmt::Display for SuiteSchemaValidationError {
237    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
238        match self {
239            SuiteSchemaValidationError::UnsupportedSuiteVersion { got } => {
240                write!(
241                    f,
242                    "Unsupported suite version: {got} (expected {SUITE_SCHEMA_VERSION_V1})"
243                )
244            }
245
246            SuiteSchemaValidationError::InvalidSuiteAuthSecretEnvEmpty => {
247                write!(f, "Invalid suite auth block: .auth.secretEnv is empty")
248            }
249            SuiteSchemaValidationError::InvalidSuiteAuthSecretEnvNotEnvVarName { value } => write!(
250                f,
251                "Invalid suite auth block: .auth.secretEnv must be a valid env var name (got: {value})"
252            ),
253            SuiteSchemaValidationError::InvalidSuiteAuthRequiredNotBoolean => {
254                write!(
255                    f,
256                    "Invalid suite auth block: .auth.required must be boolean"
257                )
258            }
259            SuiteSchemaValidationError::InvalidSuiteAuthProviderRequiredWhenBothPresent => write!(
260                f,
261                "Invalid suite auth block: .auth.provider is required when both .auth.rest and .auth.graphql are present"
262            ),
263            SuiteSchemaValidationError::InvalidSuiteAuthProviderValue { value } => write!(
264                f,
265                "Invalid suite auth block: .auth.provider must be one of: rest, graphql (got: {value})"
266            ),
267
268            SuiteSchemaValidationError::InvalidSuiteAuthRestMissingLoginRequestTemplate => write!(
269                f,
270                "Invalid suite auth.rest block: missing loginRequestTemplate"
271            ),
272            SuiteSchemaValidationError::InvalidSuiteAuthRestMissingCredentialsJq => {
273                write!(f, "Invalid suite auth.rest block: missing credentialsJq")
274            }
275
276            SuiteSchemaValidationError::InvalidSuiteAuthGraphqlMissingLoginOp => {
277                write!(f, "Invalid suite auth.graphql block: missing loginOp")
278            }
279            SuiteSchemaValidationError::InvalidSuiteAuthGraphqlMissingLoginVarsTemplate => write!(
280                f,
281                "Invalid suite auth.graphql block: missing loginVarsTemplate"
282            ),
283            SuiteSchemaValidationError::InvalidSuiteAuthGraphqlMissingCredentialsJq => {
284                write!(f, "Invalid suite auth.graphql block: missing credentialsJq")
285            }
286
287            SuiteSchemaValidationError::CaseMissingId { index } => {
288                write!(f, "Case is missing id at index {index}")
289            }
290            SuiteSchemaValidationError::CaseMissingType { id } => {
291                write!(f, "Case '{id}' is missing type")
292            }
293
294            SuiteSchemaValidationError::RestCaseMissingRequest { id } => {
295                write!(f, "REST case '{id}' is missing request")
296            }
297            SuiteSchemaValidationError::GrpcCaseMissingRequest { id } => {
298                write!(f, "gRPC case '{id}' is missing request")
299            }
300            SuiteSchemaValidationError::WebsocketCaseMissingRequest { id } => {
301                write!(f, "WebSocket case '{id}' is missing request")
302            }
303            SuiteSchemaValidationError::RestFlowCaseMissingLoginRequest { id } => {
304                write!(f, "rest-flow case '{id}' is missing loginRequest")
305            }
306            SuiteSchemaValidationError::RestFlowCaseMissingRequest { id } => {
307                write!(f, "rest-flow case '{id}' is missing request")
308            }
309
310            SuiteSchemaValidationError::GraphqlCaseMissingOp { id } => {
311                write!(f, "GraphQL case '{id}' is missing op")
312            }
313            SuiteSchemaValidationError::GraphqlCaseAllowErrorsInvalid { id } => write!(
314                f,
315                "GraphQL case '{id}' has invalid allowErrors (expected boolean)"
316            ),
317            SuiteSchemaValidationError::GraphqlCaseAllowErrorsTrueRequiresExpectJq { id } => {
318                write!(
319                    f,
320                    "GraphQL case '{id}' with allowErrors=true must set expect.jq"
321                )
322            }
323
324            SuiteSchemaValidationError::UnknownCaseType { id, case_type } => {
325                write!(f, "Unknown case type '{case_type}' for case '{id}'")
326            }
327        }
328    }
329}
330
331impl std::error::Error for SuiteSchemaValidationError {}
332
333fn is_valid_env_var_name(name: &str) -> bool {
334    let name = name.trim();
335    let mut chars = name.chars();
336    let Some(first) = chars.next() else {
337        return false;
338    };
339    if !(first.is_ascii_alphabetic() || first == '_') {
340        return false;
341    }
342    chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
343}
344
345fn parse_bool_raw(raw: &RawText) -> Option<bool> {
346    match raw.trimmed_lower().as_str() {
347        "true" => Some(true),
348        "false" => Some(false),
349        _ => None,
350    }
351}
352
353fn auth_provider_effective(
354    auth: &SuiteAuthV1,
355) -> Result<Option<String>, SuiteSchemaValidationError> {
356    let provider_raw = auth
357        .provider
358        .as_deref()
359        .unwrap_or_default()
360        .trim()
361        .to_ascii_lowercase();
362
363    if !provider_raw.is_empty() {
364        return Ok(Some(provider_raw));
365    }
366
367    let has_rest = auth.rest.is_some();
368    let has_graphql = auth.graphql.is_some();
369
370    if has_rest && !has_graphql {
371        return Ok(Some("rest".to_string()));
372    }
373    if !has_rest && has_graphql {
374        return Ok(Some("graphql".to_string()));
375    }
376
377    Err(SuiteSchemaValidationError::InvalidSuiteAuthProviderRequiredWhenBothPresent)
378}
379
380impl SuiteManifestV1 {
381    pub fn validate(&self) -> Result<(), SuiteSchemaValidationError> {
382        if self.version != SUITE_SCHEMA_VERSION_V1 {
383            return Err(SuiteSchemaValidationError::UnsupportedSuiteVersion { got: self.version });
384        }
385
386        if let Some(auth) = &self.auth {
387            let secret_env = auth
388                .secret_env
389                .as_deref()
390                .unwrap_or("API_TEST_AUTH_JSON")
391                .trim()
392                .to_string();
393            if secret_env.is_empty() {
394                return Err(SuiteSchemaValidationError::InvalidSuiteAuthSecretEnvEmpty);
395            }
396            if !is_valid_env_var_name(&secret_env) {
397                return Err(
398                    SuiteSchemaValidationError::InvalidSuiteAuthSecretEnvNotEnvVarName {
399                        value: secret_env,
400                    },
401                );
402            }
403
404            if let Some(required) = &auth.required
405                && parse_bool_raw(required).is_none()
406            {
407                return Err(SuiteSchemaValidationError::InvalidSuiteAuthRequiredNotBoolean);
408            }
409
410            let mut provider = auth_provider_effective(auth)?;
411            if let Some(p) = &provider
412                && p == "gql"
413            {
414                provider = Some("graphql".to_string());
415            }
416
417            match provider.as_deref() {
418                None => {}
419                Some("rest") => {
420                    let rest = auth.rest.as_ref().ok_or(
421                        SuiteSchemaValidationError::InvalidSuiteAuthRestMissingLoginRequestTemplate,
422                    )?;
423
424                    let login = rest
425                        .login_request_template
426                        .as_deref()
427                        .unwrap_or_default()
428                        .trim();
429                    if login.is_empty() {
430                        return Err(
431                            SuiteSchemaValidationError::InvalidSuiteAuthRestMissingLoginRequestTemplate,
432                        );
433                    }
434                    let creds = rest.credentials_jq.as_deref().unwrap_or_default().trim();
435                    if creds.is_empty() {
436                        return Err(
437                            SuiteSchemaValidationError::InvalidSuiteAuthRestMissingCredentialsJq,
438                        );
439                    }
440                }
441                Some("graphql") => {
442                    let gql = auth
443                        .graphql
444                        .as_ref()
445                        .ok_or(SuiteSchemaValidationError::InvalidSuiteAuthGraphqlMissingLoginOp)?;
446
447                    let login_op = gql.login_op.as_deref().unwrap_or_default().trim();
448                    if login_op.is_empty() {
449                        return Err(
450                            SuiteSchemaValidationError::InvalidSuiteAuthGraphqlMissingLoginOp,
451                        );
452                    }
453                    let login_vars = gql
454                        .login_vars_template
455                        .as_deref()
456                        .unwrap_or_default()
457                        .trim();
458                    if login_vars.is_empty() {
459                        return Err(
460                            SuiteSchemaValidationError::InvalidSuiteAuthGraphqlMissingLoginVarsTemplate,
461                        );
462                    }
463                    let creds = gql.credentials_jq.as_deref().unwrap_or_default().trim();
464                    if creds.is_empty() {
465                        return Err(
466                            SuiteSchemaValidationError::InvalidSuiteAuthGraphqlMissingCredentialsJq,
467                        );
468                    }
469                }
470                Some(other) => {
471                    return Err(SuiteSchemaValidationError::InvalidSuiteAuthProviderValue {
472                        value: other.to_string(),
473                    });
474                }
475            }
476        }
477
478        for (index, case) in self.cases.iter().enumerate() {
479            let id = case.id.as_deref().unwrap_or_default().trim().to_string();
480            if id.is_empty() {
481                return Err(SuiteSchemaValidationError::CaseMissingId { index });
482            }
483
484            let case_type_raw = case
485                .case_type
486                .as_deref()
487                .unwrap_or_default()
488                .trim()
489                .to_string();
490            let case_type = case_type_raw.to_ascii_lowercase();
491            if case_type.is_empty() {
492                return Err(SuiteSchemaValidationError::CaseMissingType { id });
493            }
494
495            match case_type.as_str() {
496                "rest" => {
497                    let request = case.request.as_deref().unwrap_or_default().trim();
498                    if request.is_empty() {
499                        return Err(SuiteSchemaValidationError::RestCaseMissingRequest { id });
500                    }
501                }
502                "grpc" => {
503                    let request = case.request.as_deref().unwrap_or_default().trim();
504                    if request.is_empty() {
505                        return Err(SuiteSchemaValidationError::GrpcCaseMissingRequest { id });
506                    }
507                }
508                "websocket" | "ws" => {
509                    let request = case.request.as_deref().unwrap_or_default().trim();
510                    if request.is_empty() {
511                        return Err(SuiteSchemaValidationError::WebsocketCaseMissingRequest { id });
512                    }
513                }
514                "rest-flow" | "rest_flow" => {
515                    let login = case.login_request.as_deref().unwrap_or_default().trim();
516                    if login.is_empty() {
517                        return Err(
518                            SuiteSchemaValidationError::RestFlowCaseMissingLoginRequest { id },
519                        );
520                    }
521                    let request = case.request.as_deref().unwrap_or_default().trim();
522                    if request.is_empty() {
523                        return Err(SuiteSchemaValidationError::RestFlowCaseMissingRequest { id });
524                    }
525                }
526                "graphql" => {
527                    let op = case.op.as_deref().unwrap_or_default().trim();
528                    if op.is_empty() {
529                        return Err(SuiteSchemaValidationError::GraphqlCaseMissingOp { id });
530                    }
531
532                    let allow_errors = case.allow_errors.as_ref();
533                    let allow_errors_value = match allow_errors {
534                        None => false,
535                        Some(raw) => match parse_bool_raw(raw) {
536                            Some(v) => v,
537                            None => {
538                                return Err(
539                                    SuiteSchemaValidationError::GraphqlCaseAllowErrorsInvalid {
540                                        id,
541                                    },
542                                );
543                            }
544                        },
545                    };
546
547                    if allow_errors_value {
548                        let expect_jq = case
549                            .graphql_expect
550                            .as_ref()
551                            .and_then(|e| e.jq.as_deref())
552                            .unwrap_or_default()
553                            .trim();
554                        if expect_jq.is_empty() {
555                            return Err(
556                                SuiteSchemaValidationError::GraphqlCaseAllowErrorsTrueRequiresExpectJq { id },
557                            );
558                        }
559                    }
560                }
561                _ => {
562                    return Err(SuiteSchemaValidationError::UnknownCaseType {
563                        id,
564                        case_type: case_type_raw,
565                    });
566                }
567            }
568        }
569
570        Ok(())
571    }
572}
573
574#[cfg(test)]
575mod tests {
576    use super::*;
577    use pretty_assertions::assert_eq;
578
579    fn base_rest_case() -> serde_json::Value {
580        serde_json::json!({
581            "id": "rest.health",
582            "type": "rest",
583            "request": "setup/rest/requests/health.request.json"
584        })
585    }
586
587    fn suite_from(value: serde_json::Value) -> SuiteManifestV1 {
588        serde_json::from_value(value).unwrap()
589    }
590
591    #[test]
592    fn suite_schema_v1_accepts_minimal_valid_suite() {
593        let suite: SuiteManifestV1 = suite_from(serde_json::json!({
594            "version": 1,
595            "name": "smoke",
596            "cases": [
597                { "id": "rest.health", "type": "rest", "request": "setup/rest/requests/health.request.json" },
598                { "id": "graphql.health", "type": "graphql", "op": "setup/graphql/ops/health.graphql" }
599            ]
600        }));
601        suite.validate().unwrap();
602    }
603
604    #[test]
605    fn suite_schema_v1_graphql_allow_errors_true_requires_expect_jq() {
606        let suite: SuiteManifestV1 = suite_from(serde_json::json!({
607            "version": 1,
608            "cases": [
609                { "id": "graphql.bad", "type": "graphql", "op": "x.graphql", "allowErrors": true }
610            ]
611        }));
612        let err = suite.validate().unwrap_err();
613        assert_eq!(
614            err,
615            SuiteSchemaValidationError::GraphqlCaseAllowErrorsTrueRequiresExpectJq {
616                id: "graphql.bad".to_string()
617            }
618        );
619        assert!(err.to_string().contains("graphql.bad"));
620    }
621
622    #[test]
623    fn suite_schema_v1_graphql_allow_errors_must_be_boolean() {
624        let suite: SuiteManifestV1 = suite_from(serde_json::json!({
625            "version": 1,
626            "cases": [
627                { "id": "graphql.bad", "type": "graphql", "op": "x.graphql", "allowErrors": "maybe" }
628            ]
629        }));
630        let err = suite.validate().unwrap_err();
631        assert_eq!(
632            err,
633            SuiteSchemaValidationError::GraphqlCaseAllowErrorsInvalid {
634                id: "graphql.bad".to_string()
635            }
636        );
637        assert!(err.to_string().contains("graphql.bad"));
638    }
639
640    #[test]
641    fn suite_schema_v1_unknown_case_type_includes_case_id() {
642        let suite: SuiteManifestV1 = suite_from(serde_json::json!({
643            "version": 1,
644            "cases": [
645                { "id": "x", "type": "soap" }
646            ]
647        }));
648        let err = suite.validate().unwrap_err();
649        assert!(err.to_string().contains("case 'x'"));
650        assert!(err.to_string().contains("soap"));
651    }
652
653    #[test]
654    fn suite_schema_v1_rest_flow_requires_login_request_and_request() {
655        let suite: SuiteManifestV1 = suite_from(serde_json::json!({
656            "version": 1,
657            "cases": [
658                { "id": "rest.flow", "type": "rest-flow", "request": "x.request.json" }
659            ]
660        }));
661        let err = suite.validate().unwrap_err();
662        assert_eq!(
663            err,
664            SuiteSchemaValidationError::RestFlowCaseMissingLoginRequest {
665                id: "rest.flow".to_string()
666            }
667        );
668    }
669
670    #[test]
671    fn suite_schema_v1_auth_secret_env_must_be_valid_env_var_name() {
672        let suite: SuiteManifestV1 = suite_from(serde_json::json!({
673            "version": 1,
674            "auth": { "secretEnv": "123" },
675            "cases": [
676                { "id": "rest.health", "type": "rest", "request": "x.request.json" }
677            ]
678        }));
679        let err = suite.validate().unwrap_err();
680        assert!(err.to_string().contains(".auth.secretEnv"));
681    }
682
683    #[test]
684    fn raw_text_deserializes_primitives() {
685        let value: RawText = serde_json::from_value(serde_json::json!(123)).unwrap();
686        assert_eq!(value.0, "123");
687        let value: RawText = serde_json::from_value(serde_json::json!(true)).unwrap();
688        assert_eq!(value.0, "true");
689        let value: RawText = serde_json::from_value(serde_json::json!(null)).unwrap();
690        assert_eq!(value.0, "");
691    }
692
693    #[test]
694    fn parse_bool_raw_accepts_true_false_and_rejects_other() {
695        assert_eq!(parse_bool_raw(&RawText("true".to_string())), Some(true));
696        assert_eq!(parse_bool_raw(&RawText("false".to_string())), Some(false));
697        assert_eq!(parse_bool_raw(&RawText("nope".to_string())), None);
698    }
699
700    #[test]
701    fn auth_provider_effective_infers_rest_or_graphql() {
702        let auth = SuiteAuthV1 {
703            provider: None,
704            required: None,
705            secret_env: None,
706            rest: Some(SuiteAuthRestV1 {
707                login_request_template: None,
708                credentials_jq: None,
709                token_jq: None,
710                config_dir: None,
711                url: None,
712                env: None,
713            }),
714            graphql: None,
715        };
716        assert_eq!(
717            auth_provider_effective(&auth).unwrap(),
718            Some("rest".to_string())
719        );
720
721        let auth = SuiteAuthV1 {
722            provider: None,
723            required: None,
724            secret_env: None,
725            rest: None,
726            graphql: Some(SuiteAuthGraphqlV1 {
727                login_op: None,
728                login_vars_template: None,
729                credentials_jq: None,
730                token_jq: None,
731                config_dir: None,
732                url: None,
733                env: None,
734            }),
735        };
736        assert_eq!(
737            auth_provider_effective(&auth).unwrap(),
738            Some("graphql".to_string())
739        );
740    }
741
742    #[test]
743    fn auth_provider_effective_requires_provider_when_both_present() {
744        let auth = SuiteAuthV1 {
745            provider: None,
746            required: None,
747            secret_env: None,
748            rest: Some(SuiteAuthRestV1 {
749                login_request_template: None,
750                credentials_jq: None,
751                token_jq: None,
752                config_dir: None,
753                url: None,
754                env: None,
755            }),
756            graphql: Some(SuiteAuthGraphqlV1 {
757                login_op: None,
758                login_vars_template: None,
759                credentials_jq: None,
760                token_jq: None,
761                config_dir: None,
762                url: None,
763                env: None,
764            }),
765        };
766        let err = auth_provider_effective(&auth).unwrap_err();
767        assert_eq!(
768            err,
769            SuiteSchemaValidationError::InvalidSuiteAuthProviderRequiredWhenBothPresent
770        );
771    }
772
773    #[test]
774    fn suite_schema_rejects_auth_required_not_boolean() {
775        let suite = suite_from(serde_json::json!({
776            "version": 1,
777            "auth": { "required": "maybe" },
778            "cases": [base_rest_case()]
779        }));
780        let err = suite.validate().unwrap_err();
781        assert_eq!(
782            err,
783            SuiteSchemaValidationError::InvalidSuiteAuthRequiredNotBoolean
784        );
785    }
786
787    #[test]
788    fn suite_schema_rejects_empty_auth_secret_env() {
789        let suite = suite_from(serde_json::json!({
790            "version": 1,
791            "auth": { "secretEnv": "   " },
792            "cases": [base_rest_case()]
793        }));
794        let err = suite.validate().unwrap_err();
795        assert_eq!(
796            err,
797            SuiteSchemaValidationError::InvalidSuiteAuthSecretEnvEmpty
798        );
799    }
800
801    #[test]
802    fn suite_schema_rejects_auth_provider_when_unknown() {
803        let suite = suite_from(serde_json::json!({
804            "version": 1,
805            "auth": { "provider": "soap" },
806            "cases": [base_rest_case()]
807        }));
808        let err = suite.validate().unwrap_err();
809        assert_eq!(
810            err,
811            SuiteSchemaValidationError::InvalidSuiteAuthProviderValue {
812                value: "soap".to_string()
813            }
814        );
815    }
816
817    #[test]
818    fn suite_schema_rejects_rest_auth_missing_login_request_template() {
819        let suite = suite_from(serde_json::json!({
820            "version": 1,
821            "auth": {
822                "provider": "rest",
823                "rest": { "credentialsJq": ".profiles[$profile]" }
824            },
825            "cases": [base_rest_case()]
826        }));
827        let err = suite.validate().unwrap_err();
828        assert_eq!(
829            err,
830            SuiteSchemaValidationError::InvalidSuiteAuthRestMissingLoginRequestTemplate
831        );
832    }
833
834    #[test]
835    fn suite_schema_rejects_rest_auth_missing_credentials_jq() {
836        let suite = suite_from(serde_json::json!({
837            "version": 1,
838            "auth": {
839                "provider": "rest",
840                "rest": { "loginRequestTemplate": "setup/rest/requests/login.request.json" }
841            },
842            "cases": [base_rest_case()]
843        }));
844        let err = suite.validate().unwrap_err();
845        assert_eq!(
846            err,
847            SuiteSchemaValidationError::InvalidSuiteAuthRestMissingCredentialsJq
848        );
849    }
850
851    #[test]
852    fn suite_schema_rejects_graphql_auth_missing_login_op() {
853        let suite = suite_from(serde_json::json!({
854            "version": 1,
855            "auth": { "provider": "graphql", "graphql": {} },
856            "cases": [base_rest_case()]
857        }));
858        let err = suite.validate().unwrap_err();
859        assert_eq!(
860            err,
861            SuiteSchemaValidationError::InvalidSuiteAuthGraphqlMissingLoginOp
862        );
863    }
864
865    #[test]
866    fn suite_schema_rejects_graphql_auth_missing_login_vars_template() {
867        let suite = suite_from(serde_json::json!({
868            "version": 1,
869            "auth": {
870                "provider": "graphql",
871                "graphql": { "loginOp": "setup/graphql/operations/login.graphql" }
872            },
873            "cases": [base_rest_case()]
874        }));
875        let err = suite.validate().unwrap_err();
876        assert_eq!(
877            err,
878            SuiteSchemaValidationError::InvalidSuiteAuthGraphqlMissingLoginVarsTemplate
879        );
880    }
881
882    #[test]
883    fn suite_schema_rejects_graphql_auth_missing_credentials_jq() {
884        let suite = suite_from(serde_json::json!({
885            "version": 1,
886            "auth": {
887                "provider": "graphql",
888                "graphql": {
889                    "loginOp": "setup/graphql/operations/login.graphql",
890                    "loginVarsTemplate": "setup/graphql/vars/login.json"
891                }
892            },
893            "cases": [base_rest_case()]
894        }));
895        let err = suite.validate().unwrap_err();
896        assert_eq!(
897            err,
898            SuiteSchemaValidationError::InvalidSuiteAuthGraphqlMissingCredentialsJq
899        );
900    }
901
902    #[test]
903    fn suite_schema_rejects_case_missing_id_and_type() {
904        let suite = suite_from(serde_json::json!({
905            "version": 1,
906            "cases": [ { "type": "rest", "request": "x.request.json" } ]
907        }));
908        let err = suite.validate().unwrap_err();
909        assert_eq!(err, SuiteSchemaValidationError::CaseMissingId { index: 0 });
910    }
911
912    #[test]
913    fn suite_schema_rejects_case_missing_type() {
914        let suite = suite_from(serde_json::json!({
915            "version": 1,
916            "cases": [ { "id": "rest.bad", "request": "x.request.json" } ]
917        }));
918        let err = suite.validate().unwrap_err();
919        assert_eq!(
920            err,
921            SuiteSchemaValidationError::CaseMissingType {
922                id: "rest.bad".to_string()
923            }
924        );
925    }
926
927    #[test]
928    fn suite_schema_rejects_rest_case_missing_request() {
929        let suite = suite_from(serde_json::json!({
930            "version": 1,
931            "cases": [ { "id": "rest.missing", "type": "rest" } ]
932        }));
933        let err = suite.validate().unwrap_err();
934        assert_eq!(
935            err,
936            SuiteSchemaValidationError::RestCaseMissingRequest {
937                id: "rest.missing".to_string()
938            }
939        );
940    }
941
942    #[test]
943    fn suite_schema_rejects_rest_flow_missing_request() {
944        let suite = suite_from(serde_json::json!({
945            "version": 1,
946            "cases": [ { "id": "rest.flow", "type": "rest-flow", "loginRequest": "x.request.json" } ]
947        }));
948        let err = suite.validate().unwrap_err();
949        assert_eq!(
950            err,
951            SuiteSchemaValidationError::RestFlowCaseMissingRequest {
952                id: "rest.flow".to_string()
953            }
954        );
955    }
956
957    #[test]
958    fn suite_schema_rejects_grpc_case_missing_request() {
959        let suite = suite_from(serde_json::json!({
960            "version": 1,
961            "cases": [ { "id": "grpc.missing", "type": "grpc" } ]
962        }));
963        let err = suite.validate().unwrap_err();
964        assert_eq!(
965            err,
966            SuiteSchemaValidationError::GrpcCaseMissingRequest {
967                id: "grpc.missing".to_string()
968            }
969        );
970    }
971
972    #[test]
973    fn suite_schema_rejects_websocket_case_missing_request() {
974        let suite = suite_from(serde_json::json!({
975            "version": 1,
976            "cases": [ { "id": "ws.missing", "type": "websocket" } ]
977        }));
978        let err = suite.validate().unwrap_err();
979        assert_eq!(
980            err,
981            SuiteSchemaValidationError::WebsocketCaseMissingRequest {
982                id: "ws.missing".to_string()
983            }
984        );
985    }
986
987    #[test]
988    fn suite_schema_accepts_ws_alias_case_type() {
989        let suite = suite_from(serde_json::json!({
990            "version": 1,
991            "cases": [ { "id": "ws.health", "type": "ws", "request": "setup/websocket/requests/health.ws.json" } ]
992        }));
993        suite.validate().unwrap();
994    }
995
996    #[test]
997    fn suite_schema_rejects_graphql_case_missing_op() {
998        let suite = suite_from(serde_json::json!({
999            "version": 1,
1000            "cases": [ { "id": "graphql.missing", "type": "graphql" } ]
1001        }));
1002        let err = suite.validate().unwrap_err();
1003        assert_eq!(
1004            err,
1005            SuiteSchemaValidationError::GraphqlCaseMissingOp {
1006                id: "graphql.missing".to_string()
1007            }
1008        );
1009    }
1010
1011    #[test]
1012    fn suite_schema_error_messages_cover_all_variants() {
1013        let cases = vec![
1014            SuiteSchemaValidationError::UnsupportedSuiteVersion { got: 2 },
1015            SuiteSchemaValidationError::InvalidSuiteAuthSecretEnvEmpty,
1016            SuiteSchemaValidationError::InvalidSuiteAuthSecretEnvNotEnvVarName {
1017                value: "123".to_string(),
1018            },
1019            SuiteSchemaValidationError::InvalidSuiteAuthRequiredNotBoolean,
1020            SuiteSchemaValidationError::InvalidSuiteAuthProviderRequiredWhenBothPresent,
1021            SuiteSchemaValidationError::InvalidSuiteAuthProviderValue {
1022                value: "soap".to_string(),
1023            },
1024            SuiteSchemaValidationError::InvalidSuiteAuthRestMissingLoginRequestTemplate,
1025            SuiteSchemaValidationError::InvalidSuiteAuthRestMissingCredentialsJq,
1026            SuiteSchemaValidationError::InvalidSuiteAuthGraphqlMissingLoginOp,
1027            SuiteSchemaValidationError::InvalidSuiteAuthGraphqlMissingLoginVarsTemplate,
1028            SuiteSchemaValidationError::InvalidSuiteAuthGraphqlMissingCredentialsJq,
1029            SuiteSchemaValidationError::CaseMissingId { index: 0 },
1030            SuiteSchemaValidationError::CaseMissingType {
1031                id: "case".to_string(),
1032            },
1033            SuiteSchemaValidationError::RestCaseMissingRequest {
1034                id: "rest".to_string(),
1035            },
1036            SuiteSchemaValidationError::GrpcCaseMissingRequest {
1037                id: "grpc".to_string(),
1038            },
1039            SuiteSchemaValidationError::WebsocketCaseMissingRequest {
1040                id: "ws".to_string(),
1041            },
1042            SuiteSchemaValidationError::RestFlowCaseMissingLoginRequest {
1043                id: "flow".to_string(),
1044            },
1045            SuiteSchemaValidationError::RestFlowCaseMissingRequest {
1046                id: "flow".to_string(),
1047            },
1048            SuiteSchemaValidationError::GraphqlCaseMissingOp {
1049                id: "gql".to_string(),
1050            },
1051            SuiteSchemaValidationError::GraphqlCaseAllowErrorsInvalid {
1052                id: "gql".to_string(),
1053            },
1054            SuiteSchemaValidationError::GraphqlCaseAllowErrorsTrueRequiresExpectJq {
1055                id: "gql".to_string(),
1056            },
1057            SuiteSchemaValidationError::UnknownCaseType {
1058                id: "case".to_string(),
1059                case_type: "soap".to_string(),
1060            },
1061        ];
1062
1063        for err in cases {
1064            let msg = err.to_string();
1065            assert!(!msg.trim().is_empty());
1066        }
1067    }
1068}