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    /// Validate federation metadata for @requires/@provides/@external directives
411    ///
412    /// Checks:
413    /// 1. @requires references existing fields
414    /// 2. @external only on @extends types
415    /// 3. No circular dependencies in @requires directives
416    /// 4. @key fields are valid
417    ///
418    /// Note: This method is used by integration tests that don't expose it via the CLI module.
419    #[allow(dead_code)]
420    pub fn validate_federation(
421        metadata: &fraiseql_core::federation::types::FederationMetadata,
422    ) -> Result<()> {
423        use fraiseql_core::federation::DependencyGraph;
424
425        // Check if federation is enabled
426        if !metadata.enabled {
427            return Ok(());
428        }
429
430        // Step 1: Validate @requires references existing fields
431        for federated_type in &metadata.types {
432            for (field_name, directives) in &federated_type.field_directives {
433                // Validate @requires fields exist
434                for required in &directives.requires {
435                    // For now, basic validation - a field was declared as required
436                    // More sophisticated validation would check against actual schema
437                    if required.path.is_empty() {
438                        return Err(anyhow::anyhow!(
439                            "Invalid @requires on {}.{}: empty field path",
440                            federated_type.name,
441                            field_name
442                        ));
443                    }
444                }
445
446                // Validate @provides fields (if any)
447                for provided in &directives.provides {
448                    if provided.path.is_empty() {
449                        return Err(anyhow::anyhow!(
450                            "Invalid @provides on {}.{}: empty field path",
451                            federated_type.name,
452                            field_name
453                        ));
454                    }
455                }
456
457                // Validate @external only on @extends types
458                if directives.external && !federated_type.is_extends {
459                    return Err(anyhow::anyhow!(
460                        "@external field {}.{} can only appear on @extends types",
461                        federated_type.name,
462                        field_name
463                    ));
464                }
465            }
466        }
467
468        // Step 2: Check for circular dependencies using DependencyGraph
469        let graph = DependencyGraph::build(metadata)
470            .map_err(|e| anyhow::anyhow!("Failed to build dependency graph: {e}"))?;
471
472        let cycles = graph.detect_cycles();
473        if !cycles.is_empty() {
474            return Err(anyhow::anyhow!("Circular @requires dependencies detected: {cycles:?}"));
475        }
476
477        info!("Federation metadata validation passed");
478        Ok(())
479    }
480
481    /// Suggest similar type names for typos
482    fn suggest_similar_type(typo: &str, available: &HashSet<String>) -> String {
483        // Simple Levenshtein-style similarity (first letter match)
484        let similar: Vec<&String> = available
485            .iter()
486            .filter(|name| {
487                name.to_lowercase().starts_with(&typo[0..1].to_lowercase())
488                    || typo.to_lowercase().starts_with(&name[0..1].to_lowercase())
489            })
490            .take(3)
491            .collect();
492
493        if similar.is_empty() {
494            available.iter().take(5).cloned().collect::<Vec<_>>().join(", ")
495        } else {
496            similar.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")
497        }
498    }
499}
500
501/// Validation report
502#[derive(Debug, Default)]
503pub struct ValidationReport {
504    /// Validation errors and warnings
505    pub errors: Vec<ValidationError>,
506}
507
508impl ValidationReport {
509    /// Check if validation passed (no errors, warnings OK)
510    pub fn is_valid(&self) -> bool {
511        !self.has_errors()
512    }
513
514    /// Check if there are any errors
515    pub fn has_errors(&self) -> bool {
516        self.errors.iter().any(|e| e.severity == ErrorSeverity::Error)
517    }
518
519    /// Count errors
520    pub fn error_count(&self) -> usize {
521        self.errors.iter().filter(|e| e.severity == ErrorSeverity::Error).count()
522    }
523
524    /// Count warnings
525    pub fn warning_count(&self) -> usize {
526        self.errors.iter().filter(|e| e.severity == ErrorSeverity::Warning).count()
527    }
528
529    /// Print formatted report
530    pub fn print(&self) {
531        if self.errors.is_empty() {
532            return;
533        }
534
535        println!("\nšŸ“‹ Validation Report:");
536
537        let errors: Vec<_> =
538            self.errors.iter().filter(|e| e.severity == ErrorSeverity::Error).collect();
539
540        let warnings: Vec<_> =
541            self.errors.iter().filter(|e| e.severity == ErrorSeverity::Warning).collect();
542
543        if !errors.is_empty() {
544            println!("\n  āŒ Errors ({}):", errors.len());
545            for error in errors {
546                println!("     {}", error.message);
547                println!("     at: {}", error.path);
548                if let Some(suggestion) = &error.suggestion {
549                    println!("     šŸ’” {suggestion}");
550                }
551                println!();
552            }
553        }
554
555        if !warnings.is_empty() {
556            println!("\n  āš ļø  Warnings ({}):", warnings.len());
557            for warning in warnings {
558                println!("     {}", warning.message);
559                println!("     at: {}", warning.path);
560                if let Some(suggestion) = &warning.suggestion {
561                    println!("     šŸ’” {suggestion}");
562                }
563                println!();
564            }
565        }
566    }
567}
568
569#[cfg(test)]
570mod tests {
571    use super::*;
572    use crate::schema::intermediate::{IntermediateQuery, IntermediateType};
573
574    #[test]
575    fn test_validate_empty_schema() {
576        let schema = IntermediateSchema {
577            security:          None,
578            version:           "2.0.0".to_string(),
579            types:             vec![],
580            enums:             vec![],
581            input_types:       vec![],
582            interfaces:        vec![],
583            unions:            vec![],
584            queries:           vec![],
585            mutations:         vec![],
586            subscriptions:     vec![],
587            fragments:         None,
588            directives:        None,
589            fact_tables:       None,
590            aggregate_queries: None,
591            observers:         None,
592        };
593
594        let report = SchemaValidator::validate(&schema).unwrap();
595        assert!(report.is_valid());
596    }
597
598    #[test]
599    fn test_detect_unknown_return_type() {
600        let schema = IntermediateSchema {
601            security:          None,
602            version:           "2.0.0".to_string(),
603            types:             vec![],
604            enums:             vec![],
605            input_types:       vec![],
606            interfaces:        vec![],
607            unions:            vec![],
608            queries:           vec![IntermediateQuery {
609                name:         "users".to_string(),
610                return_type:  "UnknownType".to_string(),
611                returns_list: true,
612                nullable:     false,
613                arguments:    vec![],
614                description:  None,
615                sql_source:   Some("users".to_string()),
616                auto_params:  None,
617                deprecated:   None,
618            }],
619            mutations:         vec![],
620            subscriptions:     vec![],
621            fragments:         None,
622            directives:        None,
623            fact_tables:       None,
624            aggregate_queries: None,
625            observers:         None,
626        };
627
628        let report = SchemaValidator::validate(&schema).unwrap();
629        assert!(!report.is_valid());
630        assert_eq!(report.error_count(), 1);
631        assert!(report.errors[0].message.contains("unknown type 'UnknownType'"));
632    }
633
634    #[test]
635    fn test_detect_duplicate_query_names() {
636        let schema = IntermediateSchema {
637            security:          None,
638            version:           "2.0.0".to_string(),
639            types:             vec![IntermediateType {
640                name:        "User".to_string(),
641                fields:      vec![],
642                description: None,
643                implements:  vec![],
644            }],
645            enums:             vec![],
646            input_types:       vec![],
647            interfaces:        vec![],
648            unions:            vec![],
649            queries:           vec![
650                IntermediateQuery {
651                    name:         "users".to_string(),
652                    return_type:  "User".to_string(),
653                    returns_list: true,
654                    nullable:     false,
655                    arguments:    vec![],
656                    description:  None,
657                    sql_source:   Some("users".to_string()),
658                    auto_params:  None,
659                    deprecated:   None,
660                },
661                IntermediateQuery {
662                    name:         "users".to_string(), // Duplicate!
663                    return_type:  "User".to_string(),
664                    returns_list: true,
665                    nullable:     false,
666                    arguments:    vec![],
667                    description:  None,
668                    sql_source:   Some("users".to_string()),
669                    auto_params:  None,
670                    deprecated:   None,
671                },
672            ],
673            mutations:         vec![],
674            subscriptions:     vec![],
675            fragments:         None,
676            directives:        None,
677            fact_tables:       None,
678            aggregate_queries: None,
679            observers:         None,
680        };
681
682        let report = SchemaValidator::validate(&schema).unwrap();
683        assert!(!report.is_valid());
684        assert!(report.errors.iter().any(|e| e.message.contains("Duplicate query name")));
685    }
686
687    #[test]
688    fn test_warning_for_query_without_sql_source() {
689        let schema = IntermediateSchema {
690            security:          None,
691            version:           "2.0.0".to_string(),
692            types:             vec![IntermediateType {
693                name:        "User".to_string(),
694                fields:      vec![],
695                description: None,
696                implements:  vec![],
697            }],
698            enums:             vec![],
699            input_types:       vec![],
700            interfaces:        vec![],
701            unions:            vec![],
702            queries:           vec![IntermediateQuery {
703                name:         "users".to_string(),
704                return_type:  "User".to_string(),
705                returns_list: true,
706                nullable:     false,
707                arguments:    vec![],
708                description:  None,
709                sql_source:   None, // Missing SQL source
710                auto_params:  None,
711                deprecated:   None,
712            }],
713            mutations:         vec![],
714            subscriptions:     vec![],
715            fragments:         None,
716            directives:        None,
717            fact_tables:       None,
718            aggregate_queries: None,
719            observers:         None,
720        };
721
722        let report = SchemaValidator::validate(&schema).unwrap();
723        assert!(report.is_valid()); // Still valid, just a warning
724        assert_eq!(report.warning_count(), 1);
725        assert!(report.errors[0].message.contains("no sql_source"));
726    }
727
728    #[test]
729    fn test_valid_observer() {
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![IntermediateType {
738                name:        "Order".to_string(),
739                fields:      vec![],
740                description: None,
741                implements:  vec![],
742            }],
743            enums:             vec![],
744            input_types:       vec![],
745            interfaces:        vec![],
746            unions:            vec![],
747            queries:           vec![],
748            mutations:         vec![],
749            subscriptions:     vec![],
750            fragments:         None,
751            directives:        None,
752            fact_tables:       None,
753            aggregate_queries: None,
754            observers:         Some(vec![IntermediateObserver {
755                name:      "onOrderCreated".to_string(),
756                entity:    "Order".to_string(),
757                event:     "INSERT".to_string(),
758                actions:   vec![json!({
759                    "type": "webhook",
760                    "url": "https://example.com/orders"
761                })],
762                condition: None,
763                retry:     IntermediateRetryConfig {
764                    max_attempts:     3,
765                    backoff_strategy: "exponential".to_string(),
766                    initial_delay_ms: 100,
767                    max_delay_ms:     60000,
768                },
769            }]),
770        };
771
772        let report = SchemaValidator::validate(&schema).unwrap();
773        assert!(report.is_valid(), "Valid observer should pass validation");
774        assert_eq!(report.error_count(), 0);
775    }
776
777    #[test]
778    fn test_observer_with_unknown_entity() {
779        use serde_json::json;
780
781        use super::super::intermediate::{IntermediateObserver, IntermediateRetryConfig};
782
783        let schema = IntermediateSchema {
784            security:          None,
785            version:           "2.0.0".to_string(),
786            types:             vec![],
787            enums:             vec![],
788            input_types:       vec![],
789            interfaces:        vec![],
790            unions:            vec![],
791            queries:           vec![],
792            mutations:         vec![],
793            subscriptions:     vec![],
794            fragments:         None,
795            directives:        None,
796            fact_tables:       None,
797            aggregate_queries: None,
798            observers:         Some(vec![IntermediateObserver {
799                name:      "onOrderCreated".to_string(),
800                entity:    "UnknownEntity".to_string(),
801                event:     "INSERT".to_string(),
802                actions:   vec![json!({"type": "webhook", "url": "https://example.com"})],
803                condition: None,
804                retry:     IntermediateRetryConfig {
805                    max_attempts:     3,
806                    backoff_strategy: "exponential".to_string(),
807                    initial_delay_ms: 100,
808                    max_delay_ms:     60000,
809                },
810            }]),
811        };
812
813        let report = SchemaValidator::validate(&schema).unwrap();
814        assert!(!report.is_valid());
815        assert!(report.errors.iter().any(|e| e.message.contains("unknown entity")));
816    }
817
818    #[test]
819    fn test_observer_with_invalid_event() {
820        use serde_json::json;
821
822        use super::super::intermediate::{IntermediateObserver, IntermediateRetryConfig};
823
824        let schema = IntermediateSchema {
825            security:          None,
826            version:           "2.0.0".to_string(),
827            types:             vec![IntermediateType {
828                name:        "Order".to_string(),
829                fields:      vec![],
830                description: None,
831                implements:  vec![],
832            }],
833            enums:             vec![],
834            input_types:       vec![],
835            interfaces:        vec![],
836            unions:            vec![],
837            queries:           vec![],
838            mutations:         vec![],
839            subscriptions:     vec![],
840            fragments:         None,
841            directives:        None,
842            fact_tables:       None,
843            aggregate_queries: None,
844            observers:         Some(vec![IntermediateObserver {
845                name:      "onOrderCreated".to_string(),
846                entity:    "Order".to_string(),
847                event:     "INVALID_EVENT".to_string(),
848                actions:   vec![json!({"type": "webhook", "url": "https://example.com"})],
849                condition: None,
850                retry:     IntermediateRetryConfig {
851                    max_attempts:     3,
852                    backoff_strategy: "exponential".to_string(),
853                    initial_delay_ms: 100,
854                    max_delay_ms:     60000,
855                },
856            }]),
857        };
858
859        let report = SchemaValidator::validate(&schema).unwrap();
860        assert!(!report.is_valid());
861        assert!(report.errors.iter().any(|e| e.message.contains("invalid event")));
862    }
863
864    #[test]
865    fn test_observer_with_invalid_action_type() {
866        use serde_json::json;
867
868        use super::super::intermediate::{IntermediateObserver, IntermediateRetryConfig};
869
870        let schema = IntermediateSchema {
871            security:          None,
872            version:           "2.0.0".to_string(),
873            types:             vec![IntermediateType {
874                name:        "Order".to_string(),
875                fields:      vec![],
876                description: None,
877                implements:  vec![],
878            }],
879            enums:             vec![],
880            input_types:       vec![],
881            interfaces:        vec![],
882            unions:            vec![],
883            queries:           vec![],
884            mutations:         vec![],
885            subscriptions:     vec![],
886            fragments:         None,
887            directives:        None,
888            fact_tables:       None,
889            aggregate_queries: None,
890            observers:         Some(vec![IntermediateObserver {
891                name:      "onOrderCreated".to_string(),
892                entity:    "Order".to_string(),
893                event:     "INSERT".to_string(),
894                actions:   vec![json!({"type": "invalid_action"})],
895                condition: None,
896                retry:     IntermediateRetryConfig {
897                    max_attempts:     3,
898                    backoff_strategy: "exponential".to_string(),
899                    initial_delay_ms: 100,
900                    max_delay_ms:     60000,
901                },
902            }]),
903        };
904
905        let report = SchemaValidator::validate(&schema).unwrap();
906        assert!(!report.is_valid());
907        assert!(report.errors.iter().any(|e| e.message.contains("invalid type")));
908    }
909
910    #[test]
911    fn test_observer_with_invalid_retry_config() {
912        use serde_json::json;
913
914        use super::super::intermediate::{IntermediateObserver, IntermediateRetryConfig};
915
916        let schema = IntermediateSchema {
917            security:          None,
918            version:           "2.0.0".to_string(),
919            types:             vec![IntermediateType {
920                name:        "Order".to_string(),
921                fields:      vec![],
922                description: None,
923                implements:  vec![],
924            }],
925            enums:             vec![],
926            input_types:       vec![],
927            interfaces:        vec![],
928            unions:            vec![],
929            queries:           vec![],
930            mutations:         vec![],
931            subscriptions:     vec![],
932            fragments:         None,
933            directives:        None,
934            fact_tables:       None,
935            aggregate_queries: None,
936            observers:         Some(vec![IntermediateObserver {
937                name:      "onOrderCreated".to_string(),
938                entity:    "Order".to_string(),
939                event:     "INSERT".to_string(),
940                actions:   vec![json!({"type": "webhook", "url": "https://example.com"})],
941                condition: None,
942                retry:     IntermediateRetryConfig {
943                    max_attempts:     3,
944                    backoff_strategy: "invalid_strategy".to_string(),
945                    initial_delay_ms: 100,
946                    max_delay_ms:     60000,
947                },
948            }]),
949        };
950
951        let report = SchemaValidator::validate(&schema).unwrap();
952        assert!(!report.is_valid());
953        assert!(report.errors.iter().any(|e| e.message.contains("invalid backoff_strategy")));
954    }
955}