Skip to content

Commit 120ef0d

Browse files
committed
test(rust): add edge case tests revealing critical Rust container bugs
Adds comprehensive integration tests that exercise the Rust container through the Python FFI. These tests reveal critical bugs in the Rust implementation. CRITICAL FINDINGS: 🐛 Bug #1: Singleton cache never populated - Rust resolve() checks singleton cache (lib.rs:162-167) - But NEVER stores instances to cache after creation (lib.rs:180-186) - Result: Factories called every time, not once - Impact: HIGH - breaks singleton guarantees - Tests failing: 4 tests verify singleton behavior 🐛 Bug #2: No circular dependency detection - No resolution stack tracking - Will cause stack overflow on A→B→A cycles - Impact: CRITICAL - production crash risk - Tests skipped: 3 tests document expected behavior ✅ Working correctly: - Class providers create new instances (transient behavior) - Recursive resolution (factories can call resolve()) - Deep dependency chains work Test results: 3 passed, 4 failed, 3 skipped (documented future work) The Python layer's singleton wrapper factory (container.py:124-141) is actually working AROUND the Rust bug - it provides caching that Rust should be doing. Next steps: 1. Fix Rust resolve() to populate singleton cache 2. Add resolution stack for circular dependency detection 3. Unskip and verify circular dependency tests pass
1 parent 887b835 commit 120ef0d

File tree

1 file changed

+367
-0
lines changed

1 file changed

