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            custom_scalars: None,
593        };
594
595        let report = SchemaValidator::validate(&schema).unwrap();
596        assert!(report.is_valid());
597    }
598
599    #[test]
600    fn test_detect_unknown_return_type() {
601        let schema = IntermediateSchema {
602            security:          None,
603            version:           "2.0.0".to_string(),
604            types:             vec![],
605            enums:             vec![],
606            input_types:       vec![],
607            interfaces:        vec![],
608            unions:            vec![],
609            queries:           vec![IntermediateQuery {
610                name:         "users".to_string(),
611                return_type:  "UnknownType".to_string(),
612                returns_list: true,
613                nullable:     false,
614                arguments:    vec![],
615                description:  None,
616                sql_source:   Some("users".to_string()),
617                auto_params:  None,
618                deprecated:   None,
619                jsonb_column: None,
620            }],
621            mutations:         vec![],
622            subscriptions:     vec![],
623            fragments:         None,
624            directives:        None,
625            fact_tables:       None,
626            aggregate_queries: None,
627            observers:         None,
628            custom_scalars: None,
629        };
630
631        let report = SchemaValidator::validate(&schema).unwrap();
632        assert!(!report.is_valid());
633        assert_eq!(report.error_count(), 1);
634        assert!(report.errors[0].message.contains("unknown type 'UnknownType'"));
635    }
636
637    #[test]
638    fn test_detect_duplicate_query_names() {
639        let schema = IntermediateSchema {
640            security:          None,
641            version:           "2.0.0".to_string(),
642            types:             vec![IntermediateType {
643                name:        "User".to_string(),
644                fields:      vec![],
645                description: None,
646                implements:  vec![],
647            }],
648            enums:             vec![],
649            input_types:       vec![],
650            interfaces:        vec![],
651            unions:            vec![],
652            queries:           vec![
653                IntermediateQuery {
654                    name:         "users".to_string(),
655                    return_type:  "User".to_string(),
656                    returns_list: true,
657                    nullable:     false,
658                    arguments:    vec![],
659                    description:  None,
660                    sql_source:   Some("users".to_string()),
661                    auto_params:  None,
662                    deprecated:   None,
663                    jsonb_column: None,
664                },
665                IntermediateQuery {
666                    name:         "users".to_string(), // Duplicate!
667                    return_type:  "User".to_string(),
668                    returns_list: true,
669                    nullable:     false,
670                    arguments:    vec![],
671                    description:  None,
672                    sql_source:   Some("users".to_string()),
673                    auto_params:  None,
674                    deprecated:   None,
675                    jsonb_column: None,
676                },
677            ],
678            mutations:         vec![],
679            subscriptions:     vec![],
680            fragments:         None,
681            directives:        None,
682            fact_tables:       None,
683            aggregate_queries: None,
684            observers:         None,
685            custom_scalars: None,
686        };
687
688        let report = SchemaValidator::validate(&schema).unwrap();
689        assert!(!report.is_valid());
690        assert!(report.errors.iter().any(|e| e.message.contains("Duplicate query name")));
691    }
692
693    #[test]
694    fn test_warning_for_query_without_sql_source() {
695        let schema = IntermediateSchema {
696            security:          None,
697            version:           "2.0.0".to_string(),
698            types:             vec![IntermediateType {
699                name:        "User".to_string(),
700                fields:      vec![],
701                description: None,
702                implements:  vec![],
703            }],
704            enums:             vec![],
705            input_types:       vec![],
706            interfaces:        vec![],
707            unions:            vec![],
708            queries:           vec![IntermediateQuery {
709                name:         "users".to_string(),
710                return_type:  "User".to_string(),
711                returns_list: true,
712                nullable:     false,
713                arguments:    vec![],
714                description:  None,
715                sql_source:   None, // Missing SQL source
716                auto_params:  None,
717                deprecated:   None,
718                jsonb_column: None,
719            }],
720            mutations:         vec![],
721            subscriptions:     vec![],
722            fragments:         None,
723            directives:        None,
724            fact_tables:       None,
725            aggregate_queries: None,
726            observers:         None,
727            custom_scalars: None,
728        };
729
730        let report = SchemaValidator::validate(&schema).unwrap();
731        assert!(report.is_valid()); // Still valid, just a warning
732        assert_eq!(report.warning_count(), 1);
733        assert!(report.errors[0].message.contains("no sql_source"));
734    }
735
736    #[test]
737    fn test_valid_observer() {
738        use serde_json::json;
739
740        use super::super::intermediate::{IntermediateObserver, IntermediateRetryConfig};
741
742        let schema = IntermediateSchema {
743            security:          None,
744            version:           "2.0.0".to_string(),
745            types:             vec![IntermediateType {
746                name:        "Order".to_string(),
747                fields:      vec![],
748                description: None,
749                implements:  vec![],
750            }],
751            enums:             vec![],
752            input_types:       vec![],
753            interfaces:        vec![],
754            unions:            vec![],
755            queries:           vec![],
756            mutations:         vec![],
757            subscriptions:     vec![],
758            fragments:         None,
759            directives:        None,
760            fact_tables:       None,
761            aggregate_queries: None,
762            observers:         Some(vec![IntermediateObserver {
763                name:      "onOrderCreated".to_string(),
764                entity:    "Order".to_string(),
765                event:     "INSERT".to_string(),
766                actions:   vec![json!({
767                    "type": "webhook",
768                    "url": "https://example.com/orders"
769                })],
770                condition: None,
771                retry:     IntermediateRetryConfig {
772                    max_attempts:     3,
773                    backoff_strategy: "exponential".to_string(),
774                    initial_delay_ms: 100,
775                    max_delay_ms:     60000,
776                },
777            }]),
778            custom_scalars: None,
779        };
780
781        let report = SchemaValidator::validate(&schema).unwrap();
782        assert!(report.is_valid(), "Valid observer should pass validation");
783        assert_eq!(report.error_count(), 0);
784    }
785
786    #[test]
787    fn test_observer_with_unknown_entity() {
788        use serde_json::json;
789
790        use super::super::intermediate::{IntermediateObserver, IntermediateRetryConfig};
791
792        let schema = IntermediateSchema {
793            security:          None,
794            version:           "2.0.0".to_string(),
795            types:             vec![],
796            enums:             vec![],
797            input_types:       vec![],
798            interfaces:        vec![],
799            unions:            vec![],
800            queries:           vec![],
801            mutations:         vec![],
802            subscriptions:     vec![],
803            fragments:         None,
804            directives:        None,
805            fact_tables:       None,
806            aggregate_queries: None,
807            observers:         Some(vec![IntermediateObserver {
808                name:      "onOrderCreated".to_string(),
809                entity:    "UnknownEntity".to_string(),
810                event:     "INSERT".to_string(),
811                actions:   vec![json!({"type": "webhook", "url": "https://example.com"})],
812                condition: None,
813                retry:     IntermediateRetryConfig {
814                    max_attempts:     3,
815                    backoff_strategy: "exponential".to_string(),
816                    initial_delay_ms: 100,
817                    max_delay_ms:     60000,
818                },
819            }]),
820            custom_scalars: None,
821        };
822
823        let report = SchemaValidator::validate(&schema).unwrap();
824        assert!(!report.is_valid());
825        assert!(report.errors.iter().any(|e| e.message.contains("unknown entity")));
826    }
827
828    #[test]
829    fn test_observer_with_invalid_event() {
830        use serde_json::json;
831
832        use super::super::intermediate::{IntermediateObserver, IntermediateRetryConfig};
833
834        let schema = IntermediateSchema {
835            security:          None,
836            version:           "2.0.0".to_string(),
837            types:             vec![IntermediateType {
838                name:        "Order".to_string(),
839                fields:      vec![],
840                description: None,
841                implements:  vec![],
842            }],
843            enums:             vec![],
844            input_types:       vec![],
845            interfaces:        vec![],
846            unions:            vec![],
847            queries:           vec![],
848            mutations:         vec![],
849            subscriptions:     vec![],
850            fragments:         None,
851            directives:        None,
852            fact_tables:       None,
853            aggregate_queries: None,
854            observers:         Some(vec![IntermediateObserver {
855                name:      "onOrderCreated".to_string(),
856                entity:    "Order".to_string(),
857                event:     "INVALID_EVENT".to_string(),
858                actions:   vec![json!({"type": "webhook", "url": "https://example.com"})],
859                condition: None,
860                retry:     IntermediateRetryConfig {
861                    max_attempts:     3,
862                    backoff_strategy: "exponential".to_string(),
863                    initial_delay_ms: 100,
864                    max_delay_ms:     60000,
865                },
866            }]),
867            custom_scalars: None,
868        };
869
870        let report = SchemaValidator::validate(&schema).unwrap();
871        assert!(!report.is_valid());
872        assert!(report.errors.iter().any(|e| e.message.contains("invalid event")));
873    }
874
875    #[test]
876    fn test_observer_with_invalid_action_type() {
877        use serde_json::json;
878
879        use super::super::intermediate::{IntermediateObserver, IntermediateRetryConfig};
880
881        let schema = IntermediateSchema {
882            security:          None,
883            version:           "2.0.0".to_string(),
884            types:             vec![IntermediateType {
885                name:        "Order".to_string(),
886                fields:      vec![],
887                description: None,
888                implements:  vec![],
889            }],
890            enums:             vec![],
891            input_types:       vec![],
892            interfaces:        vec![],
893            unions:            vec![],
894            queries:           vec![],
895            mutations:         vec![],
896            subscriptions:     vec![],
897            fragments:         None,
898            directives:        None,
899            fact_tables:       None,
900            aggregate_queries: None,
901            observers:         Some(vec![IntermediateObserver {
902                name:      "onOrderCreated".to_string(),
903                entity:    "Order".to_string(),
904                event:     "INSERT".to_string(),
905                actions:   vec![json!({"type": "invalid_action"})],
906                condition: None,
907                retry:     IntermediateRetryConfig {
908                    max_attempts:     3,
909                    backoff_strategy: "exponential".to_string(),
910                    initial_delay_ms: 100,
911                    max_delay_ms:     60000,
912                },
913            }]),
914            custom_scalars: 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 type")));
920    }
921
922    #[test]
923    fn test_observer_with_invalid_retry_config() {
924        use serde_json::json;
925
926        use super::super::intermediate::{IntermediateObserver, IntermediateRetryConfig};
927
928        let schema = IntermediateSchema {
929            security:          None,
930            version:           "2.0.0".to_string(),
931            types:             vec![IntermediateType {
932                name:        "Order".to_string(),
933                fields:      vec![],
934                description: None,
935                implements:  vec![],
936            }],
937            enums:             vec![],
938            input_types:       vec![],
939            interfaces:        vec![],
940            unions:            vec![],
941            queries:           vec![],
942            mutations:         vec![],
943            subscriptions:     vec![],
944            fragments:         None,
945            directives:        None,
946            fact_tables:       None,
947            aggregate_queries: None,
948            observers:         Some(vec![IntermediateObserver {
949                name:      "onOrderCreated".to_string(),
950                entity:    "Order".to_string(),
951                event:     "INSERT".to_string(),
952                actions:   vec![json!({"type": "webhook", "url": "https://example.com"})],
953                condition: None,
954                retry:     IntermediateRetryConfig {
955                    max_attempts:     3,
956                    backoff_strategy: "invalid_strategy".to_string(),
957                    initial_delay_ms: 100,
958                    max_delay_ms:     60000,
959                },
960            }]),
961            custom_scalars: None,
962        };
963
964        let report = SchemaValidator::validate(&schema).unwrap();
965        assert!(!report.is_valid());
966        assert!(report.errors.iter().any(|e| e.message.contains("invalid backoff_strategy")));
967    }
968}