diff --git a/src/Attributes/Websocket.php b/src/Attributes/Websocket.php index d6d2d2a..a821962 100644 --- a/src/Attributes/Websocket.php +++ b/src/Attributes/Websocket.php @@ -37,17 +37,36 @@ final class Websocket { public function __construct( /** - * Full event name, e.g. "flightschool.index". Wins over `prefix`. + * Full event name, e.g. "flightschool.index". Wins over both + * `prefix` and `suffix`. */ public ?string $event = null, /** - * Event prefix override (the part before the ".") — useful when the - * controller's class name doesn't match the desired event prefix. - * The method name supplies the suffix. + * Event prefix override — the part **before** the dot. Useful when + * the controller's class name (or namespace) doesn't match the + * desired event prefix. + * + * Defaults to a kebab-case derivation of the controller's path + * relative to `App\Http\Controllers\`: + * - App\Http\Controllers\Api\V1\MeController → 'api-v1-me' + * - App\Http\Controllers\Admin\UserSettingsController → 'admin-user-settings' */ public ?string $prefix = null, + /** + * Event suffix override — the part **after** the dot. Useful when + * the WS event name should differ from the PHP method name. + * + * Defaults to the actual method name (matching how the WS dispatcher + * calls `event[1]` verbatim as the method name on the resolved + * controller). + * + * Class-level usage of this argument is ignored — the suffix is a + * per-method concept by definition. + */ + public ?string $suffix = null, + /** * Whether the method requires an authenticated websocket connection. * Mirrors the `$need_auth` property on existing WS controllers. diff --git a/src/Websocket/Controller.php b/src/Websocket/Controller.php index 745b338..b2e1c83 100644 --- a/src/Websocket/Controller.php +++ b/src/Websocket/Controller.php @@ -76,6 +76,15 @@ class Controller $controllerClass = ControllerResolver::resolve($eventPrefix); if (! $controllerClass) { + // Fallback: an HTTP controller method tagged with the + // #[Websocket] attribute may handle this event. The registry + // exposes a flat event-name → callable map built by + // reflecting attribute-tagged methods at scan time. + $target = EventRegistry::resolve($message['event']); + if ($target) { + return self::dispatchHttpAttributeTarget($connection, $message, $target); + } + return self::send_error($connection, $message, 'Event could not be associated'); } @@ -165,6 +174,118 @@ class Controller } } + /** + * Dispatch an event whose target is an HTTP controller method tagged + * with the {@see \BlaxSoftware\LaravelWebSockets\Attributes\Websocket} + * attribute (resolved via {@see EventRegistry}). + * + * HTTP controllers don't accept the WS-specific constructor args + * ($connection, $channel, $event, $channelManager), so we resolve them + * through the Laravel container and shape the call to look like a + * normal HTTP invocation: + * + * - request()->merge($data) so `request('foo')` works + * - method positional args resolved by name from $data + * - JsonResponse/Response payloads unwrapped to plain data + * + * Auth gating mirrors the standard flow: `needAuth` enforces an + * authenticated $connection->user, with the same self-heal hop via + * the auth token if the parent process didn't see it. + * + * @param array{class: class-string, method: string, needAuth: bool} $target + */ + protected static function dispatchHttpAttributeTarget( + ConnectionInterface $connection, + array $message, + array $target + ) { + if ($target['needAuth'] && ! ($connection->user ?? null)) { + $authtoken = @$message['data']['authtoken'] ?? null; + if ($authtoken) { + try { + $resolved = self::resolveUserFromToken($authtoken); + if ($resolved) { + $connection->user = $resolved; + Auth::login($connection->user); + } + } catch (\Throwable $e) { + // self-heal failed; fall through to the unauthorized branch + } + } + + if (! ($connection->user ?? null)) { + return self::send_error($connection, $message, 'Unauthorized'); + } + } + + $data = is_array($message['data'] ?? null) ? $message['data'] : []; + + // Make WS data visible to anything that calls `request()`. + try { + request()->merge($data); + } catch (\Throwable $e) { + // request() not bound — shouldn't happen in a Laravel app, ignore. + } + + $instance = app($target['class']); + $args = self::resolveAttributeMethodArgs($target['class'], $target['method'], $data); + + $payload = $instance->{$target['method']}(...$args); + + // Normalize Response/JsonResponse → plain data + if ($payload instanceof \Illuminate\Http\JsonResponse) { + $payload = $payload->getData(true); + } elseif ($payload instanceof \Symfony\Component\HttpFoundation\Response) { + $decoded = json_decode($payload->getContent() ?: 'null', true); + $payload = (json_last_error() === JSON_ERROR_NONE) ? $decoded : $payload->getContent(); + } + + $connection->send(json_encode([ + 'event' => $message['event'] . ':response', + 'data' => $payload, + 'channel' => $message['channel'] ?? null, + ])); + + return $payload; + } + + /** + * Reflect the target method and pull positional args by parameter name + * from the WS payload. This mirrors how Laravel's HTTP route bindings + * pass URL segments to controller methods (e.g. `show(string $slug)` + * gets the `slug` value from $data['slug']). + * + * Falls through to default values, then null for nullable params. + * + * @return array + */ + protected static function resolveAttributeMethodArgs(string $class, string $method, array $data): array + { + try { + $reflection = new \ReflectionMethod($class, $method); + } catch (\Throwable) { + return []; + } + + $args = []; + foreach ($reflection->getParameters() as $param) { + $name = $param->getName(); + if (array_key_exists($name, $data)) { + $args[] = $data[$name]; + } elseif ($param->isDefaultValueAvailable()) { + $args[] = $param->getDefaultValue(); + } elseif ($param->allowsNull()) { + $args[] = null; + } else { + // Required scalar with no value provided — leave the + // method to throw/validate so the error reaches the client. + break; + } + } + + return $args; + } + /** * Resolve a user from an authtoken string. First tries the configured * `websockets.auth_resolver` callable; falls back to Laravel Sanctum's diff --git a/src/Websocket/EventRegistry.php b/src/Websocket/EventRegistry.php index c1fb1cd..47ba356 100644 --- a/src/Websocket/EventRegistry.php +++ b/src/Websocket/EventRegistry.php @@ -105,9 +105,12 @@ class EventRegistry * Api\V1FlightschoolController, then * Api\V1\FlightschoolController. * - * That last form matches our v1 layout exactly — so an event sent to - * `api-v1-flightschool.index` ends up at the same controller - * regardless of which side (registry or resolver) finds it first. + * That last form matches the v1 layout exactly — so an event sent to + * `api-v1-flightschool.index` ends up at the same controller regardless + * of which side (registry or resolver) finds it first. + * + * Override per controller via `#[Websocket(prefix: '…')]`, or per method + * via `#[Websocket(suffix: '…')]` for the after-dot part. */ public static function eventPrefixFor(string $fqcn, string $baseNamespace = 'App\\Http\\Controllers\\'): string { @@ -116,7 +119,6 @@ class EventRegistry if (str_starts_with($fqcn, $baseNamespace)) { $relative = substr($fqcn, strlen($baseNamespace)); } else { - // Fallback: just the short class name $relative = ltrim(strrchr($fqcn, '\\') ?: $fqcn, '\\'); } @@ -127,8 +129,7 @@ class EventRegistry $last = preg_replace('/Controller$/', '', $last) ?? $last; if ($last === '') { - // Defensive: class literally named "Controller" — fall back to - // parent folder if any. + // Defensive: class literally named "Controller" $last = array_pop($segments) ?? 'controller'; } @@ -145,9 +146,7 @@ class EventRegistry } /** - * Backwards-compatible alias for {@see eventPrefixFor()}. - * - * @deprecated Use {@see eventPrefixFor()} which understands folder structure. + * @deprecated Use {@see eventPrefixFor()}. */ public static function defaultPrefixFor(string $shortClassName): string { @@ -168,18 +167,29 @@ class EventRegistry $autoPrefix = self::eventPrefixFor($class, $baseNamespace); $classPrefix = null; + $classNeedAuth = false; - // Class-level attribute: applies prefix to every public method + // Class-level attribute: applies prefix (and default needAuth) to every + // public method. The class-level `suffix` is intentionally ignored — + // suffix is a per-method concept by definition. $classAttr = $reflection->getAttributes(Websocket::class)[0] ?? null; if ($classAttr) { /** @var Websocket $instance */ $instance = $classAttr->newInstance(); $classPrefix = $instance->prefix ?? $autoPrefix; + $classNeedAuth = $instance->needAuth; foreach ($reflection->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { if ($method->isStatic() || $method->isAbstract() || $method->getDeclaringClass()->getName() !== $class) { continue; } + + // Skip methods that carry their own attribute — they're handled + // (and override the class-level entry) in the per-method pass below. + if (count($method->getAttributes(Websocket::class)) > 0) { + continue; + } + $event = $instance->event ?? ($classPrefix . '.' . $method->getName()); self::$map[$event] = [ 'class' => $class, @@ -189,7 +199,7 @@ class EventRegistry } } - // Method-level attributes override or supplement the class-level map + // Method-level attributes — override or supplement the class-level map foreach ($reflection->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { if ($method->getDeclaringClass()->getName() !== $class) { continue; @@ -199,16 +209,26 @@ class EventRegistry /** @var Websocket $instance */ $instance = $attr->newInstance(); + // Resolution order for the before-dot part: + // 1. explicit `event:` (full string wins, see below) + // 2. method-level `prefix:` + // 3. class-level `prefix:` (already merged into $classPrefix) + // 4. derived `eventPrefixFor($class, $baseNamespace)` $prefix = $instance->prefix ?? $classPrefix ?? $autoPrefix; - $event = $instance->event ?? ($prefix . '.' . $method->getName()); + // After-dot part — defaults to the actual PHP method name + // (matches how Controller::handle() uses event[1] verbatim + // as the method name on the resolved controller). + $suffix = $instance->suffix ?? $method->getName(); + + $event = $instance->event ?? ($prefix . '.' . $suffix); self::$map[$event] = [ 'class' => $class, 'method' => $method->getName(), - 'needAuth' => $instance->needAuth, + 'needAuth' => $instance->needAuth || $classNeedAuth, ]; } } diff --git a/tests/Unit/DispatcherFallbackTest.php b/tests/Unit/DispatcherFallbackTest.php new file mode 100644 index 0000000..b1ae9bc --- /dev/null +++ b/tests/Unit/DispatcherFallbackTest.php @@ -0,0 +1,245 @@ +send(...) + */ +class DispatcherFallbackTest extends TestCase +{ + private const FIXTURES_NS = 'BlaxSoftware\\LaravelWebSockets\\Test\\Unit\\Fixtures\\Controllers'; + + private LocalChannelManager $localChannelManager; + private Channel $testChannel; + + public function setUp(): void + { + parent::setUp(); + + ControllerResolver::clearCache(); + EventRegistry::clear(); + EventRegistry::setSearchPaths([ + __DIR__ . '/Fixtures/Controllers' => self::FIXTURES_NS, + ]); + + $this->localChannelManager = new LocalChannelManager(LoopFactory::create()); + $this->testChannel = new Channel('test-channel'); + } + + public function tearDown(): void + { + EventRegistry::clear(); + EventRegistry::setSearchPaths([ + __DIR__ . '/Fixtures/Controllers' => self::FIXTURES_NS, + ]); + parent::tearDown(); + } + + /** + * Build a WS message envelope. + * + * @param array $data + */ + private function message(string $event, array $data = []): array + { + return [ + 'event' => $event, + 'data' => $data, + 'channel' => 'test-channel', + ]; + } + + /** + * Run a full dispatch through `controll_message` and return both the + * direct return value and the recorded `send()` payload. + * + * @return array{return: mixed, sent: mixed, connection: RecordingConnection} + */ + private function dispatch(array $message, ?object $user = null): array + { + $connection = new RecordingConnection(); + $connection->user = $user; + + $return = Controller::controll_message($connection, $this->testChannel, $message, $this->localChannelManager); + + return [ + 'return' => $return, + 'sent' => $connection->lastPayload(), + 'connection' => $connection, + ]; + } + + // ───────────────────────────────────────────────────────────────── + // Fallback path + // ───────────────────────────────────────────────────────────────── + + /** @test */ + public function it_routes_to_a_registered_http_controller_when_resolver_misses() + { + $result = $this->dispatch($this->message('dispatchable.array')); + + $this->assertSame(['kind' => 'array', 'ok' => true], $result['return']); + $this->assertSame('dispatchable.array:response', $result['sent']['event']); + $this->assertSame(['kind' => 'array', 'ok' => true], $result['sent']['data']); + $this->assertSame('test-channel', $result['sent']['channel']); + } + + /** @test */ + public function it_falls_through_to_the_send_error_when_neither_resolver_nor_registry_matches() + { + $result = $this->dispatch($this->message('not-a-real-thing.show')); + + // send_error returns null and pushes an error envelope + $this->assertNull($result['return']); + $this->assertNotNull($result['sent']); + $this->assertArrayHasKey('event', $result['sent']); + $this->assertSame('not-a-real-thing.show:error', $result['sent']['event']); + } + + // ───────────────────────────────────────────────────────────────── + // Response normalization + // ───────────────────────────────────────────────────────────────── + + /** @test */ + public function it_unwraps_a_json_response_payload() + { + $result = $this->dispatch($this->message('dispatchable.json')); + + $this->assertSame(['kind' => 'json-response', 'ok' => true], $result['sent']['data']); + } + + /** @test */ + public function it_decodes_a_plain_response_with_json_body_into_an_array() + { + $result = $this->dispatch($this->message('dispatchable.response-json-body')); + + $this->assertSame(['kind' => 'response-json'], $result['sent']['data']); + } + + /** @test */ + public function it_passes_through_a_plain_response_with_text_body() + { + $result = $this->dispatch($this->message('dispatchable.response-text')); + + $this->assertSame('plain-text-body', $result['sent']['data']); + } + + // ───────────────────────────────────────────────────────────────── + // Argument resolution (positional from $data) + // ───────────────────────────────────────────────────────────────── + + /** @test */ + public function it_resolves_positional_arguments_by_parameter_name() + { + $result = $this->dispatch( + $this->message('dispatchable.with-arg', ['slug' => 'hello-world']) + ); + + $this->assertSame(['kind' => 'with-arg', 'slug' => 'hello-world'], $result['sent']['data']); + } + + /** @test */ + public function it_uses_default_argument_value_when_data_is_missing() + { + $result = $this->dispatch( + $this->message('dispatchable.with-default', /* no mode key */) + ); + + $this->assertSame(['kind' => 'with-default', 'mode' => 'fallback'], $result['sent']['data']); + } + + // ───────────────────────────────────────────────────────────────── + // Auth gating + // ───────────────────────────────────────────────────────────────── + + /** @test */ + public function it_blocks_a_protected_method_for_an_unauthenticated_connection() + { + $result = $this->dispatch($this->message('dispatchable.protected')); + + // No user attached → Unauthorized + $this->assertSame('dispatchable.protected:error', $result['sent']['event']); + $this->assertStringContainsString('Unauthorized', json_encode($result['sent']['data'])); + } + + /** @test */ + public function it_allows_a_protected_method_for_an_authenticated_connection() + { + // Any object with at least the shape Controller::dispatchHttpAttributeTarget() reads + $fakeUser = new \stdClass(); + $fakeUser->id = 42; + + $result = $this->dispatch($this->message('dispatchable.protected'), $fakeUser); + + $this->assertSame(['kind' => 'protected', 'ok' => true], $result['sent']['data']); + $this->assertSame('dispatchable.protected:response', $result['sent']['event']); + } + + // ───────────────────────────────────────────────────────────────── + // resolveAttributeMethodArgs (via reflection — covers edge cases the + // public dispatch test would only exercise indirectly). + // ───────────────────────────────────────────────────────────────── + + /** @test */ + public function resolveAttributeMethodArgs_picks_values_by_name() + { + $args = $this->callResolveArgs('withArg', ['slug' => 'foo', 'extra' => 'ignored']); + + $this->assertSame(['foo'], $args); + } + + /** @test */ + public function resolveAttributeMethodArgs_falls_back_to_default_value() + { + $args = $this->callResolveArgs('withDefault', []); + + $this->assertSame(['fallback'], $args); + } + + /** @test */ + public function resolveAttributeMethodArgs_breaks_at_required_missing_param() + { + // `withArg(string $slug)` — required, no default, not nullable → return [] so + // PHP's own ArgumentCountError surfaces during the actual invocation + $args = $this->callResolveArgs('withArg', []); + + $this->assertSame([], $args); + } + + /** + * @param array $data + * @return array + */ + private function callResolveArgs(string $method, array $data): array + { + $reflection = new \ReflectionMethod(Controller::class, 'resolveAttributeMethodArgs'); + $reflection->setAccessible(true); + + return $reflection->invoke( + null, + self::FIXTURES_NS . '\\DispatchableController', + $method, + $data + ); + } +} diff --git a/tests/Unit/EventRegistryTest.php b/tests/Unit/EventRegistryTest.php new file mode 100644 index 0000000..d0ba853 --- /dev/null +++ b/tests/Unit/EventRegistryTest.php @@ -0,0 +1,315 @@ + self::FIXTURES_NS, + ]); + } + + public function tearDown(): void + { + EventRegistry::clear(); + // Reset to default-detection so other tests aren't affected + EventRegistry::setSearchPaths([ + __DIR__ . '/Fixtures/Controllers' => self::FIXTURES_NS, + ]); + parent::tearDown(); + } + + // ───────────────────────────────────────────────────────────────── + // eventPrefixFor() — direct algorithm tests + // ───────────────────────────────────────────────────────────────── + + /** @test */ + public function it_derives_prefix_for_a_flat_controller() + { + $this->assertSame( + 'plain', + EventRegistry::eventPrefixFor( + self::FIXTURES_NS . '\\PlainController', + self::FIXTURES_NS . '\\' + ) + ); + } + + /** @test */ + public function it_derives_folder_aware_prefix_for_a_nested_controller() + { + $this->assertSame( + 'api-v1-me', + EventRegistry::eventPrefixFor( + self::FIXTURES_NS . '\\Api\\V1\\MeController', + self::FIXTURES_NS . '\\' + ) + ); + } + + /** @test */ + public function it_kebabs_multi_word_class_names() + { + $this->assertSame( + 'admin-user-settings', + EventRegistry::eventPrefixFor( + 'App\\Http\\Controllers\\Admin\\UserSettingsController', + 'App\\Http\\Controllers\\' + ) + ); + } + + /** @test */ + public function it_falls_back_to_short_name_when_base_namespace_does_not_match() + { + $this->assertSame( + 'foo', + EventRegistry::eventPrefixFor( + 'Some\\Other\\FooController', + 'App\\Http\\Controllers\\' + ) + ); + } + + /** @test */ + public function it_strips_the_controller_suffix() + { + $this->assertSame( + 'something', + EventRegistry::eventPrefixFor( + 'App\\Http\\Controllers\\SomethingController', + 'App\\Http\\Controllers\\' + ) + ); + } + + /** @test */ + public function it_handles_a_class_named_only_controller_defensively() + { + // 'App\\Http\\Controllers\\Controller' → strip 'Controller' from leaf + // leaves an empty leaf — fall back to a non-empty placeholder. + $prefix = EventRegistry::eventPrefixFor( + 'App\\Http\\Controllers\\Controller', + 'App\\Http\\Controllers\\' + ); + $this->assertNotSame('', $prefix); + } + + // ───────────────────────────────────────────────────────────────── + // map() — discovery + auto-defaults + // ───────────────────────────────────────────────────────────────── + + /** @test */ + public function it_auto_registers_tagged_methods_with_default_event_names() + { + $map = EventRegistry::map(); + + $this->assertArrayHasKey('plain.alpha', $map); + $this->assertArrayHasKey('plain.bravo', $map); + $this->assertSame(self::FIXTURES_NS . '\\PlainController', $map['plain.alpha']['class']); + $this->assertSame('alpha', $map['plain.alpha']['method']); + $this->assertFalse($map['plain.alpha']['needAuth']); + } + + /** @test */ + public function it_skips_untagged_methods() + { + $map = EventRegistry::map(); + + $this->assertArrayNotHasKey('plain.charlie', $map); + } + + /** @test */ + public function it_uses_folder_aware_prefix_for_nested_namespaces() + { + $map = EventRegistry::map(); + + $this->assertArrayHasKey('api-v1-me.show', $map); + $this->assertSame(self::FIXTURES_NS . '\\Api\\V1\\MeController', $map['api-v1-me.show']['class']); + $this->assertSame('show', $map['api-v1-me.show']['method']); + $this->assertTrue($map['api-v1-me.show']['needAuth']); + } + + /** @test */ + public function it_skips_abstract_classes() + { + $map = EventRegistry::map(); + + // AbstractBaseController is tagged but abstract; nothing should resolve to it + foreach ($map as $event => $target) { + $this->assertStringNotContainsString('AbstractBaseController', $target['class'], "Abstract class leaked via event '{$event}'"); + } + } + + // ───────────────────────────────────────────────────────────────── + // Override behavior: prefix, suffix, event + // ───────────────────────────────────────────────────────────────── + + /** @test */ + public function it_uses_default_event_name_when_no_arguments_given() + { + $map = EventRegistry::map(); + + $this->assertArrayHasKey('override.defaulted', $map); + } + + /** @test */ + public function it_honors_a_prefix_only_override() + { + $map = EventRegistry::map(); + + $this->assertArrayHasKey('custom-prefix.prefixed', $map); + $this->assertArrayNotHasKey('override.prefixed', $map, 'Auto-prefix should NOT also register when prefix: is overridden'); + $this->assertSame('prefixed', $map['custom-prefix.prefixed']['method']); + } + + /** @test */ + public function it_honors_a_suffix_only_override() + { + $map = EventRegistry::map(); + + $this->assertArrayHasKey('override.custom-suffix', $map); + $this->assertArrayNotHasKey('override.suffixed', $map, 'Default method-name suffix should NOT also register when suffix: is overridden'); + $this->assertSame('suffixed', $map['override.custom-suffix']['method'], 'PHP method name preserved even when WS suffix differs'); + } + + /** @test */ + public function it_honors_prefix_and_suffix_combined() + { + $map = EventRegistry::map(); + + $this->assertArrayHasKey('pre.post', $map); + $this->assertSame('bothOverridden', $map['pre.post']['method']); + } + + /** @test */ + public function the_event_argument_wins_over_prefix_and_suffix() + { + $map = EventRegistry::map(); + + $this->assertArrayHasKey('totally.custom', $map); + $this->assertArrayNotHasKey('ignored.ignored', $map); + $this->assertArrayNotHasKey('ignored.fullOverride', $map); + $this->assertSame('fullOverride', $map['totally.custom']['method']); + } + + // ───────────────────────────────────────────────────────────────── + // Class-level attribute + // ───────────────────────────────────────────────────────────────── + + /** @test */ + public function class_level_attribute_applies_to_every_public_method() + { + $map = EventRegistry::map(); + + $this->assertArrayHasKey('class-prefixed.alpha', $map); + $this->assertArrayHasKey('class-prefixed.bravo', $map); + } + + /** @test */ + public function class_level_need_auth_propagates_to_every_method() + { + $map = EventRegistry::map(); + + $this->assertTrue($map['class-prefixed.alpha']['needAuth']); + $this->assertTrue($map['class-prefixed.bravo']['needAuth']); + } + + /** @test */ + public function method_level_attribute_overrides_class_level_for_that_method_only() + { + $map = EventRegistry::map(); + + // Only the suffix differs — prefix is inherited from class-level + $this->assertArrayHasKey('class-prefixed.remapped', $map); + $this->assertArrayNotHasKey('class-prefixed.overridden', $map, 'Method-level override must replace, not duplicate'); + + // Other methods on the same class still use the default suffix + $this->assertArrayHasKey('class-prefixed.alpha', $map); + } + + // ───────────────────────────────────────────────────────────────── + // resolve(), clear(), setSearchPaths() + // ───────────────────────────────────────────────────────────────── + + /** @test */ + public function resolve_returns_null_for_unknown_events() + { + $this->assertNull(EventRegistry::resolve('this.does-not-exist')); + } + + /** @test */ + public function resolve_returns_target_for_known_events() + { + $target = EventRegistry::resolve('plain.alpha'); + + $this->assertNotNull($target); + $this->assertSame(self::FIXTURES_NS . '\\PlainController', $target['class']); + $this->assertSame('alpha', $target['method']); + $this->assertFalse($target['needAuth']); + } + + /** @test */ + public function clear_invalidates_the_cache() + { + // Build the map once to populate the cache + EventRegistry::map(); + + // Re-point search paths to a non-existent directory and clear + EventRegistry::clear(); + EventRegistry::setSearchPaths(['/nonexistent/path/that/does/not/exist' => 'App\\']); + + // Map should now be empty (no fixtures discoverable from the bogus path) + $this->assertSame([], EventRegistry::map()); + } + + /** @test */ + public function setSearchPaths_supports_explicit_path_to_namespace_map() + { + EventRegistry::clear(); + EventRegistry::setSearchPaths([ + __DIR__ . '/Fixtures/Controllers' => self::FIXTURES_NS, + ]); + + $this->assertNotEmpty(EventRegistry::map()); + } + + /** @test */ + public function the_attribute_constructor_defaults_are_all_null_or_false() + { + $attr = new Websocket(); + + $this->assertNull($attr->event); + $this->assertNull($attr->prefix); + $this->assertNull($attr->suffix); + $this->assertFalse($attr->needAuth); + } + + /** @test */ + public function the_attribute_accepts_named_arguments() + { + $attr = new Websocket(event: 'a.b', prefix: 'a', suffix: 'b', needAuth: true); + + $this->assertSame('a.b', $attr->event); + $this->assertSame('a', $attr->prefix); + $this->assertSame('b', $attr->suffix); + $this->assertTrue($attr->needAuth); + } +} diff --git a/tests/Unit/Fixtures/Controllers/AbstractBaseController.php b/tests/Unit/Fixtures/Controllers/AbstractBaseController.php new file mode 100644 index 0000000..c8ad876 --- /dev/null +++ b/tests/Unit/Fixtures/Controllers/AbstractBaseController.php @@ -0,0 +1,20 @@ + 'api-v1-me.show']; + } +} diff --git a/tests/Unit/Fixtures/Controllers/ClassLevelController.php b/tests/Unit/Fixtures/Controllers/ClassLevelController.php new file mode 100644 index 0000000..6048222 --- /dev/null +++ b/tests/Unit/Fixtures/Controllers/ClassLevelController.php @@ -0,0 +1,33 @@ + 'class-prefixed.alpha']; + } + + public function bravo(): array + { + return ['endpoint' => 'class-prefixed.bravo']; + } + + // Per-method override: suffix only — picks up class-level prefix + #[Websocket(suffix: 'remapped')] + public function overridden(): array + { + return ['endpoint' => 'class-prefixed.remapped']; + } +} diff --git a/tests/Unit/Fixtures/Controllers/DispatchableController.php b/tests/Unit/Fixtures/Controllers/DispatchableController.php new file mode 100644 index 0000000..95c544c --- /dev/null +++ b/tests/Unit/Fixtures/Controllers/DispatchableController.php @@ -0,0 +1,65 @@ + 'array', 'ok' => true]; + } + + /** JsonResponse → unwrapped to its `getData(true)` array. */ + #[Websocket(suffix: 'json')] + public function returnsJson(): JsonResponse + { + return new JsonResponse(['kind' => 'json-response', 'ok' => true]); + } + + /** Plain Response with JSON body → decoded to an array. */ + #[Websocket(suffix: 'response-json-body')] + public function returnsJsonBodyResponse(): Response + { + return new Response(json_encode(['kind' => 'response-json']), 200, ['Content-Type' => 'application/json']); + } + + /** Plain Response with non-JSON body → returned verbatim as a string. */ + #[Websocket(suffix: 'response-text')] + public function returnsTextResponse(): Response + { + return new Response('plain-text-body'); + } + + /** Receives a positional arg matched by parameter name from $data. */ + #[Websocket(suffix: 'with-arg')] + public function withArg(string $slug): array + { + return ['kind' => 'with-arg', 'slug' => $slug]; + } + + /** Optional default arg — used when caller omits it from $data. */ + #[Websocket(suffix: 'with-default')] + public function withDefault(string $mode = 'fallback'): array + { + return ['kind' => 'with-default', 'mode' => $mode]; + } + + /** Auth-required method. */ + #[Websocket(suffix: 'protected', needAuth: true)] + public function protectedAction(): array + { + return ['kind' => 'protected', 'ok' => true]; + } +} diff --git a/tests/Unit/Fixtures/Controllers/OverrideController.php b/tests/Unit/Fixtures/Controllers/OverrideController.php new file mode 100644 index 0000000..2ccbcc4 --- /dev/null +++ b/tests/Unit/Fixtures/Controllers/OverrideController.php @@ -0,0 +1,48 @@ + 'override.defaulted']; + } + + // prefix override only — keeps method name as suffix + #[Websocket(prefix: 'custom-prefix')] + public function prefixed(): array + { + return ['endpoint' => 'custom-prefix.prefixed']; + } + + // suffix override only — keeps derived prefix + #[Websocket(suffix: 'custom-suffix')] + public function suffixed(): array + { + return ['endpoint' => 'override.custom-suffix']; + } + + // both overrides combined + #[Websocket(prefix: 'pre', suffix: 'post')] + public function bothOverridden(): array + { + return ['endpoint' => 'pre.post']; + } + + // full event string — wins over both prefix and suffix + #[Websocket(event: 'totally.custom', prefix: 'ignored', suffix: 'ignored')] + public function fullOverride(): array + { + return ['endpoint' => 'totally.custom']; + } +} diff --git a/tests/Unit/Fixtures/Controllers/PlainController.php b/tests/Unit/Fixtures/Controllers/PlainController.php new file mode 100644 index 0000000..5c73e70 --- /dev/null +++ b/tests/Unit/Fixtures/Controllers/PlainController.php @@ -0,0 +1,34 @@ + 'plain.alpha']; + } + + #[Websocket] + public function bravo(string $id): array + { + return ['endpoint' => 'plain.bravo', 'id' => $id]; + } + + // Untagged — must NOT appear in the registry + public function charlie(): array + { + return ['endpoint' => 'plain.charlie']; + } +} diff --git a/tests/Unit/Support/RecordingConnection.php b/tests/Unit/Support/RecordingConnection.php new file mode 100644 index 0000000..c0e2036 --- /dev/null +++ b/tests/Unit/Support/RecordingConnection.php @@ -0,0 +1,49 @@ + */ + public array $sentRaw = []; + + /** @var array */ + public array $sentDecoded = []; + + /** + * @param string $data + */ + public function send($data): self + { + $this->sentRaw[] = (string) $data; + $decoded = json_decode((string) $data, true); + $this->sentDecoded[] = (json_last_error() === JSON_ERROR_NONE) ? $decoded : (string) $data; + return $this; + } + + public function close(): void + { + // no-op + } + + /** + * Return the most recent payload sent (decoded if it was JSON). + */ + public function lastPayload(): mixed + { + return $this->sentDecoded[array_key_last($this->sentDecoded)] ?? null; + } +}