Skip to main content

this/core/
extractors.rs

1//! Axum extractors for entities and links
2//!
3//! This module provides HTTP extractors that automatically:
4//! - Deserialize and validate entities from request bodies
5//! - Parse link routes and resolve definitions
6
7use axum::Json;
8use axum::http::StatusCode;
9use axum::response::{IntoResponse, Response};
10use uuid::Uuid;
11
12use crate::config::LinksConfig;
13use crate::core::LinkDefinition;
14use crate::links::registry::{LinkDirection, LinkRouteRegistry};
15
16/// Errors that can occur during extraction
17#[derive(Debug, Clone)]
18pub enum ExtractorError {
19    InvalidPath,
20    InvalidEntityId,
21    RouteNotFound(String),
22    LinkNotFound,
23    JsonError(String),
24}
25
26impl std::fmt::Display for ExtractorError {
27    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28        match self {
29            ExtractorError::InvalidPath => write!(f, "Invalid path format"),
30            ExtractorError::InvalidEntityId => write!(f, "Invalid entity ID format"),
31            ExtractorError::RouteNotFound(route) => write!(f, "Route not found: {}", route),
32            ExtractorError::LinkNotFound => write!(f, "Link not found"),
33            ExtractorError::JsonError(msg) => write!(f, "JSON error: {}", msg),
34        }
35    }
36}
37
38impl std::error::Error for ExtractorError {}
39
40impl IntoResponse for ExtractorError {
41    fn into_response(self) -> Response {
42        let (status, message) = match self {
43            ExtractorError::InvalidPath => (StatusCode::BAD_REQUEST, self.to_string()),
44            ExtractorError::InvalidEntityId => (StatusCode::BAD_REQUEST, self.to_string()),
45            ExtractorError::RouteNotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
46            ExtractorError::LinkNotFound => (StatusCode::NOT_FOUND, self.to_string()),
47            ExtractorError::JsonError(_) => (StatusCode::BAD_REQUEST, self.to_string()),
48        };
49
50        (status, Json(serde_json::json!({ "error": message }))).into_response()
51    }
52}
53
54/// Extractor for link information from path
55///
56/// Automatically parses the path and resolves link definitions.
57/// Supports both forward and reverse navigation.
58#[derive(Debug, Clone)]
59pub struct LinkExtractor {
60    pub entity_id: Uuid,
61    pub entity_type: String,
62    pub link_definition: LinkDefinition,
63    pub direction: LinkDirection,
64}
65
66impl LinkExtractor {
67    /// Parse a link route path
68    ///
69    /// Expected format: `/{entity_type}/{entity_id}/{route_name}`
70    /// Example: `/users/123.../cars-owned`
71    pub fn from_path_and_registry(
72        path_parts: (String, Uuid, String),
73        registry: &LinkRouteRegistry,
74        config: &LinksConfig,
75    ) -> Result<Self, ExtractorError> {
76        let (entity_type_plural, entity_id, route_name) = path_parts;
77
78        // Convert plural to singular
79        let entity_type = config
80            .entities
81            .iter()
82            .find(|e| e.plural == entity_type_plural)
83            .map(|e| e.singular.clone())
84            .unwrap_or(entity_type_plural);
85
86        // Resolve the route
87        let (link_definition, direction) = registry
88            .resolve_route(&entity_type, &route_name)
89            .map_err(|_| ExtractorError::RouteNotFound(route_name.clone()))?;
90
91        Ok(Self {
92            entity_id,
93            entity_type,
94            link_definition,
95            direction,
96        })
97    }
98}
99
100/// Extractor for direct link creation/deletion/update
101///
102/// Format: `/{source_type}/{source_id}/{route_name}/{target_id}`
103/// Example: `/users/123.../cars-owned/456...`
104///
105/// This uses the route_name (e.g., "cars-owned") instead of link_type (e.g., "owner")
106/// to provide more semantic and RESTful URLs.
107#[derive(Debug, Clone)]
108pub struct DirectLinkExtractor {
109    pub source_id: Uuid,
110    pub source_type: String,
111    pub target_id: Uuid,
112    pub target_type: String,
113    pub link_definition: LinkDefinition,
114    pub direction: LinkDirection,
115}
116
117impl DirectLinkExtractor {
118    /// Parse a direct link path using route_name
119    ///
120    /// path_parts = (source_type_plural, source_id, route_name, target_id)
121    ///
122    /// The route_name is resolved to a link definition using the LinkRouteRegistry,
123    /// which handles both forward and reverse navigation automatically.
124    pub fn from_path(
125        path_parts: (String, Uuid, String, Uuid),
126        registry: &LinkRouteRegistry,
127        config: &LinksConfig,
128    ) -> Result<Self, ExtractorError> {
129        let (source_type_plural, source_id, route_name, target_id) = path_parts;
130
131        // Convert plural to singular
132        let source_type = config
133            .entities
134            .iter()
135            .find(|e| e.plural == source_type_plural)
136            .map(|e| e.singular.clone())
137            .unwrap_or(source_type_plural);
138
139        // Resolve the route to get link definition and direction
140        let (link_definition, direction) = registry
141            .resolve_route(&source_type, &route_name)
142            .map_err(|_| ExtractorError::RouteNotFound(route_name.clone()))?;
143
144        // Determine target type based on direction
145        let target_type = match direction {
146            LinkDirection::Forward => link_definition.target_type.clone(),
147            LinkDirection::Reverse => link_definition.source_type.clone(),
148        };
149
150        Ok(Self {
151            source_id,
152            source_type,
153            target_id,
154            target_type,
155            link_definition,
156            direction,
157        })
158    }
159}
160
161/// Segment d'une chaîne de liens imbriqués
162#[derive(Debug, Clone, serde::Serialize)]
163pub struct LinkPathSegment {
164    /// Type d'entité (singulier)
165    pub entity_type: String,
166    /// ID de l'entité
167    pub entity_id: Uuid,
168    /// Nom de la route (si présent)
169    pub route_name: Option<String>,
170    /// Définition du lien (si présent)
171    pub link_definition: Option<LinkDefinition>,
172    /// Direction du lien (Forward ou Reverse)
173    #[serde(skip_serializing)]
174    pub link_direction: Option<LinkDirection>,
175}
176
177/// Extractor pour chemins imbriqués de profondeur illimitée
178///
179/// Parse dynamiquement des chemins comme:
180/// - /users/123/invoices/456/orders
181/// - /users/123/invoices/456/orders/789/payments/101
182#[derive(Debug, Clone)]
183pub struct RecursiveLinkExtractor {
184    pub chain: Vec<LinkPathSegment>,
185    /// True si le chemin se termine par une route (liste)
186    /// False si le chemin se termine par un ID (item spécifique)
187    pub is_list: bool,
188}
189
190impl RecursiveLinkExtractor {
191    /// Parse un chemin complet dynamiquement
192    pub fn from_segments(
193        segments: Vec<String>,
194        registry: &LinkRouteRegistry,
195        config: &LinksConfig,
196    ) -> Result<Self, ExtractorError> {
197        if segments.len() < 2 {
198            return Err(ExtractorError::InvalidPath);
199        }
200
201        let mut chain = Vec::new();
202        let mut i = 0;
203        let mut current_entity_type: Option<String> = None;
204
205        // Pattern attendu: type/id/route/id/route/id...
206        // Premier segment: toujours un type d'entité
207        while i < segments.len() {
208            // 1. Type d'entité (soit depuis URL pour le 1er, soit depuis link_def pour les suivants)
209            let entity_type_singular = if let Some(ref entity_type) = current_entity_type {
210                // Type connu depuis la résolution précédente
211                entity_type.clone()
212            } else {
213                // Premier segment: lire le type depuis l'URL
214                let entity_type_plural = &segments[i];
215                let singular = config
216                    .entities
217                    .iter()
218                    .find(|e| e.plural == *entity_type_plural)
219                    .map(|e| e.singular.clone())
220                    .ok_or(ExtractorError::InvalidPath)?;
221                i += 1;
222                singular
223            };
224
225            // Reset pour la prochaine itération
226            current_entity_type = None;
227
228            // 2. ID de l'entité (peut ne pas exister si fin du chemin)
229            let entity_id = if i < segments.len() {
230                segments[i]
231                    .parse::<Uuid>()
232                    .map_err(|_| ExtractorError::InvalidEntityId)?
233            } else {
234                // Pas d'ID = liste finale
235                chain.push(LinkPathSegment {
236                    entity_type: entity_type_singular,
237                    entity_id: Uuid::nil(),
238                    route_name: None,
239                    link_definition: None,
240                    link_direction: None,
241                });
242                break;
243            };
244            i += 1;
245
246            // 3. Nom de route (peut ne pas exister si fin du chemin)
247            let route_name = if i < segments.len() {
248                Some(segments[i].clone())
249            } else {
250                None
251            };
252
253            if route_name.is_some() {
254                i += 1;
255            }
256
257            // Résoudre la définition du lien si on a une route
258            let (link_def, link_dir) = if let Some(route_name) = &route_name {
259                let (link_def, direction) = registry
260                    .resolve_route(&entity_type_singular, route_name)
261                    .map_err(|_| ExtractorError::RouteNotFound(route_name.clone()))?;
262
263                // Préparer le type pour la prochaine itération
264                // Pour Forward: on va vers target_type
265                // Pour Reverse: on va vers source_type (car on remonte la chaîne)
266                current_entity_type = Some(match direction {
267                    crate::links::registry::LinkDirection::Forward => link_def.target_type.clone(),
268                    crate::links::registry::LinkDirection::Reverse => link_def.source_type.clone(),
269                });
270
271                (Some(link_def), Some(direction))
272            } else {
273                (None, None)
274            };
275
276            chain.push(LinkPathSegment {
277                entity_type: entity_type_singular,
278                entity_id,
279                route_name,
280                link_definition: link_def,
281                link_direction: link_dir,
282            });
283        }
284
285        // Si current_entity_type est défini, cela signifie que le chemin se termine par une route
286        // et qu'on doit ajouter un segment final pour l'entité cible (liste)
287        if let Some(final_entity_type) = current_entity_type {
288            chain.push(LinkPathSegment {
289                entity_type: final_entity_type,
290                entity_id: Uuid::nil(), // Pas d'ID spécifique = liste
291                route_name: None,
292                link_definition: None,
293                link_direction: None,
294            });
295        }
296
297        // Déterminer si c'est une liste ou un item spécifique
298        // Format: type/id/route/id/route → 5 segments → liste
299        // Format: type/id/route/id/route/id → 6 segments → item
300        // Si impair ≥ 5: liste, si pair ≥ 6: item spécifique
301        let is_list = (segments.len() % 2 == 1) && (segments.len() >= 5);
302
303        Ok(Self { chain, is_list })
304    }
305
306    /// Obtenir l'ID final et le type pour la requête finale
307    pub fn final_target(&self) -> (Uuid, String) {
308        let last = self.chain.last().unwrap();
309        (last.entity_id, last.entity_type.clone())
310    }
311
312    /// Obtenir la définition du dernier lien
313    pub fn final_link_def(&self) -> Option<&LinkDefinition> {
314        // Le dernier segment n'a pas de link_def, le pénultième oui
315        if self.chain.len() >= 2 {
316            self.chain
317                .get(self.chain.len() - 2)
318                .and_then(|s| s.link_definition.as_ref())
319        } else {
320            None
321        }
322    }
323
324    /// Obtenir l'avant-dernier segment (celui qui a le lien)
325    pub fn penultimate_segment(&self) -> Option<&LinkPathSegment> {
326        if self.chain.len() >= 2 {
327            self.chain.get(self.chain.len() - 2)
328        } else {
329            None
330        }
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337    use crate::config::{EntityAuthConfig, EntityConfig, LinksConfig};
338    use crate::core::LinkDefinition;
339    use crate::links::registry::LinkRouteRegistry;
340    use std::sync::Arc;
341    use uuid::Uuid;
342
343    /// Build a minimal LinksConfig + LinkRouteRegistry for testing.
344    /// Entities: user (users), order (orders), invoice (invoices)
345    /// Links: user->order (ownership), order->invoice (billing)
346    fn test_config_and_registry() -> (Arc<LinksConfig>, LinkRouteRegistry) {
347        let config = Arc::new(LinksConfig {
348            entities: vec![
349                EntityConfig {
350                    singular: "user".to_string(),
351                    plural: "users".to_string(),
352                    auth: EntityAuthConfig::default(),
353                },
354                EntityConfig {
355                    singular: "order".to_string(),
356                    plural: "orders".to_string(),
357                    auth: EntityAuthConfig::default(),
358                },
359                EntityConfig {
360                    singular: "invoice".to_string(),
361                    plural: "invoices".to_string(),
362                    auth: EntityAuthConfig::default(),
363                },
364            ],
365            links: vec![
366                LinkDefinition {
367                    link_type: "ownership".to_string(),
368                    source_type: "user".to_string(),
369                    target_type: "order".to_string(),
370                    forward_route_name: "orders-owned".to_string(),
371                    reverse_route_name: "owner".to_string(),
372                    description: None,
373                    required_fields: None,
374                    auth: None,
375                },
376                LinkDefinition {
377                    link_type: "billing".to_string(),
378                    source_type: "order".to_string(),
379                    target_type: "invoice".to_string(),
380                    forward_route_name: "invoices".to_string(),
381                    reverse_route_name: "order".to_string(),
382                    description: None,
383                    required_fields: None,
384                    auth: None,
385                },
386            ],
387            validation_rules: None,
388            events: None,
389            sinks: None,
390        });
391        let registry = LinkRouteRegistry::new(config.clone());
392        (config, registry)
393    }
394
395    // === ExtractorError Display + IntoResponse ===
396
397    #[test]
398    fn test_extractor_error_display_invalid_path() {
399        let err = ExtractorError::InvalidPath;
400        assert_eq!(err.to_string(), "Invalid path format");
401    }
402
403    #[test]
404    fn test_extractor_error_display_invalid_entity_id() {
405        let err = ExtractorError::InvalidEntityId;
406        assert_eq!(err.to_string(), "Invalid entity ID format");
407    }
408
409    #[test]
410    fn test_extractor_error_display_route_not_found() {
411        let err = ExtractorError::RouteNotFound("my-route".to_string());
412        assert_eq!(err.to_string(), "Route not found: my-route");
413    }
414
415    #[test]
416    fn test_extractor_error_display_link_not_found() {
417        let err = ExtractorError::LinkNotFound;
418        assert_eq!(err.to_string(), "Link not found");
419    }
420
421    #[test]
422    fn test_extractor_error_display_json_error() {
423        let err = ExtractorError::JsonError("bad json".to_string());
424        assert_eq!(err.to_string(), "JSON error: bad json");
425    }
426
427    #[test]
428    fn test_extractor_error_into_response_invalid_path_400() {
429        let err = ExtractorError::InvalidPath;
430        let response = err.into_response();
431        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
432    }
433
434    #[test]
435    fn test_extractor_error_into_response_invalid_entity_id_400() {
436        let err = ExtractorError::InvalidEntityId;
437        let response = err.into_response();
438        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
439    }
440
441    #[test]
442    fn test_extractor_error_into_response_route_not_found_404() {
443        let err = ExtractorError::RouteNotFound("test".to_string());
444        let response = err.into_response();
445        assert_eq!(response.status(), StatusCode::NOT_FOUND);
446    }
447
448    #[test]
449    fn test_extractor_error_into_response_link_not_found_404() {
450        let err = ExtractorError::LinkNotFound;
451        let response = err.into_response();
452        assert_eq!(response.status(), StatusCode::NOT_FOUND);
453    }
454
455    #[test]
456    fn test_extractor_error_into_response_json_error_400() {
457        let err = ExtractorError::JsonError("oops".to_string());
458        let response = err.into_response();
459        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
460    }
461
462    // === LinkExtractor ===
463
464    #[test]
465    fn test_link_extractor_forward_route() {
466        let (config, registry) = test_config_and_registry();
467        let id = Uuid::new_v4();
468        let result = LinkExtractor::from_path_and_registry(
469            ("users".to_string(), id, "orders-owned".to_string()),
470            &registry,
471            &config,
472        );
473        assert!(result.is_ok());
474        let ext = result.expect("should succeed");
475        assert_eq!(ext.entity_type, "user");
476        assert_eq!(ext.entity_id, id);
477        assert_eq!(ext.link_definition.link_type, "ownership");
478        assert!(matches!(ext.direction, LinkDirection::Forward));
479    }
480
481    #[test]
482    fn test_link_extractor_reverse_route() {
483        let (config, registry) = test_config_and_registry();
484        let id = Uuid::new_v4();
485        let result = LinkExtractor::from_path_and_registry(
486            ("orders".to_string(), id, "owner".to_string()),
487            &registry,
488            &config,
489        );
490        assert!(result.is_ok());
491        let ext = result.expect("should succeed");
492        assert_eq!(ext.entity_type, "order");
493        assert!(matches!(ext.direction, LinkDirection::Reverse));
494    }
495
496    #[test]
497    fn test_link_extractor_route_not_found() {
498        let (config, registry) = test_config_and_registry();
499        let id = Uuid::new_v4();
500        let result = LinkExtractor::from_path_and_registry(
501            ("users".to_string(), id, "nonexistent".to_string()),
502            &registry,
503            &config,
504        );
505        assert!(result.is_err());
506        assert!(matches!(
507            result.unwrap_err(),
508            ExtractorError::RouteNotFound(_)
509        ));
510    }
511
512    #[test]
513    fn test_link_extractor_plural_to_singular_conversion() {
514        let (config, registry) = test_config_and_registry();
515        let id = Uuid::new_v4();
516        let result = LinkExtractor::from_path_and_registry(
517            ("users".to_string(), id, "orders-owned".to_string()),
518            &registry,
519            &config,
520        );
521        let ext = result.expect("should succeed");
522        // "users" converted to "user"
523        assert_eq!(ext.entity_type, "user");
524    }
525
526    #[test]
527    fn test_link_extractor_unknown_plural_used_as_is() {
528        let (config, registry) = test_config_and_registry();
529        let id = Uuid::new_v4();
530        // "widgets" not in config → used as-is as entity_type
531        let result = LinkExtractor::from_path_and_registry(
532            ("widgets".to_string(), id, "orders-owned".to_string()),
533            &registry,
534            &config,
535        );
536        // Route resolution will likely fail since "widgets" is not a known entity
537        assert!(result.is_err());
538    }
539
540    // === DirectLinkExtractor ===
541
542    #[test]
543    fn test_direct_link_extractor_forward() {
544        let (config, registry) = test_config_and_registry();
545        let source_id = Uuid::new_v4();
546        let target_id = Uuid::new_v4();
547        let result = DirectLinkExtractor::from_path(
548            (
549                "users".to_string(),
550                source_id,
551                "orders-owned".to_string(),
552                target_id,
553            ),
554            &registry,
555            &config,
556        );
557        assert!(result.is_ok());
558        let ext = result.expect("should succeed");
559        assert_eq!(ext.source_type, "user");
560        assert_eq!(ext.source_id, source_id);
561        assert_eq!(ext.target_id, target_id);
562        assert_eq!(ext.target_type, "order"); // Forward → target_type
563        assert!(matches!(ext.direction, LinkDirection::Forward));
564    }
565
566    #[test]
567    fn test_direct_link_extractor_reverse() {
568        let (config, registry) = test_config_and_registry();
569        let source_id = Uuid::new_v4();
570        let target_id = Uuid::new_v4();
571        let result = DirectLinkExtractor::from_path(
572            (
573                "orders".to_string(),
574                source_id,
575                "owner".to_string(),
576                target_id,
577            ),
578            &registry,
579            &config,
580        );
581        assert!(result.is_ok());
582        let ext = result.expect("should succeed");
583        assert_eq!(ext.source_type, "order");
584        assert_eq!(ext.target_type, "user"); // Reverse → source_type
585        assert!(matches!(ext.direction, LinkDirection::Reverse));
586    }
587
588    #[test]
589    fn test_direct_link_extractor_route_not_found() {
590        let (config, registry) = test_config_and_registry();
591        let result = DirectLinkExtractor::from_path(
592            (
593                "users".to_string(),
594                Uuid::new_v4(),
595                "nope".to_string(),
596                Uuid::new_v4(),
597            ),
598            &registry,
599            &config,
600        );
601        assert!(result.is_err());
602        assert!(matches!(
603            result.unwrap_err(),
604            ExtractorError::RouteNotFound(_)
605        ));
606    }
607
608    // === RecursiveLinkExtractor ===
609
610    #[test]
611    fn test_recursive_too_few_segments_error() {
612        let (config, registry) = test_config_and_registry();
613        let result =
614            RecursiveLinkExtractor::from_segments(vec!["users".to_string()], &registry, &config);
615        assert!(result.is_err());
616        assert!(matches!(result.unwrap_err(), ExtractorError::InvalidPath));
617    }
618
619    #[test]
620    fn test_recursive_entity_type_and_id() {
621        let (config, registry) = test_config_and_registry();
622        let id = Uuid::new_v4();
623        let result = RecursiveLinkExtractor::from_segments(
624            vec!["users".to_string(), id.to_string()],
625            &registry,
626            &config,
627        );
628        assert!(result.is_ok());
629        let ext = result.expect("should succeed");
630        assert_eq!(ext.chain.len(), 1);
631        assert_eq!(ext.chain[0].entity_type, "user");
632        assert_eq!(ext.chain[0].entity_id, id);
633    }
634
635    #[test]
636    fn test_recursive_invalid_uuid_error() {
637        let (config, registry) = test_config_and_registry();
638        let result = RecursiveLinkExtractor::from_segments(
639            vec!["users".to_string(), "not-a-uuid".to_string()],
640            &registry,
641            &config,
642        );
643        assert!(result.is_err());
644        assert!(matches!(
645            result.unwrap_err(),
646            ExtractorError::InvalidEntityId
647        ));
648    }
649
650    #[test]
651    fn test_recursive_unknown_entity_type_error() {
652        let (config, registry) = test_config_and_registry();
653        let result = RecursiveLinkExtractor::from_segments(
654            vec!["widgets".to_string(), Uuid::new_v4().to_string()],
655            &registry,
656            &config,
657        );
658        assert!(result.is_err());
659        assert!(matches!(result.unwrap_err(), ExtractorError::InvalidPath));
660    }
661
662    #[test]
663    fn test_recursive_entity_id_route_forward() {
664        let (config, registry) = test_config_and_registry();
665        let user_id = Uuid::new_v4();
666        let result = RecursiveLinkExtractor::from_segments(
667            vec![
668                "users".to_string(),
669                user_id.to_string(),
670                "orders-owned".to_string(),
671            ],
672            &registry,
673            &config,
674        );
675        assert!(result.is_ok());
676        let ext = result.expect("should succeed");
677        // Chain: user(user_id, route=orders-owned) → order(nil, list)
678        assert_eq!(ext.chain.len(), 2);
679        assert_eq!(ext.chain[0].entity_type, "user");
680        assert_eq!(ext.chain[0].entity_id, user_id);
681        assert_eq!(ext.chain[0].route_name.as_deref(), Some("orders-owned"));
682        assert_eq!(
683            ext.chain[0]
684                .link_definition
685                .as_ref()
686                .expect("should have link_def")
687                .link_type,
688            "ownership"
689        );
690        assert_eq!(ext.chain[1].entity_type, "order");
691        assert!(ext.chain[1].entity_id.is_nil()); // list segment
692    }
693
694    #[test]
695    fn test_recursive_multi_level_chain() {
696        let (config, registry) = test_config_and_registry();
697        let user_id = Uuid::new_v4();
698        let order_id = Uuid::new_v4();
699        let result = RecursiveLinkExtractor::from_segments(
700            vec![
701                "users".to_string(),
702                user_id.to_string(),
703                "orders-owned".to_string(),
704                order_id.to_string(),
705                "invoices".to_string(),
706            ],
707            &registry,
708            &config,
709        );
710        assert!(result.is_ok());
711        let ext = result.expect("should succeed");
712        // Chain: user(user_id) → order(order_id) → invoice(nil, list)
713        assert_eq!(ext.chain.len(), 3);
714        assert_eq!(ext.chain[0].entity_type, "user");
715        assert_eq!(ext.chain[0].entity_id, user_id);
716        assert_eq!(ext.chain[1].entity_type, "order");
717        assert_eq!(ext.chain[1].entity_id, order_id);
718        assert_eq!(ext.chain[2].entity_type, "invoice");
719        assert!(ext.is_list); // 5 segments → list
720    }
721
722    #[test]
723    fn test_recursive_multi_level_specific_item() {
724        let (config, registry) = test_config_and_registry();
725        let user_id = Uuid::new_v4();
726        let order_id = Uuid::new_v4();
727        let invoice_id = Uuid::new_v4();
728        let result = RecursiveLinkExtractor::from_segments(
729            vec![
730                "users".to_string(),
731                user_id.to_string(),
732                "orders-owned".to_string(),
733                order_id.to_string(),
734                "invoices".to_string(),
735                invoice_id.to_string(),
736            ],
737            &registry,
738            &config,
739        );
740        assert!(result.is_ok());
741        let ext = result.expect("should succeed");
742        assert_eq!(ext.chain.len(), 3);
743        assert_eq!(ext.chain[2].entity_id, invoice_id);
744        assert!(!ext.is_list); // 6 segments → specific item
745    }
746
747    #[test]
748    fn test_recursive_route_not_found_mid_chain() {
749        let (config, registry) = test_config_and_registry();
750        let result = RecursiveLinkExtractor::from_segments(
751            vec![
752                "users".to_string(),
753                Uuid::new_v4().to_string(),
754                "nonexistent-route".to_string(),
755            ],
756            &registry,
757            &config,
758        );
759        assert!(result.is_err());
760        assert!(matches!(
761            result.unwrap_err(),
762            ExtractorError::RouteNotFound(_)
763        ));
764    }
765
766    #[test]
767    fn test_recursive_reverse_direction_propagation() {
768        let (config, registry) = test_config_and_registry();
769        let order_id = Uuid::new_v4();
770        // orders/{id}/owner → reverse → navigates to user
771        let result = RecursiveLinkExtractor::from_segments(
772            vec![
773                "orders".to_string(),
774                order_id.to_string(),
775                "owner".to_string(),
776            ],
777            &registry,
778            &config,
779        );
780        assert!(result.is_ok());
781        let ext = result.expect("should succeed");
782        assert_eq!(ext.chain.len(), 2);
783        assert_eq!(ext.chain[0].entity_type, "order");
784        assert!(matches!(
785            ext.chain[0].link_direction,
786            Some(LinkDirection::Reverse)
787        ));
788        // Reverse direction → target entity is source_type (user)
789        assert_eq!(ext.chain[1].entity_type, "user");
790    }
791
792    // === final_target / final_link_def / penultimate_segment ===
793
794    #[test]
795    fn test_final_target_returns_last_segment() {
796        let (config, registry) = test_config_and_registry();
797        let user_id = Uuid::new_v4();
798        let ext = RecursiveLinkExtractor::from_segments(
799            vec![
800                "users".to_string(),
801                user_id.to_string(),
802                "orders-owned".to_string(),
803            ],
804            &registry,
805            &config,
806        )
807        .expect("should succeed");
808        let (id, entity_type) = ext.final_target();
809        assert_eq!(entity_type, "order");
810        assert!(id.is_nil()); // list target
811    }
812
813    #[test]
814    fn test_final_link_def_returns_penultimate_link() {
815        let (config, registry) = test_config_and_registry();
816        let user_id = Uuid::new_v4();
817        let ext = RecursiveLinkExtractor::from_segments(
818            vec![
819                "users".to_string(),
820                user_id.to_string(),
821                "orders-owned".to_string(),
822            ],
823            &registry,
824            &config,
825        )
826        .expect("should succeed");
827        let link_def = ext.final_link_def();
828        assert!(link_def.is_some());
829        assert_eq!(link_def.expect("should have link").link_type, "ownership");
830    }
831
832    #[test]
833    fn test_final_link_def_single_segment_returns_none() {
834        let (config, registry) = test_config_and_registry();
835        let ext = RecursiveLinkExtractor::from_segments(
836            vec!["users".to_string(), Uuid::new_v4().to_string()],
837            &registry,
838            &config,
839        )
840        .expect("should succeed");
841        assert!(ext.final_link_def().is_none());
842    }
843
844    #[test]
845    fn test_penultimate_segment_returns_correct() {
846        let (config, registry) = test_config_and_registry();
847        let user_id = Uuid::new_v4();
848        let ext = RecursiveLinkExtractor::from_segments(
849            vec![
850                "users".to_string(),
851                user_id.to_string(),
852                "orders-owned".to_string(),
853            ],
854            &registry,
855            &config,
856        )
857        .expect("should succeed");
858        let pen = ext.penultimate_segment();
859        assert!(pen.is_some());
860        assert_eq!(pen.expect("should exist").entity_type, "user");
861        assert_eq!(pen.expect("should exist").entity_id, user_id);
862    }
863
864    #[test]
865    fn test_penultimate_segment_single_segment_returns_none() {
866        let (config, registry) = test_config_and_registry();
867        let ext = RecursiveLinkExtractor::from_segments(
868            vec!["users".to_string(), Uuid::new_v4().to_string()],
869            &registry,
870            &config,
871        )
872        .expect("should succeed");
873        assert!(ext.penultimate_segment().is_none());
874    }
875}