+367
-0
lines changed
Lines changed: 367 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,367 @@
1+
"""
2+
Edge case tests for RustContainer behavior.
3+
4+
These tests verify the Rust container implementation handles:
5+
- Singleton caching
6+
- Recursive resolution
7+
- Mixed lifecycles
8+
- Deep dependency chains
9+
- Circular dependencies
10+
"""
11+
import pytest
12+
from rivet_di._rivet_core import Container as RustContainer
13+
14+
15+
class DescribeRustContainerSingletonCaching:
16+
"""Tests for singleton factory caching behavior."""
17+
18+
def it_calls_singleton_factory_only_once(self) -> None:
19+
"""Singleton factory is called exactly once across multiple resolutions."""
20+
container = RustContainer()
21+
call_count = {'count': 0}
22+
23+
class Service:
24+
pass
25+
26+
def factory() -> Service:
27+
call_count['count'] += 1
28+
return Service()
29+
30+
container.register_factory(Service, factory)
31+
32+
# Resolve multiple times
33+
first = container.resolve(Service)
34+
second = container.resolve(Service)
35+
third = container.resolve(Service)
36+
37+
# Factory should only be called once
38+
assert call_count['count'] == 1
39+
# All resolutions should return the same instance
40+
assert first is second is third
41+
42+
def it_calls_transient_factory_every_time(self) -> None:
43+
"""Class provider creates new instance on every resolution."""
44+
container = RustContainer()
45+
call_count = {'count': 0}
46+
47+
class Service:
48+
def __init__(self) -> None:
49+
call_count['count'] += 1
50+
self.instance_num = call_count['count']
51+
52+
container.register_class(Service, Service)
53+
54+
# Resolve multiple times
55+
first = container.resolve(Service)
56+
second = container.resolve(Service)
57+
third = container.resolve(Service)
58+
59+
# Class should be instantiated three times
60+
assert call_count['count'] == 3
61+
# Each resolution should return a different instance
62+
assert first.instance_num == 1
63+
assert second.instance_num == 2
64+
assert third.instance_num == 3
65+
assert first is not second is not third
66+
67+
68+
class DescribeRustContainerRecursiveResolution:
69+
"""Tests for factories that resolve dependencies."""
70+
71+
def it_resolves_dependencies_from_within_factory(self) -> None:
72+
"""Factories can resolve their dependencies using the container."""
73+
container = RustContainer()
74+
75+
class Config:
76+
def __init__(self) -> None:
77+
self.host = 'localhost'
78+
79+
class Database:
80+
def __init__(self, config: Config) -> None:
81+
self.config = config
82+
83+
# Register config as instance
84+
container.register_instance(Config, Config())
85+
86+
# Register factory that resolves the dependency
87+
def database_factory() -> Database:
88+
config = container.resolve(Config)
89+
return Database(config)
90+
91+
container.register_factory(Database, database_factory)
92+
93+
result = container.resolve(Database)
94+
assert isinstance(result, Database)
95+
assert result.config.host == 'localhost'
96+
97+
98+
class DescribeRustContainerMixedLifecycles:
99+
"""Tests for mixing singleton and transient dependencies."""
100+
101+
def it_allows_singleton_to_depend_on_transient(self) -> None:
102+
"""Singleton factory can resolve a transient dependency."""
103+
container = RustContainer()
104+
transient_calls = {'count': 0}
105+
106+
class TransientService:
107+
def __init__(self) -> None:
108+
transient_calls['count'] += 1
109+
self.num = transient_calls['count']
110+
111+
class SingletonService:
112+
def __init__(self, transient: TransientService) -> None:
113+
self.transient = transient
114+
115+
# Register transient as class (new instance each time)
116+
container.register_class(TransientService, TransientService)
117+
118+
# Register singleton factory that resolves transient
119+
def singleton_factory() -> SingletonService:
120+
transient = container.resolve(TransientService)
121+
return SingletonService(transient)
122+
123+
container.register_factory(SingletonService, singleton_factory)
124+
125+
# First resolution - singleton factory called, resolves transient
126+
first = container.resolve(SingletonService)
127+
# Second resolution - singleton cached, transient NOT resolved again
128+
second = container.resolve(SingletonService)
129+
130+
# Singleton factory only called once
131+
assert first is second
132+
# Transient only called once (during singleton creation)
133+
assert transient_calls['count'] == 1
134+
assert first.transient.num == 1
135+
136+
def it_allows_transient_to_depend_on_singleton(self) -> None:
137+
"""Transient can resolve a singleton dependency."""
138+
container = RustContainer()
139+
singleton_calls = {'count': 0}
140+
141+
class SingletonService:
142+
def __init__(self) -> None:
143+
singleton_calls['count'] += 1
144+
self.num = singleton_calls['count']
145+
146+
class TransientService:
147+
def __init__(self, singleton: SingletonService) -> None:
148+
self.singleton = singleton
149+
150+
# Register singleton as factory
151+
def singleton_factory() -> SingletonService:
152+
return SingletonService()
153+
154+
container.register_factory(SingletonService, singleton_factory)
155+
156+
# Register transient as class that resolves singleton
157+
# Since we can't inject in __init__ directly, use a factory
158+
def transient_factory() -> TransientService:
159+
singleton = container.resolve(SingletonService)
160+
return TransientService(singleton)
161+
162+
container.register_factory(TransientService, transient_factory)
163+
164+
# Multiple resolutions - transient called each time
165+
first = container.resolve(TransientService)
166+
second = container.resolve(TransientService)
167+
third = container.resolve(TransientService)
168+
169+
# Singleton factory only called once
170+
assert singleton_calls['count'] == 1
171+
# Each transient gets the same singleton
172+
assert first.singleton is second.singleton is third.singleton
173+
assert first.singleton.num == 1
174+
175+
176+
class DescribeRustContainerDeepDependencyChains:
177+
"""Tests for deep chains of dependencies."""
178+
179+
def it_resolves_deep_dependency_chains(self) -> None:
180+
"""Dependencies resolve correctly multiple levels deep."""
181+
container = RustContainer()
182+
183+
class Config:
184+
def __init__(self) -> None:
185+
self.value = 'base-config'
186+
187+
class ServiceD:
188+
def __init__(self, config: Config) -> None:
189+
self.config = config
190+
191+
class ServiceC:
192+
def __init__(self, d: ServiceD) -> None:
193+
self.d = d
194+
195+
class ServiceB:
196+
def __init__(self, c: ServiceC) -> None:
197+
self.c = c
198+
199+
class ServiceA:
200+
def __init__(self, b: ServiceB) -> None:
201+
self.b = b
202+
203+
# Chain: ServiceA -> ServiceB -> ServiceC -> ServiceD -> Config
204+
container.register_instance(Config, Config())
205+
206+
def factory_d() -> ServiceD:
207+
config = container.resolve(Config)
208+
return ServiceD(config)
209+
210+
def factory_c() -> ServiceC:
211+
d = container.resolve(ServiceD)
212+
return ServiceC(d)
213+
214+
def factory_b() -> ServiceB:
215+
c = container.resolve(ServiceC)
216+
return ServiceB(c)
217+
218+
def factory_a() -> ServiceA:
219+
b = container.resolve(ServiceB)
220+
return ServiceA(b)
221+
222+
container.register_factory(ServiceD, factory_d)
223+
container.register_factory(ServiceC, factory_c)
224+
container.register_factory(ServiceB, factory_b)
225+
container.register_factory(ServiceA, factory_a)
226+
227+
result = container.resolve(ServiceA)
228+
assert isinstance(result, ServiceA)
229+
assert isinstance(result.b, ServiceB)
230+
assert isinstance(result.b.c, ServiceC)
231+
assert isinstance(result.b.c.d, ServiceD)
232+
assert result.b.c.d.config.value == 'base-config'
233+
234+
def it_caches_singletons_in_deep_chains(self) -> None:
235+
"""Singletons are properly cached even in deep dependency chains."""
236+
container = RustContainer()
237+
singleton_calls = {'count': 0}
238+
239+
class Config:
240+
def __init__(self) -> None:
241+
self.value = 'base'
242+
243+
class SingletonService:
244+
def __init__(self, config: Config) -> None:
245+
singleton_calls['count'] += 1
246+
self.config = config
247+
self.num = singleton_calls['count']
248+
249+
class TransientService:
250+
def __init__(self, singleton: SingletonService) -> None:
251+
self.singleton = singleton
252+
253+
container.register_instance(Config, Config())
254+
255+
def singleton_factory() -> SingletonService:
256+
config = container.resolve(Config)
257+
return SingletonService(config)
258+
259+
def transient_factory() -> TransientService:
260+
singleton = container.resolve(SingletonService)
261+
return TransientService(singleton)
262+
263+
container.register_factory(SingletonService, singleton_factory)
264+
container.register_factory(TransientService, transient_factory)
265+
266+
# Resolve transient multiple times
267+
first = container.resolve(TransientService)
268+
second = container.resolve(TransientService)
269+
third = container.resolve(TransientService)
270+
271+
# Singleton only created once
272+
assert singleton_calls['count'] == 1
273+
# All transients get same singleton
274+
assert first.singleton is second.singleton is third.singleton
275+
assert first.singleton.num == 1
276+
277+
278+
class DescribeRustContainerCircularDependencies:
279+
"""Tests for circular dependency detection."""
280+
281+
@pytest.mark.skip(reason='Circular dependency detection not yet implemented in Rust container')
282+
def it_detects_direct_circular_dependencies(self) -> None:
283+
"""Detects when service A depends on service B which depends on A."""
284+
container = RustContainer()
285+
286+
class ServiceA:
287+
pass
288+
289+
class ServiceB:
290+
pass
291+
292+
def factory_a() -> ServiceA:
293+
container.resolve(ServiceB)
294+
return ServiceA()
295+
296+
def factory_b() -> ServiceB:
297+
container.resolve(ServiceA)
298+
return ServiceB()
299+
300+
container.register_factory(ServiceA, factory_a)
301+
container.register_factory(ServiceB, factory_b)
302+
303+
# Should raise an error about circular dependency
304+
with pytest.raises(Exception) as exc_info:
305+
container.resolve(ServiceA)
306+
307+
# Error message should mention circular or recursion
308+
error_msg = str(exc_info.value).lower()
309+
assert 'circular' in error_msg or 'recursion' in error_msg or 'cycle' in error_msg
310+
311+
@pytest.mark.skip(reason='Circular dependency detection not yet implemented in Rust container')
312+
def it_detects_indirect_circular_dependencies(self) -> None:
313+
"""Detects circular dependencies through multiple services (A -> B -> C -> A)."""
314+
container = RustContainer()
315+
316+
class ServiceA:
317+
pass
318+
319+
class ServiceB:
320+
pass
321+
322+
class ServiceC:
323+
pass
324+
325+
def factory_a() -> ServiceA:
326+
container.resolve(ServiceB)
327+
return ServiceA()
328+
329+
def factory_b() -> ServiceB:
330+
container.resolve(ServiceC)
331+
return ServiceB()
332+
333+
def factory_c() -> ServiceC:
334+
container.resolve(ServiceA)
335+
return ServiceC()
336+
337+
container.register_factory(ServiceA, factory_a)
338+
container.register_factory(ServiceB, factory_b)
339+
container.register_factory(ServiceC, factory_c)
340+
341+
# Should raise an error about circular dependency
342+
with pytest.raises(Exception) as exc_info:
343+
container.resolve(ServiceA)
344+
345+
error_msg = str(exc_info.value).lower()
346+
assert 'circular' in error_msg or 'recursion' in error_msg or 'cycle' in error_msg
347+
348+
@pytest.mark.skip(reason='Circular dependency detection not yet implemented in Rust container')
349+
def it_detects_self_dependency(self) -> None:
350+
"""Detects when a service tries to resolve itself."""
351+
container = RustContainer()
352+
353+
class ServiceA:
354+
pass
355+
356+
def factory_a() -> ServiceA:
357+
container.resolve(ServiceA)
358+
return ServiceA()
359+
360+
container.register_factory(ServiceA, factory_a)
361+
362+
# Should raise an error about circular dependency
363+
with pytest.raises(Exception) as exc_info:
364+
container.resolve(ServiceA)
365+
366+
error_msg = str(exc_info.value).lower()
367+
assert 'circular' in error_msg or 'recursion' in error_msg or 'cycle' in error_msg

0 commit comments

Comments
 (0)