diff --git a/AGENTS.md b/AGENTS.md index b7ba15bda..ab843c23a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -479,7 +479,7 @@ When writing or porting source classes that use static properties for caching (e 1. Add a `public static function flushState(): void` method that resets the static properties to their initial values 2. Check whether the subscriber (`src/testing/src/PHPUnit/AfterEachTestSubscriber.php`) should call it — if the cached state could leak between tests and cause failures, add the call -Framework-owned classes go in `AfterEachTestSubscriber`. First-party optional framework packages may stay in grouped optional methods at the bottom of that subscriber. Third-party packages, private packages, and applications should register their cleanup through `extra.hypervel.test-state` and a `TestState` registrar instead of hardcoding their classes into the framework subscriber. +Framework-owned classes go in `AfterEachTestSubscriber`. First-party optional framework packages should stay in grouped optional methods at the bottom of that subscriber. Third-party packages, private packages, and applications should register cleanup for process-local state that survives application teardown through `extra.hypervel.test-state` and a `TestState` registrar instead of hardcoding their classes into the framework subscriber. These callbacks run after the test application is destroyed, so they must not resolve container services; external resources remain owned by their test traits. Do not add `Hypervel\Testing\PHPUnit\AfterEachTestCleanup` itself to `AfterEachTestSubscriber`. Its callbacks are suite-level registrations that must persist for the PHPUnit worker lifetime. diff --git a/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md b/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md index 929f0cf23..30da5a91f 100644 --- a/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md +++ b/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md @@ -990,9 +990,9 @@ An exceptionally large shared work unit may receive its own linked detail plan w This compact index routes the completed-work history that must be consulted with the full plan after compaction. Detailed history remains in the [companion ledger](2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md). -- **Active package or work unit:** none — HTTP and Scout are complete and the next package is not selected. -- **Ledger entries required for the active work:** none. -- **Pending revalidation carried into the active work:** none. +- **Active package or work unit:** None. +- **Ledger entries required for the active work:** None. +- **Pending revalidation carried into the active work:** None. Update these three lines when a package starts, completes, or gains a cross-package dependency. Name exact work-unit headings or shared finding IDs from the companion ledger; never use “see recent entries” or require a full-ledger reread. @@ -1048,12 +1048,12 @@ Add one row only for a shared finding or changed lower-level assumption that ano | `events-01` | `foundation` | `events` and `foundation` (revalidation complete) | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `events-01` | | `events-03` | `events`, `queue` | `queue` (revalidation complete) | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `events-03` | | `events-04` | `events`, `foundation` | `foundation` (revalidation complete) | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `events-04` | -| `events-05` | `events`, `broadcasting` | later full `broadcasting` audit | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `events-05` | +| `events-05` | `events`, `broadcasting` | `broadcasting` (revalidation complete) | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `events-05` | | `events-06` | `events`, `foundation` | `foundation` (revalidation complete) | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `events-06` | -| `queue-11` | `queue` | `events` and `queue` (revalidation complete), `broadcasting`; later full `broadcasting` audit | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `queue-11` | -| `queue-12` | `bus`, `queue` | `events`, `bus`, and `queue` (revalidation complete), `broadcasting`; later full `broadcasting` audit | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `queue-12` | +| `queue-11` | `queue` | `events`, `queue`, and `broadcasting` (revalidation complete) | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `queue-11` | +| `queue-12` | `bus`, `queue` | `events`, `bus`, `queue`, and `broadcasting` (revalidation complete) | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `queue-12` | | `foundation-01` | `foundation` | `support` and `foundation` (revalidation complete) | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `foundation-01` | -| `support-02` | `support` | `auth`, `broadcasting`, `bus` (revalidation complete), `cache` (revalidation complete), `concurrency`, `console` (revalidation complete), `container`, `contracts`, `cookie`, `database` (revalidation complete), `events`, `filesystem` (revalidation complete), `foundation` (revalidation complete), `hashing` (revalidation complete), `horizon` (revalidation complete), `inertia`, `jwt`, `log`, `mail`, `notifications`, `permission`, `pipeline`, `queue` (revalidation complete), `redis` (revalidation complete), `reverb` (revalidation complete), `routing`, `sanctum`, `scout`, `session` (revalidation complete), `socialite`, `telescope`, `testbench`, `translation`; later full consumer audits | `Normalize framework enum identifiers at string boundaries`; finding `support-02`; sibling findings `translation-01` and `reverb-03`; linked detail plan `2026-07-15-0920-framework-enum-identifier-contracts.md` | +| `support-02` | `support` | `auth`, `broadcasting` (revalidation complete), `bus` (revalidation complete), `cache` (revalidation complete), `concurrency`, `console` (revalidation complete), `container`, `contracts`, `cookie`, `database` (revalidation complete), `events`, `filesystem` (revalidation complete), `foundation` (revalidation complete), `hashing` (revalidation complete), `horizon` (revalidation complete), `inertia`, `jwt`, `log`, `mail`, `notifications`, `permission`, `pipeline`, `queue` (revalidation complete), `redis` (revalidation complete), `reverb` (revalidation complete), `routing`, `sanctum`, `scout`, `session` (revalidation complete), `socialite`, `telescope`, `testbench`, `translation`; later full consumer audits | `Normalize framework enum identifiers at string boundaries`; finding `support-02`; sibling findings `translation-01` and `reverb-03`; linked detail plan `2026-07-15-0920-framework-enum-identifier-contracts.md` | | `auth-01` | `support`, `auth` | later full `auth` audit | `Correct Support utility boundaries and authentication timing isolation`; finding `auth-01` | | `encryption-03` | `encryption` | `contracts`, `support`, `filesystem`, and `foundation` (revalidation complete) | `Harden encryption rotation, key publication, and global lifecycle state`; finding `encryption-03` | | `sanctum-01` | `sanctum` | `encryption`; later full `sanctum` audit | `Harden encryption rotation, key publication, and global lifecycle state`; finding `sanctum-01` | @@ -1097,7 +1097,7 @@ Add one row only for a shared finding or changed lower-level assumption that ano | `redis-10` | `redis` | `reverb` (revalidation complete) | `Complete Redis pooling, subscriber transport, topology, parity, and lifecycle safety`; finding `redis-10` | | `redis-11` | `redis` | `reverb` (revalidation complete) | `Complete Redis pooling, subscriber transport, topology, parity, and lifecycle safety`; finding `redis-11` | | `redis-12` | `redis`, `cache` | `redis` and `cache` (revalidation complete) | `Complete Redis pooling, subscriber transport, topology, parity, and lifecycle safety`; finding `redis-12` | -| `redis-13` | `redis` | `horizon`, `cache`, `queue`, and `session` (revalidation complete), `broadcasting`; later full `broadcasting` audit | `Complete Redis pooling, subscriber transport, topology, parity, and lifecycle safety`; finding `redis-13` | +| `redis-13` | `redis` | `horizon`, `cache`, `queue`, `session`, and `broadcasting` (revalidation complete) | `Complete Redis pooling, subscriber transport, topology, parity, and lifecycle safety`; finding `redis-13` | | `redis-21` | `redis` | `queue` (revalidation complete) | `Complete Queue pooling, payload durability, and current Laravel parity`; finding `redis-21` | | `redis-22` | `redis` | `queue` and `support` (revalidation complete) | `Complete Queue pooling, payload durability, and current Laravel parity`; finding `redis-22` | | `reverb-05` | `reverb` | `redis` and `reverb` (revalidation complete) | `Complete Redis pooling, subscriber transport, topology, parity, and lifecycle safety`; finding `reverb-05` | @@ -1109,7 +1109,7 @@ Add one row only for a shared finding or changed lower-level assumption that ano | `cache-04` | `cache` | `auth`, `sanctum`, and `testbench` (revalidation complete); later full consumer audits | `Complete Cache parity, cleanup, permanence, and tagged ownership`; finding `cache-04` | | `filesystem-12` | `filesystem` | `session` (revalidation complete) | `Complete Session lifecycles, persistence, and current Laravel parity`; finding `filesystem-12` | | `session-23` | `cache` | `session` (revalidation complete) | `Complete Session lifecycles, persistence, and current Laravel parity`; finding `session-23` | -| `contracts-09` | `contracts` | `foundation` (revalidation complete), `broadcasting`; later full `broadcasting` audit | `Complete Queue pooling, payload durability, and current Laravel parity`; finding `contracts-09` | +| `contracts-09` | `contracts` | `foundation` and `broadcasting` (revalidation complete) | `Complete Queue pooling, payload durability, and current Laravel parity`; finding `contracts-09` | | `notifications-07` | `contracts` | later full `notifications` audit | `Complete Queue pooling, payload durability, and current Laravel parity`; finding `notifications-07` | | `queue-22` | `queue` | `horizon` (revalidation complete); later full `telescope` audit | `Complete Queue pooling, payload durability, and current Laravel parity`; finding `queue-22` | | `queue-29` | `queue` | `foundation` (revalidation complete) | `Complete Queue pooling, payload durability, and current Laravel parity`; finding `queue-29` | @@ -1236,7 +1236,7 @@ The order is lower-level first where practical. Hypervel has cross-cutting depen - [x] `http` - [ ] `api-client` - [x] `grpc` -- [ ] `broadcasting` +- [x] `broadcasting` - [ ] `mail` - [ ] `notifications` diff --git a/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md b/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md index 7b53b0403..ced6515e1 100644 --- a/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md +++ b/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md @@ -90,7 +90,7 @@ Append package entries in checklist order. Keep each entry compact but complete | `notifications-07` | Contract defect | Major | High | The Notification Factory rejects a supported single notifiable even though every implementation and the Dispatcher accepts it | Widen Factory notifiables to `mixed`, retain its two-parameter `sendNow()`, and keep channels on the Dispatcher contract | - **Important rejected concerns:** Do not rewrite validation around only `ValidationRule`; Laravel's deprecated contracts remain its live internal execution protocol, userland already receives the modern API, and a rewrite would add permanent synchronization cost without behavior gain. Do not remove `Console\Application::add()`: Symfony deprecated its underlying method, but Laravel deliberately retains, uses, and tests a non-deprecated wrapper over `addCommand()`. Do not add orphan Image contracts before Hypervel has a coherent Image package. Do not relocate the Monolog-specific context contract without a separately approved API redesign. Do not add optional dependencies merely because they appear only in lazy parameter/return types. Do not make public `View::share()` request-dependent or add a public request-sharing API. Do not keep a throwing `Request::get()` tombstone; record the intentional omission so static analysis rejects it. -- **Cross-package implications:** `validation-01` affects `validation`; `view-01` affects `view` and `foundation`; `filesystem-01` affects `filesystem`; `queue-01` affects `queue`; `contracts-04` affects `server`, `server-process`, `websocket-server`, and `reverb`; `contracts-05` affects `http`, `routing`, `foundation`, `console`, and `database`; `contracts-09` affects Foundation and Broadcasting; and `notifications-07` affects Notifications. Queue, Foundation, and HTTP revalidation is complete; the later HTTP work also completed the Contracts-owned part of `routing-01` by restoring the URL generator's open parameter-normalization contract. The remaining package checkboxes stay open until their own complete audits. +- **Cross-package implications:** `validation-01` affects `validation`; `view-01` affects `view` and `foundation`; `filesystem-01` affects `filesystem`; `queue-01` affects `queue`; `contracts-04` affects `server`, `server-process`, `websocket-server`, and `reverb`; `contracts-05` affects `http`, `routing`, `foundation`, `console`, and `database`; `contracts-09` affects Foundation and Broadcasting; and `notifications-07` affects Notifications. Queue, Foundation, Broadcasting, and HTTP revalidation is complete; the later HTTP work also completed the Contracts-owned part of `routing-01` by restoring the URL generator's open parameter-normalization contract. The remaining package checkboxes stay open until their own complete audits. - **Implementation:** Declared every external parent-interface dependency in the split package; added evidence-backed contract and implementation types; ported `ShouldBeDiscovered` with current upstream source, fixtures, tests, and docs; corrected the handshake/process spellings without aliases; removed only APIs directly deprecated by Laravel plus dead migration wiring; retained Laravel's live `Application::add()` wrapper; normalized every statically valid `resolveCommands()` argument shape; corrected Engine diagnostics; completed contract docs and upstream references; and added lifecycle warnings to concrete worker-state mutators. The later Queue work removed the optional broadcaster capability and widened Factory notifiables without changing Factory `sendNow()` arity or the Dispatcher channel boundary. The validation, view, filesystem, and queue corrections below were implemented at their owning boundaries, with superseded state writes, loose construction types, unrestorable identifier conversion, raw morph-alias restoration, dead dependencies, obsolete tests, and stale comments removed. - **Regression tests:** Added split-package dependency-presence coverage, discovery opt-out fixtures, exact Engine diagnostic coverage inside and outside a coroutine, custom-filesystem result validation, configured-rule clone/isolation coverage, deterministic concurrent view-error isolation, ordinary serialization for non-Eloquent queue contracts, morph-mapped Eloquent collection restoration, and all supported scalar/array `resolveCommands()` forms. Updated every affected contract implementation and test double, including integration-only caster fixtures; the later Queue gate verifies the broadcaster command and Notification implementers against the corrected contracts. - **Performance and complexity:** Owner approved one clone per configured validation-default resolution and one coroutine-context lookup per rendered View object. The View path retains one array merge. Custom filesystem validation runs only on construction/cache miss; discovery is boot-only; all other accepted changes add no meaningful runtime work. No locks, registries, compatibility shims, or speculative abstractions are introduced. @@ -355,7 +355,7 @@ Append package entries in checklist order. Keep each entry compact but complete - **Important rejected concerns:** Do not add registry synchronization, request-local listener registries, static cleanup entries, general transient-container construction, deep dependency cloning, a mutex, context slot, state machine, general uncloneable-object handling, unique-lock cleanup after an ambiguous queue-push exception, reflection-cache routing, or cosmetic strictness churn. Preserve `hasListeners()`, `observe()`, bare-`*` passive routing, interface/wildcard listener caches, EventFake semantics, Telescope consumers, and normal stateless listener auto-singletons. `dispatchIf()` and `dispatchUnless()` have never forwarded first-job constructor arguments and remain unchanged. - **Approved implementation boundary:** The owner approved every measured correctness cost: approximately 27 nanoseconds only when classifying an already-after-commit listener; approximately 33 nanoseconds only when isolating a queued consumer that uses `InteractsWithQueue`; approximately 8.7 nanoseconds at each queued enum clone boundary; and approximately 99 nanoseconds only when generating a custom display-name lock key before cache I/O. The owner also approved the additive `PendingChain::dispatch(mixed ...$arguments)` signature divergence from current Laravel's still-defective non-variadic method. Both the real and fake overrides must change together to avoid a class-load fatal. Event Dispatchable variadics, after-commit cancellation, Backoff variadics, and lock hashing catch up to current Laravel; PendingChain advances a verified upstream bug fix. The Backoff variadic key normalization additionally corrects a named-argument regression present in current Laravel's own variadic constructor; that fix and the queued enum bug are both shared with current Laravel and should be proposed upstream. -- **Cross-package implications:** Events owns listener dispatch, queued-listener construction, and the exact enum clone decisions. Queue owns mapped-handler job injection, Backoff, and lock generation. Broadcasting owns its truthful backoff property. Foundation owns event and pending-dispatch forwarding plus the real PendingChain; Support owns the matching fake. Queue, Foundation, and Support revalidation is complete; the later full Broadcasting audit must retain these boundaries. +- **Cross-package implications:** Events owns listener dispatch, queued-listener construction, and the exact enum clone decisions. Queue owns mapped-handler job injection, Backoff, and lock generation. Broadcasting owns its truthful backoff property. Foundation owns event and pending-dispatch forwarding plus the real PendingChain; Support owns the matching fake. Queue, Foundation, Support, and Broadcasting revalidation is complete; Broadcasting retains the truthful `array|int|null` backoff boundary. - **Regression strategy:** Port current upstream named event dispatch/broadcast and after-commit cancellation coverage; deterministically interleave two queued listeners and two mapped handlers to prove per-job handle isolation; cover Closure and non-Closure deduplicators through dispatch; cover scalar, array, named-argument scalar and array, method, and variadic-attribute backoffs across listeners and broadcasts; cover int-backed groups through PendingDispatch; queue and serialize enum events; replace hanging coroutine test orchestration with `parallel()`; preserve valid array listener cloning/handling; pin corrected reflection-listener inference; assert exact current Laravel unique/overlap keys; and exercise named constructor arguments through both real and fake PendingChain dispatch. - **Implementation:** Event and PendingChain forwarders now preserve named constructor arguments; pre-mutation after-commit observers remain synchronous; job-aware container-resolved queued listeners and mapped handlers are cloned before per-execution job injection; enum cases survive queued-event cloning; callable deduplicators, integer message groups, array backoff, and current Laravel lock keys flow through their complete producer/consumer chains. Backoff accepts positional scalar, positional array, positional variadic, named scalar, and named array forms. Obsolete serialized-string listener payload handling is removed, coroutine regressions use `parallel()`, and provenance, API docs, and the intentional Laravel difference are current. - **Regression tests:** Focused coverage deterministically reproduces queued-listener and mapped-handler job-handle races, proves after-commit cancellation and timing, exercises named dispatch/broadcast and real/fake chain construction, preserves enum queue serialization, and covers every accepted group, deduplicator, backoff, and lock-key shape. Reflection's corrected first-parameter inference is pinned at the Events consumer boundary, and coroutine-event tests now propagate child failures without strandable wait-group bookkeeping. @@ -1200,7 +1200,7 @@ Append package entries in checklist order. Keep each entry compact but complete - **Approved owner gates and intentional differences:** The owner approved rejecting pooled RESET and sharded subscription, deleting automatic framework replay and the incompatible named-proxy extension surface, changing Reverb outages to truthful publish failures, adding Laravel-shaped boot-only event controls and macros, retaining connection-local topology, and the source-proven noise-level command-name normalization and macro lookup. Connector-driver `extend()` / `setDriver()`, pooled RESET, and pooled/dedicated `ssubscribe()` are recorded at their README, source, and test locations with supported alternatives. - **Important rejected concerns:** Do not add a retry registry, command ambiguity state machine, connector hierarchy, public transport abstraction, sharded Pub/Sub router, pool retrofit mechanism, publisher queue, generic finalizer, configurable channel policy, raw-client wrapper, recursive object serializer, or compatibility layer. Idle subscriber receive remains correctly unbounded. Cluster subscriber transport follows only `cluster.context`, matching phpredis node sockets; unsupported endpoint ambiguity is rejected rather than guessed. The identical two Cache limiter callback sites remain accepted under `redis-12` for the active Cache work; they require the same direct precedence correction, not a shared abstraction. - **Implementation:** Command failures now determine only connection disposition and never repeat work. The subscriber uses exact RESP2 stream parsing, full I/O, semantic routing, exact channel/pattern accounting, retained receive causes, bounded foreground operations, and complete standalone/Sentinel/Cluster construction. Reverb publishes directly and cleans failed metric requests. Redis limiter cleanup preserves the primary callback failure. Current supported phpredis validation, credentials, options, macros, event controls, Horizon topology, config-owned application prefix default, both prefix precedence levels, facade metadata, and static cleanup are complete. The later Queue work made SafeScan reject transformed connections before any scan/deletion and reconciled every case-insensitive command signature plus the held-transaction discard boundary. The Horizon work added callback-sensitive MultiExec return contracts without changing runtime behavior. Pooled RESET and sharded subscription fail before native state changes. Telescope formatting/filtering and Sentry database metadata are truthful. Dead replay, Engine EOF framing, timer-per-message delivery, named-proxy extensions, stale compatibility, duplicate config parsing, unbounded Reverb queueing, obsolete exceptions, and misleading documentation are removed. -- **Cross-package implications and revalidation:** `redis-09` was revalidated by Cache. `redis-10` and `redis-11` revalidated Reverb's dedicated subscriber use; `reverb-05` remains for Reverb's full audit. `redis-12` is complete at both Redis and Cache limiter sites. `redis-13` is complete for Horizon, Cache, Queue, and Session; Broadcasting remains routed to its full audit. `redis-15` revalidated Telescope and Sentry boot integration. `redis-21` and `redis-22` were completed through Queue's raw inspection/removal and command-metadata work, including Support facade revalidation. `redis-23` is complete through Horizon's Cluster-aware batching and exact result shapes. `horizon-01` is complete after Horizon's full audit; `telescope-01`, `telescope-02`, and `sentry-01` remain recorded for those packages' later full audits. Every carried `redis-01` through `redis-08`, `pool-04`, `pool-05`, `pool-08`, `database-05`, `database-06`, and `support-02` assumption was revalidated. +- **Cross-package implications and revalidation:** `redis-09` was revalidated by Cache. `redis-10` and `redis-11` revalidated Reverb's dedicated subscriber use; `reverb-05` remains for Reverb's full audit. `redis-12` is complete at both Redis and Cache limiter sites. `redis-13` is complete for Horizon, Cache, Queue, Session, and Broadcasting; Broadcasting now consumes canonical connection prefix precedence and separates native Cluster publication from manually prefixed Lua arguments. `redis-15` revalidated Telescope and Sentry boot integration. `redis-21` and `redis-22` were completed through Queue's raw inspection/removal and command-metadata work, including Support facade revalidation. `redis-23` is complete through Horizon's Cluster-aware batching and exact result shapes. `horizon-01` is complete after Horizon's full audit; `telescope-01`, `telescope-02`, and `sentry-01` remain recorded for those packages' later full audits. Every carried `redis-01` through `redis-08`, `pool-04`, `pool-05`, `pool-08`, `database-05`, `database-06`, and `support-02` assumption was revalidated. - **Regression tests:** Focused and live-service coverage proves no replay and exact failure disposition; immediate callback release and terminal defer ownership; real MULTI, PIPELINE, WATCH, DISCARD, RESET rejection, and mixed-case routing; callback and no-callback MultiExec result shapes; byte-exact fragmented RESP, protocol failures, timeouts, close races, TLS, Unix, IPv6, Sentinel, and Cluster subscription paths; complete phpredis options and credentials; macros without checkout and one-event granularity; boot-only event overrides; Horizon config defaults, topology, prefix publication, and hash tags; direct Reverb publishing and metric cleanup; both limiter failure matrices; raw SafeScan/FlushByPattern operation and transformed-mode rejection before deletion; all case-insensitive command pairs and held-transaction discard semantics; facade/manifest invariants; Telescope no-user-code formatting; and Sentry success/failure database metadata. - **Performance and complexity:** Ordinary proxy commands add two lowercase operations on a short method name, one static macro-table lookup, and fixed guard comparisons; these are owner-approved measurement-noise costs with no config read, container lookup, lock, context operation, yield, retry, log, reconnect, or extra network command. Failure classification, topology, option normalization, observability, boot overrides, SafeScan's one construction-time raw-mode guard, and command metadata stay on cold or exceptional paths. Exact subscriber parsing removes one timer coroutine per message, and direct Reverb publishing removes unbounded retained outage memory. No new registry, retry queue, state machine, or unbounded worker state is retained. - **Laravel-facing result:** Current supported phpredis options, validation, macros, manager event controls, facade metadata, tests, and task-first documentation are restored while Hypervel retains phpredis-only pooling, exact coroutine ownership, connection-local topology, transforms, SafeScan, raw held access, immediate callback release, and other advanced helpers. The deliberate differences are limited to behaviors Laravel's request-lifetime connector model cannot safely provide through a pooled Swoole connection. @@ -1327,7 +1327,7 @@ Append package entries in checklist order. Keep each entry compact but complete - **Approved owner gates and intentional differences:** The owner approved every current Laravel API, the owner-first protected enqueue callback, one fresh after-commit pooled lease, SQS overflow and batching behavior, concrete-only inspection, Redis raw-scan and held-transaction APIs, boot-only worker controls, contract corrections, explicit failed-provider selection, and every source-proven noise-level cost. Hypervel retains concurrent workers, pooling, Queue routes, debounce, payload context, SafeScan, transformed Redis operations, and Laravel payload interoperability. `--timeout=0` deliberately leaves both job execution and stop-path draining unbounded. - **Important rejected concerns:** Do not add a poison registry, retry/tombstone state machine, generic proxy forwarding, second inspection contract, pagination/lazy-result API, transaction dispatcher service, queue schema validator, generic cleanup abstraction, SQS publication registry, pool retrofit, worker singleton current-job state, arbitrary stop timeout, Redis transformed-scan compatibility, global PHPStan rule, or extra contract methods for optional concrete capabilities. Do not retain a queue lease across user callbacks or transactions. - **Implementation:** Pooled dispatch now gives immediate work its current owner and deferred work one fresh lease, with logical name/dispatcher state reset on every borrow/release. SQS batches and overflow bodies preserve ordering, exact identifiers, publication ambiguity, and terminal cleanup. Payloads decode once; malformed jobs retain exact evidence, terminate without release, and remain observable without hiding unrelated failures. A secondary Batchable rollback failure no longer replaces the authoritative timeout or prevents failed-job transaction cleanup. Redis reservation keeps invalid bytes atomic. Workers use monotonic lifecycle state, truthful timeouts, complete stop draining, exact configuration, and current events/options/output. The later Horizon work made Redis bulk topology-aware and restored property/attribute delay semantics in Redis and Database bulk. Middleware, providers, fakes, inspection, contracts, metadata, facade docs, and public guides now match the supported surface. Superseded duplicate fake state, stale defaults/comments/ignores, ambiguous Redis declarations, and false contract requirements are removed. -- **Cross-package revalidation:** Completed Contracts owns `contracts-09` and the already-canonical `notifications-07`; Foundation is revalidated for Queue configuration and channel enumeration; Database owns `database-14`; Redis owns `redis-21`/`redis-22`; Support is revalidated for QueueFake plus Queue/Redis facade metadata. Queue revalidated carried `queue-01`, `queue-11`, `queue-12`, `queue-14`, `reflection-04`, `events-03`, `support-02`, `bus-03`, `bus-10`, `bus-17`, `bus-18`, and `redis-13`. The Eloquent identity work adds `queue-41`, jointly owned with Database, and revalidates Notifications' model-notifiable serialization with a restorable collection identifier. `queue-22` is complete for Horizon and remains required for Telescope; `queue-40` is complete at Queue and Horizon's dispatch boundary; `contracts-09` remains for Broadcasting and `notifications-07` for Notifications. +- **Cross-package revalidation:** Completed Contracts owns `contracts-09` and the already-canonical `notifications-07`; Foundation and Broadcasting are revalidated for channel enumeration; Database owns `database-14`; Redis owns `redis-21`/`redis-22`; Support is revalidated for QueueFake plus Queue/Redis facade metadata. Queue revalidated carried `queue-01`, `queue-11`, `queue-12`, `queue-14`, `reflection-04`, `events-03`, `support-02`, `bus-03`, `bus-10`, `bus-17`, `bus-18`, and `redis-13`. Broadcasting completed `queue-11` and `queue-12` revalidation through its truthful backoff property, canonical attribute reader, Bus-owned `UniqueLock`, and exact display-name lock-key regressions. The Eloquent identity work adds `queue-41`, jointly owned with Database, and revalidates Notifications' model-notifiable serialization with a restorable collection identifier. `queue-22` is complete for Horizon and remains required for Telescope; `queue-40` is complete at Queue and Horizon's dispatch boundary; `notifications-07` remains for Notifications. - **Regression tests:** Deterministic coverage proves max-one-pool immediate/commit/rollback/concurrent ownership, one SQS lease per deferred batch, every overflow failure/ambiguity/cleanup transition, credentials and exact FIFO zero, one checked payload decode, poison event/report/delete behavior, timeout preservation across a throwing Batchable rollback, exact Redis raw reservation, failed-record durability, timeout/quiet/pause/drain lifecycle, middleware exactness, named Queue-connection merging versus replace-whole failed/batching blocks, provider-owned optional file defaults and atomic mode-preserving publication, real Redis inspection including literal standalone hash tags, held transactions, truthful command aliases/facades, Cluster-aware Redis bulk and Redis/Database attribute delays, disjoint QueueFake metrics and hooks, Beanstalk totals, keyless single and collection model publication rejection, restorable queued notification model identity, and all affected Horizon/Telescope/Bus/contract consumers. - **Performance and complexity:** Ordinary dispatch remains one checkout and does not allocate a per-dispatch dispatcher; a real deferred transaction adds one necessary checkout at commit and retains no lease. SQS batching reduces network calls. Valid jobs decode once. Redis invalid handling remains inside the existing Lua round trip. Pause reads use one cache `many()` call. Inspection is explicitly eager and operational: while live, its materialized collection shares the Swoole worker heap with unrelated coroutines. Its Redis removal member is the exact raw value. QueueFake and metadata changes add no production cost. No lock, retry loop, registry, context slot, compatibility shim, or unbounded retained state was added. - **Laravel-facing result:** Current supported Queue, SQS, worker, middleware, failed-provider, fake, inspection, facade, configuration, and documentation APIs are restored while Hypervel's coroutine/pool adaptations remain explicit. Verified upstream overflow, malformed-payload, fake, exact-zero, and contract defects are corrected rather than copied. @@ -1592,3 +1592,22 @@ Append package entries in checklist order. Keep each entry compact but complete - **Performance and compatibility:** Normal operations add only approved fresh parameterized construction, small filter checks, three queued-job config reads, and four short-lived Typesense wrappers. Optional-event construction/dispatch and multiple Typesense network calls are removed, while unbounded wrapper retention is eliminated. Laravel APIs are restored or preserved except for the explicitly approved Hypervel configuration, coroutine, concurrency, Algolia-4, error-truthfulness, and Typesense operator decisions, plus the approved `database-21` invalid-input correction. - **Implementation and regressions:** Scout now uses structured comparisons across every engine; exact remote escaping, scalar/operator validation, targets, absence classes, and request counts; one force-delete owner; nesting-safe coroutine suppression; through-aware macros; substitutable fresh builders and paginators; listener-gated events; current job options and unique jobs; exact query-free removal identities; unified boolean after-commit configuration; first-failure concurrent imports; validated ordered ranges; and bounded Typesense wrapper ownership. Regressions cover these paths, strict platform-extreme range types, Database chunk/cursor/attribute owners, split metadata, public docs, config, and test typing. - **Validation and review:** Every changed test file and affected package group passed during implementation. The authoritative `composer fix` gate changed no formatting, both PHPStan configurations passed, and the complete parallel components, Testbench package, and dogfood suites passed. Split metadata, `git diff --check`, package-checklist parity, finding-ID uniqueness, broad stale-reference scans, and a fresh caller/callee, API, coroutine, retained-state, I/O, hot-path, and overengineering review are complete. Independent review re-read the complete diff, verified the final corrections, Database regressions, and Foundation search-task harness changes, and signed off with no remaining findings. + +### Complete Broadcasting correctness, direct SDK lifecycles, and current parity + +- **Status and inspected surface:** Complete; implementation, focused validation, the authoritative gate, fresh self-review, and independent code review are signed off. The audit covered every Broadcasting source and test file; Foundation configuration; split/root metadata; Boost and package documentation; Redis and Object Pool ownership; current Laravel source and originating PRs; installed Ably, Pusher, Guzzle, and phpredis behavior; and carried `events-05`, `support-02`, `redis-13`, `contracts-09`, `queue-11`, and `queue-12`. The detailed design is recorded in [`2026-08-03-1218-broadcasting-correctness-direct-sdk-lifecycles-and-current-parity.md`](2026-08-03-1218-broadcasting-correctness-direct-sdk-lifecycles-and-current-parity.md). + +| Findings | Final decision | +|---|---| +| `broadcasting-01`–`broadcasting-04` | Use Redis-owned prefix precedence, separate native Cluster from manually prefixed Lua publication, remove one leading authorization prefix, and require explicit Pusher JSONP opt-in. | +| `broadcasting-05`–`broadcasting-09` | Preserve enum cases at exact clone boundaries, construct only the selected queue wrapper, resolve built-in SDK drivers directly while retaining explicit custom pooling, release ordinary Ably channels while retaining encrypted channels, and accept single-string channels. | +| `broadcasting-10`–`broadcasting-16` | Throw on invalid transport JSON, restore current creator coverage and a truthful anonymous assertion, correct metadata/container/types/docs, and remove the dead rescue fallback. | + +- **Important rejected concerns:** Do not add request-scoped broadcaster or SDK registries, recursive JSON validation, generic cloneability machinery, pool compatibility wrappers, extra configuration, or a guard in `validAuthenticationResponse()`. Do not release configured encrypted Ably channels. Do not add return types to destructors: PHP forbids them; this is a language constraint, not an untyped API decision. Keep the single local Ably PHPStan suppression because the SDK's duplicate `@method publish()` annotations misdescribe its real variadic runtime API. +- **Implementation and boundaries:** The worker-cached manager now owns direct concurrent built-in clients and preserves the complete opt-in custom-pool surface. Redis resolves its canonical connection prefix once at construction; native Cluster publication leaves prefixing to phpredis, while Lua arguments use the protected manual formatter. Authorization guards raw missing input before strict string helpers and uses the stripped logical name for normalization and guard selection. Enum names and clone boundaries, Ably channel ownership, Pusher JSONP, string channels, JSON failure, container resolution, metadata, and lifecycle warnings now match their truthful contracts. Superseded pool defaults, stale todo/docs, false dependencies, redundant manager binding, and dead fallback code are removed. +- **Cross-package revalidation:** `events-05`, `queue-11`, and `queue-12` remain satisfied by the truthful backoff property, canonical queue-attribute reader, Bus-owned unique lock, and exact lock-key regressions. `support-02` is complete at the last BroadcastEvent string boundary. `redis-13` is complete through canonical prefix ownership and the Cluster/Lua split. `contracts-09` is complete while optional channel enumeration remains on concrete broadcasters, the pool proxy, facade, and command rather than the core contract. +- **Regression tests:** Counterfactual coverage distinguishes Redis prefix precedence, Cluster/Lua publication, leading-only authorization stripping and missing input; enum/string event names, enum-safe immediate, queued, unique, and wrapper-clone paths, one selected wrapper, and exact unique keys; direct built-ins plus explicit custom pools and purge/forget behavior; Ably success/failure release and encrypted retention; explicit JSONP; single-string channels; throwing JSON; package metadata; and Foundation defaults. The prior Cluster expectation encoded duplicate prefixing, the prior built-in-pool assertions encoded the default deliberately removed here, and the anonymous socket assertion was vacuous; all three are accepted test corrections rather than weakened tests. Provider identity, ordinary-object clone isolation, the single ordinary-path clone count, and creator-shape coverage remain guards rather than counterfactual proofs. +- **Performance and compatibility:** Built-in drivers remove pool borrow/proxy overhead and remain manager-cached. Wrapper construction removes one discarded object, clone, and repeated metadata reads. Redis adds one construction-time config resolution and one leading-prefix comparison and slice during authorization. Ordinary Ably publication adds one in-memory release after the existing network operation and prevents unbounded worker retention. No request-time lock, registry, retry, context slot, serialization layer, extra network round trip, or retained request state is added. +- **Laravel-facing result:** Current supported Laravel broadcasting APIs, protected extension points, creator shapes, enum names, and channel forms are restored or preserved. Hypervel retains worker-wide channel/auth registries and formatter callbacks, explicit custom pooling, direct built-in clients, and no deferred-provider marker. Invalid Redis connection configuration now fails at construction through the manager's established `RuntimeException`, preserving the helper's `InvalidArgumentException` message and cause. +- **Validation and review:** Every changed test file and affected package group passed during implementation. The final `composer fix` gate changed no formatting, both PHPStan configurations passed, and the complete parallel components, Testbench package, and dogfood suites passed. `git diff --check`, stale-reference scans, and fresh caller/callee, API, coroutine, SDK ownership, retained-state, hot-path, dead-code, and overengineering review are complete. Independent review read every changed source and test file, verified the amendments, and signed off with no remaining findings. +- **Assessment:** Broadcasting is current, coroutine-safe, failure-truthful, and bounded for long-lived workers. Every accepted finding is fixed at its owning boundary without a workaround, speculative abstraction, Laravel API break, meaningful hot-path regression, or stale superseded path. diff --git a/docs/plans/2026-08-03-1218-broadcasting-correctness-direct-sdk-lifecycles-and-current-parity.md b/docs/plans/2026-08-03-1218-broadcasting-correctness-direct-sdk-lifecycles-and-current-parity.md new file mode 100644 index 000000000..9c88196b5 --- /dev/null +++ b/docs/plans/2026-08-03-1218-broadcasting-correctness-direct-sdk-lifecycles-and-current-parity.md @@ -0,0 +1,472 @@ +# Broadcasting Correctness, Direct SDK Lifecycles, and Current Parity + +## Goal and boundary + +Complete the Broadcasting package audit by correcting the verified defects, closing every carried revalidation, restoring current Laravel-facing behavior, and simplifying built-in SDK ownership without adding request-scoped machinery. Package-wide discovery was completed before this plan; implementation, any same-family findings exposed during it, final validation, self-review, and code review complete the audit and its checklist. + +## Audit principles + +### 1. Verify before changing + +A suspicious pattern is not an actionable finding until the audit establishes: + +- the exact file and symbol; +- every relevant caller and callee across `src/` and `tests/`; +- the state or resource owner; +- the initialization, commit, use, and cleanup boundaries; +- a realistic production or test failure schedule; +- why current guards and tests do not prevent it; +- sibling implementations and same-family sites; +- relevant upstream behavior; +- the lowest correct fix boundary; +- a regression strategy; +- the performance and complexity effect of the proposed fix. + +Use a focused probe when source reasoning cannot settle native or scheduler behavior. Do not repeatedly run the full suite hoping to reproduce a rare flake. + +### 2. Fix the lowest inconsistent contract + +Do not add local compensation when a shared lower-level contract is wrong. A caller catch is not enough when a typed filesystem method can return `false`; a per-consumer spawn catch is not enough when Engine exposes an ambiguous spawn contract; a proxy workaround is not enough when pool ownership is undefined. + +After changing a lower-level contract, re-audit every affected caller and revisit completed packages that depend on it. Record cross-references in both the owning package and each affected package ledger entry. + +### 3. Make ownership explicit + +The component that acquires or registers a resource records the exact handle and releases that exact handle. Cleanup must not reconstruct identity from mutable state when the original handle can be retained. + +Examples include coroutine IDs, timer IDs, process IDs plus incarnation checks, listener callbacks, pool leases, subscriber objects, stream handles, temporary filenames, signal watcher IDs, and channel tokens. + +### 4. Make creation transactional + +If code reserves capacity or publishes state before a later operation can fail, it must either finish creation or roll back every earlier change. Do not expose half-initialized objects, registered-but-dead pools, leaked wait-group counts, or published runtime paths without their cleanup owner. + +### 5. Make cleanup exhaustive + +Independent cleanup steps run even when an earlier step fails. The earliest operation or cleanup failure remains primary. Cleanup failures must not corrupt bookkeeping, skip unrelated cleanup, or turn a successful ownership transfer into a reported failure. + +### 6. Bound only external progress + +Use deadlines where progress depends on a process, socket peer, lock owner, IPC child, or external service that can disappear. Do not add arbitrary timeouts to ordinary internal coroutine joins once successful creation and ownership guarantee completion. + +### 7. Preserve hot-path quality + +For every fix, inspect: + +- additional allocations; +- container or facade resolutions; +- locking and atomics; +- hashing and serialization; +- new yields or sleeps; +- retries and polling; +- logging or exception construction; +- retained worker memory; +- cache invalidation and eviction. + +A correctness guard on a cold failure path has a different cost from a new lock or resolver on every request. State the difference explicitly. + +Any proposed change with a measured or source-proven hot-path regression requires explicit owner approval before implementation, even when it fixes a defect. Present the expected frequency and magnitude, the evidence, and the viable alternatives. Do not hide an unavoidable tradeoff inside a general correctness claim. + +Performance improvements must provide a meaningful practical benefit after accounting for code complexity and divergence from upstream. Measure representative behavior where practical. Always surface an evidence-backed opportunity to the owner, but do not implement it without approval; a micro-optimization within measurement noise is neither a reason to diverge nor an actionable finding. + +### 8. Remove superseded design completely + +When a fix changes the owning model, delete obsolete helpers, callbacks, properties, config keys, comments, tests, and documentation. Do not leave a compatibility path or comment describing behavior that no longer exists. Preserve intentional upstream comments unless the new design makes them incorrect. + +### 9. Treat remediation patterns as candidates + +The established patterns later in this plan are a vocabulary, not a lookup table. Choose among per-call parameters, immutable values, scoped bindings, cloning, CoroutineContext, factories, explicit ownership, static reset, or resource teardown only after proving the real lifetime and owner. + +### 10. Reject speculative complexity + +Record low-confidence concerns under rejected or unresolved analysis. Do not implement them. Surface every evidence-backed, meaningful non-defect improvement to the owner with its benefit, cost, and alternatives, then stop for explicit approval. This requirement exists to keep worthwhile opportunities visible, not to discourage finding them. + +## Evidence and references + +- The completed package investigation covered every Broadcasting source and test file, split/root metadata, Foundation broadcasting configuration, Boost/package documentation, Redis/Object Pool ownership, current Laravel source and originating PR surfaces, installed SDK internals, and every routed cross-package finding. +- Hypervel source and tests in this worktree, including Redis configuration ownership in `src/redis/src/RedisConfig.php` and object-pool behavior in `src/object-pool`. +- Current Laravel Framework `13.x` at `2c410561c21452de2f164caea64ab0fcac692a5d`. +- Laravel PR surfaces, followed by current source as the implementation reference: + - PR #60483 / `c69bf137e3`: enum broadcast names (`BroadcastEvent` and tests). + - PR #58971 / `4a92d0d9fe`: explicit Pusher JSONP enablement. + - PR #56538 / `ad1d5e316b`: anonymous `toOthers` regression. + - PRs #59470 / #59614 (`3f651af035`, `76025599b3`): static-closure and invokable custom creators. +- Installed SDKs used for source tracing: Ably 1.1.12, Pusher 7.2.8, Guzzle 7.15.2. +- Ably 1.0.0 tagged source confirms the entire admitted `^1.0` range has `Channels::release()`, `Channel::getCipherParams()`, and the sole `ChannelOptions::$cipher` option. +- Live phpredis 6.3.0 probe and extension source confirm native `publish()` applies `OPT_PREFIX`, while Lua arguments passed as `ARGV` with `num_keys = 0` do not. The behavior is identical for `Redis` and `RedisCluster` native publish. + +## Architecture and ownership + +`BroadcastManager` is a worker-lifetime singleton through Hypervel's automatic concrete resolution. It caches one broadcaster per named connection. Pusher, Reverb, and Ably SDK clients are concurrency-safe for the reached operations and own no borrow-scoped request state, so built-in pooling only adds proxy/borrow overhead, retains extra client graphs, caps concurrency, and hides concrete Laravel APIs. + +After this work: + +- built-in Pusher, Reverb, and Ably connections resolve directly and remain manager-cached; +- explicit custom poolable drivers retain the complete existing proxy, callback, purge, and pool-definition API; +- ordinary Ably channel objects are released after each publication because the SDK otherwise caches every dynamic name for the worker lifetime; +- application-configured encrypted Ably channels remain cached because their channel object owns the cipher options used by later publications; +- Redis prefix precedence is resolved once when the broadcaster is constructed, with no per-publication config work. + +## Final finding set + +| ID | Category | Severity | Decision | +|---|---|---:|---| +| `broadcasting-01` | Defect | Major | Use canonical Redis connection prefix precedence and prevent cluster double-prefixing. | +| `broadcasting-02` | Defect | Major | Remove exactly one leading Redis prefix during both authorization response paths. | +| `broadcasting-03` | Defect | Major | Normalize string and enum broadcast names to the strict string transport contract. | +| `broadcasting-04` | Security defect | Major | Require explicit per-connection opt-in before honoring Pusher JSONP callbacks. | +| `broadcasting-05` | Defect | Major | Do not clone enum cases at immediate, queued, unique, or wrapper clone boundaries. | +| `broadcasting-06` | Improvement | Minor | Construct only the selected ordinary/unique wrapper once, removing redundant cloning and metadata reads. | +| `broadcasting-07` | Architecture/API defect | Major | Resolve built-in SDK drivers directly while preserving explicit custom pooling. | +| `broadcasting-08` | Lifecycle defect | Major | Release ordinary Ably channels after success/failure while retaining configured encrypted channels. | +| `broadcasting-09` | Contract defect | Major | Accept Laravel-documented single-string `ShouldBroadcast::broadcastOn()` results. | +| `broadcasting-10` | Defect | Major | Throw on invalid JSON at Redis, Ably, and Log transport boundaries. | +| `broadcasting-11` | Test defect/parity | Minor | Correct the vacuous anonymous assertion and cover current creator shapes. | +| `broadcasting-12` | Metadata defect | Minor | Declare actual package dependencies and remove stale direct dependencies. | +| `broadcasting-13` | Container cleanup | Minor | Remove the redundant manager self-binding while preserving contract identities. | +| `broadcasting-14` | Type/lifecycle cleanup | Minor | Complete bounded native types and worker-lifetime mutator warnings. | +| `broadcasting-15` | Documentation defect | Minor | Add provenance and concise intentional differences; update public usage docs. | +| `broadcasting-16` | Dead code | Minor | Remove the unreachable fallback inside the protected `rescue()` extension point. | + +## Implementation design + +### 1. Redis prefix ownership and publication + +In `BroadcastManager::createRedisDriver()`, derive the selected connection once through the Redis-owned helper rather than duplicating or partially reading Redis config: + +```php +$connectionName = $config['connection'] ?? 'default'; +$redisConfig = $this->app->make(RedisConfig::class)->connectionConfig($connectionName); + +return new RedisBroadcaster( + $this->app, + $redis, + $connectionName, + (string) ($redisConfig['options']['prefix'] ?? ''), +); +``` + +This keeps the Redis package optional/lazy: its concrete helper is resolved only when the Redis broadcasting driver is selected. Do not widen the generic Redis Factory contract or duplicate merge rules. An invalid connection now fails during broadcaster construction instead of first publication. The manager wraps the helper's `InvalidArgumentException` in its established driver-construction `RuntimeException`, preserving the original message and exception as the cause. + +Keep the existing protected `RedisBroadcaster::formatChannels()` semantics for Lua and subclasses. Split publication deliberately: + +```php +if ($connection->isCluster()) { + // formatChannels() is Laravel's protected manual-prefix extension point. + // Native phpredis publish owns the prefix here, so retain only the parent formatter/cast. + foreach (parent::formatChannels($channels) as $channel) { + $connection->publish($channel, $payload); + } +} else { + // Lua receives channels as ARGV, which phpredis does not prefix. + $connection->eval($script, 0, $payload, ...$this->formatChannels($channels)); +} +``` + +Correct the existing cluster expectation from `redis.application.orders` to `application.orders`; it currently encodes the double-prefix defect. Cover shared options, connection options, top-level connection prefix precedence, empty prefix, and scalar-to-string normalization. + +### 2. Redis authorization normalization + +Replace global substring removal with one private literal-boundary helper: + +```php +private function removeLeadingPrefix(string $channel): string +{ + return $this->prefix !== '' && str_starts_with($channel, $this->prefix) + ? substr($channel, strlen($this->prefix)) + : $channel; +} +``` + +In `auth()`, preserve the existing empty-input guard before calling this helper. `Request::input()` returns `null` for a missing key, while Hypervel's helper and channel normalizer require strings, so Laravel's untyped normalize-first order is unsafe here. Reassign the stripped logical name before both Pusher-convention normalization and guard selection. Public `validAuthenticationResponse()` keeps its existing unguarded contract and only applies the helper before normalization. Do not use regex or a generic parser. Tests must cover a missing `null` channel, a leading prefix, a later identical byte sequence, an absent prefix, an empty prefix, and configured guard selection after logical-name normalization. + +### 3. Event names and clone boundaries + +Normalize custom names at the transport boundary: + +```php +$name = method_exists($this->event, 'broadcastAs') + ? (string) enum_value($this->event->broadcastAs()) + : get_class($this->event); +``` + +At each exact clone boundary, preserve enum cases and clone ordinary objects: + +```php +$event instanceof UnitEnum ? $event : clone $event +``` + +Apply that expression to immediate dispatch, ordinary queued dispatch, unique queued dispatch, and `BroadcastEvent::__clone()`. Do not introduce reflection, a clone service, or a public helper. + +Construct only the chosen wrapper: + +```php +$broadcastEvent = $event instanceof ShouldBeUnique + ? new UniqueBroadcastEvent($event instanceof UnitEnum ? $event : clone $event) + : new BroadcastEvent($event instanceof UnitEnum ? $event : clone $event); +``` + +This removes the unique path's discarded ordinary wrapper, extra clone, and repeated cached attribute lookups. It does not alter public APIs or queue payloads. + +### 4. Explicit Pusher JSONP + +Add an optional third constructor parameter after Hypervel's existing dependencies: + +```php +public function __construct( + protected Container $container, + protected Pusher $pusher, + protected bool $allowJsonp = false, +) {} +``` + +Pass `(bool) ($config['jsonp'] ?? false)` from `createPusherDriver()`. `decodePusherResponse()` uses JSONP only when both the request callback and the connection opt-in are present. Add `'jsonp' => false` to the shipped Foundation Reverb and Pusher connection entries. Keep the constructor/config fallback because named connection arrays may replace framework defaults. Existing two-argument construction remains valid. + +```php +if (! $request->input('callback', false) || ! $this->allowJsonp) { + return json_decode($response, true); +} +``` + +### 5. Direct built-in SDK ownership and explicit custom pools + +Change the manager default only: + +```php +protected array $poolables = []; +``` + +Delete shipped Pusher and Ably `pool` blocks and the completed pooling todo. Preserve `HasPoolProxy`, `BroadcastPoolProxy`, `poolDefinition()`, `poolFactory()`, `purge()`, `addPoolable()`, `removePoolable()`, `setPoolables()`, and callback transfer. Stale application `pool` keys on a non-poolable connection remain ignored like other presentation-only config. Named connections intentionally own separate manager-cached clients, matching Laravel's manager model. + +Rename and correct the existing `testReverbResolvesDirectlyWhileExistingPoolableDriversRemainUnchanged()` regression: it currently asserts the built-in `pusher`/`ably` defaults this work removes. The replacement must assert an empty default poolable list, no registered pool, concrete Pusher broadcasters for Reverb/Pusher, a concrete Ably broadcaster for Ably, and manager-reachable `getPusher()` / `setPusher()` / `getAbly()` / `setAbly()` methods. This is the counterfactual API proof: direct broadcaster unit construction cannot detect that the old manager returned a proxy with no `__call()`. + +Retain and extend the existing custom-pool regressions rather than duplicating them: + +- `testAuthenticatedUserResolverWorksThroughPooledManagerDriver()` proves explicit `addPoolable()`, borrow, and `configureBorrowed()`; +- `testEquivalentConnectionsConvergeAndCustomCreatorNeverReceivesPoolMetadata()` proves identity convergence and construction-config stripping; +- `testPurgeInvalidatesCachedAndUncachedBroadcasterPoolsWhileForgetIsCacheOnly()` proves release/recreation, purge, and cache-only forgetting. + +Update `purge()`'s stale docblock: + +```text +Disconnect the given driver and remove it from the local cache. + +Boot or tests only, plus operational recovery for explicitly pooled drivers. +Direct drivers are only removed from the manager cache; an explicitly pooled +driver also invalidates its shared pool. +``` + +Add concise warnings to `setPusher()` and `setAbly()`: + +```text +Boot or tests only. Replaces the SDK client on this worker-cached broadcaster; +per-request mutation races across coroutines. +``` + +Update both Broadcasting references in `src/boost/docs/pools.md`: the introduction must not imply built-in broadcasters are pooled, and Consumer Integration must state that Broadcasting builds pool definitions only for drivers explicitly marked poolable. Keep the broader Filesystem, Mail, and Queue guidance unchanged. + +### 6. Ably channel cache lifecycle + +Use the already-formatted name, get the channel once, publish in `try`, and release only ordinary/default channels in `finally`: + +```php +foreach ($this->formatChannels($channels) as $name) { + $channel = $this->ably->channels->get($name); + + try { + $channel->publish($this->buildAblyMessage($event, $payload)); + } finally { + if ($channel->getCipherParams() === null) { + $this->ably->channels->release($name); + } + } +} +``` + +Keep the `publish()` invocation on one line below one identifier-scoped `@phpstan-ignore arguments.count, argument.type`: Ably declares two same-name `@method publish()` signatures, PHPStan retains only the last string/data declaration, and the real variadic implementation accepts a `Message` as its sole argument. The one-line placement lets the single local suppression cover both diagnostics without a stub, wrapper, or runtime change. + +Do not release an encrypted channel: application code may configure it through `getAbly()->channels->get($formattedName, ['cipher' => ...])`, and releasing it would silently remove encryption from later broadcasts. Ably 1.x has no other channel option. The intentionally retained set is therefore only explicitly configured encrypted channels using Ably's formatted names (`public:`, `private:`, or `presence:`), not ordinary dynamic names. + +Use a real `AblyRest` configured with the SDK's `httpClass` fake seam. Do not reflect into the SDK or make live network calls. + +### 7. Single-string channel contract + +Widen the existing Laravel-facing contract without an adapter: + +```php +/** + * Get the channels the event should broadcast on. + * + * @return Channel|Channel[]|string|string[] + */ +public function broadcastOn(): array|Channel|string; +``` + +Runtime already uses `Arr::wrap()`. Cover a real queued event returning a single string and update the two guide passages that currently describe only a channel or array. + +### 8. Throwing JSON boundaries + +Use `JSON_THROW_ON_ERROR` only where arbitrary user data is encoded: + +```php +json_encode($value, JSON_THROW_ON_ERROR); +json_encode($payload, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR); +``` + +Apply it to Redis presence `channel_data`, Redis event payloads, Ably presence user data and signature input, and Log payloads. Keep Redis's boolean encode unchanged because it cannot fail. Pusher already delegates to a throwing SDK encoder. Let `JsonException` propagate without prewalking data, double encoding, helper services, or exception translation. + +### 9. Current creator behavior and anonymous regression + +Change the sole assignment inside the `toOthers()` dispatch assertion to strict comparison: + +```php +Event::assertDispatched(AnonymousEvent::class, function ($event) { + return $event->socket === '12345'; +}); +``` + +Port current static-closure and invokable-object custom creator coverage. `RebindsCallbacksToSelf` is already the correct shared owner; do not change source unless a counterfactual test proves a defect. + +### 10. Container, dead code, metadata, and bounded types + +Remove the redundant `BroadcastManager::class` singleton declaration. Preserve the Factory alias and Broadcaster contract singleton; test that concrete and Factory resolve the same worker singleton and that the Broadcaster contract resolves the selected connection. + +Replace the complete four-site array-offset surface in the substantially edited manager with the canonical APIs: + +- `routes()` and `userRoutes()` resolve `router` through `make()`; +- `socket()` resolves the bound request through `make()`; +- `setDefaultDriver()` resolves config through `make()` and writes through `set()`. + +Add `use Hypervel\Routing\Router;` for the router narrowing. Keep `getDefaultDriver()`'s existing typed `string()` getter and `getConfig()`'s nullable `get()` call. Add a local `@var` only if a string service key otherwise infers the wrong type. These are four one-line ownership-preserving substitutions with identical resolution frequency, not a repository-wide syntax sweep. + +```php +/** @var Router $router */ +$router = $this->app->make('router'); + +/** @var Request $request */ +$request = $request ?: $this->app->make('request'); + +$this->app->make('config')->set('broadcasting.default', $name); +``` + +Keep the protected Laravel `rescue()` extension point, but remove its unreachable fallback: + +```php +protected function rescue(Closure $callback): mixed +{ + return rescue($callback); +} +``` + +Complete only these native types: + +- `BroadcastEvent::__clone(): void`; +- `Broadcaster::normalizeChannelHandlerToCallable(callable|string): callable`. + +For `normalizeChannelHandlerToCallable()`, retain the method-title docblock but delete the now-redundant and contradictory `@param mixed` plus restating `@return callable` annotations. + +Both destructors keep their existing title-only docblocks. No `@return void` is added: PHP forbids return types on `__destruct()`, while `__clone(): void` is legal and remains required. + +Manually add `: void` to the 65 real untyped PHPUnit `test*` methods in already-touched Ably, Broadcaster, BroadcastEvent, Pusher, Redis, and anonymous-event test files. Constructors and other magic methods are not part of this set. Do not change `FakeBroadcasterUsingPusherChannelsNames::testChannelNameMatchesPattern(...): bool`; it is a callable probe, not a PHPUnit test. + +Update `src/broadcasting/composer.json`: + +```json +"psr/log": "^3.0", +"hypervel/routing": "^0.4" +``` + +Remove stale direct `hypervel/auth` and `hypervel/cache`; Bus owns unique locking and remains direct. Keep Contracts direct. Add a focused metadata test for required dependencies, SDK suggestions, provider metadata, and removed false dependencies. + +The metadata regression must also assert that `hypervel/redis` remains an optional suggestion with its existing wording. The new lazy `RedisConfig::class` reference is valid only when the Redis driver is selected and must not accidentally promote Redis to a hard dependency. + +### 11. Documentation and records + +Keep user documentation concise: + +- Broadcasting guide: enum broadcast names, single-string channels, and explicit `jsonp` opt-in/default. +- Pools guide: both Broadcasting references describe pooling only for explicitly marked drivers. +- Package README: title, Boost docs link, current Laravel provenance, and only intentional differences: + - worker-wide static channel/auth-option registry; + - worker-wide outgoing formatter and incoming authorizer; + - explicit custom poolable drivers; + - no `DeferrableProvider` marker because Hypervel has no matching provider mechanism. + +Do not document internal bug fixes or duplicate the Boost guide. + +Add a complete Broadcasting package block to the companion ledger. Its **Status and inspected surface** must say the audit is complete and name the package source, tests, configuration, metadata, documentation, Laravel PR/current-source references, Redis/Object Pool consumers, SDK ownership, and all carried findings inspected. Follow it with the findings table, **Important rejected concerns**, **Implementation and boundaries**, **Cross-package revalidation**, **Regression tests**, **Performance and compatibility**, **Laravel-facing result**, **Validation and review**, and **Assessment**. Link this detailed plan and check off Broadcasting only after implementation, the complete gate, fresh self-review, and code-review signoff. + +Under **Important rejected concerns**, record that no destructor return type was added because PHP forbids any return type on `__destruct()`; this is a language constraint, not an untyped Laravel or Hypervel API choice. + +Dispose of every dependency-index row that names Broadcasting: + +- `events-05`: revalidated as already satisfied by `BroadcastEvent::$backoff` being `array|int|null`; mark Broadcasting complete in the index/Events record. +- `support-02`: complete for Broadcasting after `broadcasting-03`; every other enum boundary already uses the established string normalization and `BroadcastEvent::handle()` is the last gap. +- `redis-13`: complete after canonical prefix ownership and cluster publication correction. +- `contracts-09`: complete while concrete/proxy/facade/command `getChannels()` support remains and the core Broadcaster contract correctly omits it. +- `queue-11`: complete for Broadcasting. `BroadcastEvent` reads Queue's variadic/array-aware `Backoff` attribute through `ReadsQueueAttributes`, its property is `array|int|null`, and existing method/array/variadic regressions remain green. +- `queue-12`: complete for Broadcasting. Unique broadcast acquisition delegates to Bus's canonical `UniqueLock`, and existing manager regressions pin the exact `xxh128` display-name key for plain, property-ID, and method-ID unique events. + +Update the corresponding owning-ledger prose as well as the dependency-index rows so no completed marker conflicts with stale text: + +- `events-05` prose currently saying the later full Broadcasting audit must retain the boundary; +- `redis-13` prose currently saying Broadcasting remains routed to its full audit; +- both `contracts-09` prose locations currently naming Broadcasting as affected/remain pending. + +`support-02` has no second prose marker. Update the `queue-11` and `queue-12` dependency-index rows to mark Broadcasting revalidation complete; their owning implementation is unchanged. + +Update the audit routing index's three active-work lines exactly. At implementation start they name the Broadcasting audit, its new ledger heading plus the six carried IDs, and all six pending revalidations. At completion reset them to no active work, no required ledger entries, and no revalidation carried into active work. Every dependency-index row naming Broadcasting must then say revalidation is complete, and the Broadcasting package checklist must be checked. + +The ledger must also say that the prior Redis cluster expectation encoded duplicate prefixing and the prior built-in-pool assertions encoded the default deliberately removed here. Correcting those assertions is part of the accepted design, not an unexplained regression. + +## Test plan + +| Surface | Required counterfactual coverage | +|---|---| +| `tests/Integration/Broadcasting/BroadcastManagerTest.php` / Redis config fixtures | canonical prefix precedence and scalar normalization at construction; immediate/queued/unique enum dispatch; ordinary object isolation; single selected wrapper construction; exact unique broadcast lock keys; corrected direct-built-in default; no default pool; manager-reachable concrete SDK getters/setters; extend existing explicit custom-pool regressions; creator shapes; provider/alias identity | +| `RedisBroadcasterTest` | Lua manual prefix; cluster native prefix ownership; formatter/cast preservation; leading-only auth prefix removal; guard selection; throwing payload/presence JSON | +| `AblyBroadcasterTest` | throwing presence JSON; ordinary channel release on success and failure; encrypted channel identity/options retained; repeated dynamic names do not accumulate | +| `PusherBroadcasterTest` | JSON default despite callback; configured JSONP; opt-in without callback remains JSON | +| `BroadcastEventTest` | string, string-backed enum, int-backed zero, unit enum names; single-string `broadcastOn()`; wrapper clone for enum and ordinary object; existing method/array/variadic Backoff behavior remains green | +| `tests/Integration/Broadcasting/SendingBroadcastsViaAnonymousEventTest.php` | strict socket comparison makes the `toOthers()` assertion counterfactual | +| `BroadcasterTest` / `LogBroadcasterTest` | callable/string normalization contract; throwing log JSON | +| `tests/Foundation/FoundationConfigTest.php` | Pusher and Reverb ship `jsonp => false`; existing Reverb `path` remains | +| `tests/Broadcasting/PackageMetadataTest.php` | mirror the HTTP metadata-test shape; decode package/root metadata with `JSON_THROW_ON_ERROR`; assert exact required/suggested/provider edges, optional Redis wording, and removed stale dependencies | + +For tests involving static registries, manager caches, config, or SDK substitution, restore exact prior state in `finally`/teardown. No live Pusher, Ably, or additional Redis round trips are required beyond existing integration facilities. + +## Implementation order + +Work one file at a time and run its focused test immediately: + +1. Redis manager/config ownership, broadcaster behavior, and Redis tests. +2. Ably lifecycle/JSON behavior and tests. +3. Pusher JSONP/client lifetime behavior, Foundation config, and tests. +4. event name/channel/clone contracts and tests; +5. direct built-in ownership, retained custom pools, provider identities, rescue cleanup, and integration tests; +6. remaining JSON/type/test hygiene; +7. metadata test and Composer metadata; +8. Boost docs, README, todo removal, plan/ledger/routing records. + +## Validation and final review + +1. Run each changed test file immediately after its owning source file. +2. Run all `tests/Broadcasting`, `tests/Integration/Broadcasting`, and affected Foundation config tests. +3. Run package metadata validation and `git diff --check`. +4. Run `composer fix` as the final fixer/PHPStan/parallel-test gate. +5. Inspect skips and warnings normally; do not weaken assertions to get green. +6. Freshly trace every changed caller/callee, clone boundary, config precedence path, pool branch, SDK cache owner, auth normalization path, JSON failure, static state/reset, and public/protected API. +7. Confirm no stale built-in pool config/docs/todo, duplicate prefixing, non-throwing arbitrary-data encode, unbounded ordinary Ably cache, false dependency, or superseded comment remains. +8. Reassess allocations, container/config resolution frequency, yields, network calls, worker-retained memory, and custom extension compatibility. +9. Request independent review of the complete diff and continue until sign-off before the owner pre-commit checkpoint. + +## Completion invariants + +- Redis logical and physical channel names agree for standalone and cluster connections under every supported prefix precedence layer. +- Authorization removes one physical prefix without corrupting the logical channel or guard selection. +- All documented broadcast name/channel forms reach strict internal contracts safely. +- Built-in clients are direct, concurrent, and manager-cached; custom pooling remains fully usable only when explicitly selected. +- Ordinary Ably dynamic channels cannot accumulate worker-wide; configured encryption is never silently discarded. +- Invalid transport JSON fails loudly before publication/signing/logging. +- No Laravel public API or protected extension point is broken. JSONP only changes from unsafe implicit behavior to current explicit opt-in. +- No added request-time I/O, serialization layer, lock, registry, retry, or meaningful hot-path overhead exists; direct SDK resolution removes proxy and pool overhead. +- The ledger, routing index, and package checklist truthfully record a completed Broadcasting audit with every carried revalidation closed. diff --git a/docs/todo.md b/docs/todo.md index 126d5d153..6aceed8db 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -15,10 +15,6 @@ - Implement Hypervel Boost's installation flow and revisit the Boost section of `installation.md` once the implementation is complete. The current docs describe the intended `composer require hypervel/boost --dev` and `php artisan boost:install` workflow, but `src/boost` currently contains the documentation package only. Correct fix: add the interactive installer command and supporting tools, then update the installation docs for any differences from Laravel Boost. -## Broadcasting - -- Review whether Pusher and Ably broadcaster pooling manages state that cannot safely be shared like the unpooled Reverb broadcaster. Their current pooling behavior remains unchanged; see `docs/plans/2026-07-10-1040-object-pool-lifecycle-and-client-pooled-filesystems.md` for the state-ownership evidence and Reverb decision. - ## Framework-wide - Convert the remaining tests that extend `PHPUnit\Framework\TestCase` to `Hypervel\Tests\TestCase` as required by `AGENTS.md`, verifying each file individually under coroutine execution and opting out only when the test explicitly exercises coroutine transitions. diff --git a/src/boost/docs/broadcasting.md b/src/boost/docs/broadcasting.md index 08c398510..b70c85989 100644 --- a/src/boost/docs/broadcasting.md +++ b/src/boost/docs/broadcasting.md @@ -154,6 +154,8 @@ PUSHER_APP_CLUSTER="mt1" The `config/broadcasting.php` file's `pusher` configuration also allows you to specify additional `options` that are supported by Channels, such as the cluster. +Pusher JSONP responses are disabled by default. If a legacy client requires JSONP, you may explicitly enable it by setting the connection's `jsonp` option to `true`. + Then, set the `BROADCAST_CONNECTION` environment variable to `pusher` in your application's `.env` file: ```ini @@ -542,7 +544,7 @@ class OrderShipmentStatusUpdated implements ShouldBroadcast } ``` -The `ShouldBroadcast` interface requires our event to define a `broadcastOn` method. This method is responsible for returning the channels that the event should broadcast on. An empty stub of this method is already defined on generated event classes, so we only need to fill in its details. We only want the creator of the order to be able to view status updates, so we will broadcast the event on a private channel that is tied to the order: +The `ShouldBroadcast` interface requires our event to define a `broadcastOn` method. This method is responsible for returning the channel, channel name, or channels that the event should broadcast on. An empty stub of this method is already defined on generated event classes, so we only need to fill in its details. We only want the creator of the order to be able to view status updates, so we will broadcast the event on a private channel that is tied to the order: ```php use Hypervel\Broadcasting\Channel; @@ -644,7 +646,7 @@ useEcho( To inform Hypervel that a given event should be broadcast, you must implement the `Hypervel\Contracts\Broadcasting\ShouldBroadcast` interface on the event class. This interface is already imported into all event classes generated by the framework so you may easily add it to any of your events. -The `ShouldBroadcast` interface requires you to implement a single method: `broadcastOn`. The `broadcastOn` method should return a channel or array of channels that the event should broadcast on. The channels should be instances of `Channel`, `PrivateChannel`, or `PresenceChannel`. Instances of `Channel` represent public channels that any user may subscribe to, while `PrivateChannels` and `PresenceChannels` represent private channels that require [channel authorization](#authorizing-channels): +The `ShouldBroadcast` interface requires you to implement a single method: `broadcastOn`. The `broadcastOn` method should return a channel, channel name, or array of channels that the event should broadcast on. Channel objects may be instances of `Channel`, `PrivateChannel`, or `PresenceChannel`. Instances of `Channel` represent public channels that any user may subscribe to, while `PrivateChannels` and `PresenceChannels` represent private channels that require [channel authorization](#authorizing-channels): ```php ### Choosing a Pool @@ -209,7 +209,7 @@ Close pools while the worker runtime is active. Application shutdown and garbage Hypervel does not provide a generic magic proxy for object pools. A proxy cannot know whether a result is complete or is a lazy stream, iterator, promise, or another object that still needs the borrowed resource. Consumer proxies should list their synchronous methods and use the protected `PoolProxy::invoke()` method. Deferred methods should keep a `Lease` until their work is finished. -Framework managers for filesystems, mail, queues, and broadcasting build definitions from the actual construction input, expose normalized `pool` configuration, and distinguish cache-only forgetting from pool-invalidating purge operations. Prefer those manager APIs when using a framework resource instead of creating definitions directly. +Framework managers for filesystems, mail, and queues build definitions from the actual construction input, expose normalized `pool` configuration, and distinguish cache-only forgetting from pool-invalidating purge operations. Broadcasting does the same only for drivers explicitly marked as poolable. Prefer those manager APIs when using a framework resource instead of creating definitions directly. ## Connection Pools diff --git a/src/broadcasting/README.md b/src/broadcasting/README.md index 3ac1db604..67697b0b1 100644 --- a/src/broadcasting/README.md +++ b/src/broadcasting/README.md @@ -2,3 +2,15 @@ Broadcasting for Hypervel === [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/broadcasting) + +Documentation: https://hypervel.org/docs/broadcasting + +## Differences From Laravel + +The outgoing channel formatter and incoming channel authorizer are also worker-wide and should be configured during worker boot. + +Built-in broadcast drivers use their SDK clients directly. Custom drivers may opt into Hypervel's connection pooling through the broadcast manager. + +The broadcast service provider does not implement Laravel's `DeferrableProvider` marker because Hypervel has no deferred service provider mechanism. + +Ported from: https://github.com/laravel/framework/tree/13.x/src/Illuminate/Broadcasting diff --git a/src/broadcasting/composer.json b/src/broadcasting/composer.json index 5a04f27e2..7f570a935 100644 --- a/src/broadcasting/composer.json +++ b/src/broadcasting/composer.json @@ -30,9 +30,7 @@ }, "require": { "php": "^8.4", - "hypervel/auth": "^0.4", "hypervel/bus": "^0.4", - "hypervel/cache": "^0.4", "hypervel/collections": "^0.4", "hypervel/container": "^0.4", "hypervel/contracts": "^0.4", @@ -41,7 +39,9 @@ "hypervel/object-pool": "^0.4", "hypervel/pool": "^0.4", "hypervel/queue": "^0.4", - "hypervel/support": "^0.4" + "hypervel/routing": "^0.4", + "hypervel/support": "^0.4", + "psr/log": "^3.0" }, "suggest": { "ext-hash": "Required to use the Ably and Pusher broadcast drivers.", diff --git a/src/broadcasting/src/BroadcastEvent.php b/src/broadcasting/src/BroadcastEvent.php index 0f0457644..8a0552c80 100644 --- a/src/broadcasting/src/BroadcastEvent.php +++ b/src/broadcasting/src/BroadcastEvent.php @@ -18,6 +18,9 @@ use ReflectionClass; use ReflectionProperty; use Throwable; +use UnitEnum; + +use function Hypervel\Support\enum_value; class BroadcastEvent implements ShouldQueue { @@ -74,7 +77,7 @@ public function __construct(mixed $event) public function handle(BroadcastingFactory $manager): void { $name = method_exists($this->event, 'broadcastAs') - ? $this->event->broadcastAs() + ? (string) enum_value($this->event->broadcastAs()) : get_class($this->event); $channels = Arr::wrap($this->event->broadcastOn()); @@ -195,8 +198,10 @@ public function displayName(): string /** * Prepare the instance for cloning. */ - public function __clone() + public function __clone(): void { - $this->event = clone $this->event; + $this->event = $this->event instanceof UnitEnum + ? $this->event + : clone $this->event; } } diff --git a/src/broadcasting/src/BroadcastManager.php b/src/broadcasting/src/BroadcastManager.php index 06277d39b..a42353975 100644 --- a/src/broadcasting/src/BroadcastManager.php +++ b/src/broadcasting/src/BroadcastManager.php @@ -23,6 +23,7 @@ use Hypervel\Contracts\Container\Container; use Hypervel\Contracts\Foundation\CachesRoutes; use Hypervel\Contracts\Queue\Factory as Queue; +use Hypervel\Contracts\Redis\Factory as RedisFactory; use Hypervel\Foundation\Http\Middleware\PreventRequestForgery; use Hypervel\Http\Request; use Hypervel\ObjectPool\Contracts\Factory as PoolFactory; @@ -30,6 +31,8 @@ use Hypervel\Queue\Attributes\Connection as ConnectionAttribute; use Hypervel\Queue\Attributes\Queue as QueueAttribute; use Hypervel\Queue\Attributes\ReadsQueueAttributes; +use Hypervel\Redis\RedisConfig; +use Hypervel\Routing\Router; use Hypervel\Support\Arr; use Hypervel\Support\Queue\Concerns\ResolvesQueueRoutes; use Hypervel\Support\RebindsCallbacksToSelf; @@ -66,7 +69,7 @@ class BroadcastManager implements BroadcastingFactoryContract /** * The array of drivers which will be wrapped as pool proxies. */ - protected array $poolables = ['ably', 'pusher']; + protected array $poolables = []; /** * Create a new manager instance. @@ -87,7 +90,10 @@ public function routes(?array $attributes = null): void $attributes = $attributes ?: ['middleware' => ['web']]; - $this->app['router']->group($attributes, function ($router) { + /** @var Router $router */ + $router = $this->app->make('router'); + + $router->group($attributes, function ($router) { $router->match( ['get', 'post'], '/broadcasting/auth', @@ -107,7 +113,10 @@ public function userRoutes(?array $attributes = null): void $attributes = $attributes ?: ['middleware' => ['web']]; - $this->app['router']->group($attributes, function ($router) { + /** @var Router $router */ + $router = $this->app->make('router'); + + $router->group($attributes, function ($router) { $router->match( ['get', 'post'], '/broadcasting/user-auth', @@ -135,7 +144,8 @@ public function socket(?Request $request = null): ?string return null; } - $request = $request ?: $this->app['request']; + /** @var Request $request */ + $request = $request ?: $this->app->make('request'); return $request->header('X-Socket-ID'); } @@ -183,7 +193,9 @@ public function queue(mixed $event): void if ($event instanceof ShouldBroadcastNow || (is_object($event) && method_exists($event, 'shouldBroadcastNow') && $event->shouldBroadcastNow()) ) { - $dispatch = fn () => $this->app->make(Dispatcher::class)->dispatchNow(new BroadcastEvent(clone $event)); + $dispatch = fn () => $this->app->make(Dispatcher::class)->dispatchNow( + new BroadcastEvent($event instanceof UnitEnum ? $event : clone $event) + ); $event instanceof ShouldRescue ? $this->rescue($dispatch) @@ -208,14 +220,12 @@ public function queue(mixed $event): void ?? null; } - $broadcastEvent = new BroadcastEvent(clone $event); + $broadcastEvent = $event instanceof ShouldBeUnique + ? new UniqueBroadcastEvent($event instanceof UnitEnum ? $event : clone $event) + : new BroadcastEvent($event instanceof UnitEnum ? $event : clone $event); - if ($event instanceof ShouldBeUnique) { - $broadcastEvent = new UniqueBroadcastEvent(clone $event); - - if ($this->mustBeUniqueAndCannotAcquireLock($broadcastEvent)) { - return; - } + if ($event instanceof ShouldBeUnique && $this->mustBeUniqueAndCannotAcquireLock($broadcastEvent)) { + return; } $push = fn () => $this->app->make(Queue::class) @@ -355,7 +365,11 @@ protected function createReverbDriver(array $config): Broadcaster */ protected function createPusherDriver(array $config): Broadcaster { - return new PusherBroadcaster($this->app, $this->pusher($config)); + return new PusherBroadcaster( + $this->app, + $this->pusher($config), + (bool) ($config['jsonp'] ?? false), + ); } /** @@ -410,14 +424,16 @@ public function ably(array $config): AblyRest */ protected function createRedisDriver(array $config): Broadcaster { - /** @var \Hypervel\Contracts\Redis\Factory $redis */ + /** @var RedisFactory $redis */ $redis = $this->app->make('redis'); + $connectionName = $config['connection'] ?? 'default'; + $redisConfig = $this->app->make(RedisConfig::class)->connectionConfig($connectionName); return new RedisBroadcaster( $this->app, $redis, - $config['connection'] ?? 'default', - $this->app->make('config')->string('database.redis.options.prefix', ''), + $connectionName, + (string) ($redisConfig['options']['prefix'] ?? ''), ); } @@ -474,15 +490,15 @@ public function setDefaultDriver(UnitEnum|string $name): void { $name = $name instanceof UnitEnum ? (string) enum_value($name) : $name; - $this->app['config']['broadcasting.default'] = $name; + $this->app->make('config')->set('broadcasting.default', $name); } /** - * Disconnect the given driver and close its shared resource pool. + * Disconnect the given driver and remove it from the local cache. * - * Boot or tests only, plus operational recovery of broken pooled - * resources. Other connections sharing the pool transparently acquire a - * fresh pool on their next operation. + * Boot or tests only, plus operational recovery for explicitly pooled drivers. + * Direct drivers are only removed from the manager cache; an explicitly pooled + * driver also invalidates its shared pool. */ public function purge(UnitEnum|string|null $name = null): void { @@ -545,11 +561,7 @@ public function extend(string $driver, Closure $callback): static */ protected function rescue(Closure $callback): mixed { - if (function_exists('rescue')) { - return rescue($callback); - } - - return $callback(); + return rescue($callback); } /** diff --git a/src/broadcasting/src/BroadcastServiceProvider.php b/src/broadcasting/src/BroadcastServiceProvider.php index 7e03a2636..cd6718c9f 100644 --- a/src/broadcasting/src/BroadcastServiceProvider.php +++ b/src/broadcasting/src/BroadcastServiceProvider.php @@ -15,8 +15,6 @@ class BroadcastServiceProvider extends ServiceProvider */ public function register(): void { - $this->app->singleton(BroadcastManager::class, fn ($app) => new BroadcastManager($app)); - $this->app->singleton(BroadcasterContract::class, function ($app) { return $app->make(BroadcastManager::class)->connection(); }); diff --git a/src/broadcasting/src/Broadcasters/AblyBroadcaster.php b/src/broadcasting/src/Broadcasters/AblyBroadcaster.php index 72a1b0699..2b4e55a73 100644 --- a/src/broadcasting/src/Broadcasters/AblyBroadcaster.php +++ b/src/broadcasting/src/Broadcasters/AblyBroadcaster.php @@ -93,7 +93,7 @@ protected function validAuthenticationResponseForChannel( return [ 'auth' => $this->getPublicToken() . ':' . $signature, - 'channel_data' => json_encode($userData), + 'channel_data' => json_encode($userData, JSON_THROW_ON_ERROR), ]; } @@ -104,7 +104,12 @@ public function generateAblySignature(string $channelName, string $socketId, ?ar { return hash_hmac( 'sha256', - sprintf('%s:%s%s', $socketId, $channelName, $userData ? ':' . json_encode($userData) : ''), + sprintf( + '%s:%s%s', + $socketId, + $channelName, + $userData ? ':' . json_encode($userData, JSON_THROW_ON_ERROR) : '', + ), $this->getPrivateToken(), ); } @@ -117,10 +122,17 @@ public function generateAblySignature(string $channelName, string $socketId, ?ar public function broadcast(array $channels, string $event, array $payload = []): void { try { - foreach ($this->formatChannels($channels) as $channel) { - $this->ably->channels->get($channel)->publish( // @phpstan-ignore-line - $this->buildAblyMessage($event, $payload) // @phpstan-ignore-line - ); // @phpstan-ignore-line + foreach ($this->formatChannels($channels) as $name) { + $channel = $this->ably->channels->get($name); + + try { + /* @phpstan-ignore arguments.count, argument.type (Ably declares two @method publish() overloads and PHPStan keeps only the last; publish(...$args) accepts a Message as its sole argument.) */ + $channel->publish($this->buildAblyMessage($event, $payload)); + } finally { + if ($channel->getCipherParams() === null) { + $this->ably->channels->release($name); + } + } } } catch (AblyException $e) { throw new BroadcastException( @@ -205,6 +217,9 @@ public function getAbly(): AblyRest /** * Set the underlying Ably SDK instance. + * + * Boot or tests only. Replaces the SDK client on this worker-cached broadcaster; + * per-request mutation races across coroutines. */ public function setAbly(AblyRest $ably): void { diff --git a/src/broadcasting/src/Broadcasters/Broadcaster.php b/src/broadcasting/src/Broadcasters/Broadcaster.php index 64480a1ce..6cddcb73d 100644 --- a/src/broadcasting/src/Broadcasters/Broadcaster.php +++ b/src/broadcasting/src/Broadcasters/Broadcaster.php @@ -331,11 +331,8 @@ protected function binder(): ?BindingRegistrar /** * Normalize the given callback into a callable. - * - * @param mixed $callback - * @return callable */ - protected function normalizeChannelHandlerToCallable($callback) + protected function normalizeChannelHandlerToCallable(callable|string $callback): callable { return is_callable($callback) ? $callback : function (...$args) use ($callback) { return $this->container->make($callback)->join(...$args); diff --git a/src/broadcasting/src/Broadcasters/LogBroadcaster.php b/src/broadcasting/src/Broadcasters/LogBroadcaster.php index f315bc1f7..db685a030 100644 --- a/src/broadcasting/src/Broadcasters/LogBroadcaster.php +++ b/src/broadcasting/src/Broadcasters/LogBroadcaster.php @@ -31,7 +31,7 @@ public function broadcast(array $channels, string $event, array $payload = []): { $channels = implode(', ', $this->formatChannels($channels)); - $payload = json_encode($payload, JSON_PRETTY_PRINT); + $payload = json_encode($payload, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR); $this->logger->info("Broadcasting [{$event}] on channels [{$channels}] with payload:" . PHP_EOL . $payload); } diff --git a/src/broadcasting/src/Broadcasters/PusherBroadcaster.php b/src/broadcasting/src/Broadcasters/PusherBroadcaster.php index 2c2457588..dd168bc36 100644 --- a/src/broadcasting/src/Broadcasters/PusherBroadcaster.php +++ b/src/broadcasting/src/Broadcasters/PusherBroadcaster.php @@ -22,7 +22,8 @@ class PusherBroadcaster extends Broadcaster */ public function __construct( protected Container $container, - protected Pusher $pusher + protected Pusher $pusher, + protected bool $allowJsonp = false, ) { } @@ -114,7 +115,7 @@ protected function validAuthenticationResponseForChannel( */ protected function decodePusherResponse(Request $request, mixed $response): mixed { - if (! $request->input('callback', false)) { + if (! $request->input('callback', false) || ! $this->allowJsonp) { return json_decode($response, true); } @@ -154,6 +155,9 @@ public function getPusher(): Pusher /** * Set the Pusher SDK instance. + * + * Boot or tests only. Replaces the SDK client on this worker-cached broadcaster; + * per-request mutation races across coroutines. */ public function setPusher(Pusher $pusher): void { diff --git a/src/broadcasting/src/Broadcasters/RedisBroadcaster.php b/src/broadcasting/src/Broadcasters/RedisBroadcaster.php index 921182881..9c41ff612 100644 --- a/src/broadcasting/src/Broadcasters/RedisBroadcaster.php +++ b/src/broadcasting/src/Broadcasters/RedisBroadcaster.php @@ -41,11 +41,11 @@ public function auth(Request $request): mixed throw new AccessDeniedHttpException; } + $channelName = $this->removeLeadingPrefix($channelName); + return parent::verifyUserCanAccessChannel( $request, - $this->normalizeChannelName( - str_replace($this->prefix, '', $channelName) - ), + $this->normalizeChannelName($channelName), $this->isGuardedChannel($channelName), ); } @@ -58,7 +58,9 @@ public function validAuthenticationResponse(Request $request, mixed $result): mi return $this->validAuthenticationResponseForChannel( $request, $result, - $this->normalizeChannelName($request->input('channel_name')), + $this->normalizeChannelName( + $this->removeLeadingPrefix($request->input('channel_name')) + ), ); } @@ -80,10 +82,13 @@ protected function validAuthenticationResponseForChannel( ? $user->getAuthIdentifierForBroadcasting() : $user->getAuthIdentifier(); - return json_encode(['channel_data' => [ - 'user_id' => $broadcastIdentifier, - 'user_info' => $result, - ]]); + return json_encode( + ['channel_data' => [ + 'user_id' => $broadcastIdentifier, + 'user_info' => $result, + ]], + JSON_THROW_ON_ERROR, + ); } /** @@ -101,18 +106,24 @@ public function broadcast(array $channels, string $event, array $payload = []): $socket = Arr::pull($payload, 'socket'); - $payload = json_encode([ - 'event' => $event, - 'data' => $payload, - 'socket' => $socket, - ]); + $payload = json_encode( + [ + 'event' => $event, + 'data' => $payload, + 'socket' => $socket, + ], + JSON_THROW_ON_ERROR, + ); try { if ($connection->isCluster()) { - foreach ($this->formatChannels($channels) as $channel) { + // Native phpredis publish applies the connection prefix, so parent:: + // deliberately skips this class's prefix-adding formatChannels() override. + foreach (parent::formatChannels($channels) as $channel) { $connection->publish($channel, $payload); } } else { + // Lua receives channels as ARGV, which phpredis does not prefix. $connection->eval( $this->broadcastMultipleChannelsScript(), 0, @@ -151,4 +162,14 @@ protected function formatChannels(array $channels): array return $this->prefix . $channel; }, parent::formatChannels($channels)); } + + /** + * Remove the configured Redis prefix from the start of the channel name. + */ + private function removeLeadingPrefix(string $channel): string + { + return $this->prefix !== '' && str_starts_with($channel, $this->prefix) + ? substr($channel, strlen($this->prefix)) + : $channel; + } } diff --git a/src/contracts/src/Broadcasting/ShouldBroadcast.php b/src/contracts/src/Broadcasting/ShouldBroadcast.php index 9a2ed7a46..e7f9a942f 100644 --- a/src/contracts/src/Broadcasting/ShouldBroadcast.php +++ b/src/contracts/src/Broadcasting/ShouldBroadcast.php @@ -11,7 +11,7 @@ interface ShouldBroadcast /** * Get the channels the event should broadcast on. * - * @return Channel|Channel[]|string[] + * @return Channel|Channel[]|string|string[] */ - public function broadcastOn(): array|Channel; + public function broadcastOn(): array|Channel|string; } diff --git a/src/foundation/config/broadcasting.php b/src/foundation/config/broadcasting.php index f1b37e8d4..a8890f288 100644 --- a/src/foundation/config/broadcasting.php +++ b/src/foundation/config/broadcasting.php @@ -45,6 +45,7 @@ 'client_options' => [ // Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html ], + 'jsonp' => false, ], 'pusher' => [ @@ -63,27 +64,12 @@ 'client_options' => [ // Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html ], - 'pool' => [ - 'min_retained_objects' => 1, - 'max_objects' => 10, - 'wait_timeout' => 3.0, - 'max_lifetime' => 60.0, - 'max_idle_time' => 0.0, - 'idle_ttl' => 300.0, - ], + 'jsonp' => false, ], 'ably' => [ 'driver' => 'ably', 'key' => env('ABLY_KEY'), - 'pool' => [ - 'min_retained_objects' => 1, - 'max_objects' => 10, - 'wait_timeout' => 3.0, - 'max_lifetime' => 60.0, - 'max_idle_time' => 0.0, - 'idle_ttl' => 300.0, - ], ], 'redis' => [ diff --git a/tests/Broadcasting/AblyBroadcasterTest.php b/tests/Broadcasting/AblyBroadcasterTest.php index 8c6c57d3c..db445f0a0 100644 --- a/tests/Broadcasting/AblyBroadcasterTest.php +++ b/tests/Broadcasting/AblyBroadcasterTest.php @@ -5,12 +5,17 @@ namespace Hypervel\Tests\Broadcasting; use Ably\AblyRest; +use Ably\Exceptions\AblyRequestException; +use Ably\Http; +use Ably\Utils\Crypto; use Hypervel\Broadcasting\Broadcasters\AblyBroadcaster; use Hypervel\Broadcasting\Broadcasters\Broadcaster; +use Hypervel\Broadcasting\BroadcastException; use Hypervel\Contracts\Container\Container; use Hypervel\Contracts\Routing\BindingRegistrar; use Hypervel\Http\Request; use Hypervel\Tests\TestCase; +use JsonException; use Mockery as m; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; @@ -32,7 +37,7 @@ protected function setUp(): void $this->broadcaster = m::mock(AblyBroadcaster::class, [$this->container, $this->ably])->makePartial(); } - public function testAuthCallValidAuthenticationResponseWithPrivateChannelWhenCallbackReturnTrue() + public function testAuthCallValidAuthenticationResponseWithPrivateChannelWhenCallbackReturnTrue(): void { $this->broadcaster->channel('test', function () { return true; @@ -51,7 +56,7 @@ public function testAuthCallValidAuthenticationResponseWithPrivateChannelWhenCal ); } - public function testAuthThrowAccessDeniedHttpExceptionWithPrivateChannelWhenCallbackReturnFalse() + public function testAuthThrowAccessDeniedHttpExceptionWithPrivateChannelWhenCallbackReturnFalse(): void { $this->expectException(AccessDeniedHttpException::class); @@ -64,7 +69,7 @@ public function testAuthThrowAccessDeniedHttpExceptionWithPrivateChannelWhenCall ); } - public function testAuthThrowAccessDeniedHttpExceptionWithPrivateChannelWhenRequestUserNotFound() + public function testAuthThrowAccessDeniedHttpExceptionWithPrivateChannelWhenRequestUserNotFound(): void { $this->expectException(AccessDeniedHttpException::class); @@ -77,7 +82,7 @@ public function testAuthThrowAccessDeniedHttpExceptionWithPrivateChannelWhenRequ ); } - public function testAuthCallValidAuthenticationResponseWithPresenceChannelWhenCallbackReturnAnArray() + public function testAuthCallValidAuthenticationResponseWithPresenceChannelWhenCallbackReturnAnArray(): void { $returnData = [1, 2, 3, 4]; $this->broadcaster->channel('test', function () use ($returnData) { @@ -107,7 +112,7 @@ public function testAuthCallValidAuthenticationResponseWithPresenceChannelWhenCa ); } - public function testAuthThrowAccessDeniedHttpExceptionWithPresenceChannelWhenCallbackReturnNull() + public function testAuthThrowAccessDeniedHttpExceptionWithPresenceChannelWhenCallbackReturnNull(): void { $this->expectException(AccessDeniedHttpException::class); @@ -119,7 +124,7 @@ public function testAuthThrowAccessDeniedHttpExceptionWithPresenceChannelWhenCal ); } - public function testAuthThrowAccessDeniedHttpExceptionWithPresenceChannelWhenRequestUserNotFound() + public function testAuthThrowAccessDeniedHttpExceptionWithPresenceChannelWhenRequestUserNotFound(): void { $this->expectException(AccessDeniedHttpException::class); @@ -201,6 +206,96 @@ public function testFormatsChannelsBeforeApplyingAblyNamespaces(): void ); } + public function testGenerateSignatureThrowsWhenUserDataCannotBeEncoded(): void + { + $this->expectException(JsonException::class); + + $this->broadcaster->generateAblySignature( + 'presence-test', + 'abcd.1234', + ['invalid' => NAN], + ); + } + + public function testPresenceAuthenticationThrowsWhenChannelDataCannotBeEncoded(): void + { + $this->expectException(JsonException::class); + + $this->broadcaster->shouldReceive('generateAblySignature') + ->once() + ->andReturn('signature'); + + $this->broadcaster->validAuthenticationResponse( + $this->getMockRequestWithUserForChannel('presence-test'), + ['invalid' => NAN], + ); + } + + public function testBroadcastReleasesOrdinaryChannelsAfterEveryPublication(): void + { + $ably = $this->createAbly(); + $broadcaster = new AblyBroadcaster($this->container, $ably); + $channel = $ably->channels->get('public:orders'); + + for ($publication = 0; $publication < 3; ++$publication) { + $broadcaster->broadcast(['orders'], 'OrderCreated'); + + $replacement = $ably->channels->get('public:orders'); + $this->assertNotSame($channel, $replacement); + $channel = $replacement; + } + + /** @var BroadcastingAblyHttpFake $http */ + $http = $ably->http; + $this->assertSame(3, $http->requestCount); + } + + public function testBroadcastReleasesOrdinaryChannelWhenPublicationFails(): void + { + $ably = $this->createAbly(); + $broadcaster = new AblyBroadcaster($this->container, $ably); + $channel = $ably->channels->get('public:orders'); + + /** @var BroadcastingAblyHttpFake $http */ + $http = $ably->http; + $http->fail = true; + + try { + $broadcaster->broadcast(['orders'], 'OrderCreated'); + $this->fail('Expected publication to fail.'); + } catch (BroadcastException $exception) { + $this->assertSame('Ably error: Publication failed.', $exception->getMessage()); + } + + $this->assertNotSame($channel, $ably->channels->get('public:orders')); + } + + public function testBroadcastRetainsConfiguredEncryptedChannel(): void + { + $ably = $this->createAbly(); + $broadcaster = new AblyBroadcaster($this->container, $ably); + $channel = $ably->channels->get('private:orders', [ + 'cipher' => ['key' => Crypto::generateRandomKey(128)], + ]); + + $broadcaster->broadcast(['private-orders'], 'OrderCreated'); + + $this->assertSame($channel, $ably->channels->get('private:orders')); + $this->assertNotNull($channel->getCipherParams()); + } + + /** + * Create a real Ably client with an in-memory HTTP transport. + */ + protected function createAbly(): AblyRest + { + return new AblyRest([ + 'key' => 'abcd:efg', + 'httpClass' => BroadcastingAblyHttpFake::class, + 'idempotentRestPublishing' => false, + ]); + } + protected function getMockRequestWithUserForChannel(string $channel): Request { $request = m::mock(Request::class); @@ -234,3 +329,24 @@ public function formatOutgoingChannels(array $channels): array return parent::formatChannels($channels); } } + +class BroadcastingAblyHttpFake extends Http +{ + public bool $fail = false; + + public int $requestCount = 0; + + public function request($method, $url, $headers = [], $params = []): array + { + ++$this->requestCount; + + if ($this->fail) { + throw new AblyRequestException('Publication failed.', 40000, 400); + } + + return [ + 'headers' => "HTTP/1.1 200 OK\n", + 'body' => [], + ]; + } +} diff --git a/tests/Broadcasting/BroadcastEventTest.php b/tests/Broadcasting/BroadcastEventTest.php index 9aae51f88..9692d19a0 100644 --- a/tests/Broadcasting/BroadcastEventTest.php +++ b/tests/Broadcasting/BroadcastEventTest.php @@ -9,6 +9,7 @@ use Hypervel\Broadcasting\InteractsWithBroadcasting; use Hypervel\Contracts\Broadcasting\Broadcaster; use Hypervel\Contracts\Broadcasting\Factory as BroadcastingFactory; +use Hypervel\Contracts\Broadcasting\ShouldBroadcast; use Hypervel\Queue\Attributes\Backoff; use Hypervel\Tests\TestCase; use Mockery as m; @@ -16,7 +17,7 @@ class BroadcastEventTest extends TestCase { - public function testBasicEventBroadcastParameterFormatting() + public function testBasicEventBroadcastParameterFormatting(): void { $broadcaster = m::mock(Broadcaster::class); @@ -35,7 +36,7 @@ public function testBasicEventBroadcastParameterFormatting() (new BroadcastEvent($event))->handle($manager); } - public function testManualParameterSpecification() + public function testManualParameterSpecification(): void { $broadcaster = m::mock(Broadcaster::class); @@ -54,7 +55,7 @@ public function testManualParameterSpecification() (new BroadcastEvent($event))->handle($manager); } - public function testSpecificBroadcasterGiven() + public function testSpecificBroadcasterGiven(): void { $broadcaster = m::mock(Broadcaster::class); @@ -69,7 +70,7 @@ public function testSpecificBroadcasterGiven() (new BroadcastEvent($event))->handle($manager); } - public function testSpecificChannelsPerConnection() + public function testSpecificChannelsPerConnection(): void { $broadcaster = m::mock(Broadcaster::class); @@ -95,7 +96,70 @@ public function testSpecificChannelsPerConnection() (new BroadcastEvent($event))->handle($manager); } - public function testMiddlewareProxiesMiddlewareFromUnderlyingEvent() + public function testBroadcastAsStringIsUsedAsEventName(): void + { + $this->assertEventBroadcastsAs( + new TestBroadcastEventWithStringName, + 'custom-name', + ); + } + + public function testBroadcastAsBackedEnumResolvesToValue(): void + { + $this->assertEventBroadcastsAs( + new TestBroadcastEventWithEnumName, + 'custom-enum-name', + ); + } + + public function testBroadcastAsIntegerBackedEnumZeroResolvesToStringValue(): void + { + $this->assertEventBroadcastsAs( + new TestBroadcastEventWithIntegerEnumName, + '0', + ); + } + + public function testBroadcastAsUnitEnumResolvesToName(): void + { + $this->assertEventBroadcastsAs( + new TestBroadcastEventWithUnitEnumName, + 'Custom', + ); + } + + public function testSingleStringChannelIsBroadcast(): void + { + $broadcaster = m::mock(Broadcaster::class); + $broadcaster->shouldReceive('broadcast') + ->once() + ->with(['test-channel'], TestBroadcastEventWithStringChannel::class, m::type('array')); + + $manager = m::mock(BroadcastingFactory::class); + $manager->shouldReceive('connection')->once()->with(null)->andReturn($broadcaster); + + (new BroadcastEvent(new TestBroadcastEventWithStringChannel))->handle($manager); + } + + public function testCloningPreservesEnumEventIdentity(): void + { + $job = new BroadcastEvent(TestBroadcastEventName::Custom); + + $clone = clone $job; + + $this->assertSame($job->event, $clone->event); + } + + public function testCloningIsolatesOrdinaryEventObjects(): void + { + $job = new BroadcastEvent(new TestBroadcastEvent); + + $clone = clone $job; + + $this->assertNotSame($job->event, $clone->event); + } + + public function testMiddlewareProxiesMiddlewareFromUnderlyingEvent(): void { $event = new class { public function middleware(): array @@ -109,7 +173,7 @@ public function middleware(): array $this->assertSame(['foo', 'bar'], $job->middleware()); } - public function testMiddlewareProxiesFailedHandlerFromUnderlyingEvent() + public function testMiddlewareProxiesFailedHandlerFromUnderlyingEvent(): void { $event = new class { public function failed(?Throwable $e = null): void @@ -126,7 +190,7 @@ public function failed(?Throwable $e = null): void $job->failed($exception); } - public function testDeleteWhenMissingModelsDefaultsToTrue() + public function testDeleteWhenMissingModelsDefaultsToTrue(): void { $event = new TestBroadcastEvent; @@ -148,6 +212,22 @@ public function testVariadicBackoffIsReadFromTheUnderlyingEvent(): void $this->assertSame([1, 5, 10], $job->backoff); } + + /** + * Assert an event uses the expected broadcast name. + */ + protected function assertEventBroadcastsAs(object $event, string $name): void + { + $broadcaster = m::mock(Broadcaster::class); + $broadcaster->shouldReceive('broadcast') + ->once() + ->with(['test-channel'], $name, m::type('array')); + + $manager = m::mock(BroadcastingFactory::class); + $manager->shouldReceive('connection')->once()->with(null)->andReturn($broadcaster); + + (new BroadcastEvent($event))->handle($manager); + } } class TestBroadcastEvent @@ -171,6 +251,61 @@ public function broadcastOn() } } +class TestBroadcastEventWithStringName extends TestBroadcastEvent +{ + public function broadcastAs(): string + { + return 'custom-name'; + } +} + +class TestBroadcastEventWithEnumName extends TestBroadcastEvent +{ + public function broadcastAs(): TestBroadcastEventName + { + return TestBroadcastEventName::Custom; + } +} + +class TestBroadcastEventWithIntegerEnumName extends TestBroadcastEvent +{ + public function broadcastAs(): TestBroadcastIntegerEventName + { + return TestBroadcastIntegerEventName::Zero; + } +} + +class TestBroadcastEventWithUnitEnumName extends TestBroadcastEvent +{ + public function broadcastAs(): TestBroadcastUnitEventName + { + return TestBroadcastUnitEventName::Custom; + } +} + +class TestBroadcastEventWithStringChannel extends TestBroadcastEvent implements ShouldBroadcast +{ + public function broadcastOn(): string + { + return 'test-channel'; + } +} + +enum TestBroadcastEventName: string +{ + case Custom = 'custom-enum-name'; +} + +enum TestBroadcastIntegerEventName: int +{ + case Zero = 0; +} + +enum TestBroadcastUnitEventName +{ + case Custom; +} + class TestBroadcastEventWithManualData extends TestBroadcastEvent { public function broadcastWith() diff --git a/tests/Broadcasting/BroadcasterTest.php b/tests/Broadcasting/BroadcasterTest.php index 7b3345d73..4f824150f 100644 --- a/tests/Broadcasting/BroadcasterTest.php +++ b/tests/Broadcasting/BroadcasterTest.php @@ -34,7 +34,7 @@ protected function setUp(): void $this->broadcaster = new FakeBroadcaster($this->container); } - public function testExtractingParametersWhileCheckingForUserAccess() + public function testExtractingParametersWhileCheckingForUserAccess(): void { $callback = function ($user, BroadcasterTestEloquentModelStub $model, $nonModel) { }; @@ -77,7 +77,7 @@ public function testExtractingParametersWhileCheckingForUserAccess() $this->assertEquals(['bound'], $parameters); } - public function testCanUseChannelClasses() + public function testCanUseChannelClasses(): void { $parameters = $this->broadcaster->extractAuthParameters('asd.{model}.{nonModel}', 'asd.1.something', DummyBroadcastingChannel::class); $this->assertCount(2, $parameters); @@ -86,7 +86,7 @@ public function testCanUseChannelClasses() $this->assertSame('something', $parameters[1]); } - public function testModelRouteBinding() + public function testModelRouteBinding(): void { $binder = m::mock(BindingRegistrar::class); $routeModelCallback = RouteBinding::forModel($this->container, BroadcasterTestEloquentModelStub::class); @@ -103,14 +103,14 @@ public function testModelRouteBinding() $this->assertSame('1', $parameters[0]->boundValue); } - public function testUnknownChannelAuthHandlerTypeThrowsException() + public function testUnknownChannelAuthHandlerTypeThrowsException(): void { $this->expectException(Exception::class); $this->broadcaster->extractAuthParameters('asd.{model}.{nonModel}', 'asd.1.something', 'notClassString'); } - public function testCanRegisterChannelsAsClasses() + public function testCanRegisterChannelsAsClasses(): void { $this->broadcaster->channel('something', function () { }); @@ -118,7 +118,7 @@ public function testCanRegisterChannelsAsClasses() $this->broadcaster->channel('somethingelse', DummyBroadcastingChannel::class); } - public function testNotFoundThrowsHttpException() + public function testNotFoundThrowsHttpException(): void { $this->expectException(HttpException::class); @@ -127,20 +127,20 @@ public function testNotFoundThrowsHttpException() $this->broadcaster->extractAuthParameters('asd.{model}', 'asd.1', $callback); } - public function testCanRegisterChannelsWithoutOptions() + public function testCanRegisterChannelsWithoutOptions(): void { $this->broadcaster->channel('somechannel', function () { }); } - public function testCanRegisterChannelsWithOptions() + public function testCanRegisterChannelsWithOptions(): void { $options = ['a' => ['b', 'c']]; $this->broadcaster->channel('somechannel', function () { }, $options); } - public function testCanRetrieveChannelsOptions() + public function testCanRetrieveChannelsOptions(): void { $options = ['a' => ['b', 'c']]; $this->broadcaster->channel('somechannel', function () { @@ -152,7 +152,7 @@ public function testCanRetrieveChannelsOptions() ); } - public function testCanRetrieveChannelsOptionsUsingAChannelNameContainingArgs() + public function testCanRetrieveChannelsOptionsUsingAChannelNameContainingArgs(): void { $options = ['a' => ['b', 'c']]; $this->broadcaster->channel('somechannel.{id}.test.{text}', function () { @@ -164,7 +164,7 @@ public function testCanRetrieveChannelsOptionsUsingAChannelNameContainingArgs() ); } - public function testCanRetrieveChannelsOptionsWhenMultipleChannelsAreRegistered() + public function testCanRetrieveChannelsOptionsWhenMultipleChannelsAreRegistered(): void { $options = ['a' => ['b', 'c']]; $this->broadcaster->channel('somechannel', function () { @@ -178,7 +178,7 @@ public function testCanRetrieveChannelsOptionsWhenMultipleChannelsAreRegistered( ); } - public function testDontRetrieveChannelsOptionsWhenChannelDoesntExists() + public function testDontRetrieveChannelsOptionsWhenChannelDoesntExists(): void { $options = ['a' => ['b', 'c']]; $this->broadcaster->channel('somechannel', function () { @@ -190,7 +190,7 @@ public function testDontRetrieveChannelsOptionsWhenChannelDoesntExists() ); } - public function testRetrieveUserWithoutGuard() + public function testRetrieveUserWithoutGuard(): void { $this->broadcaster->channel('somechannel', function () { }); @@ -207,7 +207,7 @@ public function testRetrieveUserWithoutGuard() ); } - public function testRetrieveUserWithOneGuardUsingAStringForSpecifyingGuard() + public function testRetrieveUserWithOneGuardUsingAStringForSpecifyingGuard(): void { $this->broadcaster->channel('somechannel', function () { }, ['guards' => 'myguard']); @@ -224,7 +224,7 @@ public function testRetrieveUserWithOneGuardUsingAStringForSpecifyingGuard() ); } - public function testRetrieveUserWithMultipleGuardsAndRespectGuardsOrder() + public function testRetrieveUserWithMultipleGuardsAndRespectGuardsOrder(): void { $this->broadcaster->channel('somechannel', function () { }, ['guards' => ['myguard1', 'myguard2']]); @@ -253,7 +253,7 @@ public function testRetrieveUserWithMultipleGuardsAndRespectGuardsOrder() ); } - public function testRetrieveUserDontUseDefaultGuardWhenOneGuardSpecified() + public function testRetrieveUserDontUseDefaultGuardWhenOneGuardSpecified(): void { $this->broadcaster->channel('somechannel', function () { }, ['guards' => 'myguard']); @@ -269,7 +269,7 @@ public function testRetrieveUserDontUseDefaultGuardWhenOneGuardSpecified() $this->broadcaster->retrieveUser($request, 'somechannel'); } - public function testRetrieveUserDontUseDefaultGuardWhenMultipleGuardsSpecified() + public function testRetrieveUserDontUseDefaultGuardWhenMultipleGuardsSpecified(): void { $this->broadcaster->channel('somechannel', function () { }, ['guards' => ['myguard1', 'myguard2']]); @@ -289,7 +289,7 @@ public function testRetrieveUserDontUseDefaultGuardWhenMultipleGuardsSpecified() $this->broadcaster->retrieveUser($request, 'somechannel'); } - public function testUserAuthenticationWithValidUser() + public function testUserAuthenticationWithValidUser(): void { $this->broadcaster->resolveAuthenticatedUserUsing(function ($request) { return ['id' => '12345', 'socket' => $request->input('socket_id')]; @@ -305,7 +305,7 @@ public function testUserAuthenticationWithValidUser() ], $user); } - public function testUserAuthenticationWithInvalidUser() + public function testUserAuthenticationWithInvalidUser(): void { $this->broadcaster->resolveAuthenticatedUserUsing(function ($request) { return null; @@ -318,7 +318,7 @@ public function testUserAuthenticationWithInvalidUser() $this->assertNull($user); } - public function testUserAuthenticationWithoutResolve() + public function testUserAuthenticationWithoutResolve(): void { $this->assertNull($this->broadcaster->resolveAuthenticatedUser( Request::create('http://exa.com/foo?socket_id=1234.1234#boom') @@ -529,12 +529,12 @@ public function testChannelFormatterIsSharedAcrossBroadcasterInstances(): void } #[DataProvider('channelNameMatchPatternProvider')] - public function testChannelNameMatchPattern($channel, $pattern, $shouldMatch) + public function testChannelNameMatchPattern(string $channel, string $pattern, bool $shouldMatch): void { - $this->assertEquals($shouldMatch, $this->broadcaster->channelNameMatchesPattern($channel, $pattern)); + $this->assertSame($shouldMatch, $this->broadcaster->channelNameMatchesPattern($channel, $pattern)); } - public static function channelNameMatchPatternProvider() + public static function channelNameMatchPatternProvider(): array { return [ ['something', 'something', true], @@ -553,7 +553,7 @@ public static function channelNameMatchPatternProvider() ]; } - public function testChannelsAreSharedAcrossBroadcasterInstances() + public function testChannelsAreSharedAcrossBroadcasterInstances(): void { // Simulate boot time: register channel on first broadcaster instance $broadcasterA = new FakeBroadcaster(m::mock(Container::class)); diff --git a/tests/Broadcasting/LogBroadcasterTest.php b/tests/Broadcasting/LogBroadcasterTest.php index c307115b8..b404de67e 100644 --- a/tests/Broadcasting/LogBroadcasterTest.php +++ b/tests/Broadcasting/LogBroadcasterTest.php @@ -7,6 +7,7 @@ use Hypervel\Broadcasting\Broadcasters\Broadcaster; use Hypervel\Broadcasting\Broadcasters\LogBroadcaster; use Hypervel\Tests\TestCase; +use JsonException; use Mockery as m; use Psr\Log\LoggerInterface; @@ -37,4 +38,18 @@ public function testBroadcastUsesFormattedChannelNames(): void ['id' => 1], ); } + + public function testBroadcastThrowsWhenPayloadCannotBeEncoded(): void + { + $this->expectException(JsonException::class); + + $logger = m::mock(LoggerInterface::class); + $logger->shouldNotReceive('info'); + + (new LogBroadcaster($logger))->broadcast( + ['orders'], + 'OrderCreated', + ['invalid' => NAN], + ); + } } diff --git a/tests/Broadcasting/PackageMetadataTest.php b/tests/Broadcasting/PackageMetadataTest.php new file mode 100644 index 000000000..2bd1f7c8c --- /dev/null +++ b/tests/Broadcasting/PackageMetadataTest.php @@ -0,0 +1,58 @@ +assertSame('^0.4', $composer['require']['hypervel/routing']); + $this->assertSame('^3.0', $composer['require']['psr/log']); + $this->assertArrayNotHasKey('hypervel/auth', $composer['require']); + $this->assertArrayNotHasKey('hypervel/cache', $composer['require']); + + $this->assertSame( + 'Required to use the Redis broadcast driver (^0.4).', + $composer['suggest']['hypervel/redis'] + ); + $this->assertSame( + 'Required to use the Ably broadcast driver (^1.0).', + $composer['suggest']['ably/ably-php'] + ); + $this->assertSame( + 'Required to use the Pusher broadcast driver (^7.2).', + $composer['suggest']['pusher/pusher-php-server'] + ); + + $providers = [BroadcastServiceProvider::class]; + + $this->assertSame($providers, $composer['extra']['hypervel']['providers']); + $this->assertContains(BroadcastServiceProvider::class, $rootComposer['extra']['hypervel']['providers']); + $this->assertSame('^3.0', $rootComposer['require']['psr/log']); + $this->assertSame('self.version', $rootComposer['replace']['hypervel/routing']); + } +} diff --git a/tests/Broadcasting/PusherBroadcasterTest.php b/tests/Broadcasting/PusherBroadcasterTest.php index d3c779f99..1b38cf012 100644 --- a/tests/Broadcasting/PusherBroadcasterTest.php +++ b/tests/Broadcasting/PusherBroadcasterTest.php @@ -6,10 +6,16 @@ use Hypervel\Broadcasting\Broadcasters\Broadcaster; use Hypervel\Broadcasting\Broadcasters\PusherBroadcaster; +use Hypervel\Container\Container as ApplicationContainer; use Hypervel\Contracts\Container\Container; use Hypervel\Contracts\Routing\BindingRegistrar; +use Hypervel\Contracts\Routing\ResponseFactory as ResponseFactoryContract; +use Hypervel\Contracts\View\Factory as ViewFactory; use Hypervel\Database\Eloquent\Model; +use Hypervel\Http\JsonResponse; use Hypervel\Http\Request; +use Hypervel\Routing\Redirector; +use Hypervel\Routing\ResponseFactory; use Hypervel\Tests\TestCase; use Mockery as m; use Pusher\Pusher; @@ -33,7 +39,7 @@ protected function setUp(): void $this->broadcaster = m::mock(PusherBroadcaster::class, [$this->container, $this->pusher])->makePartial(); } - public function testAuthCallValidAuthenticationResponseWithPrivateChannelWhenCallbackReturnTrue() + public function testAuthCallValidAuthenticationResponseWithPrivateChannelWhenCallbackReturnTrue(): void { $this->broadcaster->channel('test', function () { return true; @@ -51,7 +57,7 @@ public function testAuthCallValidAuthenticationResponseWithPrivateChannelWhenCal ); } - public function testAuthThrowAccessDeniedHttpExceptionWithPrivateChannelWhenCallbackReturnFalse() + public function testAuthThrowAccessDeniedHttpExceptionWithPrivateChannelWhenCallbackReturnFalse(): void { $this->expectException(AccessDeniedHttpException::class); @@ -64,7 +70,7 @@ public function testAuthThrowAccessDeniedHttpExceptionWithPrivateChannelWhenCall ); } - public function testAuthThrowAccessDeniedHttpExceptionWithPrivateChannelWhenRequestUserNotFound() + public function testAuthThrowAccessDeniedHttpExceptionWithPrivateChannelWhenRequestUserNotFound(): void { $this->expectException(AccessDeniedHttpException::class); @@ -77,7 +83,7 @@ public function testAuthThrowAccessDeniedHttpExceptionWithPrivateChannelWhenRequ ); } - public function testAuthCallValidAuthenticationResponseWithPresenceChannelWhenCallbackReturnAnArray() + public function testAuthCallValidAuthenticationResponseWithPresenceChannelWhenCallbackReturnAnArray(): void { $returnData = [1, 2, 3, 4]; $this->broadcaster->channel('test', function () use ($returnData) { @@ -96,7 +102,7 @@ public function testAuthCallValidAuthenticationResponseWithPresenceChannelWhenCa ); } - public function testAuthThrowAccessDeniedHttpExceptionWithPresenceChannelWhenCallbackReturnNull() + public function testAuthThrowAccessDeniedHttpExceptionWithPresenceChannelWhenCallbackReturnNull(): void { $this->expectException(AccessDeniedHttpException::class); @@ -108,7 +114,7 @@ public function testAuthThrowAccessDeniedHttpExceptionWithPresenceChannelWhenCal ); } - public function testAuthThrowAccessDeniedHttpExceptionWithPresenceChannelWhenRequestUserNotFound() + public function testAuthThrowAccessDeniedHttpExceptionWithPresenceChannelWhenRequestUserNotFound(): void { $this->expectException(AccessDeniedHttpException::class); @@ -168,7 +174,7 @@ function ($authenticatedUser, PusherBroadcasterTestEloquentModelStub $order) use $this->assertSame('5', $boundOrder->boundValue); } - public function testValidAuthenticationResponseCallPusherSocketAuthMethodWithPrivateChannel() + public function testValidAuthenticationResponseCallPusherSocketAuthMethodWithPrivateChannel(): void { $request = $this->getMockRequestWithUserForChannel('private-test'); @@ -186,7 +192,7 @@ public function testValidAuthenticationResponseCallPusherSocketAuthMethodWithPri ); } - public function testValidAuthenticationResponseCallPusherPresenceAuthMethodWithPresenceChannel() + public function testValidAuthenticationResponseCallPusherPresenceAuthMethodWithPresenceChannel(): void { $request = $this->getMockRequestWithUserForChannel('presence-test'); @@ -208,7 +214,7 @@ public function testValidAuthenticationResponseCallPusherPresenceAuthMethodWithP ); } - public function testUserAuthenticationForPusher() + public function testUserAuthenticationForPusher(): void { $authenticateUser = [ 'auth' => '278d425bdf160c739803:4708d583dada6a56435fb8bc611c77c359a31eebde13337c16ab43aa6de336ba', @@ -246,15 +252,33 @@ public function testBroadcastUsesFormattedChannelNames(): void $this->broadcaster->broadcast(['orders'], 'OrderCreated', ['id' => 1]); } - public function testDecodePusherResponseWithJsonpCallback() + public function testJsonpCallbackReturnsJsonWithoutExplicitOptIn(): void { - // Register ResponseFactory so the response() helper works - $container = \Hypervel\Container\Container::getInstance(); + $request = m::mock(Request::class); + $request->shouldReceive('input')->with('channel_name')->andReturn('private-test'); + $request->shouldReceive('input')->with('socket_id')->andReturn('abcd.1234'); + $request->shouldReceive('input')->with('callback', false)->andReturn('myCallback'); + $request->shouldReceive('user')->andReturn(m::mock('User')); + + $data = ['auth' => 'abcd:efgh']; + + $this->pusher->shouldReceive('authorizeChannel') + ->once() + ->andReturn(json_encode($data)); + + $response = $this->broadcaster->validAuthenticationResponse($request, true); + + $this->assertSame($data, $response); + } + + public function testJsonpCallbackReturnsJsonpWhenExplicitlyEnabled(): void + { + $container = ApplicationContainer::getInstance(); $container->singleton( - \Hypervel\Contracts\Routing\ResponseFactory::class, - fn () => new \Hypervel\Routing\ResponseFactory( - m::mock(\Hypervel\Contracts\View\Factory::class), - m::mock(\Hypervel\Routing\Redirector::class), + ResponseFactoryContract::class, + fn () => new ResponseFactory( + m::mock(ViewFactory::class), + m::mock(Redirector::class), ) ); @@ -271,9 +295,38 @@ public function testDecodePusherResponseWithJsonpCallback() ->once() ->andReturn(json_encode($data)); - $response = $this->broadcaster->validAuthenticationResponse($request, true); + $broadcaster = m::mock( + PusherBroadcaster::class, + [$this->container, $this->pusher, true], + )->makePartial(); + + $response = $broadcaster->validAuthenticationResponse($request, true); + + $this->assertInstanceOf( + JsonResponse::class, + $response, + ); + $this->assertStringStartsWith('/**/myCallback(', (string) $response->getContent()); + } + + public function testExplicitJsonpOptInWithoutCallbackReturnsJson(): void + { + $request = $this->getMockRequestWithUserForChannel('private-test'); + $data = ['auth' => 'abcd:efgh']; - $this->assertInstanceOf(\Hypervel\Http\JsonResponse::class, $response); + $this->pusher->shouldReceive('authorizeChannel') + ->once() + ->andReturn(json_encode($data)); + + $broadcaster = m::mock( + PusherBroadcaster::class, + [$this->container, $this->pusher, true], + )->makePartial(); + + $this->assertSame( + $data, + $broadcaster->validAuthenticationResponse($request, true), + ); } protected function getMockRequestWithUserForChannel(string $channel): Request diff --git a/tests/Broadcasting/RedisBroadcasterTest.php b/tests/Broadcasting/RedisBroadcasterTest.php index 8a8e2c2dc..0d28fd355 100644 --- a/tests/Broadcasting/RedisBroadcasterTest.php +++ b/tests/Broadcasting/RedisBroadcasterTest.php @@ -12,6 +12,7 @@ use Hypervel\Http\Request; use Hypervel\Redis\RedisProxy; use Hypervel\Tests\TestCase; +use JsonException; use Mockery as m; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; @@ -33,7 +34,7 @@ protected function setUp(): void $this->broadcaster = m::mock(RedisBroadcaster::class, [$this->container, $this->redis])->makePartial(); } - public function testAuthCallValidAuthenticationResponseWithPrivateChannelWhenCallbackReturnTrue() + public function testAuthCallValidAuthenticationResponseWithPrivateChannelWhenCallbackReturnTrue(): void { $this->broadcaster->channel('test', function () { return true; @@ -47,7 +48,17 @@ public function testAuthCallValidAuthenticationResponseWithPrivateChannelWhenCal ); } - public function testAuthThrowAccessDeniedHttpExceptionWithPrivateChannelWhenCallbackReturnFalse() + public function testAuthRejectsMissingChannelName(): void + { + $this->expectException(AccessDeniedHttpException::class); + + $request = m::mock(Request::class); + $request->shouldReceive('input')->with('channel_name')->andReturn(null); + + $this->broadcaster->auth($request); + } + + public function testAuthThrowAccessDeniedHttpExceptionWithPrivateChannelWhenCallbackReturnFalse(): void { $this->expectException(AccessDeniedHttpException::class); @@ -60,7 +71,7 @@ public function testAuthThrowAccessDeniedHttpExceptionWithPrivateChannelWhenCall ); } - public function testAuthThrowAccessDeniedHttpExceptionWithPrivateChannelWhenRequestUserNotFound() + public function testAuthThrowAccessDeniedHttpExceptionWithPrivateChannelWhenRequestUserNotFound(): void { $this->expectException(AccessDeniedHttpException::class); @@ -73,7 +84,7 @@ public function testAuthThrowAccessDeniedHttpExceptionWithPrivateChannelWhenRequ ); } - public function testAuthCallValidAuthenticationResponseWithPresenceChannelWhenCallbackReturnAnArray() + public function testAuthCallValidAuthenticationResponseWithPresenceChannelWhenCallbackReturnAnArray(): void { $returnData = [1, 2, 3, 4]; $this->broadcaster->channel('test', function () use ($returnData) { @@ -93,7 +104,7 @@ public function testAuthCallValidAuthenticationResponseWithPresenceChannelWhenCa ); } - public function testAuthThrowAccessDeniedHttpExceptionWithPresenceChannelWhenCallbackReturnNull() + public function testAuthThrowAccessDeniedHttpExceptionWithPresenceChannelWhenCallbackReturnNull(): void { $this->expectException(AccessDeniedHttpException::class); @@ -105,7 +116,7 @@ public function testAuthThrowAccessDeniedHttpExceptionWithPresenceChannelWhenCal ); } - public function testAuthThrowAccessDeniedHttpExceptionWithPresenceChannelWhenRequestUserNotFound() + public function testAuthThrowAccessDeniedHttpExceptionWithPresenceChannelWhenRequestUserNotFound(): void { $this->expectException(AccessDeniedHttpException::class); @@ -120,13 +131,17 @@ public function testAuthThrowAccessDeniedHttpExceptionWithPresenceChannelWhenReq public function testAuthUsesRewrittenChannelForConfiguredGuardAndPresenceUser(): void { + $broadcaster = m::mock( + RedisBroadcaster::class, + [$this->container, $this->redis, 'default', 'redis.'], + )->makePartial(); $user = m::mock('User'); $user->shouldReceive('getAuthIdentifier')->once()->andReturn(42); $request = m::mock(Request::class); $request->shouldReceive('input') ->with('channel_name') - ->andReturn('presence-application.tenant.orders.5'); + ->andReturn('redis.presence-application.tenant.orders.5'); $request->shouldReceive('user')->times(3)->with('members')->andReturn($user); $request->shouldNotReceive('user')->withNoArgs(); @@ -139,7 +154,7 @@ public function testAuthUsesRewrittenChannelForConfiguredGuardAndPresenceUser(): : null; }); - $this->broadcaster->channel( + $broadcaster->channel( 'application.orders.{order}', static fn ($authenticatedUser, string $order): array|false => $authenticatedUser === $user && $order === '5' ? ['role' => 'viewer'] @@ -154,12 +169,44 @@ public function testAuthUsesRewrittenChannelForConfiguredGuardAndPresenceUser(): 'user_info' => ['role' => 'viewer'], ], ]), - $this->broadcaster->auth($request), + $broadcaster->auth($request), ); $this->assertSame(1, $calls); } - public function testValidAuthenticationResponseWithPrivateChannel() + public function testAuthDoesNotRemoveConfiguredPrefixFromTheMiddleOfAChannel(): void + { + $broadcaster = m::mock( + RedisBroadcaster::class, + [$this->container, $this->redis, 'default', 'redis.'], + )->makePartial(); + $broadcaster->channel('orders.redis.audit', static fn (): bool => true); + + $this->assertSame( + json_encode(true), + $broadcaster->auth( + $this->getMockRequestWithUserForChannel('private-orders.redis.audit') + ), + ); + } + + public function testAuthLeavesAChannelWithoutTheConfiguredPrefixUnchanged(): void + { + $broadcaster = m::mock( + RedisBroadcaster::class, + [$this->container, $this->redis, 'default', 'redis.'], + )->makePartial(); + $broadcaster->channel('orders', static fn (): bool => true); + + $this->assertSame( + json_encode(true), + $broadcaster->auth( + $this->getMockRequestWithUserForChannel('private-orders') + ), + ); + } + + public function testValidAuthenticationResponseWithPrivateChannel(): void { $request = $this->getMockRequestWithUserForChannel('private-test'); @@ -169,7 +216,7 @@ public function testValidAuthenticationResponseWithPrivateChannel() ); } - public function testValidAuthenticationResponseWithPresenceChannel() + public function testValidAuthenticationResponseWithPresenceChannel(): void { $request = $this->getMockRequestWithUserForChannel('presence-test'); @@ -190,7 +237,17 @@ public function testValidAuthenticationResponseWithPresenceChannel() ); } - public function testBroadcastUsesPublishPerChannelOnCluster() + public function testPresenceAuthenticationThrowsWhenUserDataCannotBeEncoded(): void + { + $this->expectException(JsonException::class); + + $this->broadcaster->validAuthenticationResponse( + $this->getMockRequestWithUserForChannel('presence-test'), + ['invalid' => NAN], + ); + } + + public function testBroadcastUsesPublishPerChannelOnCluster(): void { $connection = m::mock(RedisProxy::class); $connection->shouldReceive('isCluster')->once()->andReturnTrue(); @@ -204,7 +261,7 @@ public function testBroadcastUsesPublishPerChannelOnCluster() $broadcaster->broadcast(['test-channel-1', 'test-channel-2'], 'test-event', ['data' => 'value']); } - public function testBroadcastUsesEvalOnNonCluster() + public function testBroadcastUsesEvalOnNonCluster(): void { $connection = m::mock(RedisProxy::class); $connection->shouldReceive('isCluster')->once()->andReturnFalse(); @@ -217,7 +274,7 @@ public function testBroadcastUsesEvalOnNonCluster() $broadcaster->broadcast(['test-channel'], 'test-event', ['data' => 'value']); } - public function testBroadcastFormatsChannelsBeforeAddingRedisPrefix(): void + public function testClusterBroadcastLeavesRedisPrefixToNativePublishAfterFormattingChannels(): void { Broadcaster::formatChannelsUsing( static fn (array $channels): array => array_map( @@ -230,7 +287,7 @@ public function testBroadcastFormatsChannelsBeforeAddingRedisPrefix(): void $connection->shouldReceive('isCluster')->once()->andReturnTrue(); $connection->shouldReceive('publish') ->once() - ->with('redis.application.orders', m::type('string')); + ->with('application.orders', m::type('string')); $this->redis->shouldReceive('connection')->once()->andReturn($connection); @@ -241,7 +298,51 @@ public function testBroadcastFormatsChannelsBeforeAddingRedisPrefix(): void ))->broadcast(['orders'], 'OrderCreated'); } - public function testBroadcastPayloadDoesNotDuplicateSocketInData() + public function testLuaBroadcastAddsRedisPrefixAfterFormattingChannels(): void + { + Broadcaster::formatChannelsUsing( + static fn (array $channels): array => array_map( + static fn (mixed $channel): string => 'application.' . $channel, + $channels, + ), + ); + + $connection = m::mock(RedisProxy::class); + $connection->shouldReceive('isCluster')->once()->andReturnFalse(); + $connection->shouldReceive('eval') + ->once() + ->with( + m::type('string'), + 0, + m::type('string'), + 'redis.application.orders', + ); + + $this->redis->shouldReceive('connection')->once()->andReturn($connection); + + (new RedisBroadcaster( + $this->container, + $this->redis, + prefix: 'redis.', + ))->broadcast(['orders'], 'OrderCreated'); + } + + public function testBroadcastThrowsWhenPayloadCannotBeEncoded(): void + { + $this->expectException(JsonException::class); + + $this->redis->shouldReceive('connection')->once()->andReturn( + m::mock(RedisProxy::class) + ); + + $this->broadcaster->broadcast( + ['test-channel'], + 'test-event', + ['invalid' => NAN], + ); + } + + public function testBroadcastPayloadDoesNotDuplicateSocketInData(): void { $connection = m::mock(RedisProxy::class); $connection->shouldReceive('isCluster')->andReturnFalse(); diff --git a/tests/Foundation/FoundationConfigTest.php b/tests/Foundation/FoundationConfigTest.php index cf20240a5..038f79e1b 100644 --- a/tests/Foundation/FoundationConfigTest.php +++ b/tests/Foundation/FoundationConfigTest.php @@ -60,6 +60,16 @@ public function testReverbBroadcastingConfigUsesTheServerPath(): void $this->assertSame('/socket', $config['connections']['reverb']['options']['path']); } + public function testBroadcastingConfigDisablesJsonpAndDoesNotShipSdkPools(): void + { + $config = require dirname(__DIR__, 2) . '/src/foundation/config/broadcasting.php'; + + $this->assertFalse($config['connections']['reverb']['jsonp']); + $this->assertFalse($config['connections']['pusher']['jsonp']); + $this->assertArrayNotHasKey('pool', $config['connections']['pusher']); + $this->assertArrayNotHasKey('pool', $config['connections']['ably']); + } + public function testViewCompiledPathFallsBackToStoragePathWhenDirectoryDoesNotExist(): void { $key = 'VIEW_COMPILED_PATH'; diff --git a/tests/Integration/Broadcasting/BroadcastManagerTest.php b/tests/Integration/Broadcasting/BroadcastManagerTest.php index c506fd75b..95b4da5ad 100644 --- a/tests/Integration/Broadcasting/BroadcastManagerTest.php +++ b/tests/Integration/Broadcasting/BroadcastManagerTest.php @@ -4,9 +4,12 @@ namespace Hypervel\Tests\Integration\Broadcasting; +use Ably\AblyRest; use Exception; +use Hypervel\Broadcasting\Broadcasters\AblyBroadcaster; use Hypervel\Broadcasting\Broadcasters\Broadcaster as BaseBroadcaster; use Hypervel\Broadcasting\Broadcasters\PusherBroadcaster; +use Hypervel\Broadcasting\Broadcasters\RedisBroadcaster; use Hypervel\Broadcasting\BroadcastEvent; use Hypervel\Broadcasting\BroadcastManager; use Hypervel\Broadcasting\BroadcastPoolProxy; @@ -15,17 +18,21 @@ use Hypervel\Config\Repository; use Hypervel\Container\Container; use Hypervel\Contracts\Broadcasting\Broadcaster; +use Hypervel\Contracts\Broadcasting\Factory as BroadcastingFactory; use Hypervel\Contracts\Broadcasting\ShouldBeUnique; use Hypervel\Contracts\Broadcasting\ShouldBroadcast; use Hypervel\Contracts\Broadcasting\ShouldBroadcastNow; use Hypervel\Contracts\Broadcasting\ShouldRescue; +use Hypervel\Contracts\Cache\Lock; use Hypervel\Contracts\Cache\Repository as Cache; use Hypervel\Contracts\Container\Container as ContainerContract; use Hypervel\Contracts\Foundation\CachesRoutes; +use Hypervel\Contracts\Redis\Factory as Redis; use Hypervel\Foundation\Http\Middleware\PreventRequestForgery; use Hypervel\Http\Request; use Hypervel\ObjectPool\Contracts\Factory as PoolFactory; use Hypervel\ObjectPool\PoolManager; +use Hypervel\Redis\RedisProxy; use Hypervel\Routing\Route; use Hypervel\Support\Facades\Broadcast; use Hypervel\Support\Facades\Bus; @@ -33,6 +40,8 @@ use Hypervel\Testbench\TestCase; use InvalidArgumentException; use Mockery as m; +use PHPUnit\Framework\Attributes\DataProvider; +use Pusher\Pusher; use RuntimeException; class BroadcastManagerTest extends TestCase @@ -48,6 +57,20 @@ public function testEventCanBeBroadcastNow(): void Queue::assertNotPushed(BroadcastEvent::class); } + public function testEnumEventCanBeBroadcastNowWithoutCloning(): void + { + Bus::fake(); + Queue::fake(); + + Broadcast::queue(TestEventNowEnum::Created); + + Bus::assertDispatched( + BroadcastEvent::class, + static fn (BroadcastEvent $job): bool => $job->event === TestEventNowEnum::Created, + ); + Queue::assertNotPushed(BroadcastEvent::class); + } + public function testEventsCanBeBroadcast(): void { Bus::fake(); @@ -59,6 +82,36 @@ public function testEventsCanBeBroadcast(): void Queue::assertPushed(BroadcastEvent::class); } + public function testEnumEventCanBeQueuedWithoutCloning(): void + { + Bus::fake(); + Queue::fake(); + + Broadcast::queue(TestEventEnum::Created); + + Bus::assertNotDispatched(BroadcastEvent::class); + Queue::assertPushed( + BroadcastEvent::class, + static fn (BroadcastEvent $job): bool => $job->event === TestEventEnum::Created, + ); + } + + public function testQueuedOrdinaryEventIsClonedOnce(): void + { + Bus::fake(); + Queue::fake(); + CloneCountingBroadcastEvent::$clones = 0; + $event = new CloneCountingBroadcastEvent; + + Broadcast::queue($event); + + Queue::assertPushed( + BroadcastEvent::class, + static fn (BroadcastEvent $job): bool => $job->event !== $event, + ); + $this->assertSame(1, CloneCountingBroadcastEvent::$clones); + } + public function testEventsCanBeBroadcastUsingQueueRoutes(): void { Bus::fake(); @@ -99,7 +152,7 @@ public function testUniqueEventsCanBeBroadcast(): void Queue::fake(); $lockKey = 'laravel_unique_job:' . hash('xxh128', TestEventUnique::class) . ':'; - $lock = m::mock(\Hypervel\Contracts\Cache\Lock::class); + $lock = m::mock(Lock::class); $lock->shouldReceive('get')->once()->andReturn(true); $cache = m::mock(Cache::class); $cache->shouldReceive('lock')->with($lockKey, 0)->andReturn($lock); @@ -111,6 +164,45 @@ public function testUniqueEventsCanBeBroadcast(): void Queue::assertPushed(UniqueBroadcastEvent::class); } + public function testUniqueEnumEventCanBeQueuedWithoutCloning(): void + { + Bus::fake(); + Queue::fake(); + + $lockKey = 'laravel_unique_job:' . hash('xxh128', TestEventUniqueEnum::class) . ':'; + $lock = m::mock(Lock::class); + $lock->shouldReceive('get')->once()->andReturn(true); + $cache = m::mock(Cache::class); + $cache->shouldReceive('lock')->with($lockKey, 0)->andReturn($lock); + $this->app->singleton(Cache::class, fn () => $cache); + + Broadcast::queue(TestEventUniqueEnum::Created); + + Queue::assertPushed( + UniqueBroadcastEvent::class, + static fn (UniqueBroadcastEvent $job): bool => $job->event === TestEventUniqueEnum::Created, + ); + } + + public function testUniqueEventConstructsOnlyTheSelectedWrapper(): void + { + Bus::fake(); + Queue::fake(); + CloneCountingUniqueBroadcastEvent::$clones = 0; + + $lockKey = 'laravel_unique_job:' . hash('xxh128', CloneCountingUniqueBroadcastEvent::class) . ':'; + $lock = m::mock(Lock::class); + $lock->shouldReceive('get')->once()->andReturn(true); + $cache = m::mock(Cache::class); + $cache->shouldReceive('lock')->with($lockKey, 0)->andReturn($lock); + $this->app->singleton(Cache::class, fn () => $cache); + + Broadcast::queue(new CloneCountingUniqueBroadcastEvent); + + Queue::assertPushed(UniqueBroadcastEvent::class); + $this->assertSame(1, CloneCountingUniqueBroadcastEvent::$clones); + } + public function testUniqueEventsCanBeBroadcastWithUniqueIdFromProperty(): void { Bus::fake(); @@ -160,6 +252,16 @@ public function testThrowExceptionWhenUnknownStoreIsUsed(): void $broadcastManager->connection('alien_connection'); } + public function testProviderResolvesManagerFactoryAndSelectedBroadcasterIdentities(): void + { + $manager = $this->app->make(BroadcastManager::class); + $factory = $this->app->make(BroadcastingFactory::class); + $broadcaster = $this->app->make(Broadcaster::class); + + $this->assertSame($manager, $factory); + $this->assertSame($manager->connection(), $broadcaster); + } + public function testEnumIdentifiersResolveSetDefaultsAndPurge(): void { $app = new Container; @@ -199,6 +301,85 @@ public function testEnumIdentifiersResolveSetDefaultsAndPurge(): void $this->assertNotSame($replacement, $manager->connection('0')); } + #[DataProvider('redisPrefixConfigurations')] + public function testRedisDriverUsesCanonicalPrefixPrecedence( + array $sharedOptions, + array $connectionConfig, + string $expectedPrefix, + ): void { + config()->set('database.redis', [ + 'client' => 'phpredis', + 'options' => $sharedOptions, + 'broadcasting' => array_merge([ + 'host' => '127.0.0.1', + 'port' => 6379, + 'database' => 0, + ], $connectionConfig), + ]); + config()->set('broadcasting.connections.redis-test', [ + 'driver' => 'redis', + 'connection' => 'broadcasting', + ]); + + $connection = m::mock(RedisProxy::class); + $connection->shouldReceive('isCluster')->once()->andReturnFalse(); + $connection->shouldReceive('eval') + ->once() + ->with( + m::type('string'), + 0, + m::type('string'), + $expectedPrefix . 'orders', + ); + + $redis = m::mock(Redis::class); + $redis->shouldReceive('connection') + ->once() + ->with('broadcasting') + ->andReturn($connection); + $this->app->instance('redis', $redis); + + $manager = new BroadcastManager($this->app); + $broadcaster = $manager->connection('redis-test'); + + $this->assertInstanceOf(RedisBroadcaster::class, $broadcaster); + $broadcaster->broadcast(['orders'], 'OrderCreated'); + } + + public static function redisPrefixConfigurations(): array + { + return [ + 'shared options' => [ + ['prefix' => 'shared.'], + [], + 'shared.', + ], + 'connection options override shared options' => [ + ['prefix' => 'shared.'], + ['options' => ['prefix' => 'connection.']], + 'connection.', + ], + 'top-level connection prefix overrides connection options' => [ + ['prefix' => 'shared.'], + [ + 'options' => ['prefix' => 'connection.'], + 'prefix' => 'top-level.', + ], + 'top-level.', + ], + 'empty top-level connection prefix overrides inherited prefix' => [ + ['prefix' => 'shared.'], + ['prefix' => ''], + '', + ], + 'scalar prefix is normalized to string' => [ + [], + ['prefix' => 123], + '123', + ], + ]; + } + public function testRoutesExcludesCsrfMiddleware(): void { $route = m::mock(Route::class); @@ -223,7 +404,7 @@ public function testRoutesExcludesCsrfMiddleware(): void ->andReturn($route); $app = m::mock(Container::class); - $app->shouldReceive('offsetGet')->with('router')->andReturn($router); + $app->shouldReceive('make')->with('router')->andReturn($router); $broadcastManager = new BroadcastManager($app); $broadcastManager->routes(); @@ -253,7 +434,7 @@ public function testUserRoutesExcludesCsrfMiddleware(): void ->andReturn($route); $app = m::mock(Container::class); - $app->shouldReceive('offsetGet')->with('router')->andReturn($router); + $app->shouldReceive('make')->with('router')->andReturn($router); $broadcastManager = new BroadcastManager($app); $broadcastManager->userRoutes(); @@ -263,7 +444,7 @@ public function testRoutesAreNotRegisteredWhenCached(): void { $app = m::mock(Container::class . ',' . CachesRoutes::class); $app->shouldReceive('routesAreCached')->once()->andReturnTrue(); - $app->shouldNotReceive('offsetGet'); + $app->shouldNotReceive('make')->with('router'); $broadcastManager = new BroadcastManager($app); $broadcastManager->routes(); @@ -348,7 +529,7 @@ function (ContainerContract $container, array $config) use (&$received): Broadca ], $received); } - public function testReverbResolvesDirectlyWhileExistingPoolableDriversRemainUnchanged(): void + public function testBuiltInSdkDriversResolveDirectlyWithoutDefaultPools(): void { $app = $this->poolingApplication([ 'reverb' => [ @@ -358,14 +539,38 @@ public function testReverbResolvesDirectlyWhileExistingPoolableDriversRemainUnch 'app_id' => 'app', 'options' => ['host' => '127.0.0.1'], ], + 'pusher' => [ + 'driver' => 'pusher', + 'key' => 'key', + 'secret' => 'secret', + 'app_id' => 'app', + 'options' => ['host' => '127.0.0.1'], + ], + 'ably' => [ + 'driver' => 'ably', + 'key' => 'abcd:efg', + ], ]); $manager = new BroadcastManager($app); $this->assertInstanceOf(PusherBroadcaster::class, $manager->connection('reverb')); + $pusherBroadcaster = $manager->connection('pusher'); + $ablyBroadcaster = $manager->connection('ably'); + + $this->assertInstanceOf(PusherBroadcaster::class, $pusherBroadcaster); + $this->assertInstanceOf(AblyBroadcaster::class, $ablyBroadcaster); $this->assertSame([], $app->make(PoolFactory::class)->pools()); - $this->assertContains('pusher', $manager->getPoolables()); - $this->assertContains('ably', $manager->getPoolables()); - $this->assertNotContains('reverb', $manager->getPoolables()); + $this->assertSame([], $manager->getPoolables()); + + $replacementPusher = m::mock(Pusher::class); + $manager->setDefaultDriver('pusher'); + $manager->setPusher($replacementPusher); + $this->assertSame($replacementPusher, $manager->getPusher()); + + $replacementAbly = new AblyRest('replacement:key'); + $manager->setDefaultDriver('ably'); + $manager->setAbly($replacementAbly); + $this->assertSame($replacementAbly, $manager->getAbly()); } public function testPurgeInvalidatesCachedAndUncachedBroadcasterPoolsWhileForgetIsCacheOnly(): void @@ -452,6 +657,43 @@ public function testCustomDriverClosureBoundObjectIsBroadcastManager(): void $this->assertSame($broadcastManager, $boundInstance); } + public function testCustomDriverStaticClosure(): void + { + $app = new Container; + $app->singleton('config', fn () => new Repository([ + 'broadcasting' => [ + 'connections' => [ + 'test' => ['driver' => 'custom'], + ], + ], + ])); + $driver = m::mock(Broadcaster::class); + $manager = new BroadcastManager($app); + + $manager->extend('custom', static fn () => $driver); + + $this->assertSame($driver, $manager->connection('test')); + } + + public function testInvokableObjectDriverClosure(): void + { + $app = new Container; + $app->singleton('config', fn () => new Repository([ + 'broadcasting' => [ + 'connections' => [ + 'test' => ['driver' => 'custom'], + ], + ], + ])); + $driver = m::mock(Broadcaster::class); + $manager = new BroadcastManager($app); + $creator = new ManagerCustomBroadcastCreator($driver); + + $manager->extend('custom', $creator(...)); + + $this->assertSame($driver, $manager->connection('test')); + } + public function testThrowExceptionWhenDriverCreationFails(): void { $this->expectException(RuntimeException::class); @@ -519,6 +761,26 @@ public function broadcastOn(): array } } +enum TestEventNowEnum implements ShouldBroadcastNow +{ + case Created; + + public function broadcastOn(): array + { + return []; + } +} + +enum TestEventEnum implements ShouldBroadcast +{ + case Created; + + public function broadcastOn(): array + { + return []; + } +} + class TestEventUnique implements ShouldBroadcast, ShouldBeUnique { /** @@ -532,6 +794,30 @@ public function broadcastOn(): array } } +enum TestEventUniqueEnum implements ShouldBroadcast, ShouldBeUnique +{ + case Created; + + public function broadcastOn(): array + { + return []; + } +} + +class CloneCountingBroadcastEvent extends TestEvent +{ + public static int $clones = 0; + + public function __clone(): void + { + ++static::$clones; + } +} + +class CloneCountingUniqueBroadcastEvent extends CloneCountingBroadcastEvent implements ShouldBeUnique +{ +} + class TestEventUniqueWithIdProperty extends TestEventUnique { public string $uniqueId = 'unique-id-property'; @@ -583,6 +869,19 @@ public function broadcast(array $channels, string $event, array $payload = []): } } +class ManagerCustomBroadcastCreator +{ + public function __construct( + protected Broadcaster $driver, + ) { + } + + public function __invoke(): Broadcaster + { + return $this->driver; + } +} + enum BroadcastUnitIdentifier { case Primary; diff --git a/tests/Integration/Broadcasting/SendingBroadcastsViaAnonymousEventTest.php b/tests/Integration/Broadcasting/SendingBroadcastsViaAnonymousEventTest.php index a39163521..3405c59da 100644 --- a/tests/Integration/Broadcasting/SendingBroadcastsViaAnonymousEventTest.php +++ b/tests/Integration/Broadcasting/SendingBroadcastsViaAnonymousEventTest.php @@ -17,7 +17,7 @@ class SendingBroadcastsViaAnonymousEventTest extends TestCase { - public function testBroadcastIsSent() + public function testBroadcastIsSent(): void { Event::fake(); @@ -34,7 +34,7 @@ public function testBroadcastIsSent() }); } - public function testBroadcastIsSentNow() + public function testBroadcastIsSentNow(): void { Event::fake(); @@ -49,7 +49,7 @@ public function testBroadcastIsSentNow() }); } - public function testDefaultNameIsSet() + public function testDefaultNameIsSet(): void { Event::fake(); @@ -84,7 +84,7 @@ public function testEmptyNameUsesDefaultName(): void }); } - public function testDefaultPayloadIsSet() + public function testDefaultPayloadIsSet(): void { Event::fake(); @@ -95,7 +95,7 @@ public function testDefaultPayloadIsSet() }); } - public function testSendToMultipleChannels() + public function testSendToMultipleChannels(): void { Event::fake(); @@ -115,7 +115,7 @@ public function testSendToMultipleChannels() }); } - public function testSendViaANonDefaultConnection() + public function testSendViaANonDefaultConnection(): void { Event::fake(); @@ -128,7 +128,7 @@ public function testSendViaANonDefaultConnection() }); } - public function testSendToOthersOnly() + public function testSendToOthersOnly(): void { Event::fake(); @@ -148,11 +148,11 @@ public function testSendToOthersOnly() ->send(); Event::assertDispatched(AnonymousEvent::class, function ($event) { - return $event->socket = '12345'; + return $event->socket === '12345'; }); } - public function testSendToPrivateChannel() + public function testSendToPrivateChannel(): void { Event::fake(); @@ -165,7 +165,7 @@ public function testSendToPrivateChannel() }); } - public function testSendToPresenceChannel() + public function testSendToPresenceChannel(): void { Event::fake();