Skip to main content

static_tr_plugin/domain/
service.rs

1//! Domain service for the static tenant resolver plugin.
2
3use std::collections::{HashMap, HashSet};
4
5use modkit_macros::domain_model;
6use tenant_resolver_sdk::{
7    BarrierMode, TenantId, TenantInfo, TenantRef, TenantResolverError, TenantStatus, matches_status,
8};
9
10use crate::config::StaticTrPluginConfig;
11
12/// Static tenant resolver service.
13///
14/// Stores tenant data in memory, loaded from configuration.
15/// Supports hierarchical tenant model with parent-child relationships.
16#[domain_model]
17pub struct Service {
18    /// Tenant info by ID.
19    pub(super) tenants: HashMap<TenantId, TenantInfo>,
20
21    /// Children index: `parent_id` -> list of child tenant IDs.
22    pub(super) children: HashMap<TenantId, Vec<TenantId>>,
23}
24
25impl Service {
26    /// Creates a new service from configuration.
27    #[must_use]
28    pub fn from_config(cfg: &StaticTrPluginConfig) -> Self {
29        let tenants: HashMap<TenantId, TenantInfo> = cfg
30            .tenants
31            .iter()
32            .map(|t| {
33                (
34                    t.id,
35                    TenantInfo {
36                        id: t.id,
37                        name: t.name.clone(),
38                        status: t.status,
39                        tenant_type: t.tenant_type.clone(),
40                        parent_id: t.parent_id,
41                        self_managed: t.self_managed,
42                    },
43                )
44            })
45            .collect();
46
47        // Build children index
48        let mut children: HashMap<TenantId, Vec<TenantId>> = HashMap::new();
49        for tenant in tenants.values() {
50            if let Some(parent_id) = tenant.parent_id {
51                children.entry(parent_id).or_default().push(tenant.id);
52            }
53        }
54
55        Self { tenants, children }
56    }
57
58    /// Check if a tenant matches the status filter.
59    pub(super) fn matches_status_filter(tenant: &TenantInfo, statuses: &[TenantStatus]) -> bool {
60        matches_status(tenant, statuses)
61    }
62
63    /// Collect ancestors from a tenant to root.
64    ///
65    /// Returns ancestors ordered from direct parent to root.
66    /// Stops at barriers unless `barrier_mode` is `Ignore`.
67    /// If the starting tenant itself is a barrier, returns empty (consistent
68    /// with `is_ancestor` returning `false` for barrier descendants).
69    ///
70    /// Note: The starting tenant is NOT included in the result.
71    pub(super) fn collect_ancestors(
72        &self,
73        id: TenantId,
74        barrier_mode: BarrierMode,
75    ) -> Vec<TenantRef> {
76        let mut ancestors = Vec::new();
77        let mut visited = HashSet::new();
78        visited.insert(id);
79
80        // Start from the tenant's parent
81        let Some(tenant) = self.tenants.get(&id) else {
82            return ancestors;
83        };
84
85        // If the starting tenant is a barrier, it cannot see its parent chain
86        if barrier_mode == BarrierMode::Respect && tenant.self_managed {
87            return ancestors;
88        }
89
90        let mut current_parent_id = tenant.parent_id;
91
92        while let Some(parent_id) = current_parent_id {
93            if !visited.insert(parent_id) {
94                break; // Cycle detected
95            }
96
97            let Some(parent) = self.tenants.get(&parent_id) else {
98                break;
99            };
100
101            // Barrier semantics: include the barrier tenant, but stop traversal
102            // at it (don't continue to its parent).
103            ancestors.push(parent.into());
104
105            if barrier_mode == BarrierMode::Respect && parent.self_managed {
106                break;
107            }
108
109            current_parent_id = parent.parent_id;
110        }
111
112        ancestors
113    }
114
115    /// Collect descendants subtree using pre-order traversal.
116    ///
117    /// Returns descendants (not including the starting tenant) in pre-order:
118    /// parent is visited before children.
119    ///
120    /// Traversal stops when:
121    /// - `self_managed` barrier is encountered (unless `barrier_mode` is `Ignore`)
122    /// - Node doesn't pass the filter (filtered nodes and their subtrees are excluded)
123    /// - `max_depth` is reached
124    pub(super) fn collect_descendants(
125        &self,
126        id: TenantId,
127        statuses: &[TenantStatus],
128        barrier_mode: BarrierMode,
129        max_depth: Option<u32>,
130    ) -> Vec<TenantRef> {
131        let mut collector = DescendantCollector {
132            tenants: &self.tenants,
133            children: &self.children,
134            statuses,
135            barrier_mode,
136            max_depth,
137            result: Vec::new(),
138            visited: HashSet::new(),
139        };
140        collector.visited.insert(id);
141        collector.collect(id, 1);
142        collector.result
143    }
144
145    /// Check if `ancestor_id` is an ancestor of `descendant_id`.
146    ///
147    /// Returns `true` if `ancestor_id` is in the parent chain of `descendant_id`.
148    /// Returns `false` if `ancestor_id == descendant_id` (self is not an ancestor of self).
149    ///
150    /// Respects barriers: if there's a barrier between them, returns `false`.
151    ///
152    /// # Errors
153    ///
154    /// Returns `TenantNotFound` if either tenant does not exist.
155    pub(super) fn is_ancestor_of(
156        &self,
157        ancestor_id: TenantId,
158        descendant_id: TenantId,
159        barrier_mode: BarrierMode,
160    ) -> Result<bool, TenantResolverError> {
161        // Self is NOT an ancestor of self
162        if ancestor_id == descendant_id {
163            if self.tenants.contains_key(&ancestor_id) {
164                return Ok(false);
165            }
166            return Err(TenantResolverError::TenantNotFound {
167                tenant_id: ancestor_id,
168            });
169        }
170
171        // Check both tenants exist
172        if !self.tenants.contains_key(&ancestor_id) {
173            return Err(TenantResolverError::TenantNotFound {
174                tenant_id: ancestor_id,
175            });
176        }
177
178        let descendant =
179            self.tenants
180                .get(&descendant_id)
181                .ok_or(TenantResolverError::TenantNotFound {
182                    tenant_id: descendant_id,
183                })?;
184
185        // If the descendant itself is a barrier, the ancestor cannot claim
186        // parentage — consistent with get_descendants excluding barriers.
187        if barrier_mode == BarrierMode::Respect && descendant.self_managed {
188            return Ok(false);
189        }
190
191        // Walk up the chain from descendant
192        let mut visited = HashSet::new();
193        visited.insert(descendant_id);
194        let mut current_parent_id = descendant.parent_id;
195
196        while let Some(parent_id) = current_parent_id {
197            if !visited.insert(parent_id) {
198                break; // Cycle detected
199            }
200
201            let Some(parent) = self.tenants.get(&parent_id) else {
202                break;
203            };
204
205            // Found the ancestor
206            if parent_id == ancestor_id {
207                return Ok(true);
208            }
209
210            // Barrier semantics: if the parent is self_managed and not the target
211            // ancestor, traversal is blocked beyond this point.
212            if barrier_mode == BarrierMode::Respect && parent.self_managed {
213                return Ok(false);
214            }
215
216            current_parent_id = parent.parent_id;
217        }
218
219        // Reached root without finding ancestor
220        Ok(false)
221    }
222}
223
224/// Encapsulates traversal state for collecting descendants.
225///
226/// Eliminates the need for passing many arguments through recursive calls.
227#[domain_model]
228struct DescendantCollector<'a> {
229    tenants: &'a HashMap<TenantId, TenantInfo>,
230    children: &'a HashMap<TenantId, Vec<TenantId>>,
231    statuses: &'a [TenantStatus],
232    barrier_mode: BarrierMode,
233    max_depth: Option<u32>,
234    result: Vec<TenantRef>,
235    visited: HashSet<TenantId>,
236}
237
238impl DescendantCollector<'_> {
239    fn collect(&mut self, parent_id: TenantId, current_depth: u32) {
240        // Check depth limit (None = unlimited)
241        if self.max_depth.is_some_and(|d| current_depth > d) {
242            return;
243        }
244
245        let Some(child_ids) = self.children.get(&parent_id) else {
246            return;
247        };
248
249        for child_id in child_ids {
250            if !self.visited.insert(*child_id) {
251                continue;
252            }
253
254            let Some(child) = self.tenants.get(child_id) else {
255                continue;
256            };
257
258            // If respecting barriers and this child is self_managed, skip it and its subtree
259            if self.barrier_mode == BarrierMode::Respect && child.self_managed {
260                continue;
261            }
262
263            // If child doesn't pass status filter, skip it AND its subtree
264            if !Service::matches_status_filter(child, self.statuses) {
265                continue;
266            }
267
268            self.result.push(child.into());
269
270            // Recurse into children
271            self.collect(*child_id, current_depth + 1);
272        }
273    }
274}
275
276#[cfg(test)]
277#[cfg_attr(coverage_nightly, coverage(off))]
278mod tests {
279    use super::*;
280    use crate::config::TenantConfig;
281    use tenant_resolver_sdk::TenantStatus;
282    use uuid::Uuid;
283
284    // Helper to create a test tenant config
285    fn tenant(id: &str, name: &str, status: TenantStatus) -> TenantConfig {
286        TenantConfig {
287            id: Uuid::parse_str(id).unwrap(),
288            name: name.to_owned(),
289            status,
290            tenant_type: None,
291            parent_id: None,
292            self_managed: false,
293        }
294    }
295
296    fn tenant_with_parent(id: &str, name: &str, parent: &str) -> TenantConfig {
297        TenantConfig {
298            id: Uuid::parse_str(id).unwrap(),
299            name: name.to_owned(),
300            status: TenantStatus::Active,
301            tenant_type: None,
302            parent_id: Some(Uuid::parse_str(parent).unwrap()),
303            self_managed: false,
304        }
305    }
306
307    fn tenant_barrier(id: &str, name: &str, parent: &str) -> TenantConfig {
308        TenantConfig {
309            id: Uuid::parse_str(id).unwrap(),
310            name: name.to_owned(),
311            status: TenantStatus::Active,
312            tenant_type: None,
313            parent_id: Some(Uuid::parse_str(parent).unwrap()),
314            self_managed: true,
315        }
316    }
317
318    // Test UUIDs
319    const TENANT_A: &str = "11111111-1111-1111-1111-111111111111";
320    const TENANT_B: &str = "22222222-2222-2222-2222-222222222222";
321    const TENANT_C: &str = "33333333-3333-3333-3333-333333333333";
322    const TENANT_D: &str = "44444444-4444-4444-4444-444444444444";
323
324    // ==================== from_config tests ====================
325
326    #[test]
327    fn from_config_empty() {
328        let cfg = StaticTrPluginConfig::default();
329        let service = Service::from_config(&cfg);
330
331        assert!(service.tenants.is_empty());
332        assert!(service.children.is_empty());
333    }
334
335    #[test]
336    fn from_config_with_tenants_only() {
337        let cfg = StaticTrPluginConfig {
338            tenants: vec![
339                tenant(TENANT_A, "Tenant A", TenantStatus::Active),
340                tenant(TENANT_B, "Tenant B", TenantStatus::Suspended),
341            ],
342            ..Default::default()
343        };
344        let service = Service::from_config(&cfg);
345
346        assert_eq!(service.tenants.len(), 2);
347        assert!(service.children.is_empty()); // No parent-child relationships
348
349        let a = service
350            .tenants
351            .get(&Uuid::parse_str(TENANT_A).unwrap())
352            .unwrap();
353        assert_eq!(a.name, "Tenant A");
354        assert_eq!(a.status, TenantStatus::Active);
355        assert!(a.parent_id.is_none());
356        assert!(!a.self_managed);
357    }
358
359    #[test]
360    fn from_config_with_hierarchy() {
361        // A -> B -> C (linear hierarchy)
362        let cfg = StaticTrPluginConfig {
363            tenants: vec![
364                tenant(TENANT_A, "Root", TenantStatus::Active),
365                tenant_with_parent(TENANT_B, "Child", TENANT_A),
366                tenant_with_parent(TENANT_C, "Grandchild", TENANT_B),
367            ],
368            ..Default::default()
369        };
370        let service = Service::from_config(&cfg);
371
372        assert_eq!(service.tenants.len(), 3);
373
374        // Check children index
375        let a_id = Uuid::parse_str(TENANT_A).unwrap();
376        let b_id = Uuid::parse_str(TENANT_B).unwrap();
377        let c_id = Uuid::parse_str(TENANT_C).unwrap();
378
379        let a_children = service.children.get(&a_id).unwrap();
380        assert_eq!(a_children.len(), 1);
381        assert!(a_children.contains(&b_id));
382
383        let b_children = service.children.get(&b_id).unwrap();
384        assert_eq!(b_children.len(), 1);
385        assert!(b_children.contains(&c_id));
386
387        // C has no children
388        assert!(!service.children.contains_key(&c_id));
389    }
390
391    // ==================== collect_ancestors tests ====================
392
393    #[test]
394    fn collect_ancestors_root_tenant() {
395        let cfg = StaticTrPluginConfig {
396            tenants: vec![tenant(TENANT_A, "Root", TenantStatus::Active)],
397            ..Default::default()
398        };
399        let service = Service::from_config(&cfg);
400        let a_id = Uuid::parse_str(TENANT_A).unwrap();
401
402        let ancestors = service.collect_ancestors(a_id, BarrierMode::Respect);
403        assert!(ancestors.is_empty());
404    }
405
406    #[test]
407    fn collect_ancestors_linear_hierarchy() {
408        // A -> B -> C
409        let cfg = StaticTrPluginConfig {
410            tenants: vec![
411                tenant(TENANT_A, "Root", TenantStatus::Active),
412                tenant_with_parent(TENANT_B, "Child", TENANT_A),
413                tenant_with_parent(TENANT_C, "Grandchild", TENANT_B),
414            ],
415            ..Default::default()
416        };
417        let service = Service::from_config(&cfg);
418
419        let a_id = Uuid::parse_str(TENANT_A).unwrap();
420        let b_id = Uuid::parse_str(TENANT_B).unwrap();
421        let c_id = Uuid::parse_str(TENANT_C).unwrap();
422
423        // Ancestors of C should be [B, A]
424        let ancestors = service.collect_ancestors(c_id, BarrierMode::Respect);
425        assert_eq!(ancestors.len(), 2);
426        assert_eq!(ancestors[0].id, b_id);
427        assert_eq!(ancestors[1].id, a_id);
428
429        // Ancestors of B should be [A]
430        let ancestors = service.collect_ancestors(b_id, BarrierMode::Respect);
431        assert_eq!(ancestors.len(), 1);
432        assert_eq!(ancestors[0].id, a_id);
433    }
434
435    #[test]
436    fn collect_ancestors_with_barrier() {
437        // A -> B (barrier) -> C
438        let cfg = StaticTrPluginConfig {
439            tenants: vec![
440                tenant(TENANT_A, "Root", TenantStatus::Active),
441                tenant_barrier(TENANT_B, "Barrier", TENANT_A),
442                tenant_with_parent(TENANT_C, "Grandchild", TENANT_B),
443            ],
444            ..Default::default()
445        };
446        let service = Service::from_config(&cfg);
447
448        let a_id = Uuid::parse_str(TENANT_A).unwrap();
449        let b_id = Uuid::parse_str(TENANT_B).unwrap();
450        let c_id = Uuid::parse_str(TENANT_C).unwrap();
451
452        // With BarrierMode::Respect, ancestors of C stop at B
453        let ancestors = service.collect_ancestors(c_id, BarrierMode::Respect);
454        assert_eq!(ancestors.len(), 1);
455        assert_eq!(ancestors[0].id, b_id);
456
457        // With BarrierMode::Ignore, ancestors of C include both B and A
458        let ancestors = service.collect_ancestors(c_id, BarrierMode::Ignore);
459        assert_eq!(ancestors.len(), 2);
460        assert_eq!(ancestors[0].id, b_id);
461        assert_eq!(ancestors[1].id, a_id);
462    }
463
464    #[test]
465    fn collect_ancestors_starting_tenant_is_barrier() {
466        // A -> B (barrier)
467        // get_ancestors(B) should return empty because B is a barrier
468        let cfg = StaticTrPluginConfig {
469            tenants: vec![
470                tenant(TENANT_A, "Root", TenantStatus::Active),
471                tenant_barrier(TENANT_B, "Barrier", TENANT_A),
472            ],
473            ..Default::default()
474        };
475        let service = Service::from_config(&cfg);
476
477        let a_id = Uuid::parse_str(TENANT_A).unwrap();
478        let b_id = Uuid::parse_str(TENANT_B).unwrap();
479
480        // With BarrierMode::Respect, B cannot see its parent chain
481        let ancestors = service.collect_ancestors(b_id, BarrierMode::Respect);
482        assert!(ancestors.is_empty());
483
484        // With BarrierMode::Ignore, B can see A
485        let ancestors = service.collect_ancestors(b_id, BarrierMode::Ignore);
486        assert_eq!(ancestors.len(), 1);
487        assert_eq!(ancestors[0].id, a_id);
488    }
489
490    // ==================== collect_descendants tests ====================
491
492    #[test]
493    fn collect_descendants_no_children() {
494        let cfg = StaticTrPluginConfig {
495            tenants: vec![tenant(TENANT_A, "Root", TenantStatus::Active)],
496            ..Default::default()
497        };
498        let service = Service::from_config(&cfg);
499        let a_id = Uuid::parse_str(TENANT_A).unwrap();
500
501        let descendants = service.collect_descendants(a_id, &[], BarrierMode::Respect, None);
502        assert!(descendants.is_empty());
503    }
504
505    #[test]
506    fn collect_descendants_linear_hierarchy() {
507        // A -> B -> C
508        let cfg = StaticTrPluginConfig {
509            tenants: vec![
510                tenant(TENANT_A, "Root", TenantStatus::Active),
511                tenant_with_parent(TENANT_B, "Child", TENANT_A),
512                tenant_with_parent(TENANT_C, "Grandchild", TENANT_B),
513            ],
514            ..Default::default()
515        };
516        let service = Service::from_config(&cfg);
517
518        let a_id = Uuid::parse_str(TENANT_A).unwrap();
519        let b_id = Uuid::parse_str(TENANT_B).unwrap();
520        let c_id = Uuid::parse_str(TENANT_C).unwrap();
521
522        // Descendants of A (unlimited depth)
523        let descendants = service.collect_descendants(a_id, &[], BarrierMode::Respect, None);
524        assert_eq!(descendants.len(), 2);
525        // Pre-order: B first, then C
526        assert_eq!(descendants[0].id, b_id);
527        assert_eq!(descendants[1].id, c_id);
528
529        // Descendants of A (depth 1 = direct children only)
530        let descendants = service.collect_descendants(a_id, &[], BarrierMode::Respect, Some(1));
531        assert_eq!(descendants.len(), 1);
532        assert_eq!(descendants[0].id, b_id);
533    }
534
535    #[test]
536    fn collect_descendants_with_barrier() {
537        // A -> B (barrier) -> C
538        let cfg = StaticTrPluginConfig {
539            tenants: vec![
540                tenant(TENANT_A, "Root", TenantStatus::Active),
541                tenant_barrier(TENANT_B, "Barrier", TENANT_A),
542                tenant_with_parent(TENANT_C, "Grandchild", TENANT_B),
543            ],
544            ..Default::default()
545        };
546        let service = Service::from_config(&cfg);
547
548        let a_id = Uuid::parse_str(TENANT_A).unwrap();
549        let b_id = Uuid::parse_str(TENANT_B).unwrap();
550        let c_id = Uuid::parse_str(TENANT_C).unwrap();
551
552        // With BarrierMode::Respect, descendants of A exclude B (barrier) and its subtree
553        let descendants = service.collect_descendants(a_id, &[], BarrierMode::Respect, None);
554        assert!(descendants.is_empty());
555
556        // With BarrierMode::Ignore, descendants include B and C
557        let descendants = service.collect_descendants(a_id, &[], BarrierMode::Ignore, None);
558        assert_eq!(descendants.len(), 2);
559        assert_eq!(descendants[0].id, b_id);
560        assert_eq!(descendants[1].id, c_id);
561    }
562
563    #[test]
564    fn collect_descendants_mixed_barrier() {
565        // A -> B (barrier) -> C
566        //   -> D (no barrier)
567        let cfg = StaticTrPluginConfig {
568            tenants: vec![
569                tenant(TENANT_A, "Root", TenantStatus::Active),
570                tenant_barrier(TENANT_B, "Barrier", TENANT_A),
571                tenant_with_parent(TENANT_C, "Under Barrier", TENANT_B),
572                tenant_with_parent(TENANT_D, "Normal Child", TENANT_A),
573            ],
574            ..Default::default()
575        };
576        let service = Service::from_config(&cfg);
577
578        let a_id = Uuid::parse_str(TENANT_A).unwrap();
579        let d_id = Uuid::parse_str(TENANT_D).unwrap();
580
581        // With BarrierMode::Respect, only D is visible
582        let descendants = service.collect_descendants(a_id, &[], BarrierMode::Respect, None);
583        assert_eq!(descendants.len(), 1);
584        assert_eq!(descendants[0].id, d_id);
585    }
586
587    // ==================== is_ancestor_of tests ====================
588
589    #[test]
590    fn is_ancestor_of_self() {
591        let cfg = StaticTrPluginConfig {
592            tenants: vec![tenant(TENANT_A, "Root", TenantStatus::Active)],
593            ..Default::default()
594        };
595        let service = Service::from_config(&cfg);
596        let a_id = Uuid::parse_str(TENANT_A).unwrap();
597
598        // Self is NOT an ancestor of self
599        assert!(
600            !service
601                .is_ancestor_of(a_id, a_id, BarrierMode::Respect)
602                .unwrap()
603        );
604    }
605
606    #[test]
607    fn is_ancestor_of_direct_parent() {
608        let cfg = StaticTrPluginConfig {
609            tenants: vec![
610                tenant(TENANT_A, "Root", TenantStatus::Active),
611                tenant_with_parent(TENANT_B, "Child", TENANT_A),
612            ],
613            ..Default::default()
614        };
615        let service = Service::from_config(&cfg);
616
617        let a_id = Uuid::parse_str(TENANT_A).unwrap();
618        let b_id = Uuid::parse_str(TENANT_B).unwrap();
619
620        assert!(
621            service
622                .is_ancestor_of(a_id, b_id, BarrierMode::Respect)
623                .unwrap()
624        );
625        assert!(
626            !service
627                .is_ancestor_of(b_id, a_id, BarrierMode::Respect)
628                .unwrap()
629        );
630    }
631
632    #[test]
633    fn is_ancestor_of_grandparent() {
634        // A -> B -> C
635        let cfg = StaticTrPluginConfig {
636            tenants: vec![
637                tenant(TENANT_A, "Root", TenantStatus::Active),
638                tenant_with_parent(TENANT_B, "Child", TENANT_A),
639                tenant_with_parent(TENANT_C, "Grandchild", TENANT_B),
640            ],
641            ..Default::default()
642        };
643        let service = Service::from_config(&cfg);
644
645        let a_id = Uuid::parse_str(TENANT_A).unwrap();
646        let c_id = Uuid::parse_str(TENANT_C).unwrap();
647
648        assert!(
649            service
650                .is_ancestor_of(a_id, c_id, BarrierMode::Respect)
651                .unwrap()
652        );
653    }
654
655    #[test]
656    fn is_ancestor_of_with_barrier() {
657        // A -> B (barrier) -> C
658        let cfg = StaticTrPluginConfig {
659            tenants: vec![
660                tenant(TENANT_A, "Root", TenantStatus::Active),
661                tenant_barrier(TENANT_B, "Barrier", TENANT_A),
662                tenant_with_parent(TENANT_C, "Grandchild", TENANT_B),
663            ],
664            ..Default::default()
665        };
666        let service = Service::from_config(&cfg);
667
668        let a_id = Uuid::parse_str(TENANT_A).unwrap();
669        let b_id = Uuid::parse_str(TENANT_B).unwrap();
670        let c_id = Uuid::parse_str(TENANT_C).unwrap();
671
672        // B is direct parent of C - no barrier crossed
673        assert!(
674            service
675                .is_ancestor_of(b_id, c_id, BarrierMode::Respect)
676                .unwrap()
677        );
678
679        // A is blocked by barrier B
680        assert!(
681            !service
682                .is_ancestor_of(a_id, c_id, BarrierMode::Respect)
683                .unwrap()
684        );
685
686        // With BarrierMode::Ignore, A is ancestor of C
687        assert!(
688            service
689                .is_ancestor_of(a_id, c_id, BarrierMode::Ignore)
690                .unwrap()
691        );
692    }
693
694    #[test]
695    fn is_ancestor_of_direct_barrier_child() {
696        // A -> B (barrier)
697        // is_ancestor(A, B) should return false because B is a barrier
698        // (consistent with get_descendants(A) excluding B)
699        let cfg = StaticTrPluginConfig {
700            tenants: vec![
701                tenant(TENANT_A, "Root", TenantStatus::Active),
702                tenant_barrier(TENANT_B, "Barrier", TENANT_A),
703            ],
704            ..Default::default()
705        };
706        let service = Service::from_config(&cfg);
707
708        let a_id = Uuid::parse_str(TENANT_A).unwrap();
709        let b_id = Uuid::parse_str(TENANT_B).unwrap();
710
711        // A is NOT ancestor of B when B is a barrier (BarrierMode::Respect)
712        assert!(
713            !service
714                .is_ancestor_of(a_id, b_id, BarrierMode::Respect)
715                .unwrap()
716        );
717
718        // With BarrierMode::Ignore, A IS ancestor of B
719        assert!(
720            service
721                .is_ancestor_of(a_id, b_id, BarrierMode::Ignore)
722                .unwrap()
723        );
724
725        // B is NOT ancestor of itself (self is not an ancestor of self)
726        assert!(
727            !service
728                .is_ancestor_of(b_id, b_id, BarrierMode::Respect)
729                .unwrap()
730        );
731    }
732
733    #[test]
734    fn is_ancestor_of_nonexistent() {
735        let cfg = StaticTrPluginConfig {
736            tenants: vec![tenant(TENANT_A, "Root", TenantStatus::Active)],
737            ..Default::default()
738        };
739        let service = Service::from_config(&cfg);
740
741        let a_id = Uuid::parse_str(TENANT_A).unwrap();
742        let nonexistent = Uuid::parse_str(TENANT_B).unwrap();
743
744        // Nonexistent ancestor
745        assert!(matches!(
746            service.is_ancestor_of(nonexistent, a_id, BarrierMode::Respect),
747            Err(TenantResolverError::TenantNotFound { tenant_id }) if tenant_id == nonexistent
748        ));
749
750        // Nonexistent descendant
751        assert!(matches!(
752            service.is_ancestor_of(a_id, nonexistent, BarrierMode::Respect),
753            Err(TenantResolverError::TenantNotFound { tenant_id }) if tenant_id == nonexistent
754        ));
755    }
756
757    #[test]
758    fn collect_ancestors_cycle_terminates() {
759        // Create a cycle: A -> B -> A (via parent_id)
760        let a_id = Uuid::parse_str(TENANT_A).unwrap();
761        let b_id = Uuid::parse_str(TENANT_B).unwrap();
762
763        let cfg = StaticTrPluginConfig {
764            tenants: vec![
765                TenantConfig {
766                    id: a_id,
767                    name: "A".to_owned(),
768                    status: TenantStatus::Active,
769                    tenant_type: None,
770                    parent_id: Some(b_id),
771                    self_managed: false,
772                },
773                TenantConfig {
774                    id: b_id,
775                    name: "B".to_owned(),
776                    status: TenantStatus::Active,
777                    tenant_type: None,
778                    parent_id: Some(a_id),
779                    self_managed: false,
780                },
781            ],
782            ..Default::default()
783        };
784        let service = Service::from_config(&cfg);
785
786        // Should terminate (not loop forever) and return at most 2 ancestors
787        let ancestors = service.collect_ancestors(a_id, BarrierMode::Ignore);
788        assert!(ancestors.len() <= 2);
789    }
790
791    #[test]
792    fn is_ancestor_of_cycle_terminates() {
793        // Create a cycle: A -> B -> A (via parent_id)
794        let a_id = Uuid::parse_str(TENANT_A).unwrap();
795        let b_id = Uuid::parse_str(TENANT_B).unwrap();
796        let c_id = Uuid::parse_str(TENANT_C).unwrap();
797
798        let cfg = StaticTrPluginConfig {
799            tenants: vec![
800                TenantConfig {
801                    id: a_id,
802                    name: "A".to_owned(),
803                    status: TenantStatus::Active,
804                    tenant_type: None,
805                    parent_id: Some(b_id),
806                    self_managed: false,
807                },
808                TenantConfig {
809                    id: b_id,
810                    name: "B".to_owned(),
811                    status: TenantStatus::Active,
812                    tenant_type: None,
813                    parent_id: Some(a_id),
814                    self_managed: false,
815                },
816                TenantConfig {
817                    id: c_id,
818                    name: "C".to_owned(),
819                    status: TenantStatus::Active,
820                    tenant_type: None,
821                    parent_id: None,
822                    self_managed: false,
823                },
824            ],
825            ..Default::default()
826        };
827        let service = Service::from_config(&cfg);
828
829        // Should terminate (not loop forever), C is not in the cycle
830        assert!(
831            !service
832                .is_ancestor_of(c_id, a_id, BarrierMode::Ignore)
833                .unwrap()
834        );
835    }
836
837    #[test]
838    fn is_ancestor_of_unrelated() {
839        // A and B are both roots (unrelated)
840        let cfg = StaticTrPluginConfig {
841            tenants: vec![
842                tenant(TENANT_A, "Root A", TenantStatus::Active),
843                tenant(TENANT_B, "Root B", TenantStatus::Active),
844            ],
845            ..Default::default()
846        };
847        let service = Service::from_config(&cfg);
848
849        let a_id = Uuid::parse_str(TENANT_A).unwrap();
850        let b_id = Uuid::parse_str(TENANT_B).unwrap();
851
852        assert!(
853            !service
854                .is_ancestor_of(a_id, b_id, BarrierMode::Respect)
855                .unwrap()
856        );
857        assert!(
858            !service
859                .is_ancestor_of(b_id, a_id, BarrierMode::Respect)
860                .unwrap()
861        );
862    }
863}