feat: enhance websocket attribute handling with prefix and suffix support; add HTTP controller fallback for event resolution; implement comprehensive tests for dispatcher and event registry

This commit is contained in:
Fabian @ Blax Software 2026-04-29 08:29:49 +02:00
parent fb84abb464
commit be3c7400fe
12 changed files with 1006 additions and 17 deletions

View File

@ -37,17 +37,36 @@ final class Websocket
{ {
public function __construct( 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, public ?string $event = null,
/** /**
* Event prefix override (the part before the ".") useful when the * Event prefix override the part **before** the dot. Useful when
* controller's class name doesn't match the desired event prefix. * the controller's class name (or namespace) doesn't match the
* The method name supplies the suffix. * 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, 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. * Whether the method requires an authenticated websocket connection.
* Mirrors the `$need_auth` property on existing WS controllers. * Mirrors the `$need_auth` property on existing WS controllers.

View File

@ -76,6 +76,15 @@ class Controller
$controllerClass = ControllerResolver::resolve($eventPrefix); $controllerClass = ControllerResolver::resolve($eventPrefix);
if (! $controllerClass) { 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'); 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<int, mixed>
*/
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 * Resolve a user from an authtoken string. First tries the configured
* `websockets.auth_resolver` callable; falls back to Laravel Sanctum's * `websockets.auth_resolver` callable; falls back to Laravel Sanctum's

View File

@ -105,9 +105,12 @@ class EventRegistry
* Api\V1FlightschoolController, then * Api\V1FlightschoolController, then
* Api\V1\FlightschoolController. * Api\V1\FlightschoolController.
* *
* That last form matches our v1 layout exactly so an event sent to * That last form matches the v1 layout exactly so an event sent to
* `api-v1-flightschool.index` ends up at the same controller * `api-v1-flightschool.index` ends up at the same controller regardless
* regardless of which side (registry or resolver) finds it first. * 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 public static function eventPrefixFor(string $fqcn, string $baseNamespace = 'App\\Http\\Controllers\\'): string
{ {
@ -116,7 +119,6 @@ class EventRegistry
if (str_starts_with($fqcn, $baseNamespace)) { if (str_starts_with($fqcn, $baseNamespace)) {
$relative = substr($fqcn, strlen($baseNamespace)); $relative = substr($fqcn, strlen($baseNamespace));
} else { } else {
// Fallback: just the short class name
$relative = ltrim(strrchr($fqcn, '\\') ?: $fqcn, '\\'); $relative = ltrim(strrchr($fqcn, '\\') ?: $fqcn, '\\');
} }
@ -127,8 +129,7 @@ class EventRegistry
$last = preg_replace('/Controller$/', '', $last) ?? $last; $last = preg_replace('/Controller$/', '', $last) ?? $last;
if ($last === '') { if ($last === '') {
// Defensive: class literally named "Controller" — fall back to // Defensive: class literally named "Controller"
// parent folder if any.
$last = array_pop($segments) ?? 'controller'; $last = array_pop($segments) ?? 'controller';
} }
@ -145,9 +146,7 @@ class EventRegistry
} }
/** /**
* Backwards-compatible alias for {@see eventPrefixFor()}. * @deprecated Use {@see eventPrefixFor()}.
*
* @deprecated Use {@see eventPrefixFor()} which understands folder structure.
*/ */
public static function defaultPrefixFor(string $shortClassName): string public static function defaultPrefixFor(string $shortClassName): string
{ {
@ -168,18 +167,29 @@ class EventRegistry
$autoPrefix = self::eventPrefixFor($class, $baseNamespace); $autoPrefix = self::eventPrefixFor($class, $baseNamespace);
$classPrefix = null; $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; $classAttr = $reflection->getAttributes(Websocket::class)[0] ?? null;
if ($classAttr) { if ($classAttr) {
/** @var Websocket $instance */ /** @var Websocket $instance */
$instance = $classAttr->newInstance(); $instance = $classAttr->newInstance();
$classPrefix = $instance->prefix ?? $autoPrefix; $classPrefix = $instance->prefix ?? $autoPrefix;
$classNeedAuth = $instance->needAuth;
foreach ($reflection->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { foreach ($reflection->getMethods(ReflectionMethod::IS_PUBLIC) as $method) {
if ($method->isStatic() || $method->isAbstract() || $method->getDeclaringClass()->getName() !== $class) { if ($method->isStatic() || $method->isAbstract() || $method->getDeclaringClass()->getName() !== $class) {
continue; 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()); $event = $instance->event ?? ($classPrefix . '.' . $method->getName());
self::$map[$event] = [ self::$map[$event] = [
'class' => $class, '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) { foreach ($reflection->getMethods(ReflectionMethod::IS_PUBLIC) as $method) {
if ($method->getDeclaringClass()->getName() !== $class) { if ($method->getDeclaringClass()->getName() !== $class) {
continue; continue;
@ -199,16 +209,26 @@ class EventRegistry
/** @var Websocket $instance */ /** @var Websocket $instance */
$instance = $attr->newInstance(); $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 $prefix = $instance->prefix
?? $classPrefix ?? $classPrefix
?? $autoPrefix; ?? $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] = [ self::$map[$event] = [
'class' => $class, 'class' => $class,
'method' => $method->getName(), 'method' => $method->getName(),
'needAuth' => $instance->needAuth, 'needAuth' => $instance->needAuth || $classNeedAuth,
]; ];
} }
} }

View File

@ -0,0 +1,245 @@
<?php
declare(strict_types=1);
namespace BlaxSoftware\LaravelWebSockets\Tests\Unit;
use BlaxSoftware\LaravelWebSockets\ChannelManagers\LocalChannelManager;
use BlaxSoftware\LaravelWebSockets\Channels\Channel;
use BlaxSoftware\LaravelWebSockets\Test\TestCase;
use BlaxSoftware\LaravelWebSockets\Test\Unit\Support\RecordingConnection;
use BlaxSoftware\LaravelWebSockets\Websocket\Controller;
use BlaxSoftware\LaravelWebSockets\Websocket\ControllerResolver;
use BlaxSoftware\LaravelWebSockets\Websocket\EventRegistry;
use React\EventLoop\Factory as LoopFactory;
/**
* End-to-end tests for the EventRegistry HTTP-controller fallback wired
* into {@see Controller::controll_message()}. The flow under test:
*
* ControllerResolver::resolve($prefix) === null
*
* EventRegistry::resolve($eventName) {class, method, needAuth}
*
* Controller::dispatchHttpAttributeTarget()
*
* payload normalized + $connection->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<string, mixed> $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<string, mixed> $data
* @return array<int, mixed>
*/
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
);
}
}

View File

@ -0,0 +1,315 @@
<?php
declare(strict_types=1);
namespace BlaxSoftware\LaravelWebSockets\Tests\Unit;
use BlaxSoftware\LaravelWebSockets\Attributes\Websocket;
use BlaxSoftware\LaravelWebSockets\Test\TestCase;
use BlaxSoftware\LaravelWebSockets\Websocket\EventRegistry;
class EventRegistryTest extends TestCase
{
/**
* Namespace prefix shared by every fixture under tests/Unit/Fixtures/Controllers.
* Tests pin this base namespace explicitly so event-prefix derivation is
* deterministic (independent of where the test runs from).
*/
private const FIXTURES_NS = 'BlaxSoftware\\LaravelWebSockets\\Test\\Unit\\Fixtures\\Controllers';
public function setUp(): void
{
parent::setUp();
EventRegistry::clear();
EventRegistry::setSearchPaths([
__DIR__ . '/Fixtures/Controllers' => 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);
}
}

View File

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace BlaxSoftware\LaravelWebSockets\Test\Unit\Fixtures\Controllers;
use BlaxSoftware\LaravelWebSockets\Attributes\Websocket;
/**
* Abstract classes must NOT register events even if attributes are present.
*/
#[Websocket]
abstract class AbstractBaseController
{
#[Websocket]
public function shouldNotRegister(): array
{
return [];
}
}

View File

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace BlaxSoftware\LaravelWebSockets\Test\Unit\Fixtures\Controllers\Api\V1;
use BlaxSoftware\LaravelWebSockets\Attributes\Websocket;
/**
* Folder-aware default: nested under Api\V1, so the auto-derived prefix is
* "api-v1-me" (mirrors what ControllerResolver would build in reverse).
*/
class MeController
{
#[Websocket(needAuth: true)]
public function show(): array
{
return ['endpoint' => 'api-v1-me.show'];
}
}

View File

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace BlaxSoftware\LaravelWebSockets\Test\Unit\Fixtures\Controllers;
use BlaxSoftware\LaravelWebSockets\Attributes\Websocket;
/**
* Class-level attribute: applies prefix + needAuth to every public method.
* A per-method attribute on `overridden()` re-routes that single method
* without affecting the rest.
*/
#[Websocket(prefix: 'class-prefixed', needAuth: true)]
class ClassLevelController
{
public function alpha(): array
{
return ['endpoint' => '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'];
}
}

View File

@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace BlaxSoftware\LaravelWebSockets\Test\Unit\Fixtures\Controllers;
use BlaxSoftware\LaravelWebSockets\Attributes\Websocket;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
/**
* Exercises the various return shapes the dispatcher must handle, plus
* positional argument resolution from the WS payload.
*/
class DispatchableController
{
/** Plain array — the simplest case. */
#[Websocket(suffix: 'array')]
public function returnsArray(): array
{
return ['kind' => '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];
}
}

View File

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace BlaxSoftware\LaravelWebSockets\Test\Unit\Fixtures\Controllers;
use BlaxSoftware\LaravelWebSockets\Attributes\Websocket;
/**
* Exercises every override: `prefix`, `suffix`, and the full `event` override.
*/
class OverrideController
{
// Default: derived prefix `override`, method name `defaulted`
#[Websocket]
public function defaulted(): array
{
return ['endpoint' => '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'];
}
}

View File

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace BlaxSoftware\LaravelWebSockets\Test\Unit\Fixtures\Controllers;
use BlaxSoftware\LaravelWebSockets\Attributes\Websocket;
/**
* Default-everything fixture: leaf class, no overrides.
* Expected event names with default scan (base = .../Fixtures/Controllers):
* plain.alpha
* plain.bravo
*/
class PlainController
{
#[Websocket]
public function alpha(): array
{
return ['endpoint' => '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'];
}
}

View File

@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace BlaxSoftware\LaravelWebSockets\Test\Unit\Support;
use Ratchet\ConnectionInterface;
/**
* Minimal Ratchet connection double for testing captures every payload
* pushed via `send()` so the test can assert on the dispatcher's output.
*
* Mirrors the public-property shape that {@see \BlaxSoftware\LaravelWebSockets\Websocket\Controller}
* pokes at (notably `$user` for the auth-gating path).
*/
class RecordingConnection implements ConnectionInterface
{
public ?object $user = null;
/** @var array<int, string> */
public array $sentRaw = [];
/** @var array<int, mixed> */
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;
}
}