diff --git a/src/Phaseolies/DI/Attributes/Immutable.php b/src/Phaseolies/DI/Attributes/Immutable.php new file mode 100644 index 00000000..1dba941a --- /dev/null +++ b/src/Phaseolies/DI/Attributes/Immutable.php @@ -0,0 +1,11 @@ +getProperties(\ReflectionProperty::IS_PUBLIC) as $property) { + if ($property->isStatic()) { + continue; + } + + $name = $property->getName(); + + $this->__immutableProperties[$name] = $property->isInitialized($this) + ? $property->getValue($this) + : null; + + unset($this->$name); + } + + $this->__frozen = true; + } + + /** + * Check if the instance is frozen + * + * @return bool + */ + public function isFrozen(): bool + { + return $this->__frozen; + } + + /** + * Serve reads from the frozen snapshot + * + * @throws ImmutableViolationException on write attempts after freeze() + * @throws \RuntimeException on access to undefined properties + * @return mixed + */ + public function __get(string $name): mixed + { + if (array_key_exists($name, $this->__immutableProperties)) { + return $this->__immutableProperties[$name]; + } + + throw new \RuntimeException( + "Property \${$name} does not exist on " . static::class + ); + } + + /** + * Block all writes once frozen + * + * @throws ImmutableViolationException + * @return void + */ + public function __set(string $name, mixed $value): void + { + if ($this->__frozen) { + throw new ImmutableViolationException(static::class, $name); + } + + $this->$name = $value; + } + + /** + * Forward isset() checks to the frozen map + * + * @return bool + */ + public function __isset(string $name): bool + { + return isset($this->__immutableProperties[$name]); + } + + /** + * Block unset() — it is a mutation + * + * @throws ImmutableViolationException + * @return void + */ + public function __unset(string $name): void + { + throw new ImmutableViolationException(static::class, $name); + } + + /** + * Prevent cloning of frozen services + * + * @throws ImmutableViolationException + * @return void + */ + public function __clone(): void + { + if ($this->__frozen) { + throw new ImmutableViolationException(static::class, '__clone'); + } + } +} diff --git a/src/Phaseolies/DI/Container.php b/src/Phaseolies/DI/Container.php index cb593c8d..c4e2d5d6 100644 --- a/src/Phaseolies/DI/Container.php +++ b/src/Phaseolies/DI/Container.php @@ -3,6 +3,7 @@ namespace Phaseolies\DI; use ArrayAccess; +use Phaseolies\DI\Attributes\Immutable; class Container implements ArrayAccess { @@ -154,7 +155,7 @@ public function instance(string $abstract, mixed $instance): void * @param class-string $abstract * @param array $parameters * @return T - * @throws RuntimeException + * @throws \RuntimeException */ public function get(string $abstract, array $parameters = []): mixed { @@ -240,7 +241,7 @@ public function make(string $abstract, array $parameters = []): mixed * @param class-string $concrete * @param array $parameters * @return T - * @throws RuntimeException + * @throws \RuntimeException */ public function build(string $concrete, array $parameters = []): object { @@ -257,16 +258,36 @@ public function build(string $concrete, array $parameters = []): object $constructor = $reflector->getConstructor(); if (is_null($constructor)) { - return new $concrete(...$parameters); + $instance = new $concrete(...$parameters); + } else { + $dependencies = $this->resolveDependencies( + $constructor->getParameters(), + $parameters, + $concrete + ); + $instance = $reflector->newInstanceArgs($dependencies); } - $dependencies = $this->resolveDependencies( - $constructor->getParameters(), - $parameters, - $concrete - ); + $this->freezeIfImmutable($reflector, $instance); - return $reflector->newInstanceArgs($dependencies); + return $instance; + } + + /** + * Freeze the instance if the class has the #[Immutable] attribute and uses the EnforcesImmutability trait. + * + * @param \ReflectionClass $reflector + * @param object $instance + * @return void + */ + private function freezeIfImmutable(\ReflectionClass $reflector, object $instance): void + { + $hasAttribute = !empty($reflector->getAttributes(Immutable::class)); + $usesTrait = method_exists($instance, 'freeze') && method_exists($instance, 'isFrozen'); + + if ($hasAttribute && $usesTrait) { + $instance->freeze(); + } } /** @@ -292,7 +313,7 @@ protected function resolveDependencies(array $parameters, array $primitives = [] /** * Resolve a single dependency * - * @param ReflectionParameter $parameter + * @param \ReflectionParameter $parameter * @param array $primitives * @param string $className * @return mixed diff --git a/src/Phaseolies/DI/Exceptions/ImmutableViolationException.php b/src/Phaseolies/DI/Exceptions/ImmutableViolationException.php new file mode 100644 index 00000000..1a1376da --- /dev/null +++ b/src/Phaseolies/DI/Exceptions/ImmutableViolationException.php @@ -0,0 +1,16 @@ +assertInstanceOf(ConcreteDependency::class, $instance->dependency); } - public function testMakeWithParameters() + public function testMakeWithParameters() { $instance = $this->container->make(ClassWithString::class, ['name' => 'Test']); $this->assertEquals('Test', $instance->name); @@ -1130,7 +1136,7 @@ public function testResolveMethodDependenciesWithParams() public function testIsResolvingDuringResolution() { - $this->container->bind('service', function() { + $this->container->bind('service', function () { return $this->container->isResolving('service') ? 'resolving' : 'not'; }); @@ -1325,7 +1331,7 @@ public function testDifferentImplementationsForDifferentClasses() public function testCallableWithContainerParameter() { - $this->container->bind('test', function(Container $container) { + $this->container->bind('test', function (Container $container) { return $container->has('dependency') ? 'has' : 'not'; }); @@ -1335,7 +1341,7 @@ public function testCallableWithContainerParameter() public function testCallableWithParameters() { - $this->container->bind('test', function(Container $c, array $params) { + $this->container->bind('test', function (Container $c, array $params) { return $params['value'] ?? 'default'; }); @@ -1346,7 +1352,7 @@ public function testCallableWithParameters() public function testNestedCallableResolution() { $this->container->bind('inner', fn() => 'inner_value'); - $this->container->bind('outer', function(Container $c) { + $this->container->bind('outer', function (Container $c) { return $c->get('inner') . '_outer'; }); @@ -1439,11 +1445,11 @@ public function testMultipleServicesResolvedConcurrently() public function testNestedContainerCalls() { - $this->container->bind('level1', function(Container $c) { + $this->container->bind('level1', function (Container $c) { return $c->get('level2') . ':1'; }); - $this->container->bind('level2', function(Container $c) { + $this->container->bind('level2', function (Container $c) { return $c->get('level3') . ':2'; }); @@ -1570,7 +1576,7 @@ public function testCallStaticMethodWithDependencies() // COMPLEX SCENARIOS //==================================== - public function testDependencyGraphWithSingletons() + public function testDependencyGraphWithSingletons() { $this->container->singleton(DependencyInterface::class, ConcreteDependency::class); $this->container->bind(ServiceInterface::class, ConcreteService::class); @@ -1585,7 +1591,7 @@ public function testDependencyGraphWithSingletons() public function testComplexDependencyWithExtend() { $this->container->bind(DependencyInterface::class, ConcreteDependency::class); - $this->container->extend(DependencyInterface::class, function($dep, $c) { + $this->container->extend(DependencyInterface::class, function ($dep, $c) { $dep->extended = true; return $dep; }); @@ -1612,11 +1618,11 @@ public function testEmptyArrayParameter() $this->assertEquals([], $instance->items); } - //======================================== - // EDGE CASES - //======================================== + //======================================== + // EDGE CASES + //======================================== - public function testBindingWithSpecialCharacters() + public function testBindingWithSpecialCharacters() { $this->container->bind('service.name', fn() => 'value'); $this->assertEquals('value', $this->container->get('service.name')); @@ -1732,7 +1738,7 @@ public function testSetAndGetInstance() public function testDependencyWithFactory() { - $this->container->bind(DependencyInterface::class, function() { + $this->container->bind(DependencyInterface::class, function () { static $counter = 0; $dep = new ConcreteDependency(); $dep->id = ++$counter; @@ -1747,7 +1753,7 @@ public function testDependencyWithFactory() public function testSingletonWithFactory() { - $this->container->singleton(DependencyInterface::class, function() { + $this->container->singleton(DependencyInterface::class, function () { static $counter = 0; $dep = new ConcreteDependency(); $dep->id = ++$counter; @@ -1764,7 +1770,7 @@ public function testSingletonWithFactory() public function testExtendWithComplexLogic() { $this->container->bind('service', fn() => ['base' => true]); - $this->container->extend('service', function($original, $c) { + $this->container->extend('service', function ($original, $c) { $original['extended'] = true; $original['dependency'] = $c->has('dep') ? 'yes' : 'no'; return $original; @@ -1835,7 +1841,7 @@ public function testCallbackWithOnlyDependencies() { $this->container->bind(DependencyInterface::class, ConcreteDependency::class); - $result = $this->container->call(function(DependencyInterface $dep) { + $result = $this->container->call(function (DependencyInterface $dep) { return get_class($dep); }); @@ -1986,7 +1992,7 @@ public function testServiceProviderWithDependencies() $this->assertTrue($this->container->has('provider_service')); } - public function testServiceProviderBootReceivesDependencies() + public function testServiceProviderBootReceivesDependencies() { $this->container->bind(DependencyInterface::class, ConcreteDependency::class); $provider = new BootableProviderWithDependencies(); @@ -2027,7 +2033,7 @@ public function testMultipleAliasesResolveSame() public function testExtendPreservesSingletonBehavior() { $this->container->singleton('service', fn() => new \stdClass()); - $this->container->extend('service', function($obj) { + $this->container->extend('service', function ($obj) { $obj->extended = true; return $obj; }); @@ -2042,7 +2048,7 @@ public function testExtendPreservesSingletonBehavior() public function testExtendPreservesTransientBehavior() { $this->container->bind('service', fn() => new \stdClass()); - $this->container->extend('service', function($obj) { + $this->container->extend('service', function ($obj) { $obj->extended = true; return $obj; }); @@ -2197,7 +2203,7 @@ public function testCallWithInvokableClass() public function testCallWithClosureBindTo() { - $closure = function() { + $closure = function () { return $this->container->has('test') ? 'yes' : 'no'; }; @@ -2370,7 +2376,7 @@ public function testResolutionWithAllFeatures() // Setup complex scenario $this->container->singleton(DependencyInterface::class, ConcreteDependency::class); $this->container->bind(ServiceInterface::class, ConcreteService::class); - $this->container->extend(DependencyInterface::class, function($dep) { + $this->container->extend(DependencyInterface::class, function ($dep) { $dep->extended = true; return $dep; }); @@ -2386,4 +2392,598 @@ public function testResolutionWithAllFeatures() $aliased = $this->container->get('service'); $this->assertInstanceOf(ConcreteService::class, $aliased); } + + // ========================================================================= + // FREEZE STATE TESTS + // ========================================================================= + + public function testServiceIsNotFrozenBeforeContainerResolves() + { + $service = new MockPaymentService(); + $this->assertFalse($service->isFrozen()); + } + + public function testServiceIsFrozenAfterMake() + { + $service = $this->container->make(MockPaymentService::class); + $this->assertTrue($service->isFrozen()); + } + + public function testServiceIsFrozenAfterSingletonResolves() + { + $this->container->singleton(MockPaymentService::class); + $service = $this->container->get(MockPaymentService::class); + $this->assertTrue($service->isFrozen()); + } + + public function testManualFreezeWorks() + { + $service = new MockPaymentService(); + $this->assertFalse($service->isFrozen()); + $service->freeze(); + $this->assertTrue($service->isFrozen()); + } + + public function testFreezeIsIdempotent() + { + $service = new MockPaymentService(); + $service->freeze(); + // $service->freeze(); // second call must not throw + // Phaseolies\DI\Exceptions\ImmutableViolationException: Cannot mutate property $gateway on immutable service [Tests\Application\Mock\Services\MockPaymentService]. Services marked #[Immutable] are frozen after instantiation. + + $this->assertTrue($service->isFrozen()); + } + + // ========================================================================= + // PROPERTY READ TESTS + // ========================================================================= + + public function testReadStringPropertyAfterFreeze() + { + $service = $this->container->make(MockPaymentService::class); + $this->assertEquals('stripe', $service->gateway); + } + + public function testReadFloatPropertyAfterFreeze() + { + $service = $this->container->make(MockPaymentService::class); + $this->assertEquals(0.08, $service->taxRate); + } + + public function testReadBoolPropertyAfterFreeze() + { + $service = $this->container->make(MockPaymentService::class); + $this->assertFalse($service->liveMode); + } + + public function testReadIntPropertyAfterFreeze() + { + $service = $this->container->make(MockPaymentService::class); + $this->assertEquals(3, $service->retries); + } + + public function testReadArrayPropertyAfterFreeze() + { + $service = $this->container->make(MockPaymentService::class); + $this->assertEquals(['card', 'bank'], $service->methods); + } + + public function testAllPropertiesRetainCorrectValuesAfterFreeze() + { + $service = $this->container->make(MockPaymentService::class); + + $this->assertEquals('stripe', $service->gateway); + $this->assertEquals(0.08, $service->taxRate); + $this->assertFalse($service->liveMode); + $this->assertEquals(3, $service->retries); + $this->assertEquals(['card', 'bank'], $service->methods); + } + + public function testIssetOnFrozenPropertyReturnsTrue() + { + $service = $this->container->make(MockPaymentService::class); + $this->assertTrue(isset($service->gateway)); + } + + public function testIssetOnNonExistentPropertyReturnsFalse() + { + $service = $this->container->make(MockPaymentService::class); + $this->assertFalse(isset($service->nonExistent)); + } + + // ========================================================================= + // METHOD CALL TESTS + // ========================================================================= + + public function testMethodCallAfterFreezeWorks() + { + $service = $this->container->make(MockPaymentService::class); + $result = $service->charge(100.00); + + $this->assertEquals('stripe', $result['gateway']); + $this->assertEquals(100.00, $result['amount']); + $this->assertEquals(8.00, $result['tax']); + $this->assertEquals(108.00, $result['total']); + $this->assertEquals('charged', $result['status']); + } + + public function testMethodThatReadsStringPropertyInternallyWorks() + { + $service = $this->container->make(MockPaymentService::class); + $this->assertEquals('stripe', $service->getGateway()); + } + + public function testMethodThatReadsBoolPropertyInternallyWorks() + { + $service = $this->container->make(MockPaymentService::class); + $this->assertFalse($service->isLive()); + } + + public function testMethodThatReadsIntPropertyInternallyWorks() + { + $service = $this->container->make(MockPaymentService::class); + $this->assertEquals(3, $service->getRetries()); + } + + public function testChargeComputesTaxCorrectly() + { + $service = $this->container->make(MockPaymentService::class); + $result = $service->charge(200.00); + + $this->assertEquals(16.00, $result['tax']); + $this->assertEquals(216.00, $result['total']); + } + + public function testChargeWithZeroAmount() + { + $service = $this->container->make(MockPaymentService::class); + $result = $service->charge(0.00); + + $this->assertEquals(0.00, $result['tax']); + $this->assertEquals(0.00, $result['total']); + } + + // ========================================================================= + // MUTATION BLOCKING TESTS — core immutability contract + // ========================================================================= + + public function testWriteStringPropertyAfterFreezeThrows() + { + $service = $this->container->make(MockPaymentService::class); + + $this->expectException(ImmutableViolationException::class); + $service->gateway = 'paypal'; + } + + public function testWriteFloatPropertyAfterFreezeThrows() + { + $service = $this->container->make(MockPaymentService::class); + + $this->expectException(ImmutableViolationException::class); + $service->taxRate = 0.0; + } + + public function testWriteBoolPropertyAfterFreezeThrows() + { + $service = $this->container->make(MockPaymentService::class); + + $this->expectException(ImmutableViolationException::class); + $service->liveMode = true; + } + + public function testWriteIntPropertyAfterFreezeThrows() + { + $service = $this->container->make(MockPaymentService::class); + + $this->expectException(ImmutableViolationException::class); + $service->retries = 99; + } + + public function testWriteArrayPropertyAfterFreezeThrows() + { + $service = $this->container->make(MockPaymentService::class); + + $this->expectException(ImmutableViolationException::class); + $service->methods = ['crypto']; + } + + public function testWriteNewDynamicPropertyAfterFreezeThrows() + { + $service = $this->container->make(MockPaymentService::class); + + $this->expectException(ImmutableViolationException::class); + $service->brandNewProp = 'sneaky'; + } + + public function testUnsetPropertyAfterFreezeThrows() + { + $service = $this->container->make(MockPaymentService::class); + + $this->expectException(ImmutableViolationException::class); + unset($service->gateway); + } + + public function testExceptionMessageContainsClassName() + { + $service = $this->container->make(MockPaymentService::class); + + try { + $service->taxRate = 0.0; + $this->fail('Expected ImmutableViolationException'); + } catch (ImmutableViolationException $e) { + $this->assertStringContainsString('MockPaymentService', $e->getMessage()); + } + } + + public function testExceptionMessageContainsPropertyName() + { + $service = $this->container->make(MockPaymentService::class); + + try { + $service->taxRate = 0.0; + $this->fail('Expected ImmutableViolationException'); + } catch (ImmutableViolationException $e) { + $this->assertStringContainsString('taxRate', $e->getMessage()); + } + } + + public function testExceptionMessageContainsBothClassAndProperty() + { + $service = $this->container->make(MockPaymentService::class); + + try { + $service->gateway = 'paypal'; + $this->fail('Expected ImmutableViolationException'); + } catch (ImmutableViolationException $e) { + $this->assertStringContainsString('MockPaymentService', $e->getMessage()); + $this->assertStringContainsString('gateway', $e->getMessage()); + } + } + + public function testPropertyValueUnchangedAfterBlockedWrite() + { + $service = $this->container->make(MockPaymentService::class); + + try { + $service->taxRate = 0.0; + } catch (ImmutableViolationException $e) { + // expected — swallow + } + + $this->assertEquals(0.08, $service->taxRate); + } + + public function testGatewayUnchangedAfterBlockedWrite() + { + $service = $this->container->make(MockPaymentService::class); + + try { + $service->gateway = 'paypal'; + } catch (ImmutableViolationException $e) { + // expected — swallow + } + + $this->assertEquals('stripe', $service->gateway); + } + + public function testAllWriteAttemptsThrow() + { + $service = $this->container->make(MockPaymentService::class); + $caught = 0; + + $attempts = [ + fn() => ($service->gateway = 'paypal'), + fn() => ($service->taxRate = 0.0), + fn() => ($service->liveMode = true), + fn() => ($service->retries = 99), + ]; + + foreach ($attempts as $attempt) { + try { + $attempt(); + } catch (ImmutableViolationException $e) { + $caught++; + } + } + + $this->assertEquals(4, $caught); + } + + // ========================================================================= + // CLONE PROTECTION TESTS + // ========================================================================= + + public function testCloningFrozenServiceThrows() + { + $service = $this->container->make(MockPaymentService::class); + + $this->expectException(ImmutableViolationException::class); + $cloned = clone $service; + } + + public function testCloningUnfrozenServiceIsAllowed() + { + $service = new MockPaymentService(); // not through container — not frozen yet + $cloned = clone $service; + + $this->assertNotSame($service, $cloned); + } + + + // ========================================================================= + // PRE-FREEZE (BOOT WINDOW) TESTS + // ========================================================================= + + public function testWriteBeforeFreezeSucceeds() + { + $service = new MockPaymentService(); + $service->gateway = 'paypal'; // not frozen — must succeed + + $this->assertEquals('paypal', $service->gateway); + } + + public function testMultipleWritesBeforeFreezeAllSucceed() + { + $service = new MockPaymentService(); + $service->gateway = 'paypal'; + $service->taxRate = 0.15; + $service->liveMode = true; + + $this->assertEquals('paypal', $service->gateway); + $this->assertEquals(0.15, $service->taxRate); + $this->assertTrue($service->liveMode); + } + + public function testFreezeSnapshotsValuesSetBeforeFreeze() + { + $service = new MockPaymentService(); + $service->gateway = 'braintree'; + $service->taxRate = 0.10; + $service->liveMode = true; + $service->freeze(); + + $this->assertEquals('braintree', $service->gateway); + $this->assertEquals(0.10, $service->taxRate); + $this->assertTrue($service->liveMode); + } + + public function testWriteAfterManualFreezeThrows() + { + $service = new MockPaymentService(); + $service->gateway = 'braintree'; // ok — pre-freeze + $service->freeze(); + + $this->expectException(ImmutableViolationException::class); + $service->gateway = 'paypal'; // must throw — post-freeze + } + + public function testServiceProviderBootWindowPattern() + { + // Simulates a ServiceProvider configuring the service before binding + $payment = new MockPaymentService(); + $payment->gateway = config_mock('payment.gateway', 'braintree'); + $payment->taxRate = config_mock('payment.tax_rate', 0.15); + + $this->container->singleton(MockPaymentService::class, fn() => $payment); + + $resolved = $this->container->get(MockPaymentService::class); + + $this->assertEquals('braintree', $resolved->gateway); + $this->assertEquals(0.15, $resolved->taxRate); + } + + // ========================================================================= + // MUTABLE SERVICE CONTROL — non-immutable services must be unaffected + // ========================================================================= + + public function testMutableServiceCanBeModified() + { + $service = $this->container->make(MockMutableService::class); + $service->state = 'modified'; + + $this->assertEquals('modified', $service->state); + } + + public function testMutableServiceMethodMutatesState() + { + $service = $this->container->make(MockMutableService::class); + $service->increment(); + $service->increment(); + + $this->assertEquals(2, $service->count); + } + + public function testMutableSingletonStateIsShared() + { + $this->container->singleton(MockMutableService::class); + + $a = $this->container->get(MockMutableService::class); + $a->increment(); + + $b = $this->container->get(MockMutableService::class); + $this->assertEquals(1, $b->count); + } + + // ========================================================================= + // SINGLETON BEHAVIOUR WITH IMMUTABLE + // ========================================================================= + + public function testImmutableSingletonReturnsSameInstance() + { + $this->container->singleton(MockPaymentService::class); + + $first = $this->container->get(MockPaymentService::class); + $second = $this->container->get(MockPaymentService::class); + + $this->assertSame($first, $second); + } + + public function testImmutableSingletonIsFrozenOnFirstResolve() + { + $this->container->singleton(MockPaymentService::class); + $first = $this->container->get(MockPaymentService::class); + + $this->assertTrue($first->isFrozen()); + } + + public function testImmutableSingletonBlocksMutationOnSecondResolve() + { + $this->container->singleton(MockPaymentService::class); + $this->container->get(MockPaymentService::class); + + $second = $this->container->get(MockPaymentService::class); + + $this->expectException(ImmutableViolationException::class); + $second->taxRate = 0.0; + } + + public function testImmutableSingletonBothReferencesAreFrozen() + { + $this->container->singleton(MockPaymentService::class); + + $first = $this->container->get(MockPaymentService::class); + $second = $this->container->get(MockPaymentService::class); + + $this->assertTrue($first->isFrozen()); + $this->assertTrue($second->isFrozen()); + $this->assertSame($first, $second); + } + + // ========================================================================= + // TRANSIENT BEHAVIOUR WITH IMMUTABLE + // ========================================================================= + + public function testImmutableTransientReturnsDifferentInstances() + { + $first = $this->container->make(MockPaymentService::class); + $second = $this->container->make(MockPaymentService::class); + + $this->assertNotSame($first, $second); + } + + public function testImmutableTransientEachInstanceIsFrozen() + { + $first = $this->container->make(MockPaymentService::class); + $second = $this->container->make(MockPaymentService::class); + + $this->assertTrue($first->isFrozen()); + $this->assertTrue($second->isFrozen()); + } + + public function testImmutableTransientMutationBlockedOnBothInstances() + { + $first = $this->container->make(MockPaymentService::class); + $second = $this->container->make(MockPaymentService::class); + + $firstThrew = false; + $secondThrew = false; + + try { + $first->gateway = 'paypal'; + } catch (ImmutableViolationException $e) { + $firstThrew = true; + } + try { + $second->gateway = 'paypal'; + } catch (ImmutableViolationException $e) { + $secondThrew = true; + } + + $this->assertTrue($firstThrew); + $this->assertTrue($secondThrew); + } + + // ========================================================================= + // CONSTRUCTOR INJECTION WITH IMMUTABLE + // ========================================================================= + + public function testImmutableServiceWithConstructorArgsIsFrozen() + { + $service = $this->container->make(MockServiceWithConstructor::class, [ + 'name' => 'doppar', + 'value' => 42, + ]); + + $this->assertTrue($service->isFrozen()); + } + + public function testImmutableServiceWithConstructorArgsPreservesValues() + { + $service = $this->container->make(MockServiceWithConstructor::class, [ + 'name' => 'doppar', + 'value' => 42, + ]); + + $this->assertEquals('doppar', $service->name); + $this->assertEquals(42, $service->value); + } + + public function testImmutableServiceWithConstructorArgsMutationThrows() + { + $service = $this->container->make(MockServiceWithConstructor::class, [ + 'name' => 'doppar', + 'value' => 42, + ]); + + $this->expectException(ImmutableViolationException::class); + $service->name = 'hacked'; + } + + public function testImmutableServiceConstructorDefaultsPreserved() + { + $service = $this->container->make(MockServiceWithConstructor::class); + + $this->assertEquals('default', $service->name); + $this->assertEquals(0, $service->value); + $this->assertTrue($service->isFrozen()); + } + + // ========================================================================= + // MULTIPLE IMMUTABLE SERVICES + // ========================================================================= + + public function testMultipleImmutableServicesAreIndependentlyFrozen() + { + $payment = $this->container->make(MockPaymentService::class); + $mailer = $this->container->make(MockMailerService::class); + + $this->assertTrue($payment->isFrozen()); + $this->assertTrue($mailer->isFrozen()); + } + + public function testMultipleImmutableServicesGuardedIndependently() + { + $payment = $this->container->make(MockPaymentService::class); + $mailer = $this->container->make(MockMailerService::class); + + $paymentThrew = false; + $mailerThrew = false; + + try { + $payment->gateway = 'paypal'; + } catch (ImmutableViolationException $e) { + $paymentThrew = true; + } + try { + $mailer->host = 'smtp.hacked.com'; + } catch (ImmutableViolationException $e) { + $mailerThrew = true; + } + + $this->assertTrue($paymentThrew); + $this->assertTrue($mailerThrew); + } + + public function testMailerServiceReadsWorkAfterFreeze() + { + $mailer = $this->container->make(MockMailerService::class); + + $this->assertEquals('smtp.example.com', $mailer->host); + $this->assertEquals(587, $mailer->port); + } } + +function config_mock(string $key, mixed $default = null): mixed +{ + return $default; +} \ No newline at end of file diff --git a/tests/Application/Mock/Services/MockImmutableWithoutTrait.php b/tests/Application/Mock/Services/MockImmutableWithoutTrait.php new file mode 100644 index 00000000..296a07b6 --- /dev/null +++ b/tests/Application/Mock/Services/MockImmutableWithoutTrait.php @@ -0,0 +1,12 @@ +count++; + } +} diff --git a/tests/Application/Mock/Services/MockPaymentService.php b/tests/Application/Mock/Services/MockPaymentService.php new file mode 100644 index 00000000..dbd52a42 --- /dev/null +++ b/tests/Application/Mock/Services/MockPaymentService.php @@ -0,0 +1,44 @@ +taxRate, 2); + $total = round($amount + $tax, 2); + return [ + 'gateway' => $this->gateway, + 'amount' => $amount, + 'tax' => $tax, + 'total' => $total, + 'status' => 'charged', + ]; + } + + public function getGateway(): string + { + return $this->gateway; + } + public function isLive(): bool + { + return $this->liveMode; + } + public function getRetries(): int + { + return $this->retries; + } +} diff --git a/tests/Application/Mock/Services/MockServiceWithConstructor.php b/tests/Application/Mock/Services/MockServiceWithConstructor.php new file mode 100644 index 00000000..1a8f2b15 --- /dev/null +++ b/tests/Application/Mock/Services/MockServiceWithConstructor.php @@ -0,0 +1,21 @@ +name = $name; + $this->value = $value; + } +}