Skip to main content

this/links/
registry.rs

1//! Route registry for link navigation
2//!
3//! Provides resolution of route names to link definitions and handles
4//! bidirectional navigation (forward and reverse)
5
6use crate::config::LinksConfig;
7use crate::core::LinkDefinition;
8use anyhow::{Result, anyhow};
9use std::collections::HashMap;
10use std::sync::Arc;
11
12/// Direction of link navigation
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum LinkDirection {
15    /// From source to target
16    Forward,
17    /// From target to source
18    Reverse,
19}
20
21/// Registry for resolving route names to link definitions
22///
23/// This allows the framework to map URL paths like "/users/{id}/cars-owned"
24/// to the appropriate link definition and direction.
25pub struct LinkRouteRegistry {
26    config: Arc<LinksConfig>,
27    /// Maps (entity_type, route_name) -> (LinkDefinition, LinkDirection)
28    routes: HashMap<(String, String), (LinkDefinition, LinkDirection)>,
29}
30
31impl LinkRouteRegistry {
32    /// Create a new registry from a links configuration
33    pub fn new(config: Arc<LinksConfig>) -> Self {
34        let mut routes = HashMap::new();
35
36        // Build the routing table
37        for link_def in &config.links {
38            // Forward route: source -> target
39            let forward_key = (
40                link_def.source_type.clone(),
41                link_def.forward_route_name.clone(),
42            );
43            routes.insert(forward_key, (link_def.clone(), LinkDirection::Forward));
44
45            // Reverse route: target -> source
46            let reverse_key = (
47                link_def.target_type.clone(),
48                link_def.reverse_route_name.clone(),
49            );
50            routes.insert(reverse_key, (link_def.clone(), LinkDirection::Reverse));
51        }
52
53        Self { config, routes }
54    }
55
56    /// Resolve a route name for a given entity type
57    ///
58    /// Returns the link definition and the direction of navigation
59    pub fn resolve_route(
60        &self,
61        entity_type: &str,
62        route_name: &str,
63    ) -> Result<(LinkDefinition, LinkDirection)> {
64        let key = (entity_type.to_string(), route_name.to_string());
65
66        self.routes.get(&key).cloned().ok_or_else(|| {
67            anyhow!(
68                "No route '{}' found for entity type '{}'",
69                route_name,
70                entity_type
71            )
72        })
73    }
74
75    /// List all available routes for a given entity type
76    pub fn list_routes_for_entity(&self, entity_type: &str) -> Vec<RouteInfo> {
77        self.routes
78            .iter()
79            .filter(|((etype, _), _)| etype == entity_type)
80            .map(|((_, route_name), (link_def, direction))| {
81                let connected_to = match direction {
82                    LinkDirection::Forward => &link_def.target_type,
83                    LinkDirection::Reverse => &link_def.source_type,
84                };
85
86                RouteInfo {
87                    route_name: route_name.clone(),
88                    link_type: link_def.link_type.clone(),
89                    direction: *direction,
90                    connected_to: connected_to.clone(),
91                    description: link_def.description.clone(),
92                }
93            })
94            .collect()
95    }
96
97    /// Get the underlying configuration
98    pub fn config(&self) -> &LinksConfig {
99        &self.config
100    }
101
102    /// Detect all possible link chains from the configuration (forward and reverse)
103    ///
104    /// Returns a list of chains like: (source_type, [(route_name, target_type), ...])
105    /// Example: (order, [("invoices", invoice), ("payments", payment)]) for the chain:
106    /// Order → Invoice → Payment
107    pub fn detect_link_chains(&self, max_depth: usize) -> Vec<LinkChain> {
108        let mut chains = Vec::new();
109
110        // Pour chaque type d'entité, trouver toutes les chaînes possibles (forward)
111        for entity_config in &self.config.entities {
112            self.find_chains_from_entity(
113                &entity_config.singular,
114                &mut vec![LinkChainStep {
115                    entity_type: entity_config.singular.clone(),
116                    route_name: None,
117                    direction: LinkDirection::Forward,
118                }],
119                &mut chains,
120                max_depth,
121                &mut std::collections::HashSet::new(),
122            );
123        }
124
125        // Pour chaque type d'entité, trouver toutes les chaînes inverses (reverse)
126        for entity_config in &self.config.entities {
127            self.find_reverse_chains_from_entity(
128                &entity_config.singular,
129                &mut vec![LinkChainStep {
130                    entity_type: entity_config.singular.clone(),
131                    route_name: None,
132                    direction: LinkDirection::Reverse,
133                }],
134                &mut chains,
135                max_depth,
136                &mut std::collections::HashSet::new(),
137            );
138        }
139
140        chains
141    }
142
143    /// Helper to recursively find chains from an entity (forward direction)
144    fn find_chains_from_entity(
145        &self,
146        entity_type: &str,
147        current_chain: &mut Vec<LinkChainStep>,
148        chains: &mut Vec<LinkChain>,
149        remaining_depth: usize,
150        visited: &mut std::collections::HashSet<String>,
151    ) {
152        if remaining_depth == 0 {
153            return;
154        }
155
156        // Trouver tous les liens sortants de cette entité
157        for link_def in &self.config.links {
158            if link_def.source_type == entity_type {
159                let edge = format!("{}->{}", link_def.source_type, link_def.target_type);
160
161                // Éviter les cycles
162                if visited.contains(&edge) {
163                    continue;
164                }
165
166                visited.insert(edge.clone());
167
168                // Ajouter cette étape à la chaîne
169                let route_name = Some(link_def.forward_route_name.clone());
170
171                current_chain.push(LinkChainStep {
172                    entity_type: link_def.target_type.clone(),
173                    route_name,
174                    direction: LinkDirection::Forward,
175                });
176
177                // Si c'est une chaîne valide (au moins 2 steps), l'ajouter
178                if current_chain.len() >= 2 {
179                    chains.push(LinkChain {
180                        steps: current_chain.clone(),
181                        config: self.config.clone(),
182                    });
183                }
184
185                // Continuer récursivement
186                self.find_chains_from_entity(
187                    &link_def.target_type,
188                    current_chain,
189                    chains,
190                    remaining_depth - 1,
191                    visited,
192                );
193
194                // Retirer cette étape
195                visited.remove(&edge);
196                current_chain.pop();
197            }
198        }
199    }
200
201    /// Helper to recursively find chains from an entity (reverse direction)
202    fn find_reverse_chains_from_entity(
203        &self,
204        entity_type: &str,
205        current_chain: &mut Vec<LinkChainStep>,
206        chains: &mut Vec<LinkChain>,
207        remaining_depth: usize,
208        visited: &mut std::collections::HashSet<String>,
209    ) {
210        if remaining_depth == 0 {
211            return;
212        }
213
214        // Trouver tous les liens entrants de cette entité
215        for link_def in &self.config.links {
216            if link_def.target_type == entity_type {
217                let edge = format!("{}<-{}", link_def.source_type, link_def.target_type);
218
219                // Éviter les cycles
220                if visited.contains(&edge) {
221                    continue;
222                }
223
224                visited.insert(edge.clone());
225
226                // Ajouter cette étape à la chaîne (avec reverse route name)
227                let route_name = Some(link_def.reverse_route_name.clone());
228
229                current_chain.push(LinkChainStep {
230                    entity_type: link_def.source_type.clone(),
231                    route_name,
232                    direction: LinkDirection::Reverse,
233                });
234
235                // Si c'est une chaîne valide (au moins 2 steps), l'ajouter
236                if current_chain.len() >= 2 {
237                    chains.push(LinkChain {
238                        steps: current_chain.clone(),
239                        config: self.config.clone(),
240                    });
241                }
242
243                // Continuer récursivement
244                self.find_reverse_chains_from_entity(
245                    &link_def.source_type,
246                    current_chain,
247                    chains,
248                    remaining_depth - 1,
249                    visited,
250                );
251
252                // Retirer cette étape
253                visited.remove(&edge);
254                current_chain.pop();
255            }
256        }
257    }
258}
259
260/// Une chaîne de liens détectée
261#[derive(Debug, Clone)]
262pub struct LinkChain {
263    pub steps: Vec<LinkChainStep>,
264    pub config: Arc<LinksConfig>,
265}
266
267/// Une étape dans une chaîne de liens
268#[derive(Debug, Clone)]
269pub struct LinkChainStep {
270    pub entity_type: String,
271    pub route_name: Option<String>,
272    pub direction: LinkDirection,
273}
274
275impl LinkChain {
276    /// Génère le pattern de route Axum pour cette chaîne
277    ///
278    /// Exemple forward: order → invoice → payment
279    ///   "/orders/{order_id}/invoices/{invoice_id}/payments"
280    ///
281    /// Exemple reverse: payment ← invoice ← order
282    ///   "/payments/{payment_id}/invoice/{invoice_id}/orders"
283    pub fn to_route_pattern(&self) -> String {
284        let mut pattern = String::new();
285        let steps_count = self.steps.len();
286
287        for (idx, step) in self.steps.iter().enumerate() {
288            if step.route_name.is_none() {
289                // Premier step: entité source
290                let plural = self.get_plural(&step.entity_type);
291                let param_name = format!("{}_id", step.entity_type);
292                pattern.push_str(&format!("/{plural}/{{{}}}", param_name));
293            } else if let Some(route_name) = &step.route_name {
294                // Step intermédiaire avec route
295                // Pour le dernier step, utiliser le pluriel au lieu du route_name
296                let segment = if idx == steps_count - 1 {
297                    // Dernier step: utiliser le pluriel
298                    self.get_plural(&step.entity_type)
299                } else {
300                    // Step intermédiaire: utiliser le route_name
301                    route_name.clone()
302                };
303                pattern.push_str(&format!("/{segment}"));
304
305                // Ajouter le param ID pour ce step
306                // SAUF si c'est le dernier step (pour la route de liste)
307                if idx < steps_count - 1 {
308                    let param_name = format!("{}_id", step.entity_type);
309                    pattern.push_str(&format!("/{{{}}}", param_name));
310                }
311            }
312        }
313
314        pattern
315    }
316
317    /// Indique si cette chaîne est en sens inverse
318    pub fn is_reverse(&self) -> bool {
319        self.steps
320            .first()
321            .map(|s| s.direction == LinkDirection::Reverse)
322            .unwrap_or(false)
323    }
324
325    fn get_plural(&self, singular: &str) -> String {
326        self.config
327            .entities
328            .iter()
329            .find(|e| e.singular == singular)
330            .map(|e| e.plural.clone())
331            .unwrap_or_else(|| format!("{}s", singular))
332    }
333}
334
335/// Information about a route available for an entity
336#[derive(Debug, Clone)]
337pub struct RouteInfo {
338    /// The route name (e.g., "cars-owned")
339    pub route_name: String,
340
341    /// The type of link (e.g., "owner")
342    pub link_type: String,
343
344    /// Direction of the relationship
345    pub direction: LinkDirection,
346
347    /// The entity type this route connects to
348    pub connected_to: String,
349
350    /// Optional description
351    pub description: Option<String>,
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357    use crate::config::EntityConfig;
358
359    fn create_test_config() -> LinksConfig {
360        LinksConfig {
361            entities: vec![
362                EntityConfig {
363                    singular: "user".to_string(),
364                    plural: "users".to_string(),
365                    auth: crate::config::EntityAuthConfig::default(),
366                },
367                EntityConfig {
368                    singular: "car".to_string(),
369                    plural: "cars".to_string(),
370                    auth: crate::config::EntityAuthConfig::default(),
371                },
372            ],
373            links: vec![
374                LinkDefinition {
375                    link_type: "owner".to_string(),
376                    source_type: "user".to_string(),
377                    target_type: "car".to_string(),
378                    forward_route_name: "cars-owned".to_string(),
379                    reverse_route_name: "users-owners".to_string(),
380                    description: Some("User owns a car".to_string()),
381                    required_fields: None,
382                    auth: None,
383                },
384                LinkDefinition {
385                    link_type: "driver".to_string(),
386                    source_type: "user".to_string(),
387                    target_type: "car".to_string(),
388                    forward_route_name: "cars-driven".to_string(),
389                    reverse_route_name: "users-drivers".to_string(),
390                    description: Some("User drives a car".to_string()),
391                    required_fields: None,
392                    auth: None,
393                },
394            ],
395            validation_rules: None,
396            events: None,
397            sinks: None,
398        }
399    }
400
401    #[test]
402    fn test_resolve_forward_route() {
403        let config = Arc::new(create_test_config());
404        let registry = LinkRouteRegistry::new(config);
405
406        let (def, direction) = registry.resolve_route("user", "cars-owned").unwrap();
407
408        assert_eq!(def.link_type, "owner");
409        assert_eq!(def.source_type, "user");
410        assert_eq!(def.target_type, "car");
411        assert_eq!(direction, LinkDirection::Forward);
412    }
413
414    #[test]
415    fn test_resolve_reverse_route() {
416        let config = Arc::new(create_test_config());
417        let registry = LinkRouteRegistry::new(config);
418
419        let (def, direction) = registry.resolve_route("car", "users-owners").unwrap();
420
421        assert_eq!(def.link_type, "owner");
422        assert_eq!(def.source_type, "user");
423        assert_eq!(def.target_type, "car");
424        assert_eq!(direction, LinkDirection::Reverse);
425    }
426
427    #[test]
428    fn test_list_routes_for_entity() {
429        let config = Arc::new(create_test_config());
430        let registry = LinkRouteRegistry::new(config);
431
432        let routes = registry.list_routes_for_entity("user");
433
434        assert_eq!(routes.len(), 2);
435
436        let route_names: Vec<_> = routes.iter().map(|r| r.route_name.as_str()).collect();
437        assert!(route_names.contains(&"cars-owned"));
438        assert!(route_names.contains(&"cars-driven"));
439    }
440
441    #[test]
442    fn test_no_route_conflicts() {
443        let config = Arc::new(create_test_config());
444        let registry = LinkRouteRegistry::new(config);
445
446        let user_routes = registry.list_routes_for_entity("user");
447        let route_names: Vec<_> = user_routes.iter().map(|r| &r.route_name).collect();
448
449        let unique_names: std::collections::HashSet<_> = route_names.iter().collect();
450        assert_eq!(
451            route_names.len(),
452            unique_names.len(),
453            "Route names must be unique"
454        );
455    }
456
457    // ── Helper: 3-entity chain config (order → invoice → payment) ──
458
459    fn create_chain_config() -> LinksConfig {
460        LinksConfig {
461            entities: vec![
462                EntityConfig {
463                    singular: "order".to_string(),
464                    plural: "orders".to_string(),
465                    auth: crate::config::EntityAuthConfig::default(),
466                },
467                EntityConfig {
468                    singular: "invoice".to_string(),
469                    plural: "invoices".to_string(),
470                    auth: crate::config::EntityAuthConfig::default(),
471                },
472                EntityConfig {
473                    singular: "payment".to_string(),
474                    plural: "payments".to_string(),
475                    auth: crate::config::EntityAuthConfig::default(),
476                },
477            ],
478            links: vec![
479                LinkDefinition {
480                    link_type: "billing".to_string(),
481                    source_type: "order".to_string(),
482                    target_type: "invoice".to_string(),
483                    forward_route_name: "invoices".to_string(),
484                    reverse_route_name: "order".to_string(),
485                    description: None,
486                    required_fields: None,
487                    auth: None,
488                },
489                LinkDefinition {
490                    link_type: "payment".to_string(),
491                    source_type: "invoice".to_string(),
492                    target_type: "payment".to_string(),
493                    forward_route_name: "payments".to_string(),
494                    reverse_route_name: "invoice".to_string(),
495                    description: None,
496                    required_fields: None,
497                    auth: None,
498                },
499            ],
500            validation_rules: None,
501            events: None,
502            sinks: None,
503        }
504    }
505
506    // ── Helper: cycle config (A → B → A) ──
507
508    fn create_cycle_config() -> LinksConfig {
509        LinksConfig {
510            entities: vec![
511                EntityConfig {
512                    singular: "a".to_string(),
513                    plural: "as".to_string(),
514                    auth: crate::config::EntityAuthConfig::default(),
515                },
516                EntityConfig {
517                    singular: "b".to_string(),
518                    plural: "bs".to_string(),
519                    auth: crate::config::EntityAuthConfig::default(),
520                },
521            ],
522            links: vec![
523                LinkDefinition {
524                    link_type: "ab".to_string(),
525                    source_type: "a".to_string(),
526                    target_type: "b".to_string(),
527                    forward_route_name: "bs".to_string(),
528                    reverse_route_name: "as-from-b".to_string(),
529                    description: None,
530                    required_fields: None,
531                    auth: None,
532                },
533                LinkDefinition {
534                    link_type: "ba".to_string(),
535                    source_type: "b".to_string(),
536                    target_type: "a".to_string(),
537                    forward_route_name: "as".to_string(),
538                    reverse_route_name: "bs-from-a".to_string(),
539                    description: None,
540                    required_fields: None,
541                    auth: None,
542                },
543            ],
544            validation_rules: None,
545            events: None,
546            sinks: None,
547        }
548    }
549
550    // ── Helper: empty config ──
551
552    fn create_empty_config() -> LinksConfig {
553        LinksConfig {
554            entities: vec![],
555            links: vec![],
556            validation_rules: None,
557            events: None,
558            sinks: None,
559        }
560    }
561
562    // ======================================================================
563    // detect_link_chains tests
564    // ======================================================================
565
566    #[test]
567    fn test_detect_link_chains_simple_chain() {
568        let config = Arc::new(create_chain_config());
569        let registry = LinkRouteRegistry::new(config);
570
571        let chains = registry.detect_link_chains(5);
572
573        // Forward chains starting from "order" should include order→invoice and order→invoice→payment
574        let forward_from_order: Vec<_> = chains
575            .iter()
576            .filter(|c| {
577                !c.is_reverse()
578                    && c.steps
579                        .first()
580                        .map(|s| s.entity_type == "order")
581                        .unwrap_or(false)
582            })
583            .collect();
584
585        assert!(
586            forward_from_order.len() >= 2,
587            "expected at least 2 forward chains from order (1-step and 2-step), got {}",
588            forward_from_order.len()
589        );
590
591        // There should be a 3-step chain: order → invoice → payment
592        let three_step = forward_from_order
593            .iter()
594            .find(|c| c.steps.len() == 3)
595            .expect("expected a 3-step chain order→invoice→payment");
596
597        assert_eq!(three_step.steps[0].entity_type, "order");
598        assert_eq!(three_step.steps[1].entity_type, "invoice");
599        assert_eq!(three_step.steps[2].entity_type, "payment");
600    }
601
602    #[test]
603    fn test_detect_link_chains_cycle_detection() {
604        let config = Arc::new(create_cycle_config());
605        let registry = LinkRouteRegistry::new(config);
606
607        // This must terminate (cycle detection prevents infinite recursion)
608        let chains = registry.detect_link_chains(10);
609
610        // Should produce chains but not infinitely loop
611        // A→B, A→B→A would be blocked by cycle detection on the edge A->B
612        // So we get A→B and B→A as 2-step chains, but not A→B→A
613        assert!(
614            !chains.is_empty(),
615            "should detect at least some chains even with cycles"
616        );
617
618        // No chain should have duplicate edges (cycle detection guarantee)
619        for chain in &chains {
620            let len = chain.steps.len();
621            assert!(
622                len <= 4,
623                "chain length {} is suspiciously long for a 2-node cycle graph",
624                len
625            );
626        }
627    }
628
629    #[test]
630    fn test_detect_link_chains_max_depth_limits_traversal() {
631        let config = Arc::new(create_chain_config());
632        let registry = LinkRouteRegistry::new(config);
633
634        let chains_depth1 = registry.detect_link_chains(1);
635        let chains_depth5 = registry.detect_link_chains(5);
636
637        // With depth=1 we can only go one hop, so max chain length is 2 steps (source + one hop)
638        for chain in &chains_depth1 {
639            assert!(
640                chain.steps.len() <= 2,
641                "max_depth=1 should limit chains to 2 steps, got {}",
642                chain.steps.len()
643            );
644        }
645
646        // With depth=5, we should get longer chains (the 3-step order→invoice→payment)
647        let has_three_step = chains_depth5.iter().any(|c| c.steps.len() == 3);
648        assert!(has_three_step, "max_depth=5 should allow 3-step chains");
649    }
650
651    #[test]
652    fn test_detect_link_chains_forward_chains_detected() {
653        let config = Arc::new(create_chain_config());
654        let registry = LinkRouteRegistry::new(config);
655
656        let chains = registry.detect_link_chains(5);
657        let forward_chains: Vec<_> = chains.iter().filter(|c| !c.is_reverse()).collect();
658
659        assert!(
660            !forward_chains.is_empty(),
661            "should detect at least one forward chain"
662        );
663
664        // All steps in forward chains should have Forward direction
665        for chain in &forward_chains {
666            for step in &chain.steps {
667                assert_eq!(
668                    step.direction,
669                    LinkDirection::Forward,
670                    "all steps in a forward chain should have Forward direction"
671                );
672            }
673        }
674    }
675
676    #[test]
677    fn test_detect_link_chains_reverse_chains_detected() {
678        let config = Arc::new(create_chain_config());
679        let registry = LinkRouteRegistry::new(config);
680
681        let chains = registry.detect_link_chains(5);
682        let reverse_chains: Vec<_> = chains.iter().filter(|c| c.is_reverse()).collect();
683
684        assert!(
685            !reverse_chains.is_empty(),
686            "should detect at least one reverse chain"
687        );
688
689        // All steps in reverse chains should have Reverse direction
690        for chain in &reverse_chains {
691            for step in &chain.steps {
692                assert_eq!(
693                    step.direction,
694                    LinkDirection::Reverse,
695                    "all steps in a reverse chain should have Reverse direction"
696                );
697            }
698        }
699    }
700
701    #[test]
702    fn test_detect_link_chains_empty_config() {
703        let config = Arc::new(create_empty_config());
704        let registry = LinkRouteRegistry::new(config);
705
706        let chains = registry.detect_link_chains(5);
707        assert!(
708            chains.is_empty(),
709            "empty config should produce no chains, got {}",
710            chains.len()
711        );
712    }
713
714    // ======================================================================
715    // LinkChain::to_route_pattern tests
716    // ======================================================================
717
718    #[test]
719    fn test_to_route_pattern_single_step_chain() {
720        let config = Arc::new(create_chain_config());
721        let registry = LinkRouteRegistry::new(config);
722
723        let chains = registry.detect_link_chains(5);
724
725        // Find a 2-step (single hop) forward chain starting from order
726        let single_hop = chains
727            .iter()
728            .find(|c| {
729                c.steps.len() == 2
730                    && !c.is_reverse()
731                    && c.steps[0].entity_type == "order"
732                    && c.steps[1].entity_type == "invoice"
733            })
734            .expect("expected a 2-step forward chain order→invoice");
735
736        let pattern = single_hop.to_route_pattern();
737
738        // Pattern should be: /orders/{order_id}/invoices
739        assert_eq!(
740            pattern, "/orders/{order_id}/invoices",
741            "single hop pattern mismatch"
742        );
743    }
744
745    #[test]
746    fn test_to_route_pattern_multi_step_chain() {
747        let config = Arc::new(create_chain_config());
748        let registry = LinkRouteRegistry::new(config);
749
750        let chains = registry.detect_link_chains(5);
751
752        // Find the 3-step forward chain: order → invoice → payment
753        let multi_hop = chains
754            .iter()
755            .find(|c| {
756                c.steps.len() == 3
757                    && !c.is_reverse()
758                    && c.steps[0].entity_type == "order"
759                    && c.steps[2].entity_type == "payment"
760            })
761            .expect("expected a 3-step forward chain order→invoice→payment");
762
763        let pattern = multi_hop.to_route_pattern();
764
765        // Pattern should be: /orders/{order_id}/invoices/{invoice_id}/payments
766        assert_eq!(
767            pattern, "/orders/{order_id}/invoices/{invoice_id}/payments",
768            "multi-step pattern mismatch"
769        );
770    }
771
772    #[test]
773    fn test_to_route_pattern_plural_fallback() {
774        // Config with an entity type that is NOT in the entities list
775        // The get_plural fallback should append "s"
776        let config = Arc::new(LinksConfig {
777            entities: vec![
778                EntityConfig {
779                    singular: "widget".to_string(),
780                    plural: "widgets".to_string(),
781                    auth: crate::config::EntityAuthConfig::default(),
782                },
783                // "gadget" entity is deliberately missing from entities list
784            ],
785            links: vec![LinkDefinition {
786                link_type: "contains".to_string(),
787                source_type: "widget".to_string(),
788                target_type: "gadget".to_string(),
789                forward_route_name: "gadgets".to_string(),
790                reverse_route_name: "widget".to_string(),
791                description: None,
792                required_fields: None,
793                auth: None,
794            }],
795            validation_rules: None,
796            events: None,
797            sinks: None,
798        });
799
800        // Manually build a chain with an unknown entity to exercise fallback
801        let chain = LinkChain {
802            steps: vec![
803                LinkChainStep {
804                    entity_type: "unknown_thing".to_string(),
805                    route_name: None,
806                    direction: LinkDirection::Forward,
807                },
808                LinkChainStep {
809                    entity_type: "gadget".to_string(),
810                    route_name: Some("gadgets".to_string()),
811                    direction: LinkDirection::Forward,
812                },
813            ],
814            config,
815        };
816
817        let pattern = chain.to_route_pattern();
818
819        // "unknown_thing" is not in entities, so fallback appends "s" → "unknown_things"
820        // "gadget" is also not in entities → "gadgets" (from fallback)
821        assert_eq!(
822            pattern, "/unknown_things/{unknown_thing_id}/gadgets",
823            "fallback plural should append 's' for unknown entity types"
824        );
825    }
826
827    // ======================================================================
828    // LinkChain::is_reverse tests
829    // ======================================================================
830
831    #[test]
832    fn test_is_reverse_forward_chain() {
833        let config = Arc::new(create_chain_config());
834
835        let chain = LinkChain {
836            steps: vec![
837                LinkChainStep {
838                    entity_type: "order".to_string(),
839                    route_name: None,
840                    direction: LinkDirection::Forward,
841                },
842                LinkChainStep {
843                    entity_type: "invoice".to_string(),
844                    route_name: Some("invoices".to_string()),
845                    direction: LinkDirection::Forward,
846                },
847            ],
848            config,
849        };
850
851        assert!(
852            !chain.is_reverse(),
853            "chain starting with Forward direction should not be reverse"
854        );
855    }
856
857    #[test]
858    fn test_is_reverse_reverse_chain() {
859        let config = Arc::new(create_chain_config());
860
861        let chain = LinkChain {
862            steps: vec![
863                LinkChainStep {
864                    entity_type: "payment".to_string(),
865                    route_name: None,
866                    direction: LinkDirection::Reverse,
867                },
868                LinkChainStep {
869                    entity_type: "invoice".to_string(),
870                    route_name: Some("invoice".to_string()),
871                    direction: LinkDirection::Reverse,
872                },
873            ],
874            config,
875        };
876
877        assert!(
878            chain.is_reverse(),
879            "chain starting with Reverse direction should be reverse"
880        );
881    }
882
883    #[test]
884    fn test_is_reverse_empty_chain() {
885        let config = Arc::new(create_chain_config());
886
887        let chain = LinkChain {
888            steps: vec![],
889            config,
890        };
891
892        assert!(
893            !chain.is_reverse(),
894            "empty chain should return false for is_reverse"
895        );
896    }
897
898    // ======================================================================
899    // Error path tests
900    // ======================================================================
901
902    #[test]
903    fn test_resolve_route_nonexistent() {
904        let config = Arc::new(create_test_config());
905        let registry = LinkRouteRegistry::new(config);
906
907        let result = registry.resolve_route("user", "nonexistent-route");
908        assert!(
909            result.is_err(),
910            "resolving a nonexistent route should return an error"
911        );
912
913        let err_msg = result.unwrap_err().to_string();
914        assert!(
915            err_msg.contains("nonexistent-route"),
916            "error message should contain the route name, got: {}",
917            err_msg
918        );
919        assert!(
920            err_msg.contains("user"),
921            "error message should contain the entity type, got: {}",
922            err_msg
923        );
924    }
925
926    #[test]
927    fn test_list_routes_for_unknown_entity() {
928        let config = Arc::new(create_test_config());
929        let registry = LinkRouteRegistry::new(config);
930
931        let routes = registry.list_routes_for_entity("unknown_type");
932        assert!(
933            routes.is_empty(),
934            "listing routes for an unknown entity should return an empty vec"
935        );
936    }
937
938    #[test]
939    fn test_resolve_route_wrong_entity_type() {
940        // "cars-owned" is a forward route from "user", not from "car"
941        let config = Arc::new(create_test_config());
942        let registry = LinkRouteRegistry::new(config);
943
944        let result = registry.resolve_route("car", "cars-owned");
945        assert!(
946            result.is_err(),
947            "resolving a route with wrong entity type should return an error"
948        );
949    }
950
951    #[test]
952    fn test_config_accessor() {
953        let config = Arc::new(create_test_config());
954        let registry = LinkRouteRegistry::new(config.clone());
955
956        let returned_config = registry.config();
957        assert_eq!(
958            returned_config.entities.len(),
959            config.entities.len(),
960            "config() should return the original configuration"
961        );
962        assert_eq!(
963            returned_config.links.len(),
964            config.links.len(),
965            "config() should return the original configuration"
966        );
967    }
968
969    #[test]
970    fn test_list_routes_for_entity_reverse_direction() {
971        let config = Arc::new(create_test_config());
972        let registry = LinkRouteRegistry::new(config);
973
974        // "car" should have reverse routes: "users-owners" and "users-drivers"
975        let car_routes = registry.list_routes_for_entity("car");
976        assert_eq!(car_routes.len(), 2, "car should have 2 reverse routes");
977
978        for route in &car_routes {
979            assert_eq!(
980                route.direction,
981                LinkDirection::Reverse,
982                "car routes should all be Reverse direction"
983            );
984            assert_eq!(
985                route.connected_to, "user",
986                "car routes should connect to user"
987            );
988        }
989    }
990}