From eee6316b473339677a53f89339b2957773d08776 Mon Sep 17 00:00:00 2001 From: Josh Date: Sat, 1 Aug 2026 10:28:44 -0400 Subject: [PATCH 1/3] fix(hooks): use strict matching when removing listeners Signed-off-by: Josh --- lib/private/Hooks/EmitterTrait.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/private/Hooks/EmitterTrait.php b/lib/private/Hooks/EmitterTrait.php index 1dddca3f35774..f33414a5c2f2f 100644 --- a/lib/private/Hooks/EmitterTrait.php +++ b/lib/private/Hooks/EmitterTrait.php @@ -42,22 +42,22 @@ public function listen($scope, $method, callable $callback) { public function removeListener($scope = null, $method = null, ?callable $callback = null) { $names = []; $allNames = array_keys($this->listeners); - if ($scope && $method) { + if ($scope !== null && $method !== null) { $name = $scope . '::' . $method; if (isset($this->listeners[$name])) { $names[] = $name; } - } elseif ($scope) { + } elseif ($scope !== null) { foreach ($allNames as $name) { $parts = explode('::', $name, 2); - if ($parts[0] == $scope) { + if ($parts[0] === $scope) { $names[] = $name; } } - } elseif ($method) { + } elseif ($method !== null) { foreach ($allNames as $name) { $parts = explode('::', $name, 2); - if ($parts[1] == $method) { + if ($parts[1] === $method) { $names[] = $name; } } From 86b9ca5e86a9507cecff3cd4811e9de0b077fe1d Mon Sep 17 00:00:00 2001 From: Josh Date: Sat, 1 Aug 2026 11:13:53 -0400 Subject: [PATCH 2/3] test(hooks): strengthen BasicEmitter listener coverage Replace exception-based removal checks and placeholder assertion counts with explicit invocation assertions. Verify scope and method isolation after each emission, validate named argument forwarding using a reversed callback signature, and ensure listener removal preserves unrelated registrations. Cover wildcard removal semantics for all listeners of an event, all methods within a scope, one method across scopes, and a specific callback across every registration. Assisted-by: Copilot:GPT-5 Signed-off-by: Josh --- tests/lib/Hooks/BasicEmitterTest.php | 315 +++++++++++++++++---------- 1 file changed, 203 insertions(+), 112 deletions(-) diff --git a/tests/lib/Hooks/BasicEmitterTest.php b/tests/lib/Hooks/BasicEmitterTest.php index 48c9ac9f3ae6e..9a642daafe80e 100644 --- a/tests/lib/Hooks/BasicEmitterTest.php +++ b/tests/lib/Hooks/BasicEmitterTest.php @@ -11,32 +11,20 @@ use OC\Hooks\BasicEmitter; use OC\Hooks\Emitter; -/** - * Class DummyEmitter - * - * class to make BasicEmitter::emit publicly available - * - * @package Test\Hooks - */ +// class to make BasicEmitter::emit publicly available class DummyEmitter extends BasicEmitter { - public function emitEvent($scope, $method, $arguments = []) { + public function emitEvent($scope, $method, $arguments = []): void { $this->emit($scope, $method, $arguments); } } -/** - * Class EmittedException - * - * a dummy exception so we can check if an event is emitted - * - * @package Test\Hooks - */ +// a dummy exception so we can check if an event is emitted class EmittedException extends \Exception { } class BasicEmitterTest extends \Test\TestCase { /** - * @var Emitter $emitter + * @var Emitter */ protected $emitter; @@ -46,34 +34,37 @@ protected function setUp(): void { $this->emitter = new DummyEmitter(); } - public function nonStaticCallBack() { + public function nonStaticCallBack(): void { throw new EmittedException; } - public static function staticCallBack() { + public static function staticCallBack(): void { throw new EmittedException; } public function testAnonymousFunction(): void { - $this->expectException(\Test\Hooks\EmittedException::class); + $this->expectException(EmittedException::class); $this->emitter->listen('Test', 'test', function (): void { throw new EmittedException; }); + $this->emitter->emitEvent('Test', 'test'); } public function testStaticCallback(): void { - $this->expectException(\Test\Hooks\EmittedException::class); + $this->expectException(EmittedException::class); + + $this->emitter->listen('Test', 'test', [self::class, 'staticCallBack']); - $this->emitter->listen('Test', 'test', ['\Test\Hooks\BasicEmitterTest', 'staticCallBack']); $this->emitter->emitEvent('Test', 'test'); } public function testNonStaticCallback(): void { - $this->expectException(\Test\Hooks\EmittedException::class); + $this->expectException(EmittedException::class); $this->emitter->listen('Test', 'test', [$this, 'nonStaticCallBack']); + $this->emitter->emitEvent('Test', 'test'); } @@ -82,194 +73,294 @@ public function testOnlyCallOnce(): void { $listener = function () use (&$count): void { $count++; }; + $this->emitter->listen('Test', 'test', $listener); $this->emitter->listen('Test', 'test', $listener); $this->emitter->emitEvent('Test', 'test'); - $this->assertEquals(1, $count, 'Listener called an invalid number of times (' . $count . ') expected 1'); + + $this->assertSame(1, $count); } public function testDifferentMethods(): void { - $count = 0; - $listener = function () use (&$count): void { - $count++; - }; - $this->emitter->listen('Test', 'test', $listener); - $this->emitter->listen('Test', 'foo', $listener); + $testCount = 0; + $fooCount = 0; + + $this->emitter->listen('Test', 'test', function () use (&$testCount): void { + $testCount++; + }); + $this->emitter->listen('Test', 'foo', function () use (&$fooCount): void { + $fooCount++; + }); + $this->emitter->emitEvent('Test', 'test'); + + $this->assertSame(1, $testCount); + $this->assertSame(0, $fooCount); + $this->emitter->emitEvent('Test', 'foo'); - $this->assertEquals(2, $count, 'Listener called an invalid number of times (' . $count . ') expected 2'); + + $this->assertSame(1, $testCount); + $this->assertSame(1, $fooCount); } public function testDifferentScopes(): void { - $count = 0; - $listener = function () use (&$count): void { - $count++; - }; - $this->emitter->listen('Test', 'test', $listener); - $this->emitter->listen('Bar', 'test', $listener); + $testScopeCount = 0; + $barScopeCount = 0; + + $this->emitter->listen('Test', 'test', function () use (&$testScopeCount): void { + $testScopeCount++; + }); + $this->emitter->listen('Bar', 'test', function () use (&$barScopeCount): void { + $barScopeCount++; + }); + $this->emitter->emitEvent('Test', 'test'); + + $this->assertSame(1, $testScopeCount); + $this->assertSame(0, $barScopeCount); + $this->emitter->emitEvent('Bar', 'test'); - $this->assertEquals(2, $count, 'Listener called an invalid number of times (' . $count . ') expected 2'); + + $this->assertSame(1, $testScopeCount); + $this->assertSame(1, $barScopeCount); } public function testDifferentCallbacks(): void { - $count = 0; - $listener1 = function () use (&$count): void { - $count++; + $listener1Count = 0; + $listener2Count = 0; + + $listener1 = function () use (&$listener1Count): void { + $listener1Count++; }; - $listener2 = function () use (&$count): void { - $count++; + $listener2 = function () use (&$listener2Count): void { + $listener2Count++; }; + $this->emitter->listen('Test', 'test', $listener1); $this->emitter->listen('Test', 'test', $listener2); $this->emitter->emitEvent('Test', 'test'); - $this->assertEquals(2, $count, 'Listener called an invalid number of times (' . $count . ') expected 2'); + + $this->assertSame(1, $listener1Count); + $this->assertSame(1, $listener2Count); } public function testArguments(): void { - $this->expectException(\Test\Hooks\EmittedException::class); + $receivedArguments = null; - $this->emitter->listen('Test', 'test', function ($foo, $bar): void { - if ($foo === 'foo' && $bar === 'bar') { - throw new EmittedException; - } + $this->emitter->listen('Test', 'test', function ($foo, $bar) use (&$receivedArguments): void { + $receivedArguments = [$foo, $bar]; }); + $this->emitter->emitEvent('Test', 'test', ['foo', 'bar']); + + $this->assertSame(['foo', 'bar'], $receivedArguments); } public function testNamedArguments(): void { - $this->expectException(\Test\Hooks\EmittedException::class); + $receivedArguments = null; - $this->emitter->listen('Test', 'test', function ($foo, $bar): void { - if ($foo === 'foo' && $bar === 'bar') { - throw new EmittedException; - } + $this->emitter->listen('Test', 'test', function ($bar, $foo) use (&$receivedArguments): void { + $receivedArguments = [$foo, $bar]; }); - $this->emitter->emitEvent('Test', 'test', ['foo' => 'foo', 'bar' => 'bar']); + + $this->emitter->emitEvent('Test', 'test', [ + 'foo' => 'foo', + 'bar' => 'bar', + ]); + + $this->assertSame(['foo', 'bar'], $receivedArguments); } - public function testRemoveAllSpecified(): void { - $listener = function (): void { - throw new EmittedException; + public function testRemoveSpecifiedCallback(): void { + $count = 0; + $listener = function () use (&$count): void { + $count++; }; + $this->emitter->listen('Test', 'test', $listener); $this->emitter->removeListener('Test', 'test', $listener); $this->emitter->emitEvent('Test', 'test'); - $this->addToAssertionCount(1); + $this->assertSame(0, $count); } - public function testRemoveWildcardListener(): void { - $listener1 = function (): void { - throw new EmittedException; + public function testRemoveAllListenersForEvent(): void { + $listener1Count = 0; + $listener2Count = 0; + + $listener1 = function () use (&$listener1Count): void { + $listener1Count++; }; - $listener2 = function (): void { - throw new EmittedException; + $listener2 = function () use (&$listener2Count): void { + $listener2Count++; }; + $this->emitter->listen('Test', 'test', $listener1); $this->emitter->listen('Test', 'test', $listener2); $this->emitter->removeListener('Test', 'test'); $this->emitter->emitEvent('Test', 'test'); - $this->addToAssertionCount(1); + $this->assertSame(0, $listener1Count); + $this->assertSame(0, $listener2Count); } - public function testRemoveWildcardMethod(): void { - $listener = function (): void { - throw new EmittedException; - }; - $this->emitter->listen('Test', 'test', $listener); - $this->emitter->listen('Test', 'foo', $listener); - $this->emitter->removeListener('Test', null, $listener); + public function testRemoveAllMethodsForScope(): void { + $testCount = 0; + $fooCount = 0; + $otherScopeCount = 0; + + $this->emitter->listen('Test', 'test', function () use (&$testCount): void { + $testCount++; + }); + $this->emitter->listen('Test', 'foo', function () use (&$fooCount): void { + $fooCount++; + }); + $this->emitter->listen('Bar', 'foo', function () use (&$otherScopeCount): void { + $otherScopeCount++; + }); + + // Remove all methods under the Test scope. + $this->emitter->removeListener('Test', null); + $this->emitter->emitEvent('Test', 'test'); $this->emitter->emitEvent('Test', 'foo'); + $this->emitter->emitEvent('Bar', 'foo'); - $this->addToAssertionCount(1); + $this->assertSame(0, $testCount); + $this->assertSame(0, $fooCount); + $this->assertSame(1, $otherScopeCount); } - public function testRemoveWildcardScope(): void { - $listener = function (): void { - throw new EmittedException; - }; - $this->emitter->listen('Test', 'test', $listener); - $this->emitter->listen('Bar', 'test', $listener); - $this->emitter->removeListener(null, 'test', $listener); + public function testRemoveMethodAcrossAllScopes(): void { + $testScopeCount = 0; + $barScopeCount = 0; + $otherMethodCount = 0; + + $this->emitter->listen('Test', 'test', function () use (&$testScopeCount): void { + $testScopeCount++; + }); + $this->emitter->listen('Bar', 'test', function () use (&$barScopeCount): void { + $barScopeCount++; + }); + $this->emitter->listen('Test', 'foo', function () use (&$otherMethodCount): void { + $otherMethodCount++; + }); + + // Remove this method across all scopes. + $this->emitter->removeListener(null, 'test'); + $this->emitter->emitEvent('Test', 'test'); $this->emitter->emitEvent('Bar', 'test'); + $this->emitter->emitEvent('Test', 'foo'); - $this->addToAssertionCount(1); + $this->assertSame(0, $testScopeCount); + $this->assertSame(0, $barScopeCount); + $this->assertSame(1, $otherMethodCount); } - public function testRemoveWildcardScopeAndMethod(): void { - $listener = function (): void { - throw new EmittedException; + public function testRemoveCallbackEverywhere(): void { + $removedCount = 0; + $keptCount = 0; + + $listener = function () use (&$removedCount): void { + $removedCount++; }; + $remainingListener = function () use (&$keptCount): void { + $keptCount++; + }; + + // Register the same target callback in multiple combinations. $this->emitter->listen('Test', 'test', $listener); $this->emitter->listen('Test', 'foo', $listener); $this->emitter->listen('Bar', 'foo', $listener); + + // Register an unrelated callback alongside each target registration. + $this->emitter->listen('Test', 'test', $remainingListener); + $this->emitter->listen('Test', 'foo', $remainingListener); + $this->emitter->listen('Bar', 'foo', $remainingListener); + + // Remove the target callback from every scope and method. $this->emitter->removeListener(null, null, $listener); + $this->emitter->emitEvent('Test', 'test'); $this->emitter->emitEvent('Test', 'foo'); $this->emitter->emitEvent('Bar', 'foo'); - $this->addToAssertionCount(1); + $this->assertSame(0, $removedCount); + $this->assertSame(3, $keptCount); } public function testRemoveKeepOtherCallback(): void { - $this->expectException(\Test\Hooks\EmittedException::class); + $remainingListenerCount = 0; - $listener1 = function (): void { - throw new EmittedException; + $listenerToRemove = function (): void { + throw new \LogicException('Removed listener was called'); }; - $listener2 = function (): void { - throw new EmittedException; + $remainingListener = function () use (&$remainingListenerCount): void { + $remainingListenerCount++; }; - $this->emitter->listen('Test', 'test', $listener1); - $this->emitter->listen('Test', 'test', $listener2); - $this->emitter->removeListener('Test', 'test', $listener1); + + $this->emitter->listen('Test', 'test', $listenerToRemove); + $this->emitter->listen('Test', 'test', $remainingListener); + $this->emitter->removeListener('Test', 'test', $listenerToRemove); $this->emitter->emitEvent('Test', 'test'); - $this->addToAssertionCount(1); + $this->assertSame(1, $remainingListenerCount); } public function testRemoveKeepOtherMethod(): void { - $this->expectException(\Test\Hooks\EmittedException::class); + $testCount = 0; + $fooCount = 0; - $listener = function (): void { - throw new EmittedException; + $testListener = function () use (&$testCount): void { + $testCount++; }; - $this->emitter->listen('Test', 'test', $listener); - $this->emitter->listen('Test', 'foo', $listener); - $this->emitter->removeListener('Test', 'foo', $listener); + $fooListener = function () use (&$fooCount): void { + $fooCount++; + }; + + $this->emitter->listen('Test', 'test', $testListener); + $this->emitter->listen('Test', 'foo', $fooListener); + $this->emitter->removeListener('Test', 'foo', $fooListener); $this->emitter->emitEvent('Test', 'test'); + $this->emitter->emitEvent('Test', 'foo'); - $this->addToAssertionCount(1); + $this->assertSame(1, $testCount); + $this->assertSame(0, $fooCount); } public function testRemoveKeepOtherScope(): void { - $this->expectException(\Test\Hooks\EmittedException::class); + $testScopeCount = 0; + $barScopeCount = 0; - $listener = function (): void { - throw new EmittedException; + $testListener = function () use (&$testScopeCount): void { + $testScopeCount++; }; - $this->emitter->listen('Test', 'test', $listener); - $this->emitter->listen('Bar', 'test', $listener); - $this->emitter->removeListener('Bar', 'test', $listener); + $barListener = function () use (&$barScopeCount): void { + $barScopeCount++; + }; + + $this->emitter->listen('Test', 'test', $testListener); + $this->emitter->listen('Bar', 'test', $barListener); + $this->emitter->removeListener('Bar', 'test', $barListener); + $this->emitter->emitEvent('Test', 'test'); + $this->emitter->emitEvent('Bar', 'test'); - $this->addToAssertionCount(1); + $this->assertSame(1, $testScopeCount); + $this->assertSame(0, $barScopeCount); } public function testRemoveNonExistingName(): void { - $this->expectException(\Test\Hooks\EmittedException::class); - - $listener = function (): void { - throw new EmittedException; + $count = 0; + $listener = function () use (&$count): void { + $count++; }; + $this->emitter->listen('Test', 'test', $listener); $this->emitter->removeListener('Bar', 'test', $listener); $this->emitter->emitEvent('Test', 'test'); - $this->addToAssertionCount(1); + $this->assertSame(1, $count); } } From 3490fc83083b8ca9ac3dc13f5bc01f8fd8c63a29 Mon Sep 17 00:00:00 2001 From: Josh Date: Sat, 1 Aug 2026 11:29:01 -0400 Subject: [PATCH 3/3] test(hooks): cover strict listener removal matching Add regression coverage for `removeListener()` behavior when scope or method names are empty strings. Verify that empty values are treated as explicit listener names rather than wildcards, while `null` continues to select wildcard removal behavior. Also cover removal of empty methods across scopes and ensure strict matching does not treat non-string values such as `0` as equivalent to string listener names. Assisted-by: Copilot:GPT-5 Signed-off-by: Josh --- tests/lib/Hooks/BasicEmitterTest.php | 106 +++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/tests/lib/Hooks/BasicEmitterTest.php b/tests/lib/Hooks/BasicEmitterTest.php index 9a642daafe80e..07fa514b7a2cb 100644 --- a/tests/lib/Hooks/BasicEmitterTest.php +++ b/tests/lib/Hooks/BasicEmitterTest.php @@ -231,6 +231,86 @@ public function testRemoveAllMethodsForScope(): void { $this->assertSame(1, $otherScopeCount); } + public function testRemoveListenerWithEmptyScope(): void { + $emptyScopeCount = 0; + $otherScopeCount = 0; + + $this->emitter->listen('', 'test', function () use (&$emptyScopeCount): void { + $emptyScopeCount++; + }); + $this->emitter->listen('Test', 'test', function () use (&$otherScopeCount): void { + $otherScopeCount++; + }); + + $this->emitter->removeListener('', 'test'); + + $this->emitter->emitEvent('', 'test'); + $this->emitter->emitEvent('Test', 'test'); + + $this->assertSame(0, $emptyScopeCount); + $this->assertSame(1, $otherScopeCount); + } + + public function testRemoveListenerWithEmptyMethod(): void { + $emptyMethodCount = 0; + $otherMethodCount = 0; + + $this->emitter->listen('Test', '', function () use (&$emptyMethodCount): void { + $emptyMethodCount++; + }); + $this->emitter->listen('Test', 'test', function () use (&$otherMethodCount): void { + $otherMethodCount++; + }); + + $this->emitter->removeListener('Test', ''); + + $this->emitter->emitEvent('Test', ''); + $this->emitter->emitEvent('Test', 'test'); + + $this->assertSame(0, $emptyMethodCount); + $this->assertSame(1, $otherMethodCount); + } + + public function testRemoveAllMethodsForEmptyScope(): void { + $emptyScopeTestCount = 0; + $emptyScopeFooCount = 0; + $otherScopeCount = 0; + + $this->emitter->listen('', 'test', function () use (&$emptyScopeTestCount): void { + $emptyScopeTestCount++; + }); + $this->emitter->listen('', 'foo', function () use (&$emptyScopeFooCount): void { + $emptyScopeFooCount++; + }); + $this->emitter->listen('Test', 'foo', function () use (&$otherScopeCount): void { + $otherScopeCount++; + }); + + $this->emitter->removeListener('', null); + + $this->emitter->emitEvent('', 'test'); + $this->emitter->emitEvent('', 'foo'); + $this->emitter->emitEvent('Test', 'foo'); + + $this->assertSame(0, $emptyScopeTestCount); + $this->assertSame(0, $emptyScopeFooCount); + $this->assertSame(1, $otherScopeCount); + } + + public function testRemoveListenerDoesNotLooselyMatchScope(): void { + $count = 0; + $listener = function () use (&$count): void { + $count++; + }; + + $this->emitter->listen('0', 'test', $listener); + + $this->emitter->removeListener(0, null, $listener); + $this->emitter->emitEvent('0', 'test'); + + $this->assertSame(1, $count); + } + public function testRemoveMethodAcrossAllScopes(): void { $testScopeCount = 0; $barScopeCount = 0; @@ -258,6 +338,32 @@ public function testRemoveMethodAcrossAllScopes(): void { $this->assertSame(1, $otherMethodCount); } + public function testRemoveEmptyMethodAcrossAllScopes(): void { + $testScopeCount = 0; + $barScopeCount = 0; + $otherMethodCount = 0; + + $this->emitter->listen('Test', '', function () use (&$testScopeCount): void { + $testScopeCount++; + }); + $this->emitter->listen('Bar', '', function () use (&$barScopeCount): void { + $barScopeCount++; + }); + $this->emitter->listen('Test', 'test', function () use (&$otherMethodCount): void { + $otherMethodCount++; + }); + + $this->emitter->removeListener(null, ''); + + $this->emitter->emitEvent('Test', ''); + $this->emitter->emitEvent('Bar', ''); + $this->emitter->emitEvent('Test', 'test'); + + $this->assertSame(0, $testScopeCount); + $this->assertSame(0, $barScopeCount); + $this->assertSame(1, $otherMethodCount); + } + public function testRemoveCallbackEverywhere(): void { $removedCount = 0; $keptCount = 0;