Skip to main content

fraiseql_cli/schema/
validator.rs

1//! Enhanced Schema Validation
2//!
3//! Provides detailed validation error reporting with line numbers and context.
4
5use std::collections::HashSet;
6
7use anyhow::Result;
8use tracing::{debug, info};
9
10use super::intermediate::IntermediateSchema;
11
12/// Detailed validation error
13#[derive(Debug, Clone)]
14pub struct ValidationError {
15    /// Error message
16    pub message:    String,
17    /// JSON path to the error (e.g., `"queries[0].return_type"`)
18    pub path:       String,
19    /// Severity level
20    pub severity:   ErrorSeverity,
21    /// Suggested fix
22    pub suggestion: Option<String>,
23}
24
25/// Error severity level
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum ErrorSeverity {
28    /// Critical error - schema is invalid
29    Error,
30    /// Warning - schema is valid but may have issues
31    Warning,
32}
33
34/// Enhanced schema validator
35pub struct SchemaValidator;
36
37impl SchemaValidator {
38    /// Validate an intermediate schema with detailed error reporting
39    pub fn validate(schema: &IntermediateSchema) -> Result<ValidationReport> {
40        info!("Validating schema structure");
41
42        let mut report = ValidationReport::default();
43
44        // Build type registry
45        let mut type_names = HashSet::new();
46        for type_def in &schema.types {
47            if type_names.contains(&type_def.name) {
48                report.errors.push(ValidationError {
49                    message:    format!("Duplicate type name: '{}'", type_def.name),
50                    path:       format!("types[{}].name", type_names.len()),
51                    severity:   ErrorSeverity::Error,
52                    suggestion: Some("Type names must be unique".to_string()),
53                });
54            }
55            type_names.insert(type_def.name.clone());
56        }
57
58        // Add built-in scalars
59        type_names.insert("Int".to_string());
60        type_names.insert("Float".to_string());
61        type_names.insert("String".to_string());
62        type_names.insert("Boolean".to_string());
63        type_names.insert("ID".to_string());
64
65        // Validate queries
66        let mut query_names = HashSet::new();
67        for (idx, query) in schema.queries.iter().enumerate() {
68            debug!("Validating query: {}", query.name);
69
70            // Check for duplicate query names
71            if query_names.contains(&query.name) {
72                report.errors.push(ValidationError {
73                    message:    format!("Duplicate query name: '{}'", query.name),
74                    path:       format!("queries[{idx}].name"),
75                    severity:   ErrorSeverity::Error,
76                    suggestion: Some("Query names must be unique".to_string()),
77                });
78            }
79            query_names.insert(query.name.clone());
80
81            // Validate return type exists
82            if !type_names.contains(&query.return_type) {
83                report.errors.push(ValidationError {
84                    message:    format!(
85                        "Query '{}' references unknown type '{}'",
86                        query.name, query.return_type
87                    ),
88                    path:       format!("queries[{idx}].return_type"),
89                    severity:   ErrorSeverity::Error,
90                    suggestion: Some(format!(
91                        "Available types: {}",
92                        Self::suggest_similar_type(&query.return_type, &type_names)
93                    )),
94                });
95            }
96
97            // Validate argument types
98            for (arg_idx, arg) in query.arguments.iter().enumerate() {
99                if !type_names.contains(&arg.arg_type) {
100                    report.errors.push(ValidationError {
101                        message:    format!(
102                            "Query '{}' argument '{}' references unknown type '{}'",
103                            query.name, arg.name, arg.arg_type
104                        ),
105                        path:       format!("queries[{idx}].arguments[{arg_idx}].type"),
106                        severity:   ErrorSeverity::Error,
107                        suggestion: Some(format!(
108                            "Available types: {}",
109                            Self::suggest_similar_type(&arg.arg_type, &type_names)
110                        )),
111                    });
112                }
113            }
114
115            // Warning for queries without SQL source
116            if query.sql_source.is_none() && query.returns_list {
117                report.errors.push(ValidationError {
118                    message:    format!(
119                        "Query '{}' returns a list but has no sql_source",
120                        query.name
121                    ),
122                    path:       format!("queries[{idx}]"),
123                    severity:   ErrorSeverity::Warning,
124                    suggestion: Some("Add sql_source for SQL-backed queries".to_string()),
125                });
126            }
127        }
128
129        // Validate mutations
130        let mut mutation_names = HashSet::new();
131        for (idx, mutation) in schema.mutations.iter().enumerate() {
132            debug!("Validating mutation: {}", mutation.name);
133
134            // Check for duplicate mutation names
135            if mutation_names.contains(&mutation.name) {
136                report.errors.push(ValidationError {
137                    message:    format!("Duplicate mutation name: '{}'", mutation.name),
138                    path:       format!("mutations[{idx}].name"),
139                    severity:   ErrorSeverity::Error,
140                    suggestion: Some("Mutation names must be unique".to_string()),
141                });
142            }
143            mutation_names.insert(mutation.name.clone());
144
145            // Validate return type exists
146            if !type_names.contains(&mutation.return_type) {
147                report.errors.push(ValidationError {
148                    message:    format!(
149                        "Mutation '{}' references unknown type '{}'",
150                        mutation.name, mutation.return_type
151                    ),
152                    path:       format!("mutations[{idx}].return_type"),
153                    severity:   ErrorSeverity::Error,
154                    suggestion: Some(format!(
155                        "Available types: {}",
156                        Self::suggest_similar_type(&mutation.return_type, &type_names)
157                    )),
158                });
159            }
160
161            // Validate argument types
162            for (arg_idx, arg) in mutation.arguments.iter().enumerate() {
163                if !type_names.contains(&arg.arg_type) {
164                    report.errors.push(ValidationError {
165                        message:    format!(
166                            "Mutation '{}' argument '{}' references unknown type '{}'",
167                            mutation.name, arg.name, arg.arg_type
168                        ),
169                        path:       format!("mutations[{idx}].arguments[{arg_idx}].type"),
170                        severity:   ErrorSeverity::Error,
171                        suggestion: Some(format!(
172                            "Available types: {}",
173                            Self::suggest_similar_type(&arg.arg_type, &type_names)
174                        )),
175                    });
176                }
177            }
178        }
179
180        // Validate observers
181        if let Some(observers) = &schema.observers {
182            let mut observer_names = HashSet::new();
183            for (idx, observer) in observers.iter().enumerate() {
184                debug!("Validating observer: {}", observer.name);
185
186                // Check for duplicate observer names
187                if observer_names.contains(&observer.name) {
188                    report.errors.push(ValidationError {
189                        message:    format!("Duplicate observer name: '{}'", observer.name),
190                        path:       format!("observers[{idx}].name"),
191                        severity:   ErrorSeverity::Error,
192                        suggestion: Some("Observer names must be unique".to_string()),
193                    });
194                }
195                observer_names.insert(observer.name.clone());
196
197                // Validate entity type exists
198                if !type_names.contains(&observer.entity) {
199                    report.errors.push(ValidationError {
200                        message:    format!(
201                            "Observer '{}' references unknown entity '{}'",
202                            observer.name, observer.entity
203                        ),
204                        path:       format!("observers[{idx}].entity"),
205                        severity:   ErrorSeverity::Error,
206                        suggestion: Some(format!(
207                            "Available types: {}",
208                            Self::suggest_similar_type(&observer.entity, &type_names)
209                        )),
210                    });
211                }
212
213                // Validate event type
214                let valid_events = ["INSERT", "UPDATE", "DELETE"];
215                if !valid_events.contains(&observer.event.as_str()) {
216                    report.errors.push(ValidationError {
217                        message:    format!(
218                            "Observer '{}' has invalid event '{}'. Must be INSERT, UPDATE, or DELETE",
219                            observer.name, observer.event
220                        ),
221                        path:       format!("observers[{idx}].event"),
222                        severity:   ErrorSeverity::Error,
223                        suggestion: Some("Valid events: INSERT, UPDATE, DELETE".to_string()),
224                    });
225                }
226
227                // Validate at least one action exists
228                if observer.actions.is_empty() {
229                    report.errors.push(ValidationError {
230                        message:    format!(
231                            "Observer '{}' must have at least one action",
232                            observer.name
233                        ),
234                        path:       format!("observers[{idx}].actions"),
235                        severity:   ErrorSeverity::Error,
236                        suggestion: Some("Add a webhook, slack, or email action".to_string()),
237                    });
238                }
239
240                // Validate each action
241                for (action_idx, action) in observer.actions.iter().enumerate() {
242                    if let Some(obj) = action.as_object() {
243                        // Check action has a type field
244                        if let Some(action_type) = obj.get("type").and_then(|v| v.as_str()) {
245                            let valid_action_types = ["webhook", "slack", "email"];
246                            if !valid_action_types.contains(&action_type) {
247                                report.errors.push(ValidationError {
248                                    message:    format!(
249                                        "Observer '{}' action {} has invalid type '{}'",
250                                        observer.name, action_idx, action_type
251                                    ),
252                                    path:       format!(
253                                        "observers[{idx}].actions[{action_idx}].type"
254                                    ),
255                                    severity:   ErrorSeverity::Error,
256                                    suggestion: Some(
257                                        "Valid action types: webhook, slack, email".to_string(),
258                                    ),
259                                });
260                            }
261
262                            // Validate action-specific required fields
263                            match action_type {
264                                "webhook" => {
265                                    let has_url = obj.contains_key("url");
266                                    let has_url_env = obj.contains_key("url_env");
267                                    if !has_url && !has_url_env {
268                                        report.errors.push(ValidationError {
269                                            message:    format!(
270                                                "Observer '{}' webhook action must have 'url' or 'url_env'",
271                                                observer.name
272                                            ),
273                                            path:       format!("observers[{idx}].actions[{action_idx}]"),
274                                            severity:   ErrorSeverity::Error,
275                                            suggestion: Some("Add 'url' or 'url_env' field".to_string()),
276                                        });
277                                    }
278                                },
279                                "slack" => {
280                                    if !obj.contains_key("channel") {
281                                        report.errors.push(ValidationError {
282                                            message:    format!(
283                                                "Observer '{}' slack action must have 'channel' field",
284                                                observer.name
285                                            ),
286                                            path:       format!("observers[{idx}].actions[{action_idx}]"),
287                                            severity:   ErrorSeverity::Error,
288                                            suggestion: Some("Add 'channel' field (e.g., '#sales')".to_string()),
289                                        });
290                                    }
291                                    if !obj.contains_key("message") {
292                                        report.errors.push(ValidationError {
293                                            message:    format!(
294                                                "Observer '{}' slack action must have 'message' field",
295                                                observer.name
296                                            ),
297                                            path:       format!("observers[{idx}].actions[{action_idx}]"),
298                                            severity:   ErrorSeverity::Error,
299                                            suggestion: Some("Add 'message' field".to_string()),
300                                        });
301                                    }
302                                },
303                                "email" => {
304                                    let required_fields = ["to", "subject", "body"];
305                                    for field in &required_fields {
306                                        if !obj.contains_key(*field) {
307                                            report.errors.push(ValidationError {
308                                                message:    format!(
309                                                    "Observer '{}' email action must have '{}' field",
310                                                    observer.name, field
311                                                ),
312                                                path:       format!("observers[{idx}].actions[{action_idx}]"),
313                                                severity:   ErrorSeverity::Error,
314                                                suggestion: Some(format!("Add '{field}' field")),
315                                            });
316                                        }
317                                    }
318                                },
319                                _ => {},
320                            }
321                        } else {
322                            report.errors.push(ValidationError {
323                                message:    format!(
324                                    "Observer '{}' action {} missing 'type' field",
325                                    observer.name, action_idx
326                                ),
327                                path:       format!("observers[{idx}].actions[{action_idx}]"),
328                                severity:   ErrorSeverity::Error,
329                                suggestion: Some(
330                                    "Add 'type' field (webhook, slack, or email)".to_string(),
331                                ),
332                            });
333                        }
334                    } else {
335                        report.errors.push(ValidationError {
336                            message:    format!(
337                                "Observer '{}' action {} must be an object",
338                                observer.name, action_idx
339                            ),
340                            path:       format!("observers[{idx}].actions[{action_idx}]"),
341                            severity:   ErrorSeverity::Error,
342                            suggestion: None,
343                        });
344                    }
345                }
346
347                // Validate retry config
348                let valid_backoff_strategies = ["exponential", "linear", "fixed"];
349                if !valid_backoff_strategies.contains(&observer.retry.backoff_strategy.as_str()) {
350                    report.errors.push(ValidationError {
351                        message:    format!(
352                            "Observer '{}' has invalid backoff_strategy '{}'",
353                            observer.name, observer.retry.backoff_strategy
354                        ),
355                        path:       format!("observers[{idx}].retry.backoff_strategy"),
356                        severity:   ErrorSeverity::Error,
357                        suggestion: Some(
358                            "Valid strategies: exponential, linear, fixed".to_string(),
359                        ),
360                    });
361                }
362
363                if observer.retry.max_attempts == 0 {
364                    report.errors.push(ValidationError {
365                        message:    format!(
366                            "Observer '{}' has max_attempts=0, actions will never execute",
367                            observer.name
368                        ),
369                        path:       format!("observers[{idx}].retry.max_attempts"),
370                        severity:   ErrorSeverity::Warning,
371                        suggestion: Some("Set max_attempts >= 1".to_string()),
372                    });
373                }
374
375                if observer.retry.initial_delay_ms == 0 {
376                    report.errors.push(ValidationError {
377                        message:    format!(
378                            "Observer '{}' has initial_delay_ms=0, retries will be immediate",
379                            observer.name
380                        ),
381                        path:       format!("observers[{idx}].retry.initial_delay_ms"),
382                        severity:   ErrorSeverity::Warning,
383                        suggestion: Some("Consider setting initial_delay_ms > 0".to_string()),
384                    });
385                }
386
387                if observer.retry.max_delay_ms < observer.retry.initial_delay_ms {
388                    report.errors.push(ValidationError {
389                        message:    format!(
390                            "Observer '{}' has max_delay_ms < initial_delay_ms",
391                            observer.name
392                        ),
393                        path:       format!("observers[{idx}].retry.max_delay_ms"),
394                        severity:   ErrorSeverity::Error,
395                        suggestion: Some("max_delay_ms must be >= initial_delay_ms".to_string()),
396                    });
397                }
398            }
399        }
400
401        info!(
402            "Validation complete: {} errors, {} warnings",
403            report.error_count(),
404            report.warning_count()
405        );
406
407        Ok(report)
408    }
409
410    /// Suggest similar type names for typos
411    fn suggest_similar_type(typo: &str, available: &HashSet<String>) -> String {
412        // Simple Levenshtein-style similarity (first letter match)
413        let similar: Vec<&String> = available
414            .iter()
415            .filter(|name| {
416                name.to_lowercase().starts_with(&typo[0..1].to_lowercase())
417                    || typo.to_lowercase().starts_with(&name[0..1].to_lowercase())
418            })
419            .take(3)
420            .collect();
421
422        if similar.is_empty() {
423            available.iter().take(5).cloned().collect::<Vec<_>>().join(", ")
424        } else {
425            similar.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")
426        }
427    }
428}
429
430/// Validation report
431#[derive(Debug, Default)]
432pub struct ValidationReport {
433    /// Validation errors and warnings
434    pub errors: Vec<ValidationError>,
435}
436
437impl ValidationReport {
438    /// Check if validation passed (no errors, warnings OK)
439    pub fn is_valid(&self) -> bool {
440        !self.has_errors()
441    }
442
443    /// Check if there are any errors
444    pub fn has_errors(&self) -> bool {
445        self.errors.iter().any(|e| e.severity == ErrorSeverity::Error)
446    }
447
448    /// Count errors
449    pub fn error_count(&self) -> usize {
450        self.errors.iter().filter(|e| e.severity == ErrorSeverity::Error).count()
451    }
452
453    /// Count warnings
454    pub fn warning_count(&self) -> usize {
455        self.errors.iter().filter(|e| e.severity == ErrorSeverity::Warning).count()
456    }
457
458    /// Print formatted report
459    pub fn print(&self) {
460        if self.errors.is_empty() {
461            return;
462        }
463
464        println!("\nšŸ“‹ Validation Report:");
465
466        let errors: Vec<_> =
467            self.errors.iter().filter(|e| e.severity == ErrorSeverity::Error).collect();
468
469        let warnings: Vec<_> =
470            self.errors.iter().filter(|e| e.severity == ErrorSeverity::Warning).collect();
471
472        if !errors.is_empty() {
473            println!("\n  āŒ Errors ({}):", errors.len());
474            for error in errors {
475                println!("     {}", error.message);
476                println!("     at: {}", error.path);
477                if let Some(suggestion) = &error.suggestion {
478                    println!("     šŸ’” {suggestion}");
479                }
480                println!();
481            }
482        }
483
484        if !warnings.is_empty() {
485            println!("\n  āš ļø  Warnings ({}):", warnings.len());
486            for warning in warnings {
487                println!("     {}", warning.message);
488                println!("     at: {}", warning.path);
489                if let Some(suggestion) = &warning.suggestion {
490                    println!("     šŸ’” {suggestion}");
491                }
492                println!();
493            }
494        }
495    }
496}
497
498#[cfg(test)]
499mod tests {
500    use super::*;
501    use crate::schema::intermediate::{IntermediateQuery, IntermediateType};
502
503    #[test]
504    fn test_validate_empty_schema() {
505        let schema = IntermediateSchema {
506            security:          None,
507            version:           "2.0.0".to_string(),
508            types:             vec![],
509            enums:             vec![],
510            input_types:       vec![],
511            interfaces:        vec![],
512            unions:            vec![],
513            queries:           vec![],
514            mutations:         vec![],
515            subscriptions:     vec![],
516            fragments:         None,
517            directives:        None,
518            fact_tables:       None,
519            aggregate_queries: None,
520            observers:         None,
521            custom_scalars:    None,
522        };
523
524        let report = SchemaValidator::validate(&schema).unwrap();
525        assert!(report.is_valid());
526    }
527
528    #[test]
529    fn test_detect_unknown_return_type() {
530        let schema = IntermediateSchema {
531            security:          None,
532            version:           "2.0.0".to_string(),
533            types:             vec![],
534            enums:             vec![],
535            input_types:       vec![],
536            interfaces:        vec![],
537            unions:            vec![],
538            queries:           vec![IntermediateQuery {
539                name:         "users".to_string(),
540                return_type:  "UnknownType".to_string(),
541                returns_list: true,
542                nullable:     false,
543                arguments:    vec![],
544                description:  None,
545                sql_source:   Some("users".to_string()),
546                auto_params:  None,
547                deprecated:   None,
548                jsonb_column: None,
549            }],
550            mutations:         vec![],
551            subscriptions:     vec![],
552            fragments:         None,
553            directives:        None,
554            fact_tables:       None,
555            aggregate_queries: None,
556            observers:         None,
557            custom_scalars:    None,
558        };
559
560        let report = SchemaValidator::validate(&schema).unwrap();
561        assert!(!report.is_valid());
562        assert_eq!(report.error_count(), 1);
563        assert!(report.errors[0].message.contains("unknown type 'UnknownType'"));
564    }
565
566    #[test]
567    fn test_detect_duplicate_query_names() {
568        let schema = IntermediateSchema {
569            security:          None,
570            version:           "2.0.0".to_string(),
571            types:             vec![IntermediateType {
572                name:        "User".to_string(),
573                fields:      vec![],
574                description: None,
575                implements:  vec![],
576            }],
577            enums:             vec![],
578            input_types:       vec![],
579            interfaces:        vec![],
580            unions:            vec![],
581            queries:           vec![
582                IntermediateQuery {
583                    name:         "users".to_string(),
584                    return_type:  "User".to_string(),
585                    returns_list: true,
586                    nullable:     false,
587                    arguments:    vec![],
588                    description:  None,
589                    sql_source:   Some("users".to_string()),
590                    auto_params:  None,
591                    deprecated:   None,
592                    jsonb_column: None,
593                },
594                IntermediateQuery {
595                    name:         "users".to_string(), // Duplicate!
596                    return_type:  "User".to_string(),
597                    returns_list: true,
598                    nullable:     false,
599                    arguments:    vec![],
600                    description:  None,
601                    sql_source:   Some("users".to_string()),
602                    auto_params:  None,
603                    deprecated:   None,
604                    jsonb_column: None,
605                },
606            ],
607            mutations:         vec![],
608            subscriptions:     vec![],
609            fragments:         None,
610            directives:        None,
611            fact_tables:       None,
612            aggregate_queries: None,
613            observers:         None,
614            custom_scalars:    None,
615        };
616
617        let report = SchemaValidator::validate(&schema).unwrap();
618        assert!(!report.is_valid());
619        assert!(report.errors.iter().any(|e| e.message.contains("Duplicate query name")));
620    }
621
622    #[test]
623    fn test_warning_for_query_without_sql_source() {
624        let schema = IntermediateSchema {
625            security:          None,
626            version:           "2.0.0".to_string(),
627            types:             vec![IntermediateType {
628                name:        "User".to_string(),
629                fields:      vec![],
630                description: None,
631                implements:  vec![],
632            }],
633            enums:             vec![],
634            input_types:       vec![],
635            interfaces:        vec![],
636            unions:            vec![],
637            queries:           vec![IntermediateQuery {
638                name:         "users".to_string(),
639                return_type:  "User".to_string(),
640                returns_list: true,
641                nullable:     false,
642                arguments:    vec![],
643                description:  None,
644                sql_source:   None, // Missing SQL source
645                auto_params:  None,
646                deprecated:   None,
647                jsonb_column: None,
648            }],
649            mutations:         vec![],
650            subscriptions:     vec![],
651            fragments:         None,
652            directives:        None,
653            fact_tables:       None,
654            aggregate_queries: None,
655            observers:         None,
656            custom_scalars:    None,
657        };
658
659        let report = SchemaValidator::validate(&schema).unwrap();
660        assert!(report.is_valid()); // Still valid, just a warning
661        assert_eq!(report.warning_count(), 1);
662        assert!(report.errors[0].message.contains("no sql_source"));
663    }
664
665    #[test]
666    fn test_valid_observer() {
667        use serde_json::json;
668
669        use super::super::intermediate::{IntermediateObserver, IntermediateRetryConfig};
670
671        let schema = IntermediateSchema {
672            security:          None,
673            version:           "2.0.0".to_string(),
674            types:             vec![IntermediateType {
675                name:        "Order".to_string(),
676                fields:      vec![],
677                description: None,
678                implements:  vec![],
679            }],
680            enums:             vec![],
681            input_types:       vec![],
682            interfaces:        vec![],
683            unions:            vec![],
684            queries:           vec![],
685            mutations:         vec![],
686            subscriptions:     vec![],
687            fragments:         None,
688            directives:        None,
689            fact_tables:       None,
690            aggregate_queries: None,
691            observers:         Some(vec![IntermediateObserver {
692                name:      "onOrderCreated".to_string(),
693                entity:    "Order".to_string(),
694                event:     "INSERT".to_string(),
695                actions:   vec![json!({
696                    "type": "webhook",
697                    "url": "https://example.com/orders"
698                })],
699                condition: None,
700                retry:     IntermediateRetryConfig {
701                    max_attempts:     3,
702                    backoff_strategy: "exponential".to_string(),
703                    initial_delay_ms: 100,
704                    max_delay_ms:     60000,
705                },
706            }]),
707            custom_scalars:    None,
708        };
709
710        let report = SchemaValidator::validate(&schema).unwrap();
711        assert!(report.is_valid(), "Valid observer should pass validation");
712        assert_eq!(report.error_count(), 0);
713    }
714
715    #[test]
716    fn test_observer_with_unknown_entity() {
717        use serde_json::json;
718
719        use super::super::intermediate::{IntermediateObserver, IntermediateRetryConfig};
720
721        let schema = IntermediateSchema {
722            security:          None,
723            version:           "2.0.0".to_string(),
724            types:             vec![],
725            enums:             vec![],
726            input_types:       vec![],
727            interfaces:        vec![],
728            unions:            vec![],
729            queries:           vec![],
730            mutations:         vec![],
731            subscriptions:     vec![],
732            fragments:         None,
733            directives:        None,
734            fact_tables:       None,
735            aggregate_queries: None,
736            observers:         Some(vec![IntermediateObserver {
737                name:      "onOrderCreated".to_string(),
738                entity:    "UnknownEntity".to_string(),
739                event:     "INSERT".to_string(),
740                actions:   vec![json!({"type": "webhook", "url": "https://example.com"})],
741                condition: None,
742                retry:     IntermediateRetryConfig {
743                    max_attempts:     3,
744                    backoff_strategy: "exponential".to_string(),
745                    initial_delay_ms: 100,
746                    max_delay_ms:     60000,
747                },
748            }]),
749            custom_scalars:    None,
750        };
751
752        let report = SchemaValidator::validate(&schema).unwrap();
753        assert!(!report.is_valid());
754        assert!(report.errors.iter().any(|e| e.message.contains("unknown entity")));
755    }
756
757    #[test]
758    fn test_observer_with_invalid_event() {
759        use serde_json::json;
760
761        use super::super::intermediate::{IntermediateObserver, IntermediateRetryConfig};
762
763        let schema = IntermediateSchema {
764            security:          None,
765            version:           "2.0.0".to_string(),
766            types:             vec![IntermediateType {
767                name:        "Order".to_string(),
768                fields:      vec![],
769                description: None,
770                implements:  vec![],
771            }],
772            enums:             vec![],
773            input_types:       vec![],
774            interfaces:        vec![],
775            unions:            vec![],
776            queries:           vec![],
777            mutations:         vec![],
778            subscriptions:     vec![],
779            fragments:         None,
780            directives:        None,
781            fact_tables:       None,
782            aggregate_queries: None,
783            observers:         Some(vec![IntermediateObserver {
784                name:      "onOrderCreated".to_string(),
785                entity:    "Order".to_string(),
786                event:     "INVALID_EVENT".to_string(),
787                actions:   vec![json!({"type": "webhook", "url": "https://example.com"})],
788                condition: None,
789                retry:     IntermediateRetryConfig {
790                    max_attempts:     3,
791                    backoff_strategy: "exponential".to_string(),
792                    initial_delay_ms: 100,
793                    max_delay_ms:     60000,
794                },
795            }]),
796            custom_scalars:    None,
797        };
798
799        let report = SchemaValidator::validate(&schema).unwrap();
800        assert!(!report.is_valid());
801        assert!(report.errors.iter().any(|e| e.message.contains("invalid event")));
802    }
803
804    #[test]
805    fn test_observer_with_invalid_action_type() {
806        use serde_json::json;
807
808        use super::super::intermediate::{IntermediateObserver, IntermediateRetryConfig};
809
810        let schema = IntermediateSchema {
811            security:          None,
812            version:           "2.0.0".to_string(),
813            types:             vec![IntermediateType {
814                name:        "Order".to_string(),
815                fields:      vec![],
816                description: None,
817                implements:  vec![],
818            }],
819            enums:             vec![],
820            input_types:       vec![],
821            interfaces:        vec![],
822            unions:            vec![],
823            queries:           vec![],
824            mutations:         vec![],
825            subscriptions:     vec![],
826            fragments:         None,
827            directives:        None,
828            fact_tables:       None,
829            aggregate_queries: None,
830            observers:         Some(vec![IntermediateObserver {
831                name:      "onOrderCreated".to_string(),
832                entity:    "Order".to_string(),
833                event:     "INSERT".to_string(),
834                actions:   vec![json!({"type": "invalid_action"})],
835                condition: None,
836                retry:     IntermediateRetryConfig {
837                    max_attempts:     3,
838                    backoff_strategy: "exponential".to_string(),
839                    initial_delay_ms: 100,
840                    max_delay_ms:     60000,
841                },
842            }]),
843            custom_scalars:    None,
844        };
845
846        let report = SchemaValidator::validate(&schema).unwrap();
847        assert!(!report.is_valid());
848        assert!(report.errors.iter().any(|e| e.message.contains("invalid type")));
849    }
850
851    #[test]
852    fn test_observer_with_invalid_retry_config() {
853        use serde_json::json;
854
855        use super::super::intermediate::{IntermediateObserver, IntermediateRetryConfig};
856
857        let schema = IntermediateSchema {
858            security:          None,
859            version:           "2.0.0".to_string(),
860            types:             vec![IntermediateType {
861                name:        "Order".to_string(),
862                fields:      vec![],
863                description: None,
864                implements:  vec![],
865            }],
866            enums:             vec![],
867            input_types:       vec![],
868            interfaces:        vec![],
869            unions:            vec![],
870            queries:           vec![],
871            mutations:         vec![],
872            subscriptions:     vec![],
873            fragments:         None,
874            directives:        None,
875            fact_tables:       None,
876            aggregate_queries: None,
877            observers:         Some(vec![IntermediateObserver {
878                name:      "onOrderCreated".to_string(),
879                entity:    "Order".to_string(),
880                event:     "INSERT".to_string(),
881                actions:   vec![json!({"type": "webhook", "url": "https://example.com"})],
882                condition: None,
883                retry:     IntermediateRetryConfig {
884                    max_attempts:     3,
885                    backoff_strategy: "invalid_strategy".to_string(),
886                    initial_delay_ms: 100,
887                    max_delay_ms:     60000,
888                },
889            }]),
890            custom_scalars:    None,
891        };
892
893        let report = SchemaValidator::validate(&schema).unwrap();
894        assert!(!report.is_valid());
895        assert!(report.errors.iter().any(|e| e.message.contains("invalid backoff_strategy")));
896    }
897}