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:
parent
fb84abb464
commit
be3c7400fe
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
* `websockets.auth_resolver` callable; falls back to Laravel Sanctum's
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 [];
|
||||
}
|
||||
}
|
||||
|
|
@ -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'];
|
||||
}
|
||||
}
|
||||
|
|
@ -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'];
|
||||
}
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
|
@ -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'];
|
||||
}
|
||||
}
|
||||
|
|
@ -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'];
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue