1use std::collections::HashSet;
6
7use anyhow::Result;
8use tracing::{debug, info};
9
10use super::intermediate::IntermediateSchema;
11
12#[derive(Debug, Clone)]
14pub struct ValidationError {
15 pub message: String,
17 pub path: String,
19 pub severity: ErrorSeverity,
21 pub suggestion: Option<String>,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum ErrorSeverity {
28 Error,
30 Warning,
32}
33
34pub struct SchemaValidator;
36
37impl SchemaValidator {
38 pub fn validate(schema: &IntermediateSchema) -> Result<ValidationReport> {
40 info!("Validating schema structure");
41
42 let mut report = ValidationReport::default();
43
44 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 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 let mut query_names = HashSet::new();
67 for (idx, query) in schema.queries.iter().enumerate() {
68 debug!("Validating query: {}", query.name);
69
70 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 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 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 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 let mut mutation_names = HashSet::new();
131 for (idx, mutation) in schema.mutations.iter().enumerate() {
132 debug!("Validating mutation: {}", mutation.name);
133
134 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 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 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 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 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 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 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 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 for (action_idx, action) in observer.actions.iter().enumerate() {
242 if let Some(obj) = action.as_object() {
243 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 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 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 #[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 if !metadata.enabled {
427 return Ok(());
428 }
429
430 for federated_type in &metadata.types {
432 for (field_name, directives) in &federated_type.field_directives {
433 for required in &directives.requires {
435 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 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 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 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 fn suggest_similar_type(typo: &str, available: &HashSet<String>) -> String {
483 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#[derive(Debug, Default)]
503pub struct ValidationReport {
504 pub errors: Vec<ValidationError>,
506}
507
508impl ValidationReport {
509 pub fn is_valid(&self) -> bool {
511 !self.has_errors()
512 }
513
514 pub fn has_errors(&self) -> bool {
516 self.errors.iter().any(|e| e.severity == ErrorSeverity::Error)
517 }
518
519 pub fn error_count(&self) -> usize {
521 self.errors.iter().filter(|e| e.severity == ErrorSeverity::Error).count()
522 }
523
524 pub fn warning_count(&self) -> usize {
526 self.errors.iter().filter(|e| e.severity == ErrorSeverity::Warning).count()
527 }
528
529 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(), 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, 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()); 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}