Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion src/Phaseolies/DI/Exceptions/ImmutableViolationException.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,20 @@ class ImmutableViolationException extends RuntimeException
{
public function __construct(string $class, string $property)
{
if ($property === '__clone') {
parent::__construct(
"Cannot clone immutable service [{$class}]. " .
"Services marked #[Immutable] are frozen once the container resolves them, " .
"and a clone would be a way around that. Resolve the service from the container instead."
);

return;
}

parent::__construct(
"Cannot mutate property \${$property} on immutable service [{$class}]. " .
"Services marked #[Immutable] are frozen after instantiation."
"Services marked #[Immutable] are frozen once the container resolves them. " .
"Configure the service in a launcher during boot, before it is first resolved, not at runtime."
);
}
}
47 changes: 45 additions & 2 deletions tests/Application/ContainerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2474,11 +2474,12 @@ public function testManualFreezeWorks()
public function testFreezeIsIdempotent()
{
$service = new MockPaymentService();
$service->gateway = 'paypal';
$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.
$service->freeze(); // second call is a no-op and keeps the values

$this->assertTrue($service->isFrozen());
$this->assertSame('paypal', $service->gateway);
}

// =========================================================================
Expand Down Expand Up @@ -2748,6 +2749,48 @@ public function testCloningFrozenServiceThrows()
$cloned = clone $service;
}

public function testMutationMessageNamesClassPropertyAndTheFix()
{
$service = $this->container->make(MockPaymentService::class);

try {
$service->taxRate = 0.0;
$this->fail('Expected ImmutableViolationException');
} catch (ImmutableViolationException $e) {
$this->assertStringContainsString('$taxRate', $e->getMessage());
$this->assertStringContainsString(MockPaymentService::class, $e->getMessage());
$this->assertStringContainsString('frozen once the container resolves them', $e->getMessage());
$this->assertStringContainsString('launcher', $e->getMessage());
$this->assertStringNotContainsString('ServiceProvider', $e->getMessage());
}
}

public function testCloneMessageSaysCloneNotProperty()
{
$service = $this->container->make(MockPaymentService::class);

try {
clone $service;
$this->fail('Expected ImmutableViolationException');
} catch (ImmutableViolationException $e) {
$this->assertStringContainsString('Cannot clone immutable service', $e->getMessage());
$this->assertStringContainsString(MockPaymentService::class, $e->getMessage());
$this->assertStringNotContainsString('$__clone', $e->getMessage());
}
}

public function testUnsetMessageNamesTheProperty()
{
$service = $this->container->make(MockPaymentService::class);

try {
unset($service->gateway);
$this->fail('Expected ImmutableViolationException');
} catch (ImmutableViolationException $e) {
$this->assertStringContainsString('$gateway', $e->getMessage());
}
}

public function testCloningUnfrozenServiceIsAllowed()
{
$service = new MockPaymentService(); // not through container — not frozen yet
Expand Down
42 changes: 34 additions & 8 deletions tests/Model/EntityModelHookTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,34 @@ protected function setUp(): void
$this->setupDatabaseConnections();
}

/**
* Hook table of MockHook as it was before a test added temporary hooks.
* MockHook registers its own hooks once per process, so restoring must
* put this back instead of removing the entry.
*/
private ?array $savedHooks = null;

private function tempHooks(array $hooks): void
{
new MockHook(); // make sure the class has booted and registered its own hooks

$this->savedHooks ??= HookHandler::$hooks[MockHook::class] ?? [];

HookHandler::register(MockHook::class, $hooks);
}

private function restoreHooks(): void
{
if ($this->savedHooks !== null) {
HookHandler::$hooks[MockHook::class] = $this->savedHooks;
$this->savedHooks = null;
}
}

protected function tearDown(): void
{
$this->restoreHooks();

$this->pdo = null;
$this->tearDownDatabaseConnections();
}
Expand Down Expand Up @@ -272,7 +298,7 @@ public function testBuilderUpdateWithoutWithHooksSkipsHooks(): void
public function testBuilderUpdateWithHooksFiresHooksPerModel(): void
{
$seen = [];
HookHandler::register(MockHook::class, [
$this->tempHooks([
'before_updated' => function ($model) use (&$seen) {
$seen[] = $model->getKey();
},
Expand All @@ -285,7 +311,7 @@ public function testBuilderUpdateWithHooksFiresHooksPerModel(): void
->withHooks()
->update(['name' => 'Hooked']);
} finally {
unset(HookHandler::$hooks[MockHook::class]);
$this->restoreHooks();
}

$this->assertTrue($result);
Expand All @@ -312,7 +338,7 @@ public function testBuilderUpdateWithHooksReachesRowsThatLeaveTheWhereClause():

public function testBuilderUpdateWithHooksStopsWhereAHookThrows(): void
{
HookHandler::register(MockHook::class, [
$this->tempHooks([
'before_updated' => function ($model) {
if ((int) $model->getKey() === 2) {
throw new \RuntimeException('not this one');
Expand All @@ -326,7 +352,7 @@ public function testBuilderUpdateWithHooksStopsWhereAHookThrows(): void
} catch (\RuntimeException $e) {
$this->assertSame('not this one', $e->getMessage());
} finally {
unset(HookHandler::$hooks[MockHook::class]);
$this->restoreHooks();
}

// Rows are written one by one and there is no implicit transaction:
Expand All @@ -339,7 +365,7 @@ public function testBuilderUpdateWithHooksStopsWhereAHookThrows(): void
public function testBuilderDeleteWithHooksFiresHooksPerModel(): void
{
$seen = [];
HookHandler::register(MockHook::class, [
$this->tempHooks([
'before_deleted' => function ($model) use (&$seen) {
$seen[] = (int) $model->getKey();
},
Expand All @@ -348,7 +374,7 @@ public function testBuilderDeleteWithHooksFiresHooksPerModel(): void
try {
MockHook::query()->where('id', '<', 3)->withHooks()->delete();
} finally {
unset(HookHandler::$hooks[MockHook::class]);
$this->restoreHooks();
}

$this->assertEqualsCanonicalizing([1, 2], $seen);
Expand All @@ -358,7 +384,7 @@ public function testBuilderDeleteWithHooksFiresHooksPerModel(): void
public function testBuilderDeleteWithoutWithHooksSkipsHooks(): void
{
$fired = false;
HookHandler::register(MockHook::class, [
$this->tempHooks([
'before_deleted' => function () use (&$fired) {
$fired = true;
},
Expand All @@ -367,7 +393,7 @@ public function testBuilderDeleteWithoutWithHooksSkipsHooks(): void
try {
MockHook::query()->where('id', 1)->delete();
} finally {
unset(HookHandler::$hooks[MockHook::class]);
$this->restoreHooks();
}

$this->assertFalse($fired);
Expand Down
Loading