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            observers_config:  None,
523            federation_config: None,
524        };
525
526        let report = SchemaValidator::validate(&schema).unwrap();
527        assert!(report.is_valid());
528    }
529
530    #[test]
531    fn test_detect_unknown_return_type() {
532        let schema = IntermediateSchema {
533            security:          None,
534            version:           "2.0.0".to_string(),
535            types:             vec![],
536            enums:             vec![],
537            input_types:       vec![],
538            interfaces:        vec![],
539            unions:            vec![],
540            queries:           vec![IntermediateQuery {
541                name:         "users".to_string(),
542                return_type:  "UnknownType".to_string(),
543                returns_list: true,
544                nullable:     false,
545                arguments:    vec![],
546                description:  None,
547                sql_source:   Some("users".to_string()),
548                auto_params:  None,
549                deprecated:   None,
550                jsonb_column: None,
551            }],
552            mutations:         vec![],
553            subscriptions:     vec![],
554            fragments:         None,
555            directives:        None,
556            fact_tables:       None,
557            aggregate_queries: None,
558            observers:         None,
559            custom_scalars:    None,
560            observers_config:  None,
561            federation_config: None,
562        };
563
564        let report = SchemaValidator::validate(&schema).unwrap();
565        assert!(!report.is_valid());
566        assert_eq!(report.error_count(), 1);
567        assert!(report.errors[0].message.contains("unknown type 'UnknownType'"));
568    }
569
570    #[test]
571    fn test_detect_duplicate_query_names() {
572        let schema = IntermediateSchema {
573            security:          None,
574            version:           "2.0.0".to_string(),
575            types:             vec![IntermediateType {
576                name:        "User".to_string(),
577                fields:      vec![],
578                description: None,
579                implements:  vec![],
580                is_error:    false,
581            }],
582            enums:             vec![],
583            input_types:       vec![],
584            interfaces:        vec![],
585            unions:            vec![],
586            queries:           vec![
587                IntermediateQuery {
588                    name:         "users".to_string(),
589                    return_type:  "User".to_string(),
590                    returns_list: true,
591                    nullable:     false,
592                    arguments:    vec![],
593                    description:  None,
594                    sql_source:   Some("users".to_string()),
595                    auto_params:  None,
596                    deprecated:   None,
597                    jsonb_column: None,
598                },
599                IntermediateQuery {
600                    name:         "users".to_string(), // Duplicate!
601                    return_type:  "User".to_string(),
602                    returns_list: true,
603                    nullable:     false,
604                    arguments:    vec![],
605                    description:  None,
606                    sql_source:   Some("users".to_string()),
607                    auto_params:  None,
608                    deprecated:   None,
609                    jsonb_column: None,
610                },
611            ],
612            mutations:         vec![],
613            subscriptions:     vec![],
614            fragments:         None,
615            directives:        None,
616            fact_tables:       None,
617            aggregate_queries: None,
618            observers:         None,
619            custom_scalars:    None,
620            observers_config:  None,
621            federation_config: None,
622        };
623
624        let report = SchemaValidator::validate(&schema).unwrap();
625        assert!(!report.is_valid());
626        assert!(report.errors.iter().any(|e| e.message.contains("Duplicate query name")));
627    }
628
629    #[test]
630    fn test_warning_for_query_without_sql_source() {
631        let schema = IntermediateSchema {
632            security:          None,
633            version:           "2.0.0".to_string(),
634            types:             vec![IntermediateType {
635                name:        "User".to_string(),
636                fields:      vec![],
637                description: None,
638                implements:  vec![],
639                is_error:    false,
640            }],
641            enums:             vec![],
642            input_types:       vec![],
643            interfaces:        vec![],
644            unions:            vec![],
645            queries:           vec![IntermediateQuery {
646                name:         "users".to_string(),
647                return_type:  "User".to_string(),
648                returns_list: true,
649                nullable:     false,
650                arguments:    vec![],
651                description:  None,
652                sql_source:   None, // Missing SQL source
653                auto_params:  None,
654                deprecated:   None,
655                jsonb_column: None,
656            }],
657            mutations:         vec![],
658            subscriptions:     vec![],
659            fragments:         None,
660            directives:        None,
661            fact_tables:       None,
662            aggregate_queries: None,
663            observers:         None,
664            custom_scalars:    None,
665            observers_config:  None,
666            federation_config: None,
667        };
668
669        let report = SchemaValidator::validate(&schema).unwrap();
670        assert!(report.is_valid()); // Still valid, just a warning
671        assert_eq!(report.warning_count(), 1);
672        assert!(report.errors[0].message.contains("no sql_source"));
673    }
674
675    #[test]
676    fn test_valid_observer() {
677        use serde_json::json;
678
679        use super::super::intermediate::{IntermediateObserver, IntermediateRetryConfig};
680
681        let schema = IntermediateSchema {
682            security:          None,
683            version:           "2.0.0".to_string(),
684            types:             vec![IntermediateType {
685                name:        "Order".to_string(),
686                fields:      vec![],
687                description: None,
688                implements:  vec![],
689                is_error:    false,
690            }],
691            enums:             vec![],
692            input_types:       vec![],
693            interfaces:        vec![],
694            unions:            vec![],
695            queries:           vec![],
696            mutations:         vec![],
697            subscriptions:     vec![],
698            fragments:         None,
699            directives:        None,
700            fact_tables:       None,
701            aggregate_queries: None,
702            observers:         Some(vec![IntermediateObserver {
703                name:      "onOrderCreated".to_string(),
704                entity:    "Order".to_string(),
705                event:     "INSERT".to_string(),
706                actions:   vec![json!({
707                    "type": "webhook",
708                    "url": "https://example.com/orders"
709                })],
710                condition: None,
711                retry:     IntermediateRetryConfig {
712                    max_attempts:     3,
713                    backoff_strategy: "exponential".to_string(),
714                    initial_delay_ms: 100,
715                    max_delay_ms:     60000,
716                },
717            }]),
718            custom_scalars:    None,
719            observers_config:  None,
720            federation_config: None,
721        };
722
723        let report = SchemaValidator::validate(&schema).unwrap();
724        assert!(report.is_valid(), "Valid observer should pass validation");
725        assert_eq!(report.error_count(), 0);
726    }
727
728    #[test]
729    fn test_observer_with_unknown_entity() {
730        use serde_json::json;
731
732        use super::super::intermediate::{IntermediateObserver, IntermediateRetryConfig};
733
734        let schema = IntermediateSchema {
735            security:          None,
736            version:           "2.0.0".to_string(),
737            types:             vec![],
738            enums:             vec![],
739            input_types:       vec![],
740            interfaces:        vec![],
741            unions:            vec![],
742            queries:           vec![],
743            mutations:         vec![],
744            subscriptions:     vec![],
745            fragments:         None,
746            directives:        None,
747            fact_tables:       None,
748            aggregate_queries: None,
749            observers:         Some(vec![IntermediateObserver {
750                name:      "onOrderCreated".to_string(),
751                entity:    "UnknownEntity".to_string(),
752                event:     "INSERT".to_string(),
753                actions:   vec![json!({"type": "webhook", "url": "https://example.com"})],
754                condition: None,
755                retry:     IntermediateRetryConfig {
756                    max_attempts:     3,
757                    backoff_strategy: "exponential".to_string(),
758                    initial_delay_ms: 100,
759                    max_delay_ms:     60000,
760                },
761            }]),
762            custom_scalars:    None,
763            observers_config:  None,
764            federation_config: None,
765        };
766
767        let report = SchemaValidator::validate(&schema).unwrap();
768        assert!(!report.is_valid());
769        assert!(report.errors.iter().any(|e| e.message.contains("unknown entity")));
770    }
771
772    #[test]
773    fn test_observer_with_invalid_event() {
774        use serde_json::json;
775
776        use super::super::intermediate::{IntermediateObserver, IntermediateRetryConfig};
777
778        let schema = IntermediateSchema {
779            security:          None,
780            version:           "2.0.0".to_string(),
781            types:             vec![IntermediateType {
782                name:        "Order".to_string(),
783                fields:      vec![],
784                description: None,
785                implements:  vec![],
786                is_error:    false,
787            }],
788            enums:             vec![],
789            input_types:       vec![],
790            interfaces:        vec![],
791            unions:            vec![],
792            queries:           vec![],
793            mutations:         vec![],
794            subscriptions:     vec![],
795            fragments:         None,
796            directives:        None,
797            fact_tables:       None,
798            aggregate_queries: None,
799            observers:         Some(vec![IntermediateObserver {
800                name:      "onOrderCreated".to_string(),
801                entity:    "Order".to_string(),
802                event:     "INVALID_EVENT".to_string(),
803                actions:   vec![json!({"type": "webhook", "url": "https://example.com"})],
804                condition: None,
805                retry:     IntermediateRetryConfig {
806                    max_attempts:     3,
807                    backoff_strategy: "exponential".to_string(),
808                    initial_delay_ms: 100,
809                    max_delay_ms:     60000,
810                },
811            }]),
812            custom_scalars:    None,
813            observers_config:  None,
814            federation_config: None,
815        };
816
817        let report = SchemaValidator::validate(&schema).unwrap();
818        assert!(!report.is_valid());
819        assert!(report.errors.iter().any(|e| e.message.contains("invalid event")));
820    }
821
822    #[test]
823    fn test_observer_with_invalid_action_type() {
824        use serde_json::json;
825
826        use super::super::intermediate::{IntermediateObserver, IntermediateRetryConfig};
827
828        let schema = IntermediateSchema {
829            security:          None,
830            version:           "2.0.0".to_string(),
831            types:             vec![IntermediateType {
832                name:        "Order".to_string(),
833                fields:      vec![],
834                description: None,
835                implements:  vec![],
836                is_error:    false,
837            }],
838            enums:             vec![],
839            input_types:       vec![],
840            interfaces:        vec![],
841            unions:            vec![],
842            queries:           vec![],
843            mutations:         vec![],
844            subscriptions:     vec![],
845            fragments:         None,
846            directives:        None,
847            fact_tables:       None,
848            aggregate_queries: None,
849            observers:         Some(vec![IntermediateObserver {
850                name:      "onOrderCreated".to_string(),
851                entity:    "Order".to_string(),
852                event:     "INSERT".to_string(),
853                actions:   vec![json!({"type": "invalid_action"})],
854                condition: None,
855                retry:     IntermediateRetryConfig {
856                    max_attempts:     3,
857                    backoff_strategy: "exponential".to_string(),
858                    initial_delay_ms: 100,
859                    max_delay_ms:     60000,
860                },
861            }]),
862            custom_scalars:    None,
863            observers_config:  None,
864            federation_config: None,
865        };
866
867        let report = SchemaValidator::validate(&schema).unwrap();
868        assert!(!report.is_valid());
869        assert!(report.errors.iter().any(|e| e.message.contains("invalid type")));
870    }
871
872    #[test]
873    fn test_observer_with_invalid_retry_config() {
874        use serde_json::json;
875
876        use super::super::intermediate::{IntermediateObserver, IntermediateRetryConfig};
877
878        let schema = IntermediateSchema {
879            security:          None,
880            version:           "2.0.0".to_string(),
881            types:             vec![IntermediateType {
882                name:        "Order".to_string(),
883                fields:      vec![],
884                description: None,
885                implements:  vec![],
886                is_error:    false,
887            }],
888            enums:             vec![],
889            input_types:       vec![],
890            interfaces:        vec![],
891            unions:            vec![],
892            queries:           vec![],
893            mutations:         vec![],
894            subscriptions:     vec![],
895            fragments:         None,
896            directives:        None,
897            fact_tables:       None,
898            aggregate_queries: None,
899            observers:         Some(vec![IntermediateObserver {
900                name:      "onOrderCreated".to_string(),
901                entity:    "Order".to_string(),
902                event:     "INSERT".to_string(),
903                actions:   vec![json!({"type": "webhook", "url": "https://example.com"})],
904                condition: None,
905                retry:     IntermediateRetryConfig {
906                    max_attempts:     3,
907                    backoff_strategy: "invalid_strategy".to_string(),
908                    initial_delay_ms: 100,
909                    max_delay_ms:     60000,
910                },
911            }]),
912            custom_scalars:    None,
913            observers_config:  None,
914            federation_config: None,
915        };
916
917        let report = SchemaValidator::validate(&schema).unwrap();
918        assert!(!report.is_valid());
919        assert!(report.errors.iter().any(|e| e.message.contains("invalid backoff_strategy")));
920    }
921}