diff --git a/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit-ledger.md b/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit-ledger.md index a28a0d08b..10ee7fcf7 100644 --- a/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit-ledger.md +++ b/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit-ledger.md @@ -388,3 +388,52 @@ Append package entries in checklist order. Keep each entry compact but complete - **Validation and review:** Focused DI, Filesystem, Foundation AOP, Support, Sentry, and Telescope tests are green. PHP CS Fixer, both PHPStan configurations, the complete parallel suite, both Testbench suites, `git diff --check`, stale-reference scans, package-checklist parity, full fresh caller/callee and generated-code review, and independent code review are complete. The final review verified native PHP 8.4 behavior, proxy fingerprints and publication, marker composition, loader isolation, filesystem boundaries, and the deliberate rejection of method-local named class declarations. - **Assessment:** The final design fixes demonstrated semantic, cache-validity, publication, filesystem, and test-isolation defects at their owning boundaries. It deletes the old duplicate dispatch path and dead metadata, improves the measured intercepted hot path, and adds no speculative synchronization, compatibility layer, or general source-transformation framework. - **Owner pre-commit review:** The owner reviewed the completed work-unit summary and approved committing the signed-off implementation. + +### Isolate logging state and capture queued payloads deterministically + +- **Architecture and inspected risk surfaces:** Log is a Laravel-derived manager and wrapper around worker-cached Monolog graphs. Channel definitions, handlers, processors, custom creators, and event listeners intentionally have worker lifetime; visible context, recursion detection, buffering, request identifiers, and in-flight exception-reporting state must instead follow coroutine/request ownership. The audit covered every Log source and test file; Monolog 3.10 logger, stream, rotating-file, fingers-crossed, and UID implementations; Foundation exception reporting and helpers; Support manager extension callbacks; Queue's sync, background, deferred, delayed, and after-commit paths; Sentry's channel factories; current Laravel source, tests, docs, and callback-rebinding trait; current Hyperf stream handling; and all relevant repository callers. + +| ID | Category | Severity | Confidence | Failure and owning boundary | Final decision | +|---|---|---|---|---|---| +| `log-01` | Defect | Major | High | Logger context keys use reusable object IDs, so a later transient logger can inherit a destroyed logger's surviving coroutine context | Allocate a worker-monotonic logger-family ID, retain it across named variants, and never reset the identity generator | +| `log-02` | Defect | Major | High | Monolog falls back from Fiber-local recursion depth to one shared logger property under Swoole, so concurrent ordinary logs can falsely trigger loop detection | Store visible context and recursion depth in one coroutine-local logger state, reset only transient depth when context is copied, clear only the context field so in-progress depth cannot be discarded, disable Monolog's incompatible detector, and preserve its warning/drop thresholds in the framework wrapper | +| `log-03` | Defect | Minor | High | On-demand stacks pass their custom name under `channel` while the parser reads `name` | Port current Laravel's correct configuration key and regression | +| `log-04` | Defect | Minor | High | Hypervel lacks current Laravel's JSON exception formatter and its reporting-state cooperation | Port the formatter, expose the Laravel-compatible exception-context helper, and track the currently reported exception in coroutine-local Foundation state with a coroutine-incarnation guard | +| `log-05` | Defect | Major | High | A worker-cached `FingersCrossedHandler` shares buffered records and permanently changed buffering state across requests | Use a vendor-compatible framework subclass whose transient buffer and buffering flag are coroutine-local and reset to fresh state when context is copied | +| `log-06` | Defect | Major | High | Monolog stream and rotation handlers install process-global PHP error handlers around hooked filesystem calls that may yield, capturing unrelated coroutine warnings | Use exact built-in safe subclasses that check suppressed native results directly, preserve Monolog retry/locking/rotation semantics, and never consult process-global error state | +| `log-07` | Defect | Major | High | A configured worker-cached Monolog `UidProcessor` emits one worker identifier for every request | Replace only the exact standard processor with a vendor-compatible coroutine-local processor preserving configured UID length and copied request identity | +| `log-08` | Defect | Major | High | `build()` retains the last dynamic logger in a synthetic named cache entry and its runtime taps are ignored in favor of nonexistent named configuration | Share one construction boundary, cache named channels only, apply taps from the actual resolved configuration, and stop manager-wide context mutation from reaching an arbitrary last-built logger | +| `log-09` | Userland footgun | Minor | High | Logger and context hydration listener registration mutates the worker-global event dispatcher without lifecycle guidance | Mark the source APIs boot-only and document advanced custom Monolog state ownership without exposing internal safe classes | +| `log-10` | Defect | Major | High | A queued payload without log context leaves an existing repository untouched, allowing stale context to survive same-coroutine jobs | Hydrate null when a repository already exists while preserving the no-instance/no-payload zero-allocation path | +| `log-11` | Documentation | Minor | High | Log lacks upstream provenance and guidance for custom worker-cached Monolog components and raw access | Add Laravel provenance and explain that custom mutable state must be coroutine-safe and raw Monolog access opts out of framework context, events, and loop detection | +| `foundation-02` | Defect | Major | High | Current exception-reporting identity would race if ported as Laravel's worker-shared scalar and would be inherited incorrectly by copied child context | Store the current coroutine ID and exception together, restore the exact prior slot in `finally`, and expose the current Laravel formatter integration methods | +| `foundation-03` | Defect | Minor | High | `logs('0')` treats a valid configured channel as falsy and returns the manager | Select the manager only for null and preserve the documented string API | +| `support-01` | Defect | Minor | High | Manager extension callbacks are rebound inconsistently and direct `bindTo()` fails for static closures and first-class callables | Port current Laravel's flat `RebindsCallbacksToSelf` trait and use it at the seven matching manager boundaries; the base Manager contract governs all eight subclasses, while Redis retains its distinct general-callable container-and-name resolver contract | +| `queue-13` | Defect | Major | High | Background, deferred, and delayed queues serialize after the dispatch boundary, losing parent context and capturing later mutable job/context state | Create the payload once at dispatch/scheduling time, delegate serialized execution through SyncQueue, preserve after-commit timing, and make serialization failures synchronous at their owning boundary | + +- **Important rejected concerns:** Do not rebuild or deep-clone complete Monolog graphs per request, serialize logging through a mutex, pool loggers, add a generic handler registry, coordinate loop detection across distinct channels, normalize arbitrary subclasses or custom drivers, reconstruct private vendor state with reflection, adapt handlers without a reproduced race, coordinate custom manager creation speculatively, widen the `logs()` helper to enums, or change scheduled-command propagation. Raw `getLogger()` remains an explicit opt-out. User-owned handler subclasses, processors, and full custom drivers remain user-owned. +- **Approved implementation boundary:** The owner approved the complete design and its four stop gates. `log-02` allocates one combined state on the first handled record for a logger family in a coroutine and reuses one lookup for context and recursion depth. `log-05` adds one context lookup only to channels configured with `action_level`. `log-07` adds one context lookup only to channels explicitly configured with the exact standard `UidProcessor`. `log-08` intentionally makes runtime build taps work, stops retaining built loggers as `ondemand`, and confines manager-wide context mutation to cached named channels. Ordinary requests, jobs, and Context operations that do not log pay none of these per-record costs. +- **Construction and native-boundary design:** Built-in single, daily, and emergency channels construct safe framework stream classes directly. The monolog driver swaps only the exact configured vendor `StreamHandler` and `RotatingFileHandler` class strings before container construction, forwarding the existing `with` arguments; subclasses pass through untouched. UID normalization resolves the configured processor first and replaces only an exact standard instance, preserving its length with `strlen(getUid())`. Native failures use deterministic operation/path diagnostics and immediate result checks, never `set_error_handler()`, `error_get_last()`, or `@` around a yielding operation. PHP's `@` suppression changes process-global error reporting across a Swoole coroutine switch, while Hypervel's runtime error handler turns unsuppressed native warnings into `ErrorException`; the local open, write, lock, permission, close, inode, directory, and rotation catches are therefore required ownership boundaries rather than optional warning cleanup. The stream implementations preserve one reopen retry, best-effort locking and permissions, chunk size, inode handling, rotation, and retention behavior. +- **State ownership design:** One logger-family state object contains visible context and recursion depth; replication copies visible context and resets depth. `withoutContext()` mutates only the visible-context field, including for a full clear, because removing the combined slot during a handler-triggered re-entrant log would reset loop detection and orphan the outer frame's depth bookkeeping. Fingers-crossed replication starts with an empty buffer and initial buffering state so a child neither races on nor duplicates its parent's history. UID replication copies the current request identifier. The exception-reporting slot is an array containing coroutine ID plus exception so a copied child cannot mistake an inherited parent report for its own. The logger-family counter is a worker-lifetime identity generator, not resettable state; resetting it can reintroduce context aliasing. +- **Queue timing and error contract:** SyncQueue creates a serialized payload at the execution scheduling boundary and delegates to `executePayload()`. Background and Deferred queues schedule only that immutable string; delayed timers capture it immediately, while after-commit callbacks continue to create it only when the commit callback runs. Serialization failures now throw synchronously to the dispatcher or commit callback, matching persistent queues; configured exception callbacks handle only asynchronous execution failures. A payload without log context flushes an already-created repository but does not allocate one solely to hydrate null. +- **Cross-package implications:** Log owns logger, handler, processor, manager, context-listener, provenance, and logging-guide changes. Foundation owns exception-reporting state and the `logs()` helper. Support owns callback rebinding, with matching direct manager consumers in Auth, Broadcasting, Cache, Filesystem, and Log. The shared `Manager` correction also restores Laravel's custom-driver callback contract across its eight Reverb, JWT, Session, Socialite, Notifications, Foundation maintenance-mode, and Hashing subclasses; stale Socialite and JWT test creators now use explicit container or local captures rather than relying on an external anonymous-closure receiver. Queue owns payload timing and serialized execution. Sentry removes its workaround and delegates action-level wrapping to Log. Later full Foundation, Support, Queue, and Sentry audits must retain and revalidate these boundaries. +- **Regression strategy:** Deterministically cover object-ID reuse, concurrent recursion depth, context clearing during a bounded re-entrant write without losing the loop warning, named stacks, current Laravel callback forms plus the eight-subclass Manager contract, direct and currently-reported JSON exceptions including copied-child context, zero-named channels, coroutine-isolated fingers-crossed buffers and buffering state, stream warning isolation and native false/retry/rotation boundaries, exact-class versus subclass/custom normalization, request UID isolation and replication, on-demand cache/taps, queue dispatch/delay/after-commit capture and fail-fast serialization, null-context repository flushing, and one safe Sentry wrapper. Port current Laravel tests where applicable and retain exact vendor `instanceof` compatibility. +- **Performance and complexity:** The approved state lookups occur only on handled log records and only for the relevant configured feature. Safe stream handlers add no lock, yield, retry beyond Monolog's existing single retry, or coroutine coordination. Callback reflection is boot-only; JSON enrichment is exception-only; payload serialization still occurs once and merely moves to the correct earlier boundary; on-demand construction retains less state. Separate state holders and exact-class adapters replace process-global vendor state without a generic abstraction or request-wide handler rebuild. +- **Laravel-facing result:** Public methods, signatures, config keys, and supported custom extension shapes remain intact. Most changes restore current Laravel behavior or add Swoole ownership behind vendor-compatible types. Observable corrections are that build-time taps run, dynamic loggers are not cached as a named channel, serialization failures surface at dispatch/commit time, and queued payloads snapshot state at that boundary. No Laravel public API is removed or renamed. +- **Validation and review:** Every changed Log, Foundation, Support, Queue, Sentry, and manager-consumer regression is green. PHP CS Fixer, both PHPStan configurations, the complete parallel suite, both Testbench suites, `git diff --check`, stale-reference and test-integrity scans, a fresh full-diff caller/callee and lifecycle review, and independent code review are complete. The final review found no unresolved correctness, API, coroutine-safety, native-boundary, performance, stale-code, or overengineering concern. +- **Assessment:** The final design moves only request-local Monolog state into coroutine ownership, preserves worker-cached logger graphs, replaces unsafe process-global native-error handling at exact built-in boundaries, and corrects queue snapshot timing without adding request-wide rebuilding, locks, retries, pooling, or a general handler abstraction. + +### Normalize framework enum identifiers at string boundaries + +- **Architecture and inspected surface:** `enum_value()` deliberately preserves a backed enum's native `int|string` value because validation, database values, session/context keys, message groups, and serialization domains need that distinction. The defect is at string-only receivers: strict Hypervel managers, cache stores, carriers, and typed properties sometimes receive the raw integer, while truthiness-based defaults mistake enum value `0` for absence. The audit mechanically classified all 191 helper calls and all 438 `UnitEnum|string`-shaped source, contract, facade, and fake surfaces. The linked [detail plan](2026-07-15-framework-enum-identifier-contracts.md) records the complete design and implementation surface. +- **Shared finding `support-02` — Defect, Major, High:** Laravel-facing enum identifier contracts are inconsistent across Support Manager and its subclasses, Broadcasting, Mail, Notifications, Queue, Session, Concurrency, Socialite, Cache and its raw failover capability, Cookie, Database, Redis, Filesystem/Storage, scheduling, Pipeline transactions and named pipelines, Bus/Events/Foundation carriers, queue backends and commands, Horizon, authorization, Permission middleware and role checks, Foundation database testing helpers, Inertia once keys, Telescope tags/job queues, and downstream configured/CLI string identifiers. Effects include immediate or delayed `TypeError`, invalid integer state retained until dispatch, incorrect default selection for `0`, queue payload/storage/reserve/observation disagreement, a MailFake call escaping to the real manager, scalar QueueRoutes being misread as connection routes, PSR cache multi-get treating numeric enum identifiers as list indexes, and integer Telescope tags violating their persisted and monitored string contract. Exact null/empty comparisons preserve established empty-string fallback behavior while allowing the valid string identifier `"0"`; CacheManager retains Laravel's deliberate null-only behavior. The Redis Doctor/Benchmark detector also casts numeric store keys to strings, and Benchmark validates a nullable detection result before publishing its non-null store property. +- **Final rule:** Normalize an enum to `(string) enum_value($enum)` only at the owning string-identifier boundary, then match that boundary's current Laravel default-selection operator: exact null/empty fallback where upstream uses `?:`, null-only where upstream uses `??`. Leave ordinary strings on the shortest path. Preserve raw enum values in every value-oriented domain. Update contracts, facades, fakes, concrete managers, carriers, and tests together; do not add a global helper, service, value object, registry, trait, or catch-and-retry layer. +- **Cross-package transaction:** Support owns the base Manager contract and exact fallback in the string-only MultipleInstanceManager. Contracts and public metadata converge for Broadcasting, Mail, Notifications, Queue, Session, Concurrency, Socialite, Hash, JWT, maintenance mode, and inherited Reverb managers. Cache owns key/tag/event normalization, truthful `FailoverStore::getRaw()` delegation, the numeric-key `getMultiple()` correction, Redis command store detection, and the matching `UnitEnum|string` event-subclass contracts; its six PSR-16 methods remain inherited from the string-only PSR contract by design, while concrete multi-key metadata and the Cache facade describe the broader enum-aware Laravel convenience API. PHP's numeric-string array-key coercion also requires `RetrievesMultipleKeys` to cast back to string before calling Hypervel's strict Store API. Native multi-read implementations were traced and need no matching source change. Bus, Events, Foundation, Broadcasting, Queue, and Pipeline own immediate carrier, route, transaction, chain, and named-pipeline delegation. `ChainedBatch` keeps copied source options verbatim so a source `''` retains Laravel behavior and resolves at the downstream queue boundary; its exact overlay and remainder-inheritance guards independently preserve directly routed and next-job `"0"` identifiers that Laravel's truthiness drops. Queue's Database, Beanstalkd, Redis, and SQS backends plus queue/Horizon commands preserve normalized zero names and established empty fallback through payload creation, storage, reserve/read, returned jobs, and observation; Redis changes logical/storage/reserved-job derivation together, while SQS deliberately keeps raw logical payload selection separate from URL/suffix resolution. Database, Redis, Filesystem/Storage, Cookie, Console scheduling, Foundation authorization/database test helpers, Permission, Mail, Inertia, Telescope, Session, Sanctum, Scout, Translation, Routing, Testbench, and the affected commands own their local string-identifier boundaries. Container and Eloquent attributes are revalidated as delegated consumers without adding duplicate normalization. Completed Events work and its Bus/Broadcasting consumers are revalidated in the same transaction. +- **Same-family exact-zero corrections:** Permission guard discovery, default selection, role checks, Passport compatibility, and `permission:show` retain guard `"0"`; Database monitoring retains `--databases=0`; route conversion retains name `"0"`; Blueprint index creation and `dropMorphs()` retain explicit index `"0"`; and Testbench SQLite create/drop commands retain `--database=0` as `0.sqlite`. Each fix replaces only a falsey absence test at the owning boundary, preserves established null/empty behavior, and has focused wrong-target regression coverage. +- **Sibling finding `translation-01` — Defect, Minor, High:** ArrayLoader maps namespace `"0"` to the wildcard namespace in both `load()` and `addMessages()`. Use exact null/empty wildcard selection so namespace `"0"` remains addressable without changing Laravel's existing null/empty behavior. +- **Sibling finding `reverb-03` — Defect, Major, High:** Presence user ID `"0"` is discarded at subscription, unsubscription, and client-event propagation, while `ChannelConnection::data('0')` returns all data. Normalize stored presence IDs before exact empty-sentinel checks, keep the shared-state absence boundary nullable, retain the ID in rebroadcast/webhook payloads, and let only a null data key request the complete payload. +- **Important rejected concerns:** Do not change `enum_value()` itself; do not stringify session/context keys, validation values, query bindings, model attributes, message groups, JSON values, or Carbon integer timezones; do not widen every string API mechanically; keep MultipleInstanceManager, Socialite `with()`/fake registration, notification `deliverVia()`, Queue pooled purge, and Filesystem pooled purge string-only; keep Inertia component rendering's explicit string-backed-enum validation. Queue and Database Capsule connection arguments plus Schema's connection accessor also remain string-only: current Laravel documents those convenience wrappers as `string|null`, and their untyped runtime acceptance of enums is incidental rather than a declared API. +- **Performance and compatibility:** No coroutine or worker state is added. Newly widened manager string paths gain one predictable `instanceof UnitEnum`; existing enum-aware paths complete their already-present conversion. Carrier work occurs at configuration time, and cache work adds no I/O, hashing, locking, or allocation class beyond existing order-of-N arrays. The changes restore current Laravel enum input parity or make existing declared Hypervel unions truthful; no public API is removed, renamed, or reconfigured. +- **Regression strategy:** Cover unit, string-backed, integer-backed, and zero-backed identifiers; manager/fake parity; cache key/tag/event/lock/limiter/multi-get and direct failover raw-read behavior; queue and broadcast carriers/routes; queue backend round trips that constrain payload/storage/reserve/returned-job agreement; chain, mailable, queue/Horizon command, and observed-job zero names; Pipeline transactions, named pipelines, and contextual-attribute delegation; cookie and scheduling paths; database/Redis/filesystem names; authorization/permission and Foundation database helpers; Inertia once keys; Telescope stored/monitored string tags; paired zero/empty behavior at every changed default boundary; Translation namespace `"0"`; Reverb presence user ID/data key `"0"`; and representative unchanged integer value domains. Normalized identifiers retain the validity of their direct string equivalent: request-side cookie key `"0"` resolves normally, while cookie creation proves that direct string and enum zero share Symfony's rejection. Replace tests that currently bless `TypeError` or delayed invalid state, run focused package suites, then `composer fix`. Laravel's current `Repository::getMultiple()` shares the numeric-enum key bug; include an upstream-ready report in the owner handoff, while leaving any external issue or pull request to owner coordination. +- **Validation and review:** Every changed identifier, fallback, carrier, backend, fake, facade, contract, command, and sibling regression is green. PHP CS Fixer, both PHPStan configurations, the complete parallel suite, both Testbench suites, `git diff --check`, the complete helper/signature/default-selection scans, a fresh full-diff caller/callee and test-integrity review, and independent code review are complete. The final review verified the exact null/empty versus null-only operator rules and found no accidental value-domain conversion, API widening, hot-path regression, stale code, or unnecessary abstraction. +- **Assessment:** The final transaction normalizes enums only where a string owner requires it and corrects falsey-zero loss at each domain's existing sentinel. It deliberately avoids a shared normalizer, registry, wrapper, context slot, or framework-wide string widening. Public Laravel call shapes remain compatible and current enum parity is restored. The later full Support and consumer-package audits must retain and revalidate these boundaries. diff --git a/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit.md b/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit.md index 3fd308c47..752fa0a00 100644 --- a/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit.md +++ b/docs/plans/2026-07-12-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-framework-coroutine-state-lifecycle-audit-ledger.md). -- **Active package or work unit:** `log` -- **Ledger entries required for the active work:** `Coordinate shared container construction and complete current contextual resolution` (`container-09`, `container-10`) -- **Pending revalidation carried into the active work:** `container-09`, `container-10` +- **Active package or work unit:** `support` +- **Ledger entries required for the active work:** `Isolate logging state and capture queued payloads deterministically` (`support-01`) and `Normalize framework enum identifiers at string boundaries` (`support-02`) +- **Pending revalidation carried into the active work:** callback rebinding across every direct Manager consumer; enum identifier ownership across Support managers and their consumers; later full `foundation`, `queue`, and `sentry` audits must retain the completed Log boundaries 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. @@ -1031,6 +1031,7 @@ Add one row only for a shared finding or changed lower-level assumption that ano | `queue-11` | `queue` | `events` (revalidation complete), `broadcasting`; later full `queue` and `broadcasting` audits | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `queue-11` | | `queue-12` | `bus`, `queue` | `events` (revalidation complete), `broadcasting`; later full `bus`, `queue`, and `broadcasting` audits | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `queue-12` | | `foundation-01` | `foundation` | `support`; later full `foundation` and `support` audits | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `foundation-01` | +| `support-02` | `support` | `auth`, `broadcasting`, `bus`, `cache`, `concurrency`, `console`, `container`, `contracts`, `cookie`, `database`, `events`, `filesystem`, `foundation`, `hashing`, `horizon`, `inertia`, `jwt`, `log`, `mail`, `notifications`, `permission`, `pipeline`, `queue`, `redis`, `reverb`, `routing`, `sanctum`, `scout`, `session`, `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-framework-enum-identifier-contracts.md` | ## Package checklist @@ -1063,7 +1064,7 @@ The order is lower-level first where practical. Hypervel has cross-cutting depen - [x] `context` - [x] `di` - [x] `events` -- [ ] `log` +- [x] `log` - [ ] `support` ### Security and request primitives diff --git a/docs/plans/2026-07-15-framework-enum-identifier-contracts.md b/docs/plans/2026-07-15-framework-enum-identifier-contracts.md new file mode 100644 index 000000000..d32023bce --- /dev/null +++ b/docs/plans/2026-07-15-framework-enum-identifier-contracts.md @@ -0,0 +1,428 @@ +# Normalize Framework Enum Identifier Contracts + +## Scope + +Correct the framework-wide mismatch between Laravel-style `UnitEnum|string` public APIs and Hypervel's strict internal string identifiers. A backed enum may legitimately expose an integer value, including `0`; `enum_value()` preserves that integer by design. Several managers, cache operations, queue carriers, and other string-only consumers currently pass that integer into a strict `string` parameter or property, producing a delayed `TypeError`, retaining an invalid value until later execution, or treating `0` as a request for the default configuration. + +This is one cross-package work unit owned by the Support identifier contract (`support-02`). It updates every affected owning boundary and consumer together. It does not mark any package's full audit complete. + +Affected or revalidated packages are: + +- `auth`, `broadcasting`, `bus`, `cache`, `concurrency`, `console`, `container`, `contracts`, `cookie`, `database`, `events`, `filesystem`, `foundation`, `hashing`, `horizon`, `inertia`, `jwt`, `log`, `mail`, `notifications`, `permission`, `pipeline`, `queue`, `redis`, `reverb`, `routing`, `sanctum`, `scout`, `session`, `socialite`, `support`, `telescope`, `testbench`, and `translation`; +- already-completed `events`, plus its completed `bus` and `broadcasting` consumer work, are explicitly reopened for enum-carrier revalidation; +- already-correct enum handling in Auth, CacheManager, LogManager, RateLimiter, and other matching APIs is retained as the reference shape; RedisManager and FilesystemManager already stringify enums but are corrected to preserve Laravel's empty-string fallback. + +## Goal + +Finish with one unambiguous rule: + +> A public enum accepted as the name of a driver, connection, queue, cache key, cookie, route target, or other string identifier is normalized once to its string value or case name at the owning boundary. A public enum used as data keeps its native backed value. + +The resulting code must: + +- make every declared `UnitEnum|string` identifier API truthful for unit, string-backed, and integer-backed enums; +- preserve the string identifier `"0"` instead of falling back to a default; +- update contracts, facades, fakes, carriers, and concrete managers together; +- retain Laravel-facing method names, behavior, and configuration; +- add no global registry, enum wrapper, cache, context slot, or shared normalization abstraction; +- add no coroutine state or worker-lifetime state; +- leave value-oriented enum APIs unchanged; +- remove tests and comments that currently bless a `TypeError` or delayed invalid state. + +## Backing research + +### `enum_value()` preserves value-domain semantics + +The Support helper intentionally returns a backed enum's native `int|string` value and a unit enum's name. Changing it to always return a string would silently alter validation values, query bindings, model attributes, schema definitions, session/context keys, message groups, JSON output, and other value domains. + +Therefore, the helper is not defective and must not be changed. String conversion belongs at the receiver that owns the string-only contract: + +```php +if ($name instanceof UnitEnum) { + $name = (string) enum_value($name); +} +``` + +For a non-nullable enum-or-string value, the equivalent expression is acceptable: + +```php +$key = $key instanceof UnitEnum + ? (string) enum_value($key) + : $key; +``` + +This keeps the common string path to one predictable `instanceof` check and avoids a new helper call or abstraction. + +### Default selection must preserve upstream empty-string semantics + +Laravel's current enum additions often use `enum_value($name) ?: $default`. Under an integer-backed enum, `0` is a valid identifier but is falsey. Hypervel must normalize it to `"0"` before selecting the default, while retaining the existing behavior for an explicit empty string. At a boundary where Laravel uses `?:`, use an exact null-or-empty comparison: + +```php +if ($name instanceof UnitEnum) { + $name = (string) enum_value($name); +} + +$name = $name === null || $name === '' + ? $this->getDefaultDriver() + : $name; +``` + +Enum normalization and default selection are separate decisions. Replace only `?:` default-selection boundaries with exact null-or-empty checks because `?:` drops the normalized identifier `"0"`. Existing `??` boundaries already preserve `"0"` and remain null-only. CacheManager is the sole `??` resolution boundary in the reviewed manager family: `store('')` remains an explicit empty store name and fails normally, while Redis, Filesystem, Support Manager, and the other manager resolution paths retain Laravel's empty-string fallback. MultipleInstanceManager's `purge()` and `forgetInstance()` plus Filesystem's `purge()` remain string-only null-coalescing maintenance boundaries. Broadcast's enum-aware `purge()` likewise retains Laravel's null-only fallback after normalization. Match each owning boundary's upstream operator; do not infer the rule from current Hypervel code because an existing divergence may be the defect. + +Do not use `?:`, `empty()`, or another truthiness test for identifier selection. Exact comparisons preserve both identifier `"0"` and the upstream empty-string contract. + +### Current upstream surface + +The originating Laravel commits identify the intended public surface: + +- `9152f353b7` — base Manager driver enums; +- `a5c4aac517` — broadcasting manager enums; +- `69a4acb877` — mail manager enums; +- `45154000d2` — queue connection enums; +- `95891fa4a7` — notification channel/driver enums; +- `7b943ac7eb` — concurrency driver enums; +- `58f015a8fc` — queue route enums; +- `a2a9be1a62` — queue, log, and session default-driver enums; +- `9bfbcee398` — mail default-driver enums. + +Those commits are discovery pointers only. The checked-out current Laravel framework and Socialite sources under `examples/laravel/` are the implementation reference, including all later corrections. Current upstream documentation contains no enum-specific guidance for these manager arguments, so there is no prose section to port. Hypervel contracts and facade metadata are the public type documentation that must be kept accurate. + +Laravel's current `Cache\Repository::getMultiple()` contains the same numeric-enum key defect described below. Record an upstream-ready report in the final owner handoff; opening an external issue or pull request remains an owner-coordinated action rather than a dependency of the Hypervel fix. + +### Complete mechanical audit + +The baseline audit inspected every one of the 191 `enum_value()` calls under `src/`, then searched all 438 `UnitEnum|string`-shaped source, contract, and facade surfaces for paths that delegate without calling the helper directly. + +The calls divide into these classes: + +| Domain | Decision | +|---|---| +| Driver, connection, queue, mailer, channel, store, cache key/tag, cookie name, table name, ability name, and telemetry tag | Normalize enum values to strings at the owning ingress | +| Manager default selection | Normalize first; exact null/empty fallback where upstream uses `?:`, null-only where upstream uses `??` | +| Queue/broadcast/job state stored in typed `?string` properties or later passed to strict string methods | Store the normalized string immediately | +| Validation values, query bindings, model attribute values, schema enum values, JSON/JS values, translation replacement values | Preserve native `int|string` values | +| Session keys and CoroutineContext keys | Preserve PHP array-key semantics, including integer enum values | +| SQS/message groups and other explicitly `int|string` domains | Preserve integer values | +| Carbon timezone helpers (`now()`, `date()`) | Preserve Carbon's intentional integer-offset support | +| Route and middleware strings already formed through concatenation/`implode()` | Retain the existing safe string boundary | +| Sanctum abilities | Preserve stored ability value semantics | +| MultipleInstanceManager `instance()`, Queue's Hypervel-specific pooled `purge()`, and other string-only `?:` boundaries | Keep their documented string-only contracts unchanged; replace truthiness with exact null-or-empty fallback so `"0"` is not lost | +| MultipleInstanceManager `purge()`/`forgetInstance()`, Filesystem `purge()`, and other string-only `??` maintenance boundaries | Keep their documented string-only contracts and null-only fallback unchanged because `??` already preserves `"0"` | +| Broadcast `purge()` | Widen with its current Laravel manager surface, normalize enums, and retain the existing null-only fallback so both enum zero and direct string `"0"` select the same connection | +| Inertia component rendering | Keep its explicit rejection of non-string-backed component enums; this is a deliberate validated boundary | + +No call outside the actionable groups below may be changed merely for visual consistency. + +## Verified defects and final design + +### 1. Manager and public metadata convergence + +The base `Hypervel\Support\Manager::driver()` currently rejects enums before its own Laravel-derived resolution logic can run. That affects every inherited manager: Hash, JWT, Foundation maintenance mode, Notifications, Reverb application/server-provider, Session, and Socialite. Socialite and Notifications have matching overrides or aliases that must expose the same contract. + +Change the base manager to accept `UnitEnum|string|null`, normalize enum identifiers to strings, and select the default for exactly null or empty string as Laravel does. Then make every manager-specific surface agree: + +| Surface | Required change | +|---|---| +| Support Manager | Widen `driver()` and normalize once | +| Broadcasting | Widen/normalize `connection()`, `driver()`, `setDefaultDriver()`, and pooled `purge()` | +| Mail | Widen/normalize `mailer()`, `driver()`, `setDefaultDriver()`, and pooled `purge()` | +| Notifications | Widen `channel()` and inherited `driver()` metadata/contract; normalize through Manager | +| Queue | Widen/normalize `connected()`, `connection()`, `setDefaultDriver()`, and route inputs; keep the Hypervel pooled `purge()` string-only while preserving its empty-string fallback | +| Session | Widen/normalize `setDefaultDriver()`; inherited `driver()` comes from Manager | +| Concurrency | Widen/normalize only `driver()` before delegating to the deliberately string-only MultipleInstanceManager | +| Socialite | Widen/normalize its manager override, contract, fake, and facade; keep `with()` and fake registration string-only as upstream declares | +| Hash, JWT, maintenance mode | Update facade metadata for the widened inherited Manager API | +| Reverb | Revalidate both inherited managers; no separate facade metadata exists | + +Mail and Notification fakes must not escape or narrow the production facade contract: + +- `MailFake::mailer()` normalizes and records an enum name; +- port the current upstream `MailFake::driver()` alias so `Mail::fake()->driver()` stays inside the fake; +- `NotificationFake::channel()` accepts the same enum union as the factory it replaces; +- `SocialiteFake::driver()` normalizes before its provider lookup and before delegating to the real factory. + +Update the corresponding contracts and facade `@method` declarations. Do not widen unrelated methods such as `extend()`, `with()`, `deliverVia()`, `MultipleInstanceManager::instance()`, Queue's pooled `purge()`, or Filesystem's pooled `purge()`. + +The normalized identifier must remain valid after delegation to a string-only lower boundary. `MultipleInstanceManager::instance()` and QueueManager's `getName()` and pooled `purge()` therefore use exact null/empty comparisons rather than truthiness. Their public types and existing empty-string behavior remain unchanged; only the incorrect interpretation of `"0"` as absence changes. + +### 2. Cache key and tag boundaries + +Cache's Laravel-specific contract methods already advertise `UnitEnum|string`, while its six PSR-16 methods deliberately remain inherited from the string-only PSR contract, matching Laravel. The concrete repository and Cache facade expose Laravel's broader enum-aware convenience surface, but Repository, cache events, tagged caches, stack-tagged caches, and Redis tagged caches pass integer enum values into strict string store/operation methods. Existing tests explicitly expect these `TypeError`s. All public cache key and tag boundaries must instead converge on the same string key used by direct string access. Keep the PSR methods inherited on the contract; add precise enum-aware iterable metadata to the concrete multi-key methods and keep the facade metadata aligned with the concrete runtime API. + +Normalize keys before strict store, event, lock, limiter, tag, and tagged-operation calls in: + +- `Repository`; +- `Events\CacheEvent`; +- `TaggedCache`; +- `StackTaggedCache`; +- `Redis\AnyTaggedCache`; +- `Redis\AllTaggedCache`. + +Normalize `FailoverStore::getRaw()` before it delegates to an inner repository because the internal `RawReadable` capability explicitly accepts `UnitEnum|string` and its sibling `MemoizedStore` already honors that contract. Keep `FailoverStore::many()` on the Store contract's list-of-string boundary: Repository normalizes public enum inputs before the store call, and widening a lower-level array contract is neither required nor correct. + +`AnyModeTaggedCache` rejects read, existence, forget, pull, and touch operations before it uses their keys. Its concrete Redis any/all implementations own the supported write operations and are the places that must normalize `put`, `add`, `forever`, `increment`, `decrement`, and remember-style identifiers. Verify this complete hierarchy rather than adding unreachable normalization to the throwing methods. + +This includes diagnostics in `string()`, `integer()`, `float()`, `boolean()`, and `array()`: an invalid cached value requested through an enum key must still throw the intended `InvalidArgumentException`, not fail while interpolating an enum object. + +Do not rebuild `putMany()` input arrays. PHP arrays cannot retain enum objects as keys, and the repository already preserves valid integer array keys across finite and forever writes. Changing this path would add bulk-write work without fixing an enum contract. + +#### PSR multi-get numeric-key defect + +`Repository::getMultiple()` currently builds an associative defaults array. A normalized integer enum value becomes a numeric PHP array key, which `many()` interprets as a list position; the default value is then read as the cache key. A stored value for enum `1` can therefore return under the wrong key or be replaced by the default. + +Resolve the requested identifiers as a list of strings first, then apply the PSR default to the result: + +```php +$resolvedKeys = []; + +foreach ($keys as $key) { + $resolvedKeys[] = $key instanceof UnitEnum + ? (string) enum_value($key) + : (string) $key; +} + +return array_map( + fn ($value) => $value ?? value($default), + $this->many($resolvedKeys), +); +``` + +This preserves the repository's established rule that a missing or cached-null value resolves to the supplied default. It adds no new result wrapper and does not change `many()`'s Laravel-compatible list/associative input API. + +The shared `RetrievesMultipleKeys` fallback must cast its reconstructed array key back to `string` before calling a strict store `get()`. PHP coerces numeric-string array keys to integers; without the cast, `ArrayStore` and every other store using the Laravel-derived fallback reject ordinary numeric-string keys. Native Redis, database, stack, Swoole, failover, and memoized multi-read paths were traced and do not repeat this strict-call defect. Numeric-string associative defaults remain subject to PHP's inherent key coercion and Laravel's list/associative heuristic; the enum-capable `getMultiple()` path uses unambiguous list input. + +Cache event subclasses with their own scalar-key constructors must retain the base `CacheEvent` contract. Widen `CacheHit`, `KeyWriteFailed`, `KeyWritten`, and `WritingKey` to `UnitEnum|string` and let the base normalize once. Multi-key events retain their array payloads because repository callers already supply normalized key lists and the arrays are observational event data rather than strict string calls. + +### 3. Queue, event, bus, and broadcasting carriers + +Several methods accept enums but store `enum_value()` directly into `?string` properties or defer the strict call until a job/event runs. Normalize at configuration time so the object is valid as soon as the fluent method returns: + +- Bus `Queueable`: connection, queue, chain connection, and chain queue; +- Bus `PendingBatch`: connection and queue options; +- Events `QueuedClosure`: connection and queue; +- Foundation `PendingChain`: connection and queue; +- Events queued-listener dispatch: normalize `viaQueue()`/attribute results before `pushOn()` or `laterOn()`; +- Broadcasting `InteractsWithBroadcasting`: normalize every scalar or array connection member; +- Broadcasting `PendingBroadcast::via()`: pass `null` or a normalized string to user events; +- Broadcasting manager/factory resolution: accept the normalized connection at dispatch time. +- Pipeline transactions: retain the public enum on `withinTransaction()` and rely on the corrected DatabaseManager boundary to resolve the matching string connection, including enum value `0` rather than the default. + +Keep message groups as `int|string`; their integer semantics are explicit and valid. + +Queue route registration must normalize scalar and array route values when they are registered. Preserve Laravel's route shape: a scalar route means queue-only, so `getConnection()` returns `null` and `getQueue()` returns the scalar. Hypervel currently returns the scalar as both connection and queue on the connection path. Fix that half-port while touching the route contract. + +#### Preserve normalized zero identifiers through queue execution + +Every lower queue boundary must use null, not truthiness, as the default sentinel. Otherwise an enum value `0` is correctly normalized to `"0"` and then silently replaced later during dispatch, storage, reserve, chain inheritance, command execution, or observation. + +Apply the correction coherently across: + +- Database, Beanstalkd, Redis, and SQS queue default resolution; +- Redis logical queue names, storage keys, and reserved-job queue state as one read/write transaction; +- SQS `push()` and `later()` payload queue selection independently from `getQueue()`, preserving the existing distinction between an unsuffixed logical payload name and the suffixed queue URL; +- Queueable and ChainedBatch connection/queue inheritance; +- ChainedBatch's direct queue/connection overlay guards when it rebuilds the PendingBatch; +- PendingChain's outer connection/queue guards and first-job inheritance; +- Mailable queued route handoff, passing the already-nullable queue name unchanged; +- Queue listen/work/clear parsing and Horizon clear/supervisor option construction; +- Telescope's observed job queue name. +- Horizon's optional wait-time queue filter. + +Do not funnel SQS payload selection through `getQueue()`: that helper also resolves URLs and suffixes and would change serialized logical queue state. Redis must change all three derivations together so a write, reserve/read, and returned job cannot disagree about queue `"0"`. + +`ChainedBatch` copies the source `PendingBatch` options verbatim before applying its own queue and connection properties. Preserve that Laravel behavior: a source empty-string option remains empty and resolves to the default at the downstream queue manager/backend boundary. The exact non-empty overlay guards exist to preserve a `"0"` route applied directly to the `ChainedBatch`, while the exact inheritance checks preserve a chained next job's `"0"` and let an empty next-job route inherit. Do not delete or normalize copied source options locally. + +### 4. Database, Redis, and filesystem identifiers + +Normalize database connection names before parsing, context storage, pool lookup, or direct resolution in: + +- `DatabaseManager::connection()`, `purge()`, `disconnect()`, `reconnect()`, `usingConnection()`, and connection-name variant lookup; +- pooled, simple, and testing connection resolvers; +- Eloquent Model and Factory `getConnectionName()` return boundaries; +- `Connection::table()` when the table argument is an enum, without altering Closure or QueryBuilder inputs. + +The Model and Factory currently declare `?string` but return an integer for an integer-backed enum. Preserve the public setter union and normalize only when exposing the connection name. + +Normalize Redis connection names to strings before config lookup, context-key construction, proxy caching, custom creator lookup, and pool purge. + +Revalidate Container's `#[Auth]`, `#[Cache]`, `#[Database]`, `#[Storage]`, and `#[Log]` contextual attributes plus Eloquent's `#[Connection]` attribute as delegated consumers. Auth, Cache, Database, Filesystem, Log, and Model own normalization; do not duplicate it in the attributes. Retain `#[Log]`'s already-correct channel normalization during this overlapping Log work unit rather than changing working code for consistency alone; its name still requires local normalization before `withName()`. + +Fix Storage's `fake()` and `persistentFake()` default selection so integer-backed disk `0` becomes disk `"0"` instead of the configured default while empty string still selects the default disk. FilesystemManager's normal `disk()` path already stringifies integer enums but incorrectly treats empty string as explicit; correct it to the same Laravel-compatible exact fallback. Its string-only pooled `purge()` contract remains unchanged. + +### 5. Cookie, scheduler, authorization, and permission boundaries + +Normalize cookie names in `get()`, `make()`, `queued()`, `hasQueued()`, and `unqueue()` so all queue/request paths use the same string key. Existing tests that expect Symfony's constructor to throw for integer enums become successful interoperability regressions. + +Normalization preserves equivalence with the direct string identifier; it does not bypass validation owned by the downstream domain. Symfony rejects the direct cookie name `"0"`, so enum value `0` remains invalid for cookie creation while still resolving the request-side key `"0"`. Regression coverage pairs the direct string and enum-zero creation failures, and uses enum value `1` to prove creation and queue interoperability. + +Normalize scheduler queue, connection, cache-store, and enum timezone values before they reach typed state or dispatch callbacks. Preserve `DateTimeZone` objects unchanged. Integer enum timezone `1` becomes string `"1"`, which retains Carbon's established `+01:00` timezone interpretation without widening the event property. + +In Foundation's `AuthorizesRequests`, normalize only an enum ability to string before the existing class-name/guessed-ability branch. Otherwise an integer-backed ability is misclassified as an object/class argument and a different inferred ability is checked. + +In Permission middleware, pass the original enum into the existing parser instead of unwrapping it into an unsupported integer first. Normalize individual parsed enum names to strings inside the parser. Role and role-or-permission middleware already keep the enum until their parser and are retained. + +Permission's role query scope also treats its nullable guard name as an identifier. Use exact null/empty defaulting so an explicit guard named `"0"` is not replaced by the model's default guard while empty string retains the established fallback. + +The same nullable guard contract appears in every role-check branch and in the role model's guard-mismatch diagnostic. Use exact null/empty checks in the integer/UID, string, Collection, and all-roles paths so guard `"0"` selects only that guard, empty string retains the established fallback, and the mismatch reports only a non-empty explicitly requested guard. The corresponding HasPermissions paths already use their established null-only selection and remain unchanged. + +### 6. Same-family identifier boundaries found by the completeness scan + +Two additional public identifier surfaces have the same defect and belong in this transaction: + +- Inertia's `ResolvesOnce::as()` stores an integer-backed enum value in a `?string` key property. Cast backed values to strings while retaining unit enum names and ordinary strings. Do not alter component rendering's deliberate string-backed-enum validation or flash-data value semantics. +- Telescope's client-request watcher promises to normalize enum tags to strings but currently retains integer enum values. This is a soft serialized-boundary defect rather than a `TypeError`: Telescope persists tags in a string database column and uses them for string monitoring and filters, so retaining an integer can record or compare the wrong tag. Cast only enum tag values to strings before creating the entry; ordinary string tags remain unchanged. +- Foundation's database testing helper accepts an explicit nullable connection name, then uses truthiness before its table-derived and database-default fallbacks. Use exact null/empty checks at both fallback boundaries so explicit connection `"0"` remains authoritative while retaining the established empty-string fallback. +- Pipeline Hub accepts a nullable named pipeline but currently routes `"0"` to `default`. Use exact null/empty default selection in `pipe()` so `"0"` remains explicit and empty string retains Laravel's default behavior; registration and public types remain unchanged. + +These are small owner-boundary corrections, not a reason to broaden the work into a generic enum serialization layer. + +### 7. String-zero sibling defects found by the completeness scan + +The final truthiness scan found several string-only identifiers that do not accept enums directly but receive the normalized string `"0"`, expose a nullable string API, or read a configured string identifier. Correct these sites without changing their existing empty-string behavior: + +- `Migrator::resolveConnection()` must use its nullable argument, including `"0"`, before the migrator's configured connection. +- Session's cache-backed handler and Sanctum's token cache must retain a configured store named `"0"` while preserving their existing null-or-empty fallback. +- Foundation maintenance mode must retain cache store `"0"` while preserving `''` as the configured-default sentinel. `app.maintenance.store` has the string config default `database`; an explicitly non-string value remains a typed configuration error. +- Scout's `--driver`, Database's `--database`, Cache Doctor and Benchmark `--store`, and Foundation's policy `--guard` options are nullable optional values. Preserve the existing fallback for null or an explicitly empty option while accepting `--...=0`. The shared Redis-store detector casts numeric configuration keys to strings, and Benchmark validates its nullable detection result before assigning its non-null store property so the no-store path fails descriptively instead of throwing a property `TypeError`. +- Broadcasting's anonymous event name must retain `as('0')` while preserving the existing class-name fallback for null or empty string. +- Translation's array loader must retain namespace `"0"` in both `load()` and `addMessages()` while preserving `'*'` for null or empty string. +- Permission guard discovery, default selection, role checks, Passport compatibility, and `permission:show --guard=0` must preserve guard `"0"` while retaining the established null/empty fallback. Numeric configuration keys are cast back to strings after collection key extraction. +- Database monitoring must retain `--databases=0`; route conversion must retain an explicit route name `"0"`; and schema index creation plus `dropMorphs(..., '0')` must target the explicit index name rather than deriving another name. +- Testbench's SQLite create/drop commands must retain `--database=0` as the filename `0.sqlite` rather than operating on the default `database.sqlite` file. + +These exact null/empty comparisons are intentionally local. A shared falsey-value helper would obscure each domain's sentinel and add abstraction without reducing complexity. + +The same scan found a separate Reverb identity defect. Presence user ID `"0"` is currently discarded during subscription, unsubscription, and client-event propagation; `ChannelConnection::data('0')` also returns the complete payload instead of key `"0"`. Normalize stored presence IDs to strings before testing the established empty-string sentinel, use null only at the shared-state absence boundary, preserve ID `"0"` in client rebroadcast/webhook payloads, and let only a null data key request the complete payload. Track this as a Reverb-scoped sibling correction rather than as enum normalization. + +## Public API and compatibility + +The work is additive and corrective: + +- current Laravel manager/route enum inputs become available where Hypervel's strict signatures omitted them; +- existing Hypervel methods that already advertise enum inputs begin honoring the full union; +- method names, return types, configuration keys, driver names, extension callbacks, facade accessors, and normal string behavior remain unchanged; +- no API is removed or renamed; +- no deprecated surface is involved; +- invalid delayed `TypeError`s and falsey-default selection are replaced by the promised behavior. + +The only behavior changes are fixes: integer-backed identifiers work as their string representation, `"0"` remains a real identifier through manager, queue, command, chain, test-helper, pipeline, configuration, translation, broadcasting, routing, schema, Testbench SQLite, and presence-channel delegation, scalar queue routes no longer masquerade as connection routes, MailFake retains `driver()` calls, and PSR multi-get no longer confuses a numeric identifier with a list index. Existing null and empty-string behavior remains unchanged at every Laravel-facing boundary, including Redis and Filesystem. CacheManager remains the sole null-only `??` resolution boundary in the reviewed manager family; MultipleInstanceManager purge/forget and Filesystem purge remain string-only null-coalescing maintenance boundaries, while Broadcast purge is enum-aware and retains the same null-only selection rule. + +## Performance and coroutine safety + +The change introduces no coroutine state, locks, yields, container resolutions, retries, allocations on unrelated requests, or worker-retained data. + +- newly widened manager paths add one predictable `instanceof UnitEnum` check to the normal string path; +- lower string-only boundaries replace truthiness with exact comparisons matching their upstream sentinel and add no new call, allocation, lock, or retained state; +- existing enum-aware cache and carrier paths already call `enum_value()`; string casting replaces or completes that normalization rather than adding coordination; +- carrier normalization happens once when a job/event/schedule is configured, not repeatedly during execution; +- cache key conversion is required at each cache operation's existing normalization boundary and does not add I/O or hashing; +- the multi-get fix builds the same order-of-N key/result arrays the method already built; +- requests that do not use these APIs pay nothing. + +There is no meaningful hot-path regression and no owner performance stop gate. The inline conditional is preferred over a framework-wide `enum_string()` helper because the helper would add a call, obscure which domains are string-only, and invite incorrect use in value domains. + +## Implementation plan + +Work one file at a time and preserve upstream method order. + +### Contracts and public metadata + +1. Widen the Broadcasting, Mail, Notification, Queue, and Socialite factory contracts at the exact manager methods that Laravel declares enum-capable. +2. Update facades for Broadcast, Concurrency, Hash, JWT, Mail, MaintenanceMode, Notification, Queue, Session, Socialite, and any matching concrete metadata discovered by the final search. +3. Re-run the complete `UnitEnum|string` signature search and verify every implementation, fake, facade, and contract agrees. + +### Manager families + +1. Correct Support Manager first and test its inherited contract with unit, string-backed, integer-backed, and zero-backed enum cases. +2. Correct Broadcasting, Mail, Notifications, Queue, Session, Concurrency, and Socialite without widening adjacent string-only APIs. +3. Correct MailFake, NotificationFake, and SocialiteFake; verify QueueFake already accepts and safely ignores the connection identifier. +4. Revalidate Hash, JWT, maintenance mode, both Reverb managers, Auth, Cache, Filesystem, and Log as inherited or sibling consumers. + +### Data carriers and package-local boundaries + +1. Correct queue/bus/events/foundation/broadcasting carriers and QueueRoutes. +2. Correct every downstream queue backend, chain, mail, command, Horizon, and Telescope default-selection boundary so normalized zero identifiers survive through execution and observation. +3. Correct Cache Repository/events/tagged implementations, including multi-get and diagnostics. +4. Correct Database manager/resolvers/model/factory/table, Redis manager, and Storage fake helpers. +5. Correct CookieJar, Schedule/ManagesFrequencies, AuthorizesRequests, PermissionMiddleware/role scope, Inertia ResolvesOnce, Telescope client tags, Foundation's database test helper, and Pipeline Hub. +6. Correct the complete downstream string-zero surface in ChainedBatch, Migrator, Horizon, Permission guard discovery/role checks/show command, Session, MaintenanceMode, Sanctum, Scout, Cache Doctor/Benchmark, Database monitor/seed commands, PolicyMakeCommand, AnonymousEvent, ArrayLoader, route conversion, schema index naming, and Testbench SQLite create/drop commands without changing existing empty-string sentinels. +7. Correct the complete Reverb presence identity and ChannelConnection data-key surface as a separately recorded sibling defect. +8. Search every changed symbol across `src/` and `tests/`; remove stale `TypeError` comments and assertions. + +## Regression plan + +Use enum fixtures that cover all distinct representations: + +```php +enum IdentifierUnit +{ + case Primary; +} + +enum IdentifierString: string +{ + case Primary = 'primary'; +} + +enum IdentifierInt: int +{ + case Primary = 1; + case Zero = 0; +} +``` + +Tests must assert behavior, not only stored private state. Cover: + +- base Manager inheritance and zero-backed default selection, paired with retained empty-string fallback; +- manager-specific contracts, facades where relevant, and fake non-escape behavior; +- Broadcasting scalar/array connections and pending dispatch; +- Queueable, PendingBatch, QueuedClosure, PendingChain, queued-listener dispatch, and queue routes; +- queue backends retain `"0"` consistently across payload creation, storage, reserve/read, returned jobs, and size/pop operations; chain and mailable routing retain an explicit `"0"`; queue/Horizon commands parse it without defaulting; +- cache read/write/forever/increment/decrement/lock/funnel/tag/event paths, typed-value diagnostics, tagged variants, numeric-enum `getMultiple()`, and string interoperability; +- direct `FailoverStore::getRaw()` enum handling and the deliberately string-only store-level `many()` boundary; +- Cookie request, creation, queue lookup, and unqueue paths, including enum/string validity equivalence for cookie name `"0"`; +- scheduled job dispatch inputs, cache mutex store, and timezone; +- database manager/resolver/pool naming, context override, Model/Factory return types, and enum table names; +- Pipeline transaction connection delegation plus representative Container and Eloquent contextual-attribute delegation; +- Redis connection/config/context/purge naming and Filesystem/Storage zero-backed disk handling, paired with their retained empty-string fallback; +- controller authorization and Permission middleware string generation; +- Permission guard discovery, role scopes/checks/mismatch diagnostics, Passport compatibility, `permission:show`, Foundation database assertions, and Pipeline Hub preserve explicit `"0"` identifiers; +- Inertia once keys and Telescope tags, including the stored/monitored string tag value; +- Session, maintenance mode, Sanctum, Scout, database monitoring/seeding, Cache Doctor, policy generation, anonymous broadcasting, routing, schema index creation/removal, Testbench SQLite create/drop commands, and Translation retain identifier `"0"` while preserving their existing null/empty fallback behavior; +- Reverb subscribes, unsubscribes, rebroadcasts, and emits webhooks for presence user ID `"0"`, and ChannelConnection retrieves data key `"0"`; +- unchanged integer semantics for at least representative session/context/message-group/value-domain APIs where a changed shared helper could otherwise regress them. + +Prefer existing package test files. Create a focused Support Manager test rather than mixing identifier behavior into the callback-rebinding test. Replace old `TypeError` tests with successful behavior regressions; do not keep both. + +Run each changed test file immediately. Then run the complete focused set for every affected package, followed by: + +```bash +composer fix +``` + +## Completeness and review gates + +Before implementation review: + +1. regenerate every `enum_value()` source hit and classify each against the final string-identifier/value-domain rule; +2. regenerate every `UnitEnum|string` source, contract, facade, and fake hit and trace delegation across the strict boundary; +3. verify no truthiness-based default selection remains on an enum-capable identifier path or string-only lower boundary receiving a normalized enum; compare every replacement with the corresponding upstream operator so exact null/empty and null-only behavior remain intentional; +4. verify no enum-capable method stores raw `enum_value()` into a typed string property or passes it to a strict string parameter; +5. verify contracts, facades, fakes, and concrete implementations agree; +6. verify no value-domain site was stringified for consistency alone; +7. verify current Laravel/Socialite source and originating changed-file surfaces were used as references; +8. verify no prose documentation claims became stale and no unnecessary enum tutorial was added; +9. inspect the complete diff for stale tests/comments, accidental API widening, hot-path costs, and unnecessary abstraction; +10. obtain independent code-review sign-off after `composer fix` is green. + +## Rejected alternatives + +- Do not change `enum_value()` to always return a string; it would corrupt legitimate integer value domains. +- Do not add `enum_string()`, an identifier value object, a registry, a normalizer service, or a trait; the owning boundary is visible and the inline conversion is smaller and faster. +- Do not widen every string parameter in the framework to `UnitEnum|string`; follow current Laravel APIs or an already-declared Hypervel contract only. +- Do not widen MultipleInstanceManager, Queue pooled purge, Filesystem pooled purge, Socialite `with()`/`fake()`, notification `deliverVia()`, or manager extension keys without upstream/API evidence; correct falsey-zero selection without changing each method's existing empty-string behavior. +- Do not widen Queue or Database Capsule connection arguments, or Schema's connection accessor. Current Laravel documents these convenience wrappers as `string|null`; their untyped runtime acceptance of enums is incidental permissiveness rather than the declared API. +- Do not convert session/context keys, validation values, query bindings, model attributes, message groups, or JSON values to strings. +- Do not preserve delayed `TypeError` tests as compatibility behavior; they prove the current contract breach. +- Do not add fallback retries or catch `TypeError`; normalize before crossing the strict boundary. +- Do not add broad prose documentation for a type-level parity feature that upstream does not separately document; keep contracts and facade metadata accurate. diff --git a/src/auth/src/AuthManager.php b/src/auth/src/AuthManager.php index 43300115d..7d8445d90 100755 --- a/src/auth/src/AuthManager.php +++ b/src/auth/src/AuthManager.php @@ -10,7 +10,10 @@ use Hypervel\Contracts\Auth\Guard; use Hypervel\Contracts\Auth\StatefulGuard; use Hypervel\Contracts\Container\Container; +use Hypervel\Support\RebindsCallbacksToSelf; use InvalidArgumentException; +use ReflectionException; +use RuntimeException; use UnitEnum; use function Hypervel\Support\enum_value; @@ -22,6 +25,7 @@ class AuthManager implements FactoryContract { use CreatesUserProviders; + use RebindsCallbacksToSelf; /** * Context key for the default guard override. @@ -326,7 +330,14 @@ public function redirectTo(callable|string|null $guests = null, callable|string| */ public function extend(string $driver, Closure $callback): static { - $this->customCreators[$driver] = $callback->bindTo($this, $this); + try { + $callback = $this->bindCallbackToSelf($callback) + ?? throw new RuntimeException('Unable to bind custom driver callback'); + } catch (ReflectionException $e) { + throw new RuntimeException('Unable to bind custom driver callback', previous: $e); + } + + $this->customCreators[$driver] = $callback; return $this; } diff --git a/src/boost/docs/logging.md b/src/boost/docs/logging.md index 7976be037..476f91807 100644 --- a/src/boost/docs/logging.md +++ b/src/boost/docs/logging.md @@ -373,6 +373,8 @@ Log::stack(['slack', $channel])->info('Something happened!'); Sometimes you may need complete control over how Monolog is configured for an existing channel. For example, you may want to configure a custom Monolog `FormatterInterface` implementation for Hypervel's built-in `single` channel. +Configured channels and their Monolog handlers and processors are reused for the lifetime of a worker. Custom handlers and processors may therefore keep only immutable worker-wide state or state that is safely isolated between coroutines. Mutable request or record state stored directly on a custom component can leak between concurrent requests. + To get started, define a `tap` array on the channel's configuration. The `tap` array should contain a list of classes that should have an opportunity to customize (or "tap" into) the Monolog instance after it is created. There is no conventional location where these classes should be placed, so you are free to create a directory within your application to contain these classes: ```php @@ -414,6 +416,8 @@ class CustomizeFormatter > [!NOTE] > All of your "tap" classes are resolved by the [service container](/docs/{{version}}/container), so any constructor dependencies they require will automatically be injected. +The `getLogger` method returns the underlying Monolog instance when direct Monolog access is required. Messages written directly to that instance bypass Hypervel's shared log context, log event dispatch, and recursion protection, so normal application logging should use the `Hypervel\Log\Logger` wrapper. + ### Creating Monolog Handler Channels diff --git a/src/broadcasting/src/AnonymousEvent.php b/src/broadcasting/src/AnonymousEvent.php index bff033faf..28da52f06 100644 --- a/src/broadcasting/src/AnonymousEvent.php +++ b/src/broadcasting/src/AnonymousEvent.php @@ -119,7 +119,9 @@ public function send(): void */ public function broadcastAs(): string { - return $this->name ?: class_basename($this); + return $this->name === null || $this->name === '' + ? class_basename($this) + : $this->name; } /** diff --git a/src/broadcasting/src/BroadcastManager.php b/src/broadcasting/src/BroadcastManager.php index 3c25af46c..06277d39b 100644 --- a/src/broadcasting/src/BroadcastManager.php +++ b/src/broadcasting/src/BroadcastManager.php @@ -32,11 +32,16 @@ use Hypervel\Queue\Attributes\ReadsQueueAttributes; use Hypervel\Support\Arr; use Hypervel\Support\Queue\Concerns\ResolvesQueueRoutes; +use Hypervel\Support\RebindsCallbacksToSelf; use InvalidArgumentException; use Psr\Log\LoggerInterface; use Pusher\Pusher; +use ReflectionException; use RuntimeException; use Throwable; +use UnitEnum; + +use function Hypervel\Support\enum_value; /** * @mixin \Hypervel\Broadcasting\Broadcasters\Broadcaster @@ -45,6 +50,7 @@ class BroadcastManager implements BroadcastingFactoryContract { use HasPoolProxy; use ReadsQueueAttributes; + use RebindsCallbacksToSelf; use ResolvesQueueRoutes; /** @@ -241,7 +247,7 @@ protected function mustBeUniqueAndCannotAcquireLock(mixed $event): bool /** * Get a driver instance. */ - public function connection(?string $driver = null): Broadcaster + public function connection(UnitEnum|string|null $driver = null): Broadcaster { return $this->driver($driver); } @@ -249,9 +255,15 @@ public function connection(?string $driver = null): Broadcaster /** * Get a driver instance. */ - public function driver(?string $name = null): Broadcaster + public function driver(UnitEnum|string|null $name = null): Broadcaster { - $name = $name ?: $this->getDefaultDriver(); + if ($name instanceof UnitEnum) { + $name = (string) enum_value($name); + } + + $name = $name === null || $name === '' + ? $this->getDefaultDriver() + : $name; return $this->drivers[$name] = $this->get($name); } @@ -458,8 +470,10 @@ public function getDefaultDriver(): string * * Boot-only. Mutates process-global config; per-request use races across coroutines. */ - public function setDefaultDriver(string $name): void + public function setDefaultDriver(UnitEnum|string $name): void { + $name = $name instanceof UnitEnum ? (string) enum_value($name) : $name; + $this->app['config']['broadcasting.default'] = $name; } @@ -470,8 +484,12 @@ public function setDefaultDriver(string $name): void * resources. Other connections sharing the pool transparently acquire a * fresh pool on their next operation. */ - public function purge(?string $name = null): void + public function purge(UnitEnum|string|null $name = null): void { + if ($name instanceof UnitEnum) { + $name = (string) enum_value($name); + } + $name ??= $this->getDefaultDriver(); $driver = $this->drivers[$name] ?? null; @@ -510,7 +528,14 @@ public function purge(?string $name = null): void */ public function extend(string $driver, Closure $callback): static { - $this->customCreators[$driver] = $callback->bindTo($this, $this); + try { + $callback = $this->bindCallbackToSelf($callback) + ?? throw new RuntimeException('Unable to bind custom driver callback'); + } catch (ReflectionException $e) { + throw new RuntimeException('Unable to bind custom driver callback', previous: $e); + } + + $this->customCreators[$driver] = $callback; return $this; } diff --git a/src/broadcasting/src/InteractsWithBroadcasting.php b/src/broadcasting/src/InteractsWithBroadcasting.php index f282901bd..10f223f4e 100644 --- a/src/broadcasting/src/InteractsWithBroadcasting.php +++ b/src/broadcasting/src/InteractsWithBroadcasting.php @@ -21,11 +21,12 @@ trait InteractsWithBroadcasting */ public function broadcastVia(UnitEnum|array|string|null $connection = null): static { - $connection = is_null($connection) ? null : enum_value($connection); - $this->broadcastConnection = is_null($connection) ? [null] - : Arr::wrap($connection); + : array_map( + fn ($value) => $value instanceof UnitEnum ? (string) enum_value($value) : $value, + Arr::wrap($connection) + ); return $this; } diff --git a/src/broadcasting/src/PendingBroadcast.php b/src/broadcasting/src/PendingBroadcast.php index 09f504a2f..c9125c4ad 100644 --- a/src/broadcasting/src/PendingBroadcast.php +++ b/src/broadcasting/src/PendingBroadcast.php @@ -26,7 +26,9 @@ public function __construct( public function via(UnitEnum|string|null $connection = null): static { if (method_exists($this->event, 'broadcastVia')) { - $this->event->broadcastVia(enum_value($connection)); + $this->event->broadcastVia( + $connection instanceof UnitEnum ? (string) enum_value($connection) : $connection + ); } return $this; diff --git a/src/bus/src/ChainedBatch.php b/src/bus/src/ChainedBatch.php index 60de0e4f2..90ead25d5 100644 --- a/src/bus/src/ChainedBatch.php +++ b/src/bus/src/ChainedBatch.php @@ -85,11 +85,11 @@ public function toPendingBatch(): PendingBatch $batch->name = $this->name; $batch->options = $this->options; - if ($this->queue) { + if ($this->queue !== null && $this->queue !== '') { $batch->onQueue($this->queue); } - if ($this->connection) { + if ($this->connection !== null && $this->connection !== '') { $batch->onConnection($this->connection); } @@ -114,8 +114,12 @@ protected function attachRemainderOfChainToEndOfBatch(PendingBatch $batch): Pend $next->chained = $this->chained; - $next->onConnection($next->connection ?: $this->chainConnection); - $next->onQueue($next->queue ?: $this->chainQueue); + $next->onConnection($next->connection === null || $next->connection === '' + ? $this->chainConnection + : $next->connection); + $next->onQueue($next->queue === null || $next->queue === '' + ? $this->chainQueue + : $next->queue); $next->chainConnection = $this->chainConnection; $next->chainQueue = $this->chainQueue; diff --git a/src/bus/src/PendingBatch.php b/src/bus/src/PendingBatch.php index 7bab5d616..012dfe93d 100644 --- a/src/bus/src/PendingBatch.php +++ b/src/bus/src/PendingBatch.php @@ -248,7 +248,9 @@ public function name(string $name): static */ public function onConnection(UnitEnum|string $connection): static { - $this->options['connection'] = enum_value($connection); + $this->options['connection'] = $connection instanceof UnitEnum + ? (string) enum_value($connection) + : $connection; return $this; } @@ -266,7 +268,9 @@ public function connection(): ?string */ public function onQueue(UnitEnum|string|null $queue): static { - $this->options['queue'] = enum_value($queue); + $this->options['queue'] = $queue instanceof UnitEnum + ? (string) enum_value($queue) + : $queue; return $this; } diff --git a/src/bus/src/Queueable.php b/src/bus/src/Queueable.php index c180ac648..99edd2c96 100644 --- a/src/bus/src/Queueable.php +++ b/src/bus/src/Queueable.php @@ -91,7 +91,9 @@ trait Queueable */ public function onConnection(UnitEnum|string|null $connection): static { - $this->connection = enum_value($connection); + $this->connection = $connection instanceof UnitEnum + ? (string) enum_value($connection) + : $connection; return $this; } @@ -101,7 +103,9 @@ public function onConnection(UnitEnum|string|null $connection): static */ public function onQueue(UnitEnum|string|null $queue): static { - $this->queue = enum_value($queue); + $this->queue = $queue instanceof UnitEnum + ? (string) enum_value($queue) + : $queue; return $this; } @@ -137,7 +141,9 @@ public function withDeduplicator(array|callable|null $deduplicator): static */ public function allOnConnection(UnitEnum|string|null $connection): static { - $resolvedConnection = enum_value($connection); + $resolvedConnection = $connection instanceof UnitEnum + ? (string) enum_value($connection) + : $connection; $this->chainConnection = $resolvedConnection; $this->connection = $resolvedConnection; @@ -150,7 +156,9 @@ public function allOnConnection(UnitEnum|string|null $connection): static */ public function allOnQueue(UnitEnum|string|null $queue): static { - $resolvedQueue = enum_value($queue); + $resolvedQueue = $queue instanceof UnitEnum + ? (string) enum_value($queue) + : $queue; $this->chainQueue = $resolvedQueue; $this->queue = $resolvedQueue; @@ -277,8 +285,12 @@ public function dispatchNextJobInChain(): void dispatch(tap(unserialize(array_shift($this->chained)), function ($next) { $next->chained = $this->chained; - $next->onConnection($next->connection ?: $this->chainConnection); - $next->onQueue($next->queue ?: $this->chainQueue); + $next->onConnection($next->connection === null || $next->connection === '' + ? $this->chainConnection + : $next->connection); + $next->onQueue($next->queue === null || $next->queue === '' + ? $this->chainQueue + : $next->queue); $next->chainConnection = $this->chainConnection; $next->chainQueue = $this->chainQueue; diff --git a/src/cache/src/CacheManager.php b/src/cache/src/CacheManager.php index 313053912..23426c5c7 100644 --- a/src/cache/src/CacheManager.php +++ b/src/cache/src/CacheManager.php @@ -13,10 +13,13 @@ use Hypervel\Contracts\Events\Dispatcher as DispatcherContract; use Hypervel\Contracts\Session\Session; use Hypervel\Support\Arr; +use Hypervel\Support\RebindsCallbacksToSelf; use Hypervel\Support\Str; use InvalidArgumentException; use Mockery; use Mockery\LegacyMockInterface; +use ReflectionException; +use RuntimeException; use UnitEnum; use function Hypervel\Support\enum_value; @@ -28,6 +31,8 @@ */ class CacheManager implements FactoryContract { + use RebindsCallbacksToSelf; + /** * The context key prefix for memoized cache repositories. */ @@ -471,7 +476,14 @@ public function purge(UnitEnum|string|null $name = null): void */ public function extend(string $driver, Closure $callback): static { - $this->customCreators[$driver] = $callback->bindTo($this, $this); + try { + $callback = $this->bindCallbackToSelf($callback) + ?? throw new RuntimeException('Unable to bind custom driver callback'); + } catch (ReflectionException $e) { + throw new RuntimeException('Unable to bind custom driver callback', previous: $e); + } + + $this->customCreators[$driver] = $callback; return $this; } diff --git a/src/cache/src/Events/CacheEvent.php b/src/cache/src/Events/CacheEvent.php index f8136f9d5..2f5df69de 100644 --- a/src/cache/src/Events/CacheEvent.php +++ b/src/cache/src/Events/CacheEvent.php @@ -31,7 +31,7 @@ abstract class CacheEvent public function __construct(?string $storeName, UnitEnum|string $key, array $tags = []) { $this->storeName = $storeName; - $this->key = enum_value($key); + $this->key = $key instanceof UnitEnum ? (string) enum_value($key) : $key; $this->tags = $tags; } diff --git a/src/cache/src/Events/CacheHit.php b/src/cache/src/Events/CacheHit.php index 88d28902f..c2f00f55f 100644 --- a/src/cache/src/Events/CacheHit.php +++ b/src/cache/src/Events/CacheHit.php @@ -4,6 +4,8 @@ namespace Hypervel\Cache\Events; +use UnitEnum; + class CacheHit extends CacheEvent { /** @@ -14,7 +16,7 @@ class CacheHit extends CacheEvent /** * Create a new event instance. */ - public function __construct(?string $storeName, string $key, mixed $value, array $tags = []) + public function __construct(?string $storeName, UnitEnum|string $key, mixed $value, array $tags = []) { parent::__construct($storeName, $key, $tags); diff --git a/src/cache/src/Events/KeyWriteFailed.php b/src/cache/src/Events/KeyWriteFailed.php index 0ecb6e63e..24b3ba19a 100644 --- a/src/cache/src/Events/KeyWriteFailed.php +++ b/src/cache/src/Events/KeyWriteFailed.php @@ -4,6 +4,8 @@ namespace Hypervel\Cache\Events; +use UnitEnum; + class KeyWriteFailed extends CacheEvent { /** @@ -19,7 +21,7 @@ class KeyWriteFailed extends CacheEvent /** * Create a new event instance. */ - public function __construct(?string $storeName, string $key, mixed $value, ?int $seconds = null, array $tags = []) + public function __construct(?string $storeName, UnitEnum|string $key, mixed $value, ?int $seconds = null, array $tags = []) { parent::__construct($storeName, $key, $tags); diff --git a/src/cache/src/Events/KeyWritten.php b/src/cache/src/Events/KeyWritten.php index 0ae83f14b..c7fdb22fa 100644 --- a/src/cache/src/Events/KeyWritten.php +++ b/src/cache/src/Events/KeyWritten.php @@ -4,6 +4,8 @@ namespace Hypervel\Cache\Events; +use UnitEnum; + class KeyWritten extends CacheEvent { /** @@ -19,7 +21,7 @@ class KeyWritten extends CacheEvent /** * Create a new event instance. */ - public function __construct(?string $storeName, string $key, mixed $value, ?int $seconds = null, array $tags = []) + public function __construct(?string $storeName, UnitEnum|string $key, mixed $value, ?int $seconds = null, array $tags = []) { parent::__construct($storeName, $key, $tags); diff --git a/src/cache/src/Events/WritingKey.php b/src/cache/src/Events/WritingKey.php index f344dbb31..8f71c29b3 100644 --- a/src/cache/src/Events/WritingKey.php +++ b/src/cache/src/Events/WritingKey.php @@ -4,6 +4,8 @@ namespace Hypervel\Cache\Events; +use UnitEnum; + class WritingKey extends CacheEvent { /** @@ -19,7 +21,7 @@ class WritingKey extends CacheEvent /** * Create a new event instance. */ - public function __construct(?string $storeName, string $key, mixed $value, ?int $seconds = null, array $tags = []) + public function __construct(?string $storeName, UnitEnum|string $key, mixed $value, ?int $seconds = null, array $tags = []) { parent::__construct($storeName, $key, $tags); diff --git a/src/cache/src/FailoverStore.php b/src/cache/src/FailoverStore.php index bbb03d692..684a92101 100644 --- a/src/cache/src/FailoverStore.php +++ b/src/cache/src/FailoverStore.php @@ -15,6 +15,8 @@ use Throwable; use UnitEnum; +use function Hypervel\Support\enum_value; + class FailoverStore extends TaggableStore implements LockProvider, RawReadable { /** @@ -54,6 +56,8 @@ public function get(string $key): mixed public function getRaw(UnitEnum|string $key): mixed { + $key = $key instanceof UnitEnum ? (string) enum_value($key) : $key; + return $this->attemptOnAllStores('getRaw', [$key]); } diff --git a/src/cache/src/Redis/AllTaggedCache.php b/src/cache/src/Redis/AllTaggedCache.php index 996aa132a..0f2a6de43 100644 --- a/src/cache/src/Redis/AllTaggedCache.php +++ b/src/cache/src/Redis/AllTaggedCache.php @@ -50,7 +50,7 @@ class AllTaggedCache extends NamespacedTaggedCache */ public function add(UnitEnum|string $key, mixed $value, DateInterval|DateTimeInterface|int|null $ttl = null): bool { - $key = enum_value($key); + $key = $key instanceof UnitEnum ? (string) enum_value($key) : $key; if ($ttl !== null) { $seconds = $this->getSeconds($ttl); @@ -94,7 +94,7 @@ public function put(array|UnitEnum|string $key, mixed $value, DateInterval|DateT return $this->putMany($key, $value); } - $key = enum_value($key); + $key = $key instanceof UnitEnum ? (string) enum_value($key) : $key; if ($ttl === null) { return $this->forever($key, $value); @@ -156,7 +156,7 @@ public function putMany(array $values, DateInterval|DateTimeInterface|int|null $ */ public function touch(UnitEnum|string $key, DateInterval|DateTimeInterface|int|null $ttl = null): bool { - $key = enum_value($key); + $key = $key instanceof UnitEnum ? (string) enum_value($key) : $key; $value = $this->getRaw($key); if (is_null($value)) { @@ -179,8 +179,10 @@ public function touch(UnitEnum|string $key, DateInterval|DateTimeInterface|int|n */ public function increment(UnitEnum|string $key, int $value = 1): bool|int { + $key = $key instanceof UnitEnum ? (string) enum_value($key) : $key; + return $this->store->allTagOps()->increment()->execute( - $this->itemKey(enum_value($key)), + $this->itemKey($key), $value, $this->tags->tagIds() ); @@ -191,8 +193,10 @@ public function increment(UnitEnum|string $key, int $value = 1): bool|int */ public function decrement(UnitEnum|string $key, int $value = 1): bool|int { + $key = $key instanceof UnitEnum ? (string) enum_value($key) : $key; + return $this->store->allTagOps()->decrement()->execute( - $this->itemKey(enum_value($key)), + $this->itemKey($key), $value, $this->tags->tagIds() ); @@ -203,7 +207,7 @@ public function decrement(UnitEnum|string $key, int $value = 1): bool|int */ public function forever(UnitEnum|string $key, mixed $value): bool { - $key = enum_value($key); + $key = $key instanceof UnitEnum ? (string) enum_value($key) : $key; $result = $this->store->allTagOps()->forever()->execute( $this->itemKey($key), @@ -260,7 +264,7 @@ public function remember(UnitEnum|string $key, DateInterval|DateTimeInterface|in return $this->rememberForever($key, $callback); } - $key = enum_value($key); + $key = $key instanceof UnitEnum ? (string) enum_value($key) : $key; $seconds = $this->getSeconds($ttl); if ($seconds <= 0) { @@ -298,7 +302,7 @@ public function remember(UnitEnum|string $key, DateInterval|DateTimeInterface|in */ public function rememberForever(UnitEnum|string $key, Closure $callback): mixed { - $key = enum_value($key); + $key = $key instanceof UnitEnum ? (string) enum_value($key) : $key; [$value, $wasHit] = $this->store->allTagOps()->rememberForever()->execute( $this->itemKey($key), diff --git a/src/cache/src/Redis/AnyTaggedCache.php b/src/cache/src/Redis/AnyTaggedCache.php index a8a0fecfc..ebfb67336 100644 --- a/src/cache/src/Redis/AnyTaggedCache.php +++ b/src/cache/src/Redis/AnyTaggedCache.php @@ -61,7 +61,7 @@ public function put(array|UnitEnum|string $key, mixed $value, DateInterval|DateT return $this->putMany($key, $value); } - $key = enum_value($key); + $key = $key instanceof UnitEnum ? (string) enum_value($key) : $key; if ($ttl === null) { return $this->forever($key, $value); @@ -121,7 +121,7 @@ public function putMany(array $values, DateInterval|DateTimeInterface|int|null $ */ public function add(UnitEnum|string $key, mixed $value, DateInterval|DateTimeInterface|int|null $ttl = null): bool { - $key = enum_value($key); + $key = $key instanceof UnitEnum ? (string) enum_value($key) : $key; if ($ttl === null) { // Default to 1 year for "null" TTL on add @@ -142,7 +142,7 @@ public function add(UnitEnum|string $key, mixed $value, DateInterval|DateTimeInt */ public function forever(UnitEnum|string $key, mixed $value): bool { - $key = enum_value($key); + $key = $key instanceof UnitEnum ? (string) enum_value($key) : $key; $result = $this->store->anyTagOps()->forever()->execute($key, $value, $this->tags->getNames()); @@ -158,7 +158,9 @@ public function forever(UnitEnum|string $key, mixed $value): bool */ public function increment(UnitEnum|string $key, int $value = 1): bool|int { - return $this->store->anyTagOps()->increment()->execute(enum_value($key), $value, $this->tags->getNames()); + $key = $key instanceof UnitEnum ? (string) enum_value($key) : $key; + + return $this->store->anyTagOps()->increment()->execute($key, $value, $this->tags->getNames()); } /** @@ -166,7 +168,9 @@ public function increment(UnitEnum|string $key, int $value = 1): bool|int */ public function decrement(UnitEnum|string $key, int $value = 1): bool|int { - return $this->store->anyTagOps()->decrement()->execute(enum_value($key), $value, $this->tags->getNames()); + $key = $key instanceof UnitEnum ? (string) enum_value($key) : $key; + + return $this->store->anyTagOps()->decrement()->execute($key, $value, $this->tags->getNames()); } /** @@ -198,7 +202,7 @@ public function remember(UnitEnum|string $key, DateInterval|DateTimeInterface|in return $this->rememberForever($key, $callback); } - $key = enum_value($key); + $key = $key instanceof UnitEnum ? (string) enum_value($key) : $key; $seconds = $this->getSeconds($ttl); if ($seconds <= 0) { @@ -236,7 +240,7 @@ public function remember(UnitEnum|string $key, DateInterval|DateTimeInterface|in */ public function rememberForever(UnitEnum|string $key, Closure $callback): mixed { - $key = enum_value($key); + $key = $key instanceof UnitEnum ? (string) enum_value($key) : $key; [$value, $wasHit] = $this->store->anyTagOps()->rememberForever()->execute( $key, diff --git a/src/cache/src/Redis/Console/BenchmarkCommand.php b/src/cache/src/Redis/Console/BenchmarkCommand.php index d467e48d1..3132efc38 100644 --- a/src/cache/src/Redis/Console/BenchmarkCommand.php +++ b/src/cache/src/Redis/Console/BenchmarkCommand.php @@ -203,14 +203,17 @@ protected function createContext(array $config, CacheContract $cacheManager): Be */ protected function setup(): bool { - $this->storeName = $this->option('store') ?? $this->detectRedisStore(); + $storeName = $this->option('store'); + $storeName = $storeName === null || $storeName === '' ? $this->detectRedisStore() : $storeName; - if (! $this->storeName) { + if ($storeName === null || $storeName === '') { $this->error('Could not detect a cache store using the "redis" driver.'); return false; } + $this->storeName = $storeName; + $cacheManager = $this->hypervel->make(CacheContract::class); try { diff --git a/src/cache/src/Redis/Console/Concerns/DetectsRedisStore.php b/src/cache/src/Redis/Console/Concerns/DetectsRedisStore.php index 2beb367de..51f5e8a6d 100644 --- a/src/cache/src/Redis/Console/Concerns/DetectsRedisStore.php +++ b/src/cache/src/Redis/Console/Concerns/DetectsRedisStore.php @@ -19,7 +19,7 @@ protected function detectRedisStore(): ?string foreach ($stores as $name => $storeConfig) { if (($storeConfig['driver'] ?? null) === 'redis') { - return $name; + return (string) $name; } } diff --git a/src/cache/src/Redis/Console/DoctorCommand.php b/src/cache/src/Redis/Console/DoctorCommand.php index 5360e2baf..5c891be7e 100644 --- a/src/cache/src/Redis/Console/DoctorCommand.php +++ b/src/cache/src/Redis/Console/DoctorCommand.php @@ -82,9 +82,10 @@ public function handle(): int $this->displaySystemInformation(); // Detect or validate store - $storeName = $this->option('store') ?: $this->detectRedisStore(); + $storeName = $this->option('store'); + $storeName = $storeName === null || $storeName === '' ? $this->detectRedisStore() : $storeName; - if (! $storeName) { + if ($storeName === null || $storeName === '') { $this->error('Could not detect a cache store using the "redis" driver.'); $this->info('Please configure a store in config/cache.php or provide one via --store.'); @@ -310,9 +311,10 @@ protected function displaySystemInformation(): void // Redis/Valkey Service try { - $storeName = $this->option('store') ?: $this->detectRedisStore(); + $storeName = $this->option('store'); + $storeName = $storeName === null || $storeName === '' ? $this->detectRedisStore() : $storeName; - if ($storeName) { + if ($storeName !== null && $storeName !== '') { $repository = $this->hypervel->make(CacheContract::class)->store($storeName); $store = $repository->getStore(); diff --git a/src/cache/src/Repository.php b/src/cache/src/Repository.php index 6cd9aa152..376bf6564 100644 --- a/src/cache/src/Repository.php +++ b/src/cache/src/Repository.php @@ -140,15 +140,25 @@ public function many(array $keys): array })->all(); } + /** + * Retrieve multiple items from the cache by key. + * + * @param iterable $keys + */ public function getMultiple(iterable $keys, mixed $default = null): iterable { - $defaults = []; + $resolvedKeys = []; foreach ($keys as $key) { - $defaults[enum_value($key)] = $default; + $resolvedKeys[] = $key instanceof UnitEnum + ? (string) enum_value($key) + : (string) $key; } - return $this->many($defaults); + return array_map( + fn ($value) => $value ?? value($default), + $this->many($resolvedKeys) + ); } /** @@ -176,6 +186,7 @@ public function pull(UnitEnum|string $key, mixed $default = null): mixed */ public function string(UnitEnum|string $key, callable|string|null $default = null): string { + $key = $key instanceof UnitEnum ? (string) enum_value($key) : $key; $value = $this->get($key, $default); if (! is_string($value)) { @@ -196,6 +207,7 @@ public function string(UnitEnum|string $key, callable|string|null $default = nul */ public function integer(UnitEnum|string $key, callable|int|null $default = null): int { + $key = $key instanceof UnitEnum ? (string) enum_value($key) : $key; $value = $this->get($key, $default); if (is_int($value)) { @@ -220,6 +232,7 @@ public function integer(UnitEnum|string $key, callable|int|null $default = null) */ public function float(UnitEnum|string $key, callable|float|null $default = null): float { + $key = $key instanceof UnitEnum ? (string) enum_value($key) : $key; $value = $this->get($key, $default); if (is_float($value)) { @@ -244,6 +257,7 @@ public function float(UnitEnum|string $key, callable|float|null $default = null) */ public function boolean(UnitEnum|string $key, callable|bool|null $default = null): bool { + $key = $key instanceof UnitEnum ? (string) enum_value($key) : $key; $value = $this->get($key, $default); if (! is_bool($value)) { @@ -266,6 +280,7 @@ public function boolean(UnitEnum|string $key, callable|bool|null $default = null */ public function array(UnitEnum|string $key, callable|array|null $default = null): array { + $key = $key instanceof UnitEnum ? (string) enum_value($key) : $key; $value = $this->get($key, $default); if (! is_array($value)) { @@ -286,7 +301,7 @@ public function put(array|UnitEnum|string $key, mixed $value, DateInterval|DateT return $this->putMany($key, $value); } - $key = enum_value($key); + $key = $key instanceof UnitEnum ? (string) enum_value($key) : $key; if ($ttl === null) { return $this->forever($key, $value); @@ -381,7 +396,7 @@ public function setMultiple(iterable $values, DateInterval|DateTimeInterface|int */ public function add(UnitEnum|string $key, mixed $value, DateInterval|DateTimeInterface|int|null $ttl = null): bool { - $key = enum_value($key); + $key = $key instanceof UnitEnum ? (string) enum_value($key) : $key; $seconds = null; @@ -419,7 +434,9 @@ public function add(UnitEnum|string $key, mixed $value, DateInterval|DateTimeInt */ public function increment(UnitEnum|string $key, int $value = 1): bool|int { - return $this->store->increment(enum_value($key), $value); + $key = $key instanceof UnitEnum ? (string) enum_value($key) : $key; + + return $this->store->increment($key, $value); } /** @@ -427,7 +444,9 @@ public function increment(UnitEnum|string $key, int $value = 1): bool|int */ public function decrement(UnitEnum|string $key, int $value = 1): bool|int { - return $this->store->decrement(enum_value($key), $value); + $key = $key instanceof UnitEnum ? (string) enum_value($key) : $key; + + return $this->store->decrement($key, $value); } /** @@ -435,7 +454,7 @@ public function decrement(UnitEnum|string $key, int $value = 1): bool|int */ public function forever(UnitEnum|string $key, mixed $value): bool { - $key = enum_value($key); + $key = $key instanceof UnitEnum ? (string) enum_value($key) : $key; $this->event(WritingKey::class, fn (): WritingKey => new WritingKey($this->getName(), $key, NullSentinel::unwrap($value))); @@ -594,7 +613,7 @@ public function rememberForeverNullable(UnitEnum|string $key, Closure $callback) */ public function flexible(UnitEnum|string $key, array $ttl, mixed $callback, ?array $lock = null, bool $alwaysDefer = false): mixed { - $key = enum_value($key); + $key = $key instanceof UnitEnum ? (string) enum_value($key) : $key; $markerKey = "hypervel:cache:flexible:created:{$key}"; [$key => $value, $markerKey => $created] = $this->manyRaw([$key, $markerKey]); @@ -669,7 +688,7 @@ public function flexibleNullable(UnitEnum|string $key, array $ttl, mixed $callba */ public function touch(UnitEnum|string $key, DateInterval|DateTimeInterface|int|null $ttl = null): bool { - $key = enum_value($key); + $key = $key instanceof UnitEnum ? (string) enum_value($key) : $key; $value = $this->getRaw($key); if (is_null($value)) { @@ -693,7 +712,9 @@ public function touch(UnitEnum|string $key, DateInterval|DateTimeInterface|int|n */ public function withoutOverlapping(UnitEnum|string $key, callable $callback, int $lockFor = 0, int $waitFor = 10, ?string $owner = null): mixed { - return $this->store->lock(enum_value($key), $lockFor, $owner)->block($waitFor, $callback); // @phpstan-ignore method.notFound (lock() is on LockProvider, not Store contract) + $key = $key instanceof UnitEnum ? (string) enum_value($key) : $key; + + return $this->store->lock($key, $lockFor, $owner)->block($waitFor, $callback); // @phpstan-ignore method.notFound (lock() is on LockProvider, not Store contract) } /** @@ -705,7 +726,9 @@ public function funnel(UnitEnum|string $name): ConcurrencyLimiterBuilder throw new BadMethodCallException('This cache store does not support locks.'); } - return new ConcurrencyLimiterBuilder($this, enum_value($name)); + $name = $name instanceof UnitEnum ? (string) enum_value($name) : $name; + + return new ConcurrencyLimiterBuilder($this, $name); } /** @@ -713,7 +736,7 @@ public function funnel(UnitEnum|string $name): ConcurrencyLimiterBuilder */ public function forget(UnitEnum|string $key): bool { - $key = enum_value($key); + $key = $key instanceof UnitEnum ? (string) enum_value($key) : $key; $this->event(ForgettingKey::class, fn (): ForgettingKey => new ForgettingKey($this->getName(), $key)); @@ -734,6 +757,11 @@ public function delete(UnitEnum|string $key): bool return $this->forget($key); } + /** + * Delete multiple items from the cache by key. + * + * @param iterable $keys + */ public function deleteMultiple(iterable $keys): bool { $result = true; @@ -812,7 +840,10 @@ public function tags(mixed $names): TaggedCache } $names = is_array($names) ? $names : func_get_args(); - $names = array_map(fn ($name) => enum_value($name), $names); + $names = array_map( + fn ($name) => $name instanceof UnitEnum ? (string) enum_value($name) : $name, + $names + ); $cache = $store->tags($names); @@ -1040,7 +1071,7 @@ protected function event(string $eventClass, Closure $event): void */ public function getRaw(UnitEnum|string $key): mixed { - $key = enum_value($key); + $key = $key instanceof UnitEnum ? (string) enum_value($key) : $key; $this->event(RetrievingKey::class, fn (): RetrievingKey => new RetrievingKey($this->getName(), $key)); diff --git a/src/cache/src/RetrievesMultipleKeys.php b/src/cache/src/RetrievesMultipleKeys.php index a3af8221b..4f9a8b6c0 100644 --- a/src/cache/src/RetrievesMultipleKeys.php +++ b/src/cache/src/RetrievesMultipleKeys.php @@ -28,7 +28,7 @@ public function many(array $keys): array foreach ($keys as $key => $default) { /* @phpstan-ignore arguments.count (some clients don't accept a default) */ - $return[$key] = $this->get($key, $default); + $return[$key] = $this->get((string) $key, $default); } return $return; diff --git a/src/cache/src/StackTaggedCache.php b/src/cache/src/StackTaggedCache.php index 06c958d33..330995955 100644 --- a/src/cache/src/StackTaggedCache.php +++ b/src/cache/src/StackTaggedCache.php @@ -55,7 +55,7 @@ public function put(array|UnitEnum|string $key, mixed $value, DateInterval|DateT return $this->putMany($key, $value); } - $key = enum_value($key); + $key = $key instanceof UnitEnum ? (string) enum_value($key) : $key; if ($ttl === null) { return $this->forever($key, $value); @@ -84,7 +84,7 @@ public function put(array|UnitEnum|string $key, mixed $value, DateInterval|DateT */ public function add(UnitEnum|string $key, mixed $value, DateInterval|DateTimeInterface|int|null $ttl = null): bool { - $key = enum_value($key); + $key = $key instanceof UnitEnum ? (string) enum_value($key) : $key; if (! is_null($this->store->get($key))) { return false; @@ -98,7 +98,7 @@ public function add(UnitEnum|string $key, mixed $value, DateInterval|DateTimeInt */ public function forever(UnitEnum|string $key, mixed $value): bool { - $key = enum_value($key); + $key = $key instanceof UnitEnum ? (string) enum_value($key) : $key; $result = $this->store->putRecordTagged($this->tags->getNames(), $key, ['value' => $value]); @@ -114,7 +114,9 @@ public function forever(UnitEnum|string $key, mixed $value): bool */ public function increment(UnitEnum|string $key, int $value = 1): bool|int { - return $this->store->incrementTagged($this->tags->getNames(), enum_value($key), $value); + $key = $key instanceof UnitEnum ? (string) enum_value($key) : $key; + + return $this->store->incrementTagged($this->tags->getNames(), $key, $value); } /** @@ -141,7 +143,7 @@ public function remember(UnitEnum|string $key, DateInterval|DateTimeInterface|in return $this->rememberForever($key, $callback); } - $key = enum_value($key); + $key = $key instanceof UnitEnum ? (string) enum_value($key) : $key; $seconds = $this->getSeconds($ttl); if ($seconds <= 0) { @@ -175,7 +177,7 @@ public function remember(UnitEnum|string $key, DateInterval|DateTimeInterface|in */ public function rememberForever(UnitEnum|string $key, Closure $callback): mixed { - $key = enum_value($key); + $key = $key instanceof UnitEnum ? (string) enum_value($key) : $key; $value = $this->store->get($key); if (! is_null($value)) { diff --git a/src/cache/src/TaggedCache.php b/src/cache/src/TaggedCache.php index 0199af81a..85bae98a1 100644 --- a/src/cache/src/TaggedCache.php +++ b/src/cache/src/TaggedCache.php @@ -54,7 +54,9 @@ public function putMany(array $values, DateInterval|DateTimeInterface|int|null $ */ public function increment(UnitEnum|string $key, int $value = 1): bool|int { - return $this->store->increment($this->itemKey(enum_value($key)), $value); + $key = $key instanceof UnitEnum ? (string) enum_value($key) : $key; + + return $this->store->increment($this->itemKey($key), $value); } /** @@ -62,7 +64,9 @@ public function increment(UnitEnum|string $key, int $value = 1): bool|int */ public function decrement(UnitEnum|string $key, int $value = 1): bool|int { - return $this->store->decrement($this->itemKey(enum_value($key)), $value); + $key = $key instanceof UnitEnum ? (string) enum_value($key) : $key; + + return $this->store->decrement($this->itemKey($key), $value); } /** diff --git a/src/concurrency/src/ConcurrencyManager.php b/src/concurrency/src/ConcurrencyManager.php index 83783b950..fc0839e39 100644 --- a/src/concurrency/src/ConcurrencyManager.php +++ b/src/concurrency/src/ConcurrencyManager.php @@ -7,6 +7,9 @@ use Hypervel\Contracts\Concurrency\Driver; use Hypervel\Process\Factory as ProcessFactory; use Hypervel\Support\MultipleInstanceManager; +use UnitEnum; + +use function Hypervel\Support\enum_value; /** * @mixin Driver @@ -16,8 +19,12 @@ class ConcurrencyManager extends MultipleInstanceManager /** * Get a driver instance by name. */ - public function driver(?string $name = null): mixed + public function driver(UnitEnum|string|null $name = null): mixed { + if ($name instanceof UnitEnum) { + $name = (string) enum_value($name); + } + return $this->instance($name); } diff --git a/src/console/src/Scheduling/ManagesFrequencies.php b/src/console/src/Scheduling/ManagesFrequencies.php index 2baa1ab2e..eb88ce8eb 100644 --- a/src/console/src/Scheduling/ManagesFrequencies.php +++ b/src/console/src/Scheduling/ManagesFrequencies.php @@ -543,7 +543,9 @@ public function days(mixed $days): static */ public function timezone(DateTimeZone|UnitEnum|string $timezone): static { - $this->timezone = enum_value($timezone); + $this->timezone = $timezone instanceof UnitEnum + ? (string) enum_value($timezone) + : $timezone; return $this; } diff --git a/src/console/src/Scheduling/Schedule.php b/src/console/src/Scheduling/Schedule.php index d77f27210..0163f8890 100644 --- a/src/console/src/Scheduling/Schedule.php +++ b/src/console/src/Scheduling/Schedule.php @@ -182,8 +182,8 @@ public function job( ): CallbackEvent { $jobName = $job; - $queue = is_null($queue) ? null : enum_value($queue); - $connection = is_null($connection) ? null : enum_value($connection); + $queue = $queue instanceof UnitEnum ? (string) enum_value($queue) : $queue; + $connection = $connection instanceof UnitEnum ? (string) enum_value($connection) : $connection; if (! is_string($job)) { $jobName = method_exists($job, 'displayName') @@ -424,7 +424,7 @@ public function useCache(UnitEnum|string|null $store): static return $this; } - $store = enum_value($store); + $store = $store instanceof UnitEnum ? (string) enum_value($store) : $store; if ($this->eventMutex instanceof CacheAware) { $this->eventMutex->useStore($store); diff --git a/src/contracts/src/Broadcasting/Factory.php b/src/contracts/src/Broadcasting/Factory.php index d8232f705..2239d8fc9 100644 --- a/src/contracts/src/Broadcasting/Factory.php +++ b/src/contracts/src/Broadcasting/Factory.php @@ -5,13 +5,14 @@ namespace Hypervel\Contracts\Broadcasting; use Hypervel\Broadcasting\PendingBroadcast; +use UnitEnum; interface Factory { /** * Get a broadcaster implementation by name. */ - public function connection(?string $name = null): Broadcaster; + public function connection(UnitEnum|string|null $name = null): Broadcaster; /** * Begin broadcasting an event. diff --git a/src/contracts/src/Debug/ExceptionHandler.php b/src/contracts/src/Debug/ExceptionHandler.php index 9cba22415..32ad61a15 100644 --- a/src/contracts/src/Debug/ExceptionHandler.php +++ b/src/contracts/src/Debug/ExceptionHandler.php @@ -9,6 +9,10 @@ use Symfony\Component\HttpFoundation\Response; use Throwable; +/** + * @method array buildContextForException(Throwable $e) + * @method bool isReporting(Throwable $e) + */ interface ExceptionHandler { /** diff --git a/src/contracts/src/Mail/Factory.php b/src/contracts/src/Mail/Factory.php index dd201d688..8a39f29d6 100644 --- a/src/contracts/src/Mail/Factory.php +++ b/src/contracts/src/Mail/Factory.php @@ -4,10 +4,12 @@ namespace Hypervel\Contracts\Mail; +use UnitEnum; + interface Factory { /** * Get a mailer instance by name. */ - public function mailer(?string $name = null): Mailer; + public function mailer(UnitEnum|string|null $name = null): Mailer; } diff --git a/src/contracts/src/Notifications/Factory.php b/src/contracts/src/Notifications/Factory.php index 2961cf5ab..af4a1d0be 100644 --- a/src/contracts/src/Notifications/Factory.php +++ b/src/contracts/src/Notifications/Factory.php @@ -5,13 +5,14 @@ namespace Hypervel\Contracts\Notifications; use Hypervel\Support\Collection; +use UnitEnum; interface Factory { /** * Get a channel instance by name. */ - public function channel(?string $name = null): mixed; + public function channel(UnitEnum|string|null $name = null): mixed; /** * Send the given notification to the given notifiable entities. diff --git a/src/contracts/src/Queue/Factory.php b/src/contracts/src/Queue/Factory.php index 1f78ecb33..2917d1c62 100644 --- a/src/contracts/src/Queue/Factory.php +++ b/src/contracts/src/Queue/Factory.php @@ -4,10 +4,12 @@ namespace Hypervel\Contracts\Queue; +use UnitEnum; + interface Factory { /** * Resolve a queue connection instance. */ - public function connection(?string $name = null): Queue; + public function connection(UnitEnum|string|null $name = null): Queue; } diff --git a/src/cookie/src/CookieJar.php b/src/cookie/src/CookieJar.php index 24694e20a..316ec5157 100644 --- a/src/cookie/src/CookieJar.php +++ b/src/cookie/src/CookieJar.php @@ -64,7 +64,10 @@ public function get(UnitEnum|string $key, ?string $default = null): ?string return null; } - return $request->cookie(enum_value($key), $default); + return $request->cookie( + $key instanceof UnitEnum ? (string) enum_value($key) : $key, + $default + ); } /** @@ -76,7 +79,9 @@ public function make(UnitEnum|string $name, ?string $value, int $minutes = 0, ?s $time = ($minutes === 0) ? 0 : $this->availableAt($minutes * 60); - return new Cookie(enum_value($name), $value, $time, $path, $domain, $secure, $httpOnly, $raw, $sameSite); + $name = $name instanceof UnitEnum ? (string) enum_value($name) : $name; + + return new Cookie($name, $value, $time, $path, $domain, $secure, $httpOnly, $raw, $sameSite); } /** @@ -100,7 +105,7 @@ public function forget(UnitEnum|string $name, ?string $path = null, ?string $dom */ public function hasQueued(UnitEnum|string $key, ?string $path = null): bool { - return ! is_null($this->queued(enum_value($key), null, $path)); + return ! is_null($this->queued($key, null, $path)); } /** @@ -108,7 +113,8 @@ public function hasQueued(UnitEnum|string $key, ?string $path = null): bool */ public function queued(UnitEnum|string $key, mixed $default = null, ?string $path = null): ?Cookie { - $queued = Arr::get($this->getQueuedCookiesRaw(), enum_value($key), []); + $key = $key instanceof UnitEnum ? (string) enum_value($key) : $key; + $queued = Arr::get($this->getQueuedCookiesRaw(), $key, []); if ($path === null) { return $queued === [] ? $default : Arr::last($queued, null, $default); @@ -152,7 +158,7 @@ public function expire(UnitEnum|string $name, ?string $path = null, ?string $dom */ public function unqueue(UnitEnum|string $name, ?string $path = null): void { - $name = enum_value($name); + $name = $name instanceof UnitEnum ? (string) enum_value($name) : $name; $cookies = $this->getQueuedCookiesRaw(); diff --git a/src/database/src/Connection.php b/src/database/src/Connection.php index 9479b3201..40d4a0d89 100755 --- a/src/database/src/Connection.php +++ b/src/database/src/Connection.php @@ -301,7 +301,11 @@ public function getSchemaState(?Filesystem $files = null, ?callable $processFact */ public function table(Closure|QueryBuilder|UnitEnum|string $table, ?string $as = null): QueryBuilder { - return $this->query()->from(enum_value($table), $as); + if ($table instanceof UnitEnum) { + $table = (string) enum_value($table); + } + + return $this->query()->from($table, $as); } /** diff --git a/src/database/src/ConnectionResolver.php b/src/database/src/ConnectionResolver.php index 011a72679..17104016b 100755 --- a/src/database/src/ConnectionResolver.php +++ b/src/database/src/ConnectionResolver.php @@ -57,7 +57,15 @@ public function __construct( */ public function connection(UnitEnum|string|null $name = null): ConnectionInterface { - $connectionName = ConnectionName::parse(enum_value($name) ?: $this->getDefaultConnection()); + if ($name instanceof UnitEnum) { + $name = (string) enum_value($name); + } + + $name = $name === null || $name === '' + ? $this->getDefaultConnection() + : $name; + + $connectionName = ConnectionName::parse($name); $contextKey = $this->getContextKey($connectionName->requested); // Check if this coroutine already has a connection diff --git a/src/database/src/Console/MonitorCommand.php b/src/database/src/Console/MonitorCommand.php index 768e63d44..d754bf44e 100644 --- a/src/database/src/Console/MonitorCommand.php +++ b/src/database/src/Console/MonitorCommand.php @@ -66,7 +66,7 @@ public function handle(): void protected function parseDatabases(?string $databases): Collection { return (new Collection(explode(',', $databases ?? '')))->map(function ($database) { - if (! $database) { + if ($database === '') { $database = $this->hypervel->make('config')->string('database.default', 'default'); } diff --git a/src/database/src/Console/Seeds/SeedCommand.php b/src/database/src/Console/Seeds/SeedCommand.php index 72d1c3c70..899466d02 100644 --- a/src/database/src/Console/Seeds/SeedCommand.php +++ b/src/database/src/Console/Seeds/SeedCommand.php @@ -114,7 +114,9 @@ protected function getDatabase(): string { $database = $this->input->getOption('database'); - return $database ?: $this->resolver->getDefaultConnection(); + return $database === null || $database === '' + ? $this->resolver->getDefaultConnection() + : $database; } /** diff --git a/src/database/src/DatabaseManager.php b/src/database/src/DatabaseManager.php index 61f53d94b..0c1b2dbce 100755 --- a/src/database/src/DatabaseManager.php +++ b/src/database/src/DatabaseManager.php @@ -101,8 +101,15 @@ public function __construct( */ public function connection(UnitEnum|string|null $name = null): ConnectionInterface { - return $this->app->make('db.resolver') - ->connection(enum_value($name) ?? $this->getDefaultConnection()); + if ($name instanceof UnitEnum) { + $name = (string) enum_value($name); + } + + $name = $name === null || $name === '' + ? $this->getDefaultConnection() + : $name; + + return $this->app->make('db.resolver')->connection($name); } /** @@ -249,7 +256,13 @@ protected function dispatchConnectionEstablishedEvent(Connection $connection): v */ public function purge(UnitEnum|string|null $name = null): void { - $requestedName = enum_value($name) ?: $this->getDefaultConnection(); + if ($name instanceof UnitEnum) { + $name = (string) enum_value($name); + } + + $requestedName = $name === null || $name === '' + ? $this->getDefaultConnection() + : $name; $connectionName = ConnectionName::parse($requestedName); $variants = $this->connectionNameVariants($requestedName); @@ -292,7 +305,13 @@ public function purge(UnitEnum|string|null $name = null): void */ public function disconnect(UnitEnum|string|null $name = null): void { - $requestedName = enum_value($name) ?: $this->getDefaultConnection(); + if ($name instanceof UnitEnum) { + $name = (string) enum_value($name); + } + + $requestedName = $name === null || $name === '' + ? $this->getDefaultConnection() + : $name; $connectionName = ConnectionName::parse($requestedName); // Pooled mode: disconnect the current coroutine's connection @@ -316,7 +335,13 @@ public function disconnect(UnitEnum|string|null $name = null): void */ public function reconnect(UnitEnum|string|null $name = null): Connection { - $name = enum_value($name) ?: $this->getDefaultConnection(); + if ($name instanceof UnitEnum) { + $name = (string) enum_value($name); + } + + $name = $name === null || $name === '' + ? $this->getDefaultConnection() + : $name; $this->disconnect($name); @@ -352,7 +377,10 @@ public function usingConnection(UnitEnum|string $name, callable $callback): mixe { $previous = CoroutineContext::get(ConnectionResolver::DEFAULT_CONNECTION_CONTEXT_KEY); - CoroutineContext::set(ConnectionResolver::DEFAULT_CONNECTION_CONTEXT_KEY, enum_value($name)); + CoroutineContext::set( + ConnectionResolver::DEFAULT_CONNECTION_CONTEXT_KEY, + $name instanceof UnitEnum ? (string) enum_value($name) : $name + ); try { return $callback(); @@ -488,7 +516,15 @@ protected function getConnectionContextKey(string $name): string */ protected function connectionNameVariants(UnitEnum|string|null $name): array { - $base = ConnectionName::parse(enum_value($name) ?: $this->getDefaultConnection())->base; + if ($name instanceof UnitEnum) { + $name = (string) enum_value($name); + } + + $name = $name === null || $name === '' + ? $this->getDefaultConnection() + : $name; + + $base = ConnectionName::parse($name)->base; return [$base, $base . '::read', $base . '::write']; } diff --git a/src/database/src/Eloquent/Factories/Factory.php b/src/database/src/Eloquent/Factories/Factory.php index aa5e55a23..65403f137 100644 --- a/src/database/src/Eloquent/Factories/Factory.php +++ b/src/database/src/Eloquent/Factories/Factory.php @@ -777,7 +777,9 @@ public function withoutParents(array $parents = []): static */ public function getConnectionName(): ?string { - return enum_value($this->connection); + return $this->connection instanceof UnitEnum + ? (string) enum_value($this->connection) + : $this->connection; } /** diff --git a/src/database/src/Eloquent/Model.php b/src/database/src/Eloquent/Model.php index 66ae2171e..e78a8779e 100644 --- a/src/database/src/Eloquent/Model.php +++ b/src/database/src/Eloquent/Model.php @@ -1872,7 +1872,9 @@ public function getConnection(): Connection */ public function getConnectionName(): ?string { - return enum_value($this->connection); + return $this->connection instanceof UnitEnum + ? (string) enum_value($this->connection) + : $this->connection; } /** diff --git a/src/database/src/Migrations/Migrator.php b/src/database/src/Migrations/Migrator.php index fb6f75bbd..5830e2c10 100755 --- a/src/database/src/Migrations/Migrator.php +++ b/src/database/src/Migrations/Migrator.php @@ -693,7 +693,7 @@ public function setConnection(?string $name): void public function resolveConnection(?string $connection): Connection { $connection = static::resolveMigrationConnectionName( - $connection ?: $this->connection + $connection === null || $connection === '' ? $this->connection : $connection ); if (static::$connectionResolverCallback) { diff --git a/src/database/src/Schema/Blueprint.php b/src/database/src/Schema/Blueprint.php index 70c6cbeeb..dfd507b55 100755 --- a/src/database/src/Schema/Blueprint.php +++ b/src/database/src/Schema/Blueprint.php @@ -505,7 +505,11 @@ public function dropRememberToken(): void */ public function dropMorphs(string $name, ?string $indexName = null): void { - $this->dropIndex($indexName ?: $this->createIndexName('index', ["{$name}_type", "{$name}_id"])); + $this->dropIndex( + $indexName === null || $indexName === '' + ? $this->createIndexName('index', ["{$name}_type", "{$name}_id"]) + : $indexName + ); $this->dropColumn("{$name}_type", "{$name}_id"); } @@ -1302,7 +1306,9 @@ protected function indexCommand(string $type, array|string $columns, ?string $in // If no name was specified for this index, we will create one using a basic // convention of the table name, followed by the columns, followed by an // index type, such as primary or index, which makes the index unique. - $index = $index ?: $this->createIndexName($type, $columns); + $index = $index === null || $index === '' + ? $this->createIndexName($type, $columns) + : $index; return $this->addCommand( $type, diff --git a/src/database/src/SimpleConnectionResolver.php b/src/database/src/SimpleConnectionResolver.php index de29ff9a6..f460d1542 100644 --- a/src/database/src/SimpleConnectionResolver.php +++ b/src/database/src/SimpleConnectionResolver.php @@ -40,9 +40,15 @@ public function __construct( */ public function connection(UnitEnum|string|null $name = null): ConnectionInterface { - return $this->manager->resolveConnectionDirectly( - enum_value($name) ?? $this->getDefaultConnection() - ); + if ($name instanceof UnitEnum) { + $name = (string) enum_value($name); + } + + $name = $name === null || $name === '' + ? $this->getDefaultConnection() + : $name; + + return $this->manager->resolveConnectionDirectly($name); } /** diff --git a/src/events/src/Dispatcher.php b/src/events/src/Dispatcher.php index ce8fa535b..6cfd07e86 100755 --- a/src/events/src/Dispatcher.php +++ b/src/events/src/Dispatcher.php @@ -861,6 +861,10 @@ protected function queueHandler(string $class, string $method, array $arguments) ? (isset($arguments[0]) ? $listener->viaConnection($arguments[0]) : $listener->viaConnection()) : $this->getAttributeValue($listener, Connection::class, 'connection'); + if ($connectionName instanceof UnitEnum) { + $connectionName = (string) enum_value($connectionName); + } + $connection = $this->resolveQueue()->connection( $connectionName ?? $this->resolveConnectionFromQueueRoute($listener) ?? null ); @@ -877,9 +881,13 @@ protected function queueHandler(string $class, string $method, array $arguments) $queue = $this->resolveQueueFromQueueRoute($listener) ?? null; } + if ($queue instanceof UnitEnum) { + $queue = (string) enum_value($queue); + } + is_null($delay) - ? $connection->pushOn(enum_value($queue), $job) - : $connection->laterOn(enum_value($queue), $delay, $job); + ? $connection->pushOn($queue, $job) + : $connection->laterOn($queue, $delay, $job); } /** diff --git a/src/events/src/QueuedClosure.php b/src/events/src/QueuedClosure.php index 788971078..86739c22c 100644 --- a/src/events/src/QueuedClosure.php +++ b/src/events/src/QueuedClosure.php @@ -65,7 +65,9 @@ public function __construct(Closure $closure) */ public function onConnection(UnitEnum|string|null $connection): static { - $this->connection = enum_value($connection); + $this->connection = $connection instanceof UnitEnum + ? (string) enum_value($connection) + : $connection; return $this; } @@ -75,7 +77,9 @@ public function onConnection(UnitEnum|string|null $connection): static */ public function onQueue(UnitEnum|string|null $queue): static { - $this->queue = enum_value($queue); + $this->queue = $queue instanceof UnitEnum + ? (string) enum_value($queue) + : $queue; return $this; } diff --git a/src/filesystem/src/FilesystemManager.php b/src/filesystem/src/FilesystemManager.php index 63d8552cf..1f87c04a9 100644 --- a/src/filesystem/src/FilesystemManager.php +++ b/src/filesystem/src/FilesystemManager.php @@ -15,6 +15,7 @@ use Hypervel\ObjectPool\PoolDefinition; use Hypervel\ObjectPool\Traits\HasPoolProxy; use Hypervel\Support\Arr; +use Hypervel\Support\RebindsCallbacksToSelf; use Hypervel\Support\Str; use InvalidArgumentException; use League\Flysystem\AwsS3V3\AwsS3V3Adapter as S3Adapter; @@ -32,6 +33,8 @@ use League\Flysystem\ReadOnly\ReadOnlyFilesystemAdapter; use League\Flysystem\UnixVisibility\PortableVisibilityConverter; use League\Flysystem\Visibility; +use ReflectionException; +use RuntimeException; use UnitEnum; use function Hypervel\Support\enum_value; @@ -43,6 +46,7 @@ class FilesystemManager implements FactoryContract { use HasPoolProxy; + use RebindsCallbacksToSelf; /** * The logical name used while resolving on-demand disks. @@ -113,7 +117,9 @@ public function drive(UnitEnum|string|null $name = null): Filesystem public function disk(UnitEnum|string|null $name = null): Filesystem { $name = enum_value($name); - $name = $name === null ? $this->getDefaultDriver() : (string) $name; + $name = $name === null || $name === '' + ? $this->getDefaultDriver() + : (string) $name; return $this->disks[$name] = $this->get($name); } @@ -750,7 +756,14 @@ public function extend(string $driver, Closure $callback, bool $poolable = false $this->addPoolable($driver); } - $this->customCreators[$driver] = $callback->bindTo($this, $this); + try { + $callback = $this->bindCallbackToSelf($callback) + ?? throw new RuntimeException('Unable to bind custom driver callback'); + } catch (ReflectionException $e) { + throw new RuntimeException('Unable to bind custom driver callback', previous: $e); + } + + $this->customCreators[$driver] = $callback; return $this; } diff --git a/src/foundation/src/Auth/Access/AuthorizesRequests.php b/src/foundation/src/Auth/Access/AuthorizesRequests.php index 8c328e839..4356fdf52 100644 --- a/src/foundation/src/Auth/Access/AuthorizesRequests.php +++ b/src/foundation/src/Auth/Access/AuthorizesRequests.php @@ -10,6 +10,7 @@ use Hypervel\Contracts\Auth\Access\Gate; use Hypervel\Http\Request; use Hypervel\Support\Str; +use UnitEnum; use function Hypervel\Support\enum_value; @@ -44,7 +45,9 @@ public function authorizeForUser(mixed $user, mixed $ability, mixed $arguments = */ protected function parseAbilityAndArguments(mixed $ability, mixed $arguments = []): array { - $ability = enum_value($ability); + if ($ability instanceof UnitEnum) { + $ability = (string) enum_value($ability); + } if (is_string($ability) && ! str_contains($ability, '\\')) { return [$ability, $arguments]; diff --git a/src/foundation/src/Bus/PendingChain.php b/src/foundation/src/Bus/PendingChain.php index eda71caa6..8bd4f3f0e 100644 --- a/src/foundation/src/Bus/PendingChain.php +++ b/src/foundation/src/Bus/PendingChain.php @@ -60,7 +60,9 @@ public function __construct( */ public function onConnection(UnitEnum|string|null $connection): static { - $this->connection = enum_value($connection); + $this->connection = $connection instanceof UnitEnum + ? (string) enum_value($connection) + : $connection; return $this; } @@ -70,7 +72,9 @@ public function onConnection(UnitEnum|string|null $connection): static */ public function onQueue(UnitEnum|string|null $queue): static { - $this->queue = enum_value($queue); + $this->queue = $queue instanceof UnitEnum + ? (string) enum_value($queue) + : $queue; return $this; } @@ -156,14 +160,18 @@ public function dispatch(mixed ...$arguments): mixed $firstJob = $this->job; } - if ($this->connection) { + if ($this->connection !== null && $this->connection !== '') { $firstJob->chainConnection = $this->connection; - $firstJob->connection = $firstJob->connection ?: $this->connection; + $firstJob->connection = $firstJob->connection === null || $firstJob->connection === '' + ? $this->connection + : $firstJob->connection; } - if ($this->queue) { + if ($this->queue !== null && $this->queue !== '') { $firstJob->chainQueue = $this->queue; - $firstJob->queue = $firstJob->queue ?: $this->queue; + $firstJob->queue = $firstJob->queue === null || $firstJob->queue === '' + ? $this->queue + : $firstJob->queue; } if ($this->delay) { diff --git a/src/foundation/src/Console/PolicyMakeCommand.php b/src/foundation/src/Console/PolicyMakeCommand.php index a1391ec7a..91f27739c 100644 --- a/src/foundation/src/Console/PolicyMakeCommand.php +++ b/src/foundation/src/Console/PolicyMakeCommand.php @@ -73,7 +73,10 @@ protected function userProviderModel(): ?string { $config = $this->hypervel->make('config'); - $guard = $this->option('guard') ?: $this->hypervel->make('auth')->getDefaultDriver(); + $guard = $this->option('guard'); + $guard = $guard === null || $guard === '' + ? $this->hypervel->make('auth')->getDefaultDriver() + : $guard; if (is_null($guardProvider = $config->get('auth.guards.' . $guard . '.provider'))) { throw new LogicException('The [' . $guard . '] guard is not defined in your "auth" configuration file.'); diff --git a/src/foundation/src/Exceptions/Handler.php b/src/foundation/src/Exceptions/Handler.php index 182cb51f8..31c0c6792 100644 --- a/src/foundation/src/Exceptions/Handler.php +++ b/src/foundation/src/Exceptions/Handler.php @@ -19,6 +19,7 @@ use Hypervel\Contracts\Debug\ShouldntReport; use Hypervel\Contracts\Foundation\ExceptionRenderer; use Hypervel\Contracts\Support\Responsable; +use Hypervel\Coroutine\Coroutine; use Hypervel\Database\Eloquent\ModelNotFoundException; use Hypervel\Database\MultipleRecordsFoundException; use Hypervel\Database\RecordNotFoundException; @@ -75,6 +76,11 @@ class Handler implements ExceptionHandlerContract */ public const AFTER_RESPONSE_CONTEXT_KEY = '__foundation.errors.after_response'; + /** + * Context key for the exception currently being reported. + */ + public const CURRENTLY_REPORTING_CONTEXT_KEY = '__foundation.errors.currently_reporting'; + /** * A list of the exception types that are not reported. * @@ -363,11 +369,37 @@ protected function reportThrowable(Throwable $e): void $level = $this->mapLogLevel($e); - $context = $this->buildExceptionContext($e); + $hadPrevious = CoroutineContext::has(self::CURRENTLY_REPORTING_CONTEXT_KEY); + $previous = CoroutineContext::get(self::CURRENTLY_REPORTING_CONTEXT_KEY); + + CoroutineContext::set(self::CURRENTLY_REPORTING_CONTEXT_KEY, [ + 'coroutineId' => Coroutine::id(), + 'exception' => $e, + ]); + + try { + $context = $this->buildExceptionContext($e); + + method_exists($logger, $level) + ? $logger->{$level}($e->getMessage(), $context) + : $logger->log($level, $e->getMessage(), $context); + } finally { + $hadPrevious + ? CoroutineContext::set(self::CURRENTLY_REPORTING_CONTEXT_KEY, $previous) + : CoroutineContext::forget(self::CURRENTLY_REPORTING_CONTEXT_KEY); + } + } + + /** + * Determine if the given exception is being reported by this coroutine. + */ + public function isReporting(Throwable $e): bool + { + $reporting = CoroutineContext::get(self::CURRENTLY_REPORTING_CONTEXT_KEY); - method_exists($logger, $level) - ? $logger->{$level}($e->getMessage(), $context) - : $logger->log($level, $e->getMessage(), $context); + return is_array($reporting) + && ($reporting['coroutineId'] ?? null) === Coroutine::id() + && ($reporting['exception'] ?? null) === $e; } /** @@ -512,12 +544,22 @@ public function stopIgnoring(array|string $exceptions): static protected function buildExceptionContext(Throwable $e): array { return array_merge( - $this->exceptionContext($e), + $this->buildContextForException($e), $this->context(), ['exception' => $e] ); } + /** + * Create the context for the given exception. + * + * @return array + */ + public function buildContextForException(Throwable $e): array + { + return $this->exceptionContext($e); + } + /** * Get the default exception context variables for logging. */ diff --git a/src/foundation/src/MaintenanceModeManager.php b/src/foundation/src/MaintenanceModeManager.php index 5838c403f..6d056ed98 100644 --- a/src/foundation/src/MaintenanceModeManager.php +++ b/src/foundation/src/MaintenanceModeManager.php @@ -23,9 +23,11 @@ protected function createFileDriver(): FileBasedMaintenanceMode */ protected function createCacheDriver(): CacheBasedMaintenanceMode { + $store = $this->config->string('app.maintenance.store'); + return new CacheBasedMaintenanceMode( $this->container->make('cache'), - $this->config->string('app.maintenance.store') ?: $this->config->string('cache.default'), + $store === '' ? $this->config->string('cache.default') : $store, 'hypervel:foundation:down' ); } diff --git a/src/foundation/src/Testing/Concerns/InteractsWithDatabase.php b/src/foundation/src/Testing/Concerns/InteractsWithDatabase.php index 5a702a874..86f8234d8 100644 --- a/src/foundation/src/Testing/Concerns/InteractsWithDatabase.php +++ b/src/foundation/src/Testing/Concerns/InteractsWithDatabase.php @@ -296,7 +296,13 @@ protected function getConnection($connection = null, $table = null) { $database = $this->app->make('db'); - $connection = $connection ?: $this->getTableConnection($table) ?: $database->getDefaultConnection(); + if ($connection === null || $connection === '') { + $connection = $this->getTableConnection($table); + } + + if ($connection === null || $connection === '') { + $connection = $database->getDefaultConnection(); + } return $database->connection($connection); } diff --git a/src/foundation/src/Testing/DatabaseConnectionResolver.php b/src/foundation/src/Testing/DatabaseConnectionResolver.php index 7ab9c7495..45fc2dbb1 100644 --- a/src/foundation/src/Testing/DatabaseConnectionResolver.php +++ b/src/foundation/src/Testing/DatabaseConnectionResolver.php @@ -152,7 +152,15 @@ public function flush(string $name): void */ public function connection(UnitEnum|string|null $name = null): ConnectionInterface { - $connectionName = ConnectionName::parse(enum_value($name) ?: $this->getDefaultConnection()); + if ($name instanceof UnitEnum) { + $name = (string) enum_value($name); + } + + $name = $name === null || $name === '' + ? $this->getDefaultConnection() + : $name; + + $connectionName = ConnectionName::parse($name); // If the pool is enabled, we should use the default connection resolver. /** @var ConfigRepository $config */ diff --git a/src/foundation/src/helpers.php b/src/foundation/src/helpers.php index 29d916465..6c8ca29fd 100644 --- a/src/foundation/src/helpers.php +++ b/src/foundation/src/helpers.php @@ -575,7 +575,7 @@ function logger(Arrayable|Jsonable|\Stringable|array|string|null $message = null */ function logs(?string $driver = null): LoggerInterface|LogManager { - return $driver ? app('log')->driver($driver) : app('log'); + return $driver !== null ? app('log')->driver($driver) : app('log'); } } diff --git a/src/horizon/src/Console/ClearCommand.php b/src/horizon/src/Console/ClearCommand.php index 94642d5c7..92d846517 100644 --- a/src/horizon/src/Console/ClearCommand.php +++ b/src/horizon/src/Console/ClearCommand.php @@ -37,8 +37,12 @@ public function handle(JobRepository $jobRepository, QueueManager $manager): ?in return 1; } - $connection = $this->argument('connection') - ?: array_first(config('horizon.defaults'))['connection'] ?? 'redis'; + $connection = $this->argument('connection'); + + if ($connection === null || $connection === '') { + $connection = array_first(config('horizon.defaults'))['connection'] ?? 'redis'; + } + $queue = $this->getQueue($connection); if (method_exists($jobRepository, 'purge')) { @@ -58,6 +62,10 @@ public function handle(JobRepository $jobRepository, QueueManager $manager): ?in */ protected function getQueue(string $connection): string { - return $this->option('queue') ?: config("queue.connections.{$connection}.queue", 'default'); + $queue = $this->option('queue'); + + return $queue === null || $queue === '' + ? config("queue.connections.{$connection}.queue", 'default') + : $queue; } } diff --git a/src/horizon/src/Console/SupervisorCommand.php b/src/horizon/src/Console/SupervisorCommand.php index 3497e2ef8..e1fa18c5c 100644 --- a/src/horizon/src/Console/SupervisorCommand.php +++ b/src/horizon/src/Console/SupervisorCommand.php @@ -140,9 +140,10 @@ protected function supervisorOptions(): SupervisorOptions */ protected function getQueue(string $connection): string { - return $this->option('queue') ?: config( - "queue.connections.{$connection}.queue", - 'default' - ); + $queue = $this->option('queue'); + + return $queue === null || $queue === '' + ? config("queue.connections.{$connection}.queue", 'default') + : $queue; } } diff --git a/src/horizon/src/SupervisorOptions.php b/src/horizon/src/SupervisorOptions.php index aace43ab2..9f915a118 100644 --- a/src/horizon/src/SupervisorOptions.php +++ b/src/horizon/src/SupervisorOptions.php @@ -74,7 +74,9 @@ public function __construct( public int $rest = 0, public ?string $autoScalingStrategy = 'time', ) { - $this->queue = $queue ?: config('queue.connections.' . $connection . '.queue'); + $this->queue = $queue === null || $queue === '' + ? config('queue.connections.' . $connection . '.queue') + : $queue; } /** diff --git a/src/horizon/src/WaitTimeCalculator.php b/src/horizon/src/WaitTimeCalculator.php index f5f9890c7..0dd01ceb4 100644 --- a/src/horizon/src/WaitTimeCalculator.php +++ b/src/horizon/src/WaitTimeCalculator.php @@ -60,7 +60,9 @@ protected function queueNames(Collection $supervisors, ?string $queue = null): C return array_keys($supervisor->processes); })->collapse()->unique()->values(); - return $queue ? $queues->intersect([$queue]) : $queues; // @phpstan-ignore argument.type + return $queue === null || $queue === '' + ? $queues + : $queues->intersect([$queue]); // @phpstan-ignore argument.type } /** diff --git a/src/inertia/src/ResolvesOnce.php b/src/inertia/src/ResolvesOnce.php index 46ba37da0..ad57f246a 100644 --- a/src/inertia/src/ResolvesOnce.php +++ b/src/inertia/src/ResolvesOnce.php @@ -82,7 +82,7 @@ public function getKey(): ?string public function as(BackedEnum|UnitEnum|string $key): static { $this->key = match (true) { - $key instanceof BackedEnum => $key->value, + $key instanceof BackedEnum => (string) $key->value, $key instanceof UnitEnum => $key->name, default => $key, }; diff --git a/src/log/README.md b/src/log/README.md index 55a777514..7c0f48c45 100644 --- a/src/log/README.md +++ b/src/log/README.md @@ -1,4 +1,6 @@ Log for Hypervel === -[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/log) \ No newline at end of file +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/log) + +Ported from: https://github.com/laravel/framework/tree/13.x/src/Illuminate/Log diff --git a/src/log/composer.json b/src/log/composer.json index 5f5222286..eb064c4c2 100644 --- a/src/log/composer.json +++ b/src/log/composer.json @@ -38,6 +38,7 @@ "hypervel/collections": "^0.4", "hypervel/conditionable": "^0.4", "hypervel/config": "^0.4", + "hypervel/container": "^0.4", "hypervel/context": "^0.4", "hypervel/contracts": "^0.4", "hypervel/macroable": "^0.4", diff --git a/src/log/src/Context/ContextServiceProvider.php b/src/log/src/Context/ContextServiceProvider.php index 6358e00ae..ce3790dbb 100644 --- a/src/log/src/Context/ContextServiceProvider.php +++ b/src/log/src/Context/ContextServiceProvider.php @@ -43,12 +43,10 @@ public function boot(): void $this->app['events']->listen(JobProcessing::class, function (JobProcessing $event): void { $context = $event->job->payload()['illuminate:log:context'] ?? null; - if ($context === null) { - return; + if ($context !== null || Repository::hasInstance()) { + /* @phpstan-ignore staticMethod.notFound */ + Context::hydrate($context); } - - /* @phpstan-ignore staticMethod.notFound */ - Context::hydrate($context); }); } } diff --git a/src/log/src/Context/Repository.php b/src/log/src/Context/Repository.php index 03a250575..f22ffeccf 100644 --- a/src/log/src/Context/Repository.php +++ b/src/log/src/Context/Repository.php @@ -548,6 +548,9 @@ public function replicate(): static /** * Register a callback to execute before context is dehydrated for a job. * + * Boot-only. Registers a listener on the worker-global event dispatcher; + * per-request registration persists and affects subsequent requests. + * * @param (callable(self): void) $callback * @return $this */ @@ -561,6 +564,9 @@ public function dehydrating(callable $callback): static /** * Register a callback to execute after context has been hydrated from a job. * + * Boot-only. Registers a listener on the worker-global event dispatcher; + * per-request registration persists and affects subsequent requests. + * * @param (callable(self): void) $callback * @return $this */ diff --git a/src/log/src/Formatters/JsonFormatter.php b/src/log/src/Formatters/JsonFormatter.php new file mode 100644 index 000000000..ee6c6ebe6 --- /dev/null +++ b/src/log/src/Formatters/JsonFormatter.php @@ -0,0 +1,59 @@ +make(ExceptionHandler::class); + } catch (Throwable) { + return array_merge($this->getExceptionContext($e, $depth), $response); + } + + // Active reports already carry this context at record level; rebuilding it can re-enter user callbacks. + if (! method_exists($handler, 'isReporting') || ! $handler->isReporting($e)) { + if (method_exists($handler, 'buildContextForException') + && is_array($context = $this->normalize($handler->buildContextForException($e), $depth + 1)) + ) { + $response = array_merge($context, $response); + } elseif (method_exists($e, 'context')) { + $response = array_merge($this->getExceptionContext($e, $depth), $response); + } + } + + return $response; + } + + /** + * Extract the context from the exception if available. + * + * @return array + */ + protected function getExceptionContext(Throwable $e, int $depth): array + { + if (! method_exists($e, 'context')) { + return []; + } + + try { + $context = $this->normalize($e->context(), $depth + 1); + } catch (Throwable) { + return []; + } + + return is_array($context) ? $context : []; + } +} diff --git a/src/log/src/Handlers/Concerns/PerformsSafeStreamOperations.php b/src/log/src/Handlers/Concerns/PerformsSafeStreamOperations.php new file mode 100644 index 000000000..3cfcadd22 --- /dev/null +++ b/src/log/src/Handlers/Concerns/PerformsSafeStreamOperations.php @@ -0,0 +1,200 @@ +stream; + $this->stream = null; + $this->safeDirectoryCreated = null; + $this->safeInodeUrl = null; + + // Streams passed in as resources are owned by their caller, matching + // Monolog's behavior. Only URL-backed streams are closed here. + if ($this->url !== null && is_resource($stream)) { + try { + fclose($stream); + } catch (Throwable) { + // Closing is best-effort and must not abort reset or rotation. + } + } + } + + /** + * Write a record while preserving Monolog's single reopen retry. + */ + protected function writeStreamSafely(LogRecord $record, bool $retrying = false): void + { + if (! $retrying && $this->hasStreamInodeChanged()) { + $this->closeStreamSafely(); + $this->writeStreamSafely($record, true); + + return; + } + + if (! is_resource($this->stream)) { + $url = $this->url; + + if ($url === null || $url === '') { + throw new LogicException( + 'Missing stream url, the stream can not be opened. This may be caused by a premature call to close().' + . Utils::getRecordMessageForException($record) + ); + } + + $this->createStreamDirectory($url); + try { + $stream = fopen($url, $this->fileOpenMode); + } catch (Throwable) { + $stream = false; + } + + if (! is_resource($stream)) { + $this->stream = null; + + throw new UnexpectedValueException( + sprintf( + 'The stream or file "%s" could not be opened using mode "%s".', + $url, + $this->fileOpenMode + ) . Utils::getRecordMessageForException($record) + ); + } + + if ($this->filePermission !== null) { + try { + chmod($url, $this->filePermission); + } catch (Throwable) { + // File permissions are best-effort, matching Monolog. + } + } + + stream_set_chunk_size($stream, $this->streamChunkSize); + $this->stream = $stream; + $this->safeInodeUrl = $this->getStreamInode(); + } + + $stream = $this->stream; + $locked = false; + + if ($this->useLocking) { + try { + $locked = flock($stream, LOCK_EX); + } catch (Throwable) { + // Locking is best-effort, matching Monolog. + } + } + + try { + try { + $written = fwrite($stream, (string) $record->formatted); + } catch (Throwable) { + $written = false; + } + } finally { + if ($locked) { + try { + flock($stream, LOCK_UN); + } catch (Throwable) { + // Unlocking is best-effort, matching Monolog. + } + } + } + + if ($written !== false) { + return; + } + + if (! $retrying && $this->url !== null && $this->url !== 'php://memory') { + $this->closeStreamSafely(); + $this->writeStreamSafely($record, true); + + return; + } + + throw new UnexpectedValueException( + sprintf( + 'Writing to the log %s failed.', + $this->url === null ? 'stream' : sprintf('file "%s"', $this->url) + ) . Utils::getRecordMessageForException($record) + ); + } + + private function createStreamDirectory(string $url): void + { + if ($this->safeDirectoryCreated === true) { + return; + } + + $directory = $this->directoryFromStream($url); + + if ($directory !== null && ! is_dir($directory)) { + try { + $created = mkdir($directory, 0777, true); + } catch (Throwable) { + $created = false; + } + + if (! $created && ! is_dir($directory)) { + throw new UnexpectedValueException( + sprintf('There is no existing directory at "%s" and it could not be created.', $directory) + ); + } + } + + $this->safeDirectoryCreated = true; + } + + private function directoryFromStream(string $stream): ?string + { + $schemePosition = strpos($stream, '://'); + + if ($schemePosition === false) { + return dirname($stream); + } + + return str_starts_with($stream, 'file://') + ? dirname(substr($stream, 7)) + : null; + } + + private function getStreamInode(): ?int + { + if ($this->url === null || str_starts_with($this->url, 'php://')) { + return null; + } + + try { + $inode = fileinode($this->url); + } catch (Throwable) { + return null; + } + + return $inode === false ? null : $inode; + } + + private function hasStreamInodeChanged(): bool + { + return $this->safeInodeUrl !== null + && $this->safeInodeUrl !== $this->getStreamInode(); + } +} diff --git a/src/log/src/Handlers/FingersCrossedHandler.php b/src/log/src/Handlers/FingersCrossedHandler.php new file mode 100644 index 000000000..105d4031c --- /dev/null +++ b/src/log/src/Handlers/FingersCrossedHandler.php @@ -0,0 +1,151 @@ +stateKey = '__log.fingers_crossed.' . ++self::$nextHandlerId; + } + + #[Override] + public function activate(): void + { + $state = $this->state(); + + if ($this->stopBuffering) { + $state->buffering = false; + } + + // Move the current batch before invoking user handlers. Re-entrant + // records must remain in the new buffer when buffering is resumed. + $buffer = $state->buffer; + $state->buffer = []; + + $this->getHandler(end($buffer) ?: null)->handleBatch($buffer); + } + + #[Override] + public function handle(LogRecord $record): bool + { + if (count($this->processors) > 0) { + $record = $this->processRecord($record); + } + + $state = $this->state(); + + if ($state->buffering) { + $state->buffer[] = $record; + + if ($this->bufferSize > 0 && count($state->buffer) > $this->bufferSize) { + array_shift($state->buffer); + } + + if ($this->activationStrategy->isHandlerActivated($record)) { + $this->activate(); + } + } else { + $this->getHandler($record)->handle($record); + } + + return $this->bubble === false; + } + + #[Override] + public function close(): void + { + $this->flushBuffer(); + $this->getHandler()->close(); + } + + #[Override] + public function reset(): void + { + $this->flushBuffer(); + $this->resetProcessors(); + + if ($this->getHandler() instanceof ResettableInterface) { + $this->getHandler()->reset(); + } + } + + #[Override] + public function clear(): void + { + $state = $this->state(); + $state->buffer = []; + $state->buffering = true; + + $this->resetProcessors(); + + if ($this->getHandler() instanceof ResettableInterface) { + $this->getHandler()->reset(); + } + } + + private function state(): FingersCrossedState + { + return CoroutineContext::getOrSet( + $this->stateKey, + static fn () => new FingersCrossedState + ); + } + + private function flushBuffer(): void + { + $state = $this->state(); + $buffer = $state->buffer; + $state->buffer = []; + $state->buffering = true; + + if ($this->passthruLevel !== null) { + $passthruLevel = $this->passthruLevel; + $buffer = array_values(array_filter( + $buffer, + static fn (LogRecord $record): bool => $passthruLevel->includes($record->level) + )); + + if ($buffer !== []) { + $this->getHandler(end($buffer))->handleBatch($buffer); + } + } + } +} diff --git a/src/log/src/Handlers/FingersCrossedState.php b/src/log/src/Handlers/FingersCrossedState.php new file mode 100644 index 000000000..d469ef9cf --- /dev/null +++ b/src/log/src/Handlers/FingersCrossedState.php @@ -0,0 +1,28 @@ + $buffer + */ + public function __construct( + public bool $buffering = true, + public array $buffer = [] + ) { + } + + /** + * Buffered history is request-local and must not cross a context copy. + */ + public function replicate(): static + { + return new self; + } +} diff --git a/src/log/src/Handlers/RotatingFileHandler.php b/src/log/src/Handlers/RotatingFileHandler.php new file mode 100644 index 000000000..ba54d51ca --- /dev/null +++ b/src/log/src/Handlers/RotatingFileHandler.php @@ -0,0 +1,113 @@ +closeStreamSafely(); + + if ($this->mustRotate === true) { + $this->rotate(); + } + } + + #[Override] + protected function write(LogRecord $record): void + { + if ($this->mustRotate === null) { + $this->mustRotate = $this->url === null || ! file_exists($this->url); + } + + if ($this->nextRotation <= $record->datetime) { + $this->mustRotate = true; + $this->close(); + } + + $this->writeStreamSafely($record); + + if ($this->mustRotate === true) { + $this->close(); + } + } + + #[Override] + protected function rotate(): void + { + $this->url = $this->getTimedFilename(); + $this->nextRotation = $this->getNextRotation(); + $this->mustRotate = false; + + if ($this->maxFiles === 0) { + return; + } + + try { + $logFiles = glob($this->getGlobPattern()); + } catch (Throwable) { + return; + } + + if ($logFiles === false || $this->maxFiles >= count($logFiles)) { + return; + } + + usort($logFiles, static fn (string $left, string $right): int => strcmp($right, $left)); + + $basePath = dirname($this->filename); + + foreach (array_slice($logFiles, $this->maxFiles) as $file) { + if (! is_writable($file)) { + continue; + } + + try { + $deleted = unlink($file); + } catch (Throwable) { + $deleted = false; + } + + if (! $deleted) { + continue; + } + + $directory = dirname($file); + + while ($directory !== $basePath) { + try { + $entries = scandir($directory); + } catch (Throwable) { + break; + } + + if ($entries === false || count(array_diff($entries, ['.', '..'])) > 0) { + break; + } + + try { + $removed = rmdir($directory); + } catch (Throwable) { + $removed = false; + } + + if (! $removed) { + break; + } + + $directory = dirname($directory); + } + } + } +} diff --git a/src/log/src/Handlers/StreamHandler.php b/src/log/src/Handlers/StreamHandler.php new file mode 100644 index 000000000..21c362fa1 --- /dev/null +++ b/src/log/src/Handlers/StreamHandler.php @@ -0,0 +1,27 @@ +closeStreamSafely(); + } + + #[Override] + protected function write(LogRecord $record): void + { + $this->writeStreamSafely($record); + } +} diff --git a/src/log/src/LogManager.php b/src/log/src/LogManager.php index 293a652ac..27e1bf1d5 100644 --- a/src/log/src/LogManager.php +++ b/src/log/src/LogManager.php @@ -11,23 +11,30 @@ use Hypervel\Contracts\Support\Arrayable; use Hypervel\Contracts\Support\Jsonable; use Hypervel\Log\Context\ResolvedContextLogProcessor; +use Hypervel\Log\Handlers\FingersCrossedHandler; +use Hypervel\Log\Handlers\RotatingFileHandler; +use Hypervel\Log\Handlers\StreamHandler; +use Hypervel\Log\Processors\UidProcessor; use Hypervel\Support\Collection; +use Hypervel\Support\RebindsCallbacksToSelf; use Hypervel\Support\Str; use InvalidArgumentException; use Monolog\Formatter\LineFormatter; use Monolog\Handler\ErrorLogHandler; -use Monolog\Handler\FingersCrossedHandler; use Monolog\Handler\FormattableHandlerInterface; use Monolog\Handler\HandlerInterface; -use Monolog\Handler\RotatingFileHandler; +use Monolog\Handler\RotatingFileHandler as MonologRotatingFileHandler; use Monolog\Handler\SlackWebhookHandler; -use Monolog\Handler\StreamHandler; +use Monolog\Handler\StreamHandler as MonologStreamHandler; use Monolog\Handler\SyslogHandler; use Monolog\Handler\WhatFailureGroupHandler; use Monolog\Logger as Monolog; use Monolog\Processor\ProcessorInterface; use Monolog\Processor\PsrLogMessageProcessor; +use Monolog\Processor\UidProcessor as MonologUidProcessor; use Psr\Log\LoggerInterface; +use ReflectionException; +use RuntimeException; use Stringable; use Throwable; use UnitEnum; @@ -40,6 +47,7 @@ class LogManager implements LoggerInterface { use ParsesLogConfiguration; + use RebindsCallbacksToSelf; /** * Context key for shared log context across channels. @@ -74,9 +82,7 @@ public function __construct( */ public function build(array $config): LoggerInterface { - unset($this->channels['ondemand']); - - return $this->get('ondemand', $config); + return $this->createLogger('ondemand', $config); } /** @@ -84,7 +90,10 @@ public function build(array $config): LoggerInterface */ public function stack(array $channels, ?string $channel = null): LoggerInterface { - $monolog = $this->createStackDriver(compact('channels', 'channel')); + $monolog = $this->createStackDriver([ + 'channels' => $channels, + 'name' => $channel, + ]); // On-demand stacks bypass get(), so push the propagated context // processor here to ensure it's present on every final logger. @@ -122,24 +131,42 @@ public function driver(UnitEnum|string|null $driver = null): LoggerInterface * Attempt to get the log from the local cache. */ protected function get(?string $name, ?array $config = null): LoggerInterface + { + if (isset($this->channels[$name])) { + return $this->channels[$name]; + } + + return $this->createLogger($name, $config, true); + } + + /** + * Create a configured logger, optionally retaining a named channel. + */ + protected function createLogger(?string $name, ?array $config = null, bool $cache = false): LoggerInterface { try { - return $this->channels[$name] ?? with($this->resolve($name, $config), function ($logger) use ($name) { - $loggerWithContext = $this->tap( - $name, - new Logger($logger, $this->app['events']) - )->withContext($this->sharedContext()); + $config ??= $this->configurationFor($name); + + if ($config === null) { + throw new InvalidArgumentException("Log [{$name}] is not defined."); + } - // Push the context processor so log records automatically - // include data from the context repository. - $underlyingLogger = $loggerWithContext->getLogger(); + $logger = $this->tap( + $config, + new Logger($this->resolve($name, $config), $this->app['events']) + )->withContext($this->sharedContext()); - if (method_exists($underlyingLogger, 'pushProcessor')) { - $underlyingLogger->pushProcessor($this->makeContextProcessor()); // @phpstan-ignore method.notFound - } + $underlyingLogger = $logger->getLogger(); - return $this->channels[$name] = $loggerWithContext; - }); + if (method_exists($underlyingLogger, 'pushProcessor')) { + $underlyingLogger->pushProcessor($this->makeContextProcessor()); // @phpstan-ignore method.notFound + } + + if ($cache) { + $this->channels[$name] = $logger; + } + + return $logger; } catch (Throwable $e) { return tap($this->createEmergencyLogger(), function ($logger) use ($e) { $logger->emergency('Unable to create configured logger. Using emergency logger.', [ @@ -152,9 +179,9 @@ protected function get(?string $name, ?array $config = null): LoggerInterface /** * Apply the configured taps for the logger. */ - protected function tap(string $name, Logger $logger): Logger + protected function tap(array $config, Logger $logger): Logger { - foreach ($this->configurationFor($name)['tap'] ?? [] as $tap) { + foreach ($config['tap'] ?? [] as $tap) { [$class, $arguments] = $this->parseTap($tap); $this->app->make($class)->__invoke($logger, ...explode(',', $arguments)); @@ -194,14 +221,8 @@ protected function createEmergencyLogger(): LoggerInterface * * @throws InvalidArgumentException */ - protected function resolve(?string $name, ?array $config = null): LoggerInterface + protected function resolve(?string $name, array $config): LoggerInterface { - $config ??= $this->configurationFor($name); - - if (is_null($config)) { - throw new InvalidArgumentException("Log [{$name}] is not defined."); - } - if (isset($this->customCreators[$config['driver']])) { return $this->callCustomCreator($config); } @@ -377,13 +398,28 @@ protected function createMonologDriver(array $config): LoggerInterface $config['handler_with'] ?? [] ); + $handlerClass = match ($config['handler']) { + MonologStreamHandler::class => StreamHandler::class, + MonologRotatingFileHandler::class => RotatingFileHandler::class, + default => $config['handler'], + }; + $handler = $this->prepareHandler( - $this->app->make($config['handler'], $with), + $this->app->make($handlerClass, $with), $config ); $processors = Collection::make($config['processors'] ?? []) - ->map(fn ($processor) => $this->app->make($processor['processor'] ?? $processor, $processor['with'] ?? [])) + ->map(function ($processor) { + $resolved = $this->app->make( + $processor['processor'] ?? $processor, + $processor['with'] ?? [] + ); + + return get_class($resolved) === MonologUidProcessor::class + ? new UidProcessor(strlen($resolved->getUid())) + : $resolved; + }) ->toArray(); return new Monolog( @@ -521,7 +557,7 @@ protected function getFallbackChannelName(): string /** * Get the log connection configuration. */ - protected function configurationFor(string $name): ?array + protected function configurationFor(?string $name): ?array { return $this->app->make('config')->get("logging.channels.{$name}"); } @@ -558,7 +594,14 @@ public function setDefaultDriver(UnitEnum|string $name): void */ public function extend(string $driver, Closure $callback): static { - $this->customCreators[$driver] = $callback->bindTo($this, $this); + try { + $callback = $this->bindCallbackToSelf($callback) + ?? throw new RuntimeException('Unable to bind custom driver callback'); + } catch (ReflectionException $e) { + throw new RuntimeException('Unable to bind custom driver callback', previous: $e); + } + + $this->customCreators[$driver] = $callback; return $this; } diff --git a/src/log/src/Logger.php b/src/log/src/Logger.php index fbddc1fd3..b572325aa 100755 --- a/src/log/src/Logger.php +++ b/src/log/src/Logger.php @@ -24,7 +24,15 @@ class Logger implements LoggerInterface /** * The CoroutineContext key prefix for per-channel logger context. */ - protected const CONTEXT_KEY_PREFIX = '__log.channel_context.'; + protected const CONTEXT_KEY_PREFIX = '__log.logger_state.'; + + /** + * The next worker-unique logger family identifier. + * + * This is an identity generator, not resettable state. Resetting it can + * alias a new logger to context retained under a destroyed logger's key. + */ + protected static int $nextFamilyId = 0; /** * The coroutine-local key for this logger channel's context. @@ -32,7 +40,7 @@ class Logger implements LoggerInterface * Named variants share their source channel's slot so context added through * either wrapper remains channel-local without leaking to other channels. */ - protected readonly string $contextKey; + protected readonly string $stateKey; /** * Create a new log writer instance. @@ -41,7 +49,13 @@ public function __construct( protected LoggerInterface $logger, protected ?Dispatcher $dispatcher = null ) { - $this->contextKey = self::CONTEXT_KEY_PREFIX . spl_object_id($this); + $this->stateKey = self::CONTEXT_KEY_PREFIX . ++self::$nextFamilyId; + + if ($this->logger instanceof Monolog) { + // Monolog's fallback detector is instance-global outside Fibers. + // The wrapper below tracks recursion in coroutine-local state. + $this->logger->useLoggingLoopDetection(false); + } } /** @@ -135,12 +149,32 @@ protected function writeLog(string $level, Arrayable|Jsonable|Stringable|array|s return; } - $this->logger->{$level}( - $message = $this->formatMessage($message), - $context = array_merge($this->getContext(), $context) - ); + $state = $this->state(); + ++$state->depth; + + try { + if ($state->depth === 3) { + $this->logger->warning( + 'A possible infinite logging loop was detected and aborted. It appears some of your handler code is triggering logging, see the previous log record for a hint as to what may be the cause.' + ); + + return; + } + + // Depth four is reserved for logging the warning above. + if ($state->depth >= 5) { + return; + } + + $this->logger->{$level}( + $message = $this->formatMessage($message), + $context = array_merge($state->context, $context) + ); - $this->fireLogEvent($level, $message, $context); + $this->fireLogEvent($level, $message, $context); + } finally { + --$state->depth; + } } /** @@ -167,9 +201,8 @@ public function withName(string $name): self */ public function withContext(array $context = []): self { - CoroutineContext::override($this->contextKey, function ($currentContext) use ($context) { - return array_merge($currentContext ?: [], $context); - }); + $state = $this->state(); + $state->context = array_merge($state->context, $context); return $this; } @@ -182,12 +215,12 @@ public function withContext(array $context = []): self */ public function withoutContext(?array $keys = null): self { + $state = $this->state(); + if (is_array($keys)) { - CoroutineContext::override($this->contextKey, function ($currentContext) use ($keys) { - return array_diff_key($currentContext ?: [], array_flip($keys)); - }); + $state->context = array_diff_key($state->context, array_flip($keys)); } else { - CoroutineContext::forget($this->contextKey); + $state->context = []; } return $this; @@ -200,12 +233,28 @@ public function withoutContext(?array $keys = null): self */ public function getContext(): array { - return (array) CoroutineContext::get($this->contextKey, []); + $state = CoroutineContext::get($this->stateKey); + + return $state instanceof LoggerState ? $state->context : []; + } + + /** + * Get the state for this logger family in the current coroutine. + */ + protected function state(): LoggerState + { + return CoroutineContext::getOrSet( + $this->stateKey, + static fn () => new LoggerState + ); } /** * Register a new callback handler for when a log event is triggered. * + * Boot-only. Registers a listener on the worker-global event dispatcher; + * per-request registration persists and affects subsequent requests. + * * @throws RuntimeException */ public function listen(Closure $callback): void diff --git a/src/log/src/LoggerState.php b/src/log/src/LoggerState.php new file mode 100644 index 000000000..104a96154 --- /dev/null +++ b/src/log/src/LoggerState.php @@ -0,0 +1,27 @@ + $context + */ + public function __construct( + public array $context = [], + public int $depth = 0 + ) { + } + + /** + * Copy durable context without inheriting an in-progress logging stack. + */ + public function replicate(): static + { + return new self($this->context); + } +} diff --git a/src/log/src/Processors/UidProcessor.php b/src/log/src/Processors/UidProcessor.php new file mode 100644 index 000000000..73bd6c083 --- /dev/null +++ b/src/log/src/Processors/UidProcessor.php @@ -0,0 +1,62 @@ + $length + */ + public function __construct( + private readonly int $length = 7 + ) { + parent::__construct($length); + + $this->contextKey = '__log.uid_processor.' . ++self::$nextProcessorId; + } + + #[Override] + public function __invoke(LogRecord $record): LogRecord + { + $record->extra['uid'] = $this->getUid(); + + return $record; + } + + #[Override] + public function getUid(): string + { + return CoroutineContext::getOrSet( + $this->contextKey, + fn (): string => $this->generateUid() + ); + } + + #[Override] + public function reset(): void + { + CoroutineContext::forget($this->contextKey); + } + + private function generateUid(): string + { + return substr(bin2hex(random_bytes((int) ceil($this->length / 2))), 0, $this->length); + } +} diff --git a/src/mail/src/MailManager.php b/src/mail/src/MailManager.php index 883922850..579a0edd3 100644 --- a/src/mail/src/MailManager.php +++ b/src/mail/src/MailManager.php @@ -37,6 +37,9 @@ use Symfony\Component\Mailer\Transport\Smtp\Stream\SocketStream; use Symfony\Component\Mailer\Transport\TransportInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; +use UnitEnum; + +use function Hypervel\Support\enum_value; /** * @mixin \Hypervel\Contracts\Mail\Mailer @@ -89,9 +92,15 @@ public function __construct( /** * Get a mailer instance by name. */ - public function mailer(?string $name = null): MailerContract + public function mailer(UnitEnum|string|null $name = null): MailerContract { - $name = $name ?: $this->getDefaultDriver(); + if ($name instanceof UnitEnum) { + $name = (string) enum_value($name); + } + + $name = $name === null || $name === '' + ? $this->getDefaultDriver() + : $name; return $this->mailers[$name] = $this->get($name); } @@ -99,7 +108,7 @@ public function mailer(?string $name = null): MailerContract /** * Get a mailer driver instance. */ - public function driver(?string $driver = null): MailerContract + public function driver(UnitEnum|string|null $driver = null): MailerContract { return $this->mailer($driver); } @@ -678,8 +687,10 @@ public function getDefaultDriver(): string * * Boot-only. Mutates process-global config; per-request use races across coroutines. */ - public function setDefaultDriver(string $name): void + public function setDefaultDriver(UnitEnum|string $name): void { + $name = $name instanceof UnitEnum ? (string) enum_value($name) : $name; + $this->config->set('mail.default', $name); } @@ -690,9 +701,15 @@ public function setDefaultDriver(string $name): void * resources. Other mailers sharing the pool transparently acquire a fresh * pool on their next operation. */ - public function purge(?string $name = null): void + public function purge(UnitEnum|string|null $name = null): void { - $name = $name ?: $this->getDefaultDriver(); + if ($name instanceof UnitEnum) { + $name = (string) enum_value($name); + } + + $name = $name === null || $name === '' + ? $this->getDefaultDriver() + : $name; $mailer = $this->mailers[$name] ?? null; unset($this->mailers[$name]); diff --git a/src/mail/src/Mailable.php b/src/mail/src/Mailable.php index ff6015db7..44f238f1c 100644 --- a/src/mail/src/Mailable.php +++ b/src/mail/src/Mailable.php @@ -206,7 +206,7 @@ public function queue(QueueFactory $queue): mixed } return $queue->connection($connection)->pushOn( - $queueName ?: null, + $queueName === '' ? null : $queueName, $this->newQueuedJob() ); } @@ -233,7 +233,7 @@ public function later(DateInterval|DateTimeInterface|int $delay, QueueFactory $q $job->delay($delay); return $queue->connection($connection)->laterOn( - $queueName ?: null, + $queueName === '' ? null : $queueName, $delay, $job ); diff --git a/src/notifications/src/ChannelManager.php b/src/notifications/src/ChannelManager.php index a77d9b280..72057ad11 100644 --- a/src/notifications/src/ChannelManager.php +++ b/src/notifications/src/ChannelManager.php @@ -17,6 +17,7 @@ use Hypervel\Support\Queue\Concerns\ResolvesQueueRoutes; use Hypervel\Support\Traits\Macroable; use InvalidArgumentException; +use UnitEnum; class ChannelManager extends Manager implements DispatcherContract, FactoryContract { @@ -72,7 +73,7 @@ public function sendNow(mixed $notifiables, mixed $notification, ?array $channel /** * Get a channel instance. */ - public function channel(?string $name = null): mixed + public function channel(UnitEnum|string|null $name = null): mixed { return $this->driver($name); } diff --git a/src/permission/src/Commands/ShowCommand.php b/src/permission/src/Commands/ShowCommand.php index 460f64ad2..1020e7159 100644 --- a/src/permission/src/Commands/ShowCommand.php +++ b/src/permission/src/Commands/ShowCommand.php @@ -34,7 +34,7 @@ public function handle(PermissionRegistrar $permissionRegistrar): int $style = (string) ($this->argument('style') ?? 'default'); $guard = $this->argument('guard'); - if ($guard) { + if ($guard !== null && $guard !== '') { $guards = Collection::make([(string) $guard]); } else { $guards = $permissionClass::query() diff --git a/src/permission/src/Guard.php b/src/permission/src/Guard.php index 780693d22..51af214b5 100644 --- a/src/permission/src/Guard.php +++ b/src/permission/src/Guard.php @@ -59,7 +59,7 @@ public static function getNames(string|Model $model): Collection $guardName = self::getDefaultGuardNameProperty($class); } - if ($guardName) { + if ($guardName !== null && $guardName !== '') { return new Collection($guardName); } @@ -167,6 +167,7 @@ protected static function getConfigAuthGuards(string $class): Collection }) ->filter(fn ($model) => $class === $model) ->keys() + ->map(static fn (int|string $guard): string => (string) $guard) ->values() ->all()); } @@ -182,7 +183,7 @@ public static function getModelForGuard(string $guard): ?string $provider = self::config()->get("auth.guards.{$guard}.provider"); - if (! $provider) { + if ($provider === null || $provider === '') { return self::$modelsForGuards[$guard] = null; } @@ -213,7 +214,9 @@ public static function getDefaultName(string|Model $class): string return $default; } - return $possibleGuards->first() ?: $default; + $first = $possibleGuards->first(); + + return $first === null || $first === '' ? $default : (string) $first; } /** @@ -227,8 +230,7 @@ public static function getPassportClient(?string $guard): ?Authorizable return null; } - /** @var string $passportGuard */ - $passportGuard = $guards->keys()[0]; + $passportGuard = (string) $guards->keys()[0]; $authGuard = self::auth()->guard($passportGuard); @@ -238,7 +240,7 @@ public static function getPassportClient(?string $guard): ?Authorizable $client = $authGuard->client(); - if (! $guard || ! $client) { + if ($guard === null || $guard === '' || ! $client) { return $client; } diff --git a/src/permission/src/Middleware/PermissionMiddleware.php b/src/permission/src/Middleware/PermissionMiddleware.php index 53f19ff08..5270066af 100644 --- a/src/permission/src/Middleware/PermissionMiddleware.php +++ b/src/permission/src/Middleware/PermissionMiddleware.php @@ -64,7 +64,7 @@ public function handle(Request $request, Closure $next, mixed $permission, ?stri */ public static function using(array|string|UnitEnum $permission, ?string $guard = null): string { - $permissionString = self::parsePermissionsToString(enum_value($permission)); + $permissionString = self::parsePermissionsToString($permission); $args = is_null($guard) ? $permissionString : "{$permissionString},{$guard}"; @@ -79,7 +79,10 @@ protected static function parsePermissionsToString(array|string|UnitEnum $permis $permission = enum_value($permission); if (is_array($permission)) { - return implode('|', array_map(fn ($r) => enum_value($r), $permission)); + return implode('|', array_map( + fn ($name) => $name instanceof UnitEnum ? (string) enum_value($name) : $name, + $permission + )); } return (string) $permission; diff --git a/src/permission/src/Models/Role.php b/src/permission/src/Models/Role.php index 80916b322..823e5c27d 100644 --- a/src/permission/src/Models/Role.php +++ b/src/permission/src/Models/Role.php @@ -281,7 +281,10 @@ public function hasPermissionTo(UnitEnum|int|string|PermissionContract $permissi } if (! $this->getGuardNames()->contains($permission->guard_name)) { - throw GuardDoesNotMatch::create($permission->guard_name, $guardName ? collect([$guardName]) : $this->getGuardNames()); + throw GuardDoesNotMatch::create( + $permission->guard_name, + $guardName !== null && $guardName !== '' ? collect([$guardName]) : $this->getGuardNames() + ); } $matches = $this->relationCollection($this, 'permissions') diff --git a/src/permission/src/Traits/HasRoles.php b/src/permission/src/Traits/HasRoles.php index 58deb4f83..a35622755 100644 --- a/src/permission/src/Traits/HasRoles.php +++ b/src/permission/src/Traits/HasRoles.php @@ -192,7 +192,10 @@ public function scopeRole(Builder $query, $roles, ?string $guard = null, bool $w $method = is_int($role) || PermissionRegistrar::isUid($role) ? 'findById' : 'findByName'; - $role = $this->getRoleClass()::{$method}($role, $guard ?: $this->getDefaultGuardName()); + $role = $this->getRoleClass()::{$method}( + $role, + $guard === null || $guard === '' ? $this->getDefaultGuardName() : $guard + ); $this->ensureRoleMatchesPartition($role, $partition); return $role; @@ -728,13 +731,13 @@ public function hasRole($roles, ?string $guard = null): bool if (is_int($roles) || PermissionRegistrar::isUid($roles)) { $key = Guard::getModelKeyName($this->getRoleClass()); - return $guard + return $guard !== null && $guard !== '' ? $roleCollection->where('guard_name', $guard)->contains($key, $roles) : $roleCollection->contains($key, $roles); } if (is_string($roles)) { - $roleNames = $guard + $roleNames = $guard !== null && $guard !== '' ? $roleCollection->where('guard_name', $guard)->pluck('name') : $roleCollection->pluck('name'); @@ -763,7 +766,9 @@ public function hasRole($roles, ?string $guard = null): bool if ($roles instanceof Collection) { $this->ensureRoleCollectionMatchesPartition($roles); - return $roles->intersect($guard ? $roleCollection->where('guard_name', $guard) : $roleCollection)->isNotEmpty(); + return $roles->intersect( + $guard !== null && $guard !== '' ? $roleCollection->where('guard_name', $guard) : $roleCollection + )->isNotEmpty(); } throw new TypeError('Unsupported type for $roles parameter to hasRole().'); @@ -813,7 +818,7 @@ public function hasAllRoles($roles, ?string $guard = null): bool $this->ensureRoleCollectionMatchesPartition($roles); $roles = $roles->map(fn ($role) => $role instanceof Role ? $role->name : enum_value($role)); - $roleNames = $guard + $roleNames = $guard !== null && $guard !== '' ? $roleCollection->where('guard_name', $guard)->pluck('name') : $this->getRoleNames(); diff --git a/src/pipeline/src/Hub.php b/src/pipeline/src/Hub.php index 7f517309a..72b132e9a 100644 --- a/src/pipeline/src/Hub.php +++ b/src/pipeline/src/Hub.php @@ -58,7 +58,7 @@ public function pipeline(string $name, Closure $callback): void */ public function pipe(mixed $object, ?string $pipeline = null): mixed { - $pipeline = $pipeline ?: 'default'; + $pipeline = $pipeline === null || $pipeline === '' ? 'default' : $pipeline; return call_user_func( $this->pipelines[$pipeline], diff --git a/src/queue/src/BackgroundQueue.php b/src/queue/src/BackgroundQueue.php index 736916878..d7d5fcd05 100644 --- a/src/queue/src/BackgroundQueue.php +++ b/src/queue/src/BackgroundQueue.php @@ -48,11 +48,19 @@ public function later(DateInterval|DateTimeInterface|int $delay, object|string $ return $this->container->make('db.transactions') ->addCallback( - fn () => $this->scheduleTimer($delay, $job, $data, $queue) + fn () => $this->scheduleTimer( + $delay, + $this->createPayload($job, $queue, $data), + $queue + ) ); } - return $this->scheduleTimer($delay, $job, $data, $queue); + return $this->scheduleTimer( + $delay, + $this->createPayload($job, $queue, $data), + $queue + ); } /** @@ -75,28 +83,28 @@ public function setExceptionCallback(?callable $callback): static * dropped rather than racing against shutdown cleanup. Devs needing * durability across worker restarts should use a persistent queue. */ - protected function scheduleTimer(DateInterval|DateTimeInterface|int $delay, object|string $job, mixed $data, ?string $queue): int + protected function scheduleTimer(DateInterval|DateTimeInterface|int $delay, string $payload, ?string $queue): int { return $this->timer->after( max(0.0, (float) $this->secondsUntil($delay)), - function (bool $isClosing = false) use ($job, $data, $queue) { + function (bool $isClosing = false) use ($payload, $queue) { if ($isClosing) { return; } - $this->executeJob($job, $data, $queue); + $this->executePayload($payload, $queue); } ); } /** - * Execute a new job in the background queue. + * Execute a serialized job in the background queue. */ - protected function executeJob(object|string $job, mixed $data = '', ?string $queue = null): int + protected function executePayload(string $payload, ?string $queue = null): int { - Coroutine::create(function () use ($job, $data, $queue) { + Coroutine::create(function () use ($payload, $queue) { try { - parent::executeJob($job, $data, $queue); + parent::executePayload($payload, $queue); } catch (Throwable $e) { if ($this->exceptionCallback) { ($this->exceptionCallback)($e); diff --git a/src/queue/src/BeanstalkdQueue.php b/src/queue/src/BeanstalkdQueue.php index 5a48604aa..e5df0e1ff 100644 --- a/src/queue/src/BeanstalkdQueue.php +++ b/src/queue/src/BeanstalkdQueue.php @@ -192,7 +192,7 @@ public function deleteMessage(string $queue, int|string $id): void */ public function getQueue(?string $queue): string { - return $queue ?: $this->default; + return $queue === null || $queue === '' ? $this->default : $queue; } /** diff --git a/src/queue/src/Console/ClearCommand.php b/src/queue/src/Console/ClearCommand.php index ae387a3a9..fea947e58 100644 --- a/src/queue/src/Console/ClearCommand.php +++ b/src/queue/src/Console/ClearCommand.php @@ -37,8 +37,11 @@ public function handle(): ?int return 1; } - $connection = $this->argument('connection') - ?: $this->hypervel->make('config')->string('queue.default'); + $connection = $this->argument('connection'); + + if ($connection === null || $connection === '') { + $connection = $this->hypervel->make('config')->string('queue.default'); + } // We need to get the right queue for the connection which is set in the queue // configuration file for the application. We will pull it based on the set @@ -65,10 +68,11 @@ public function handle(): ?int */ protected function getQueue(string $connection): string { - return $this->option('queue') ?: $this->hypervel->make('config')->string( - "queue.connections.{$connection}.queue", - 'default' - ); + $queue = $this->option('queue'); + + return $queue === null || $queue === '' + ? $this->hypervel->make('config')->string("queue.connections.{$connection}.queue", 'default') + : $queue; } /** diff --git a/src/queue/src/Console/Concerns/ParsesQueue.php b/src/queue/src/Console/Concerns/ParsesQueue.php index ea553c5ff..621b52e9e 100644 --- a/src/queue/src/Console/Concerns/ParsesQueue.php +++ b/src/queue/src/Console/Concerns/ParsesQueue.php @@ -17,7 +17,7 @@ protected function parseQueue(string $queue): array return [ $connection ?? $this->hypervel->make('config')->string('queue.default'), - $queue ?: 'default', + $queue === null || $queue === '' ? 'default' : $queue, ]; } } diff --git a/src/queue/src/Console/ListenCommand.php b/src/queue/src/Console/ListenCommand.php index df57f402e..0f539c0f3 100644 --- a/src/queue/src/Console/ListenCommand.php +++ b/src/queue/src/Console/ListenCommand.php @@ -73,12 +73,15 @@ public function handle() */ protected function getQueue(?string $connection): string { - $connection = $connection ?: $this->config->string('queue.default'); + $connection = $connection === null || $connection === '' + ? $this->config->string('queue.default') + : $connection; - return $this->input->getOption('queue') ?: $this->config->string( - "queue.connections.{$connection}.queue", - 'default' - ); + $queue = $this->input->getOption('queue'); + + return $queue === null || $queue === '' + ? $this->config->string("queue.connections.{$connection}.queue", 'default') + : $queue; } /** diff --git a/src/queue/src/Console/WorkCommand.php b/src/queue/src/Console/WorkCommand.php index df30f2f0b..1cc2292e0 100644 --- a/src/queue/src/Console/WorkCommand.php +++ b/src/queue/src/Console/WorkCommand.php @@ -95,8 +95,11 @@ public function handle(): ?int // which jobs are coming through a queue and be informed on its progress. $this->listenForEvents(); - $connection = $this->argument('connection') - ?: $this->config->string('queue.default'); + $connection = $this->argument('connection'); + + if ($connection === null || $connection === '') { + $connection = $this->config->string('queue.default'); + } // We need to get the right queue for the connection which is set in the queue // configuration file for the application. We will pull it based on the set @@ -323,10 +326,11 @@ protected function logFailedJob(JobFailed $event): void */ protected function getQueue(?string $connection): string { - return $this->option('queue') ?: $this->config->string( - "queue.connections.{$connection}.queue", - 'default' - ); + $queue = $this->option('queue'); + + return $queue === null || $queue === '' + ? $this->config->string("queue.connections.{$connection}.queue", 'default') + : $queue; } /** diff --git a/src/queue/src/DatabaseQueue.php b/src/queue/src/DatabaseQueue.php index 9b7b285e1..63c6729ee 100644 --- a/src/queue/src/DatabaseQueue.php +++ b/src/queue/src/DatabaseQueue.php @@ -357,7 +357,7 @@ public function clear(?string $queue): int */ public function getQueue(?string $queue): string { - return $queue ?: $this->default; + return $queue === null || $queue === '' ? $this->default : $queue; } /** diff --git a/src/queue/src/DeferredQueue.php b/src/queue/src/DeferredQueue.php index 96e68b437..113a99e91 100644 --- a/src/queue/src/DeferredQueue.php +++ b/src/queue/src/DeferredQueue.php @@ -48,11 +48,19 @@ public function later(DateInterval|DateTimeInterface|int $delay, object|string $ return $this->container->make('db.transactions') ->addCallback( - fn () => $this->scheduleTimer($delay, $job, $data, $queue) + fn () => $this->scheduleTimer( + $delay, + $this->createPayload($job, $queue, $data), + $queue + ) ); } - return $this->scheduleTimer($delay, $job, $data, $queue); + return $this->scheduleTimer( + $delay, + $this->createPayload($job, $queue, $data), + $queue + ); } /** @@ -75,28 +83,28 @@ public function setExceptionCallback(?callable $callback): static * dropped rather than racing against shutdown cleanup. Devs needing * durability across worker restarts should use a persistent queue. */ - protected function scheduleTimer(DateInterval|DateTimeInterface|int $delay, object|string $job, mixed $data, ?string $queue): int + protected function scheduleTimer(DateInterval|DateTimeInterface|int $delay, string $payload, ?string $queue): int { return $this->timer->after( max(0.0, (float) $this->secondsUntil($delay)), - function (bool $isClosing = false) use ($job, $data, $queue) { + function (bool $isClosing = false) use ($payload, $queue) { if ($isClosing) { return; } - $this->executeJob($job, $data, $queue); + $this->executePayload($payload, $queue); } ); } /** - * Defer a new job onto the deferred queue. + * Defer a serialized job onto the deferred queue. */ - protected function executeJob(object|string $job, mixed $data = '', ?string $queue = null): int + protected function executePayload(string $payload, ?string $queue = null): int { - Coroutine::defer(function () use ($job, $data, $queue) { + Coroutine::defer(function () use ($payload, $queue) { try { - parent::executeJob($job, $data, $queue); + parent::executePayload($payload, $queue); } catch (Throwable $e) { if ($this->exceptionCallback) { ($this->exceptionCallback)($e); diff --git a/src/queue/src/QueueManager.php b/src/queue/src/QueueManager.php index f313c64b5..83d9133b6 100644 --- a/src/queue/src/QueueManager.php +++ b/src/queue/src/QueueManager.php @@ -17,6 +17,9 @@ use Hypervel\Support\Arr; use Hypervel\Support\Queue\Concerns\ResolvesQueueRoutes; use InvalidArgumentException; +use UnitEnum; + +use function Hypervel\Support\enum_value; /** * @mixin \Hypervel\Contracts\Queue\Queue @@ -141,7 +144,7 @@ public function stopping(mixed $callback): void * * @param array|class-string $class */ - public function route(array|string $class, ?string $queue = null, ?string $connection = null): void + public function route(array|string $class, UnitEnum|string|null $queue = null, UnitEnum|string|null $connection = null): void { $this->queueRoutes()->set($class, $queue, $connection); } @@ -217,17 +220,31 @@ public function withoutInterruptionPolling(): void /** * Determine if the driver is connected. */ - public function connected(?string $name = null): bool + public function connected(UnitEnum|string|null $name = null): bool { - return isset($this->connections[$name ?: $this->getDefaultDriver()]); + if ($name instanceof UnitEnum) { + $name = (string) enum_value($name); + } + + $name = $name === null || $name === '' + ? $this->getDefaultDriver() + : $name; + + return isset($this->connections[$name]); } /** * Resolve a queue connection instance. */ - public function connection(?string $name = null): Queue + public function connection(UnitEnum|string|null $name = null): Queue { - $name = $name ?: $this->getDefaultDriver(); + if ($name instanceof UnitEnum) { + $name = (string) enum_value($name); + } + + $name = $name === null || $name === '' + ? $this->getDefaultDriver() + : $name; // If the connection has not been resolved yet we will resolve it now as all // of the connections are resolved when they are actually needed so we do @@ -342,8 +359,10 @@ public function getDefaultDriver(): string * * Boot-only. Mutates process-global config; per-request use races across coroutines. */ - public function setDefaultDriver(string $name): void + public function setDefaultDriver(UnitEnum|string $name): void { + $name = $name instanceof UnitEnum ? (string) enum_value($name) : $name; + $this->app->make('config')->set('queue.default', $name); } @@ -352,7 +371,9 @@ public function setDefaultDriver(string $name): void */ public function getName(?string $connection = null): string { - return $connection ?: $this->getDefaultDriver(); + return $connection === null || $connection === '' + ? $this->getDefaultDriver() + : $connection; } /** @@ -364,7 +385,9 @@ public function getName(?string $connection = null): string */ public function purge(?string $name = null): void { - $name = $name ?: $this->getDefaultDriver(); + $name = $name === null || $name === '' + ? $this->getDefaultDriver() + : $name; $connection = $this->connections[$name] ?? null; unset($this->connections[$name]); diff --git a/src/queue/src/QueueRoutes.php b/src/queue/src/QueueRoutes.php index d9c1582e6..ad1a79ec2 100644 --- a/src/queue/src/QueueRoutes.php +++ b/src/queue/src/QueueRoutes.php @@ -4,6 +4,10 @@ namespace Hypervel\Queue; +use UnitEnum; + +use function Hypervel\Support\enum_value; + class QueueRoutes { /** @@ -25,7 +29,7 @@ public function getConnection(object $queueable): ?string } return is_string($route) - ? $route + ? null : $route[0]; } @@ -78,14 +82,19 @@ class_uses_recursive($queueable) * Boot-only. The route persists on the singleton registry for the worker * lifetime and affects every subsequent dispatch of that class. * - * @param array|class-string $class + * @param array|class-string $class */ - public function set(array|string $class, ?string $queue = null, ?string $connection = null): void + public function set(array|string $class, UnitEnum|string|null $queue = null, UnitEnum|string|null $connection = null): void { $routes = is_array($class) ? $class : [$class => [$connection, $queue]]; foreach ($routes as $from => $to) { - $this->routes[$from] = $to; + $this->routes[$from] = is_array($to) + ? array_map( + fn ($value) => $value instanceof UnitEnum ? (string) enum_value($value) : $value, + $to + ) + : ($to instanceof UnitEnum ? (string) enum_value($to) : $to); } } diff --git a/src/queue/src/RedisQueue.php b/src/queue/src/RedisQueue.php index 4103164b7..d372343e0 100644 --- a/src/queue/src/RedisQueue.php +++ b/src/queue/src/RedisQueue.php @@ -231,7 +231,7 @@ public function pop(?string $queue = null, int $index = 0): ?JobContract $job, $reserved, $this->connectionName, - $queue ?: $this->default + $queue === null || $queue === '' ? $this->default : $queue ); } @@ -351,7 +351,7 @@ protected function getRandomId(): string */ public function getQueue(?string $queue): string { - return 'queues:' . ($queue ?: $this->default); + return 'queues:' . ($queue === null || $queue === '' ? $this->default : $queue); } /** @@ -363,7 +363,7 @@ public function getQueue(?string $queue): string */ protected function getQueueRedisKey(?string $queue = null): string { - $queue = $queue ?: $this->default; + $queue = $queue === null || $queue === '' ? $this->default : $queue; return $this->isClusterConnection() && ! RedisConnection::hasHashTag($queue) ? $this->getQueue('{' . $queue . '}') diff --git a/src/queue/src/SqsQueue.php b/src/queue/src/SqsQueue.php index e9ec8f968..6a231187e 100644 --- a/src/queue/src/SqsQueue.php +++ b/src/queue/src/SqsQueue.php @@ -111,7 +111,11 @@ public function push(object|string $job, mixed $data = '', ?string $queue = null { return $this->enqueueUsing( $job, - $this->createPayload($job, $queue ?: $this->default, $data), + $this->createPayload( + $job, + $queue === null || $queue === '' ? $this->default : $queue, + $data + ), $queue, null, function ($payload, $queue) use ($job) { @@ -137,7 +141,12 @@ public function later(DateInterval|DateTimeInterface|int $delay, object|string $ { return $this->enqueueUsing( $job, - $this->createPayload($job, $queue ?: $this->default, $data, $delay), + $this->createPayload( + $job, + $queue === null || $queue === '' ? $this->default : $queue, + $data, + $delay + ), $queue, $delay, function ($payload, $queue, $delay) use ($job) { @@ -205,7 +214,7 @@ public function clear(?string $queue): int protected function getQueueableOptions(object|string $job, ?string $queue, string $payload, DateInterval|DateTimeInterface|int|null $delay = null): array { // Make sure we have a queue name to properly determine if it's a FIFO queue... - $queue ??= $this->default; + $queue = $queue === null || $queue === '' ? $this->default : $queue; $isObject = is_object($job); $isFifo = str_ends_with((string) $queue, '.fifo'); @@ -260,7 +269,7 @@ protected function getQueueableOptions(object|string $job, ?string $queue, strin */ public function getQueue(?string $queue): string { - $queue = $queue ?: $this->default; + $queue = $queue === null || $queue === '' ? $this->default : $queue; return filter_var($queue, FILTER_VALIDATE_URL) === false ? $this->suffixQueue($queue, $this->suffix) diff --git a/src/queue/src/SyncQueue.php b/src/queue/src/SyncQueue.php index 5e9b0ed17..8c61d03da 100644 --- a/src/queue/src/SyncQueue.php +++ b/src/queue/src/SyncQueue.php @@ -94,7 +94,17 @@ public function push(object|string $job, mixed $data = '', ?string $queue = null */ protected function executeJob(object|string $job, mixed $data = '', ?string $queue = null): int { - $queueJob = $this->resolveJob($this->createPayload($job, $queue, $data), $queue); + return $this->executePayload($this->createPayload($job, $queue, $data), $queue); + } + + /** + * Execute a serialized job payload synchronously. + * + * @throws Throwable + */ + protected function executePayload(string $payload, ?string $queue = null): int + { + $queueJob = $this->resolveJob($payload, $queue); try { $this->raiseBeforeJobEvent($queueJob); diff --git a/src/redis/src/RedisManager.php b/src/redis/src/RedisManager.php index c1fbec7d5..c5f82b81f 100644 --- a/src/redis/src/RedisManager.php +++ b/src/redis/src/RedisManager.php @@ -53,7 +53,11 @@ public function __construct( */ public function connection(UnitEnum|string|null $name = null): RedisProxy { - $name = enum_value($name) ?? 'default'; + if ($name instanceof UnitEnum) { + $name = (string) enum_value($name); + } + + $name = $name === null || $name === '' ? 'default' : $name; if (isset($this->connections[$name])) { return $this->connections[$name]; @@ -87,7 +91,11 @@ public function connection(UnitEnum|string|null $name = null): RedisProxy */ public function purge(UnitEnum|string|null $name = null): void { - $name = enum_value($name) ?? 'default'; + if ($name instanceof UnitEnum) { + $name = (string) enum_value($name); + } + + $name = $name === null || $name === '' ? 'default' : $name; unset($this->connections[$name]); diff --git a/src/reverb/src/Protocols/Pusher/Channels/ChannelConnection.php b/src/reverb/src/Protocols/Pusher/Channels/ChannelConnection.php index 9bde1e99d..09bcf9b2f 100644 --- a/src/reverb/src/Protocols/Pusher/Channels/ChannelConnection.php +++ b/src/reverb/src/Protocols/Pusher/Channels/ChannelConnection.php @@ -51,7 +51,7 @@ public function connection(): Connection */ public function data(?string $key = null): mixed { - return $key ? Arr::get($this->data, $key) : $this->data; + return $key !== null ? Arr::get($this->data, $key) : $this->data; } /** diff --git a/src/reverb/src/Protocols/Pusher/Channels/Concerns/InteractsWithPresenceChannels.php b/src/reverb/src/Protocols/Pusher/Channels/Concerns/InteractsWithPresenceChannels.php index 8a04c4d2c..705b02199 100644 --- a/src/reverb/src/Protocols/Pusher/Channels/Concerns/InteractsWithPresenceChannels.php +++ b/src/reverb/src/Protocols/Pusher/Channels/Concerns/InteractsWithPresenceChannels.php @@ -24,7 +24,7 @@ public function subscribe(Connection $connection, ?string $auth = null, ?string $userData = $data ? json_decode($data, associative: true, flags: JSON_THROW_ON_ERROR) : []; $presenceUserId = (string) ($userData['user_id'] ?? ''); - parent::subscribe($connection, $auth, $data, $presenceUserId ?: null); + parent::subscribe($connection, $auth, $data, $presenceUserId === '' ? null : $presenceUserId); $result = $this->lastSubscriptionResult(); @@ -78,13 +78,13 @@ public function subscribe(Connection $connection, ?string $auth = null, ?string public function unsubscribe(Connection $connection, ?string $userId = null): void { $subscription = $this->connections->find($connection); - $presenceUserId = $subscription?->data('user_id'); + $presenceUserId = (string) ($subscription?->data('user_id') ?? ''); - parent::unsubscribe($connection, $presenceUserId ? (string) $presenceUserId : null); + parent::unsubscribe($connection, $presenceUserId === '' ? null : $presenceUserId); $result = $this->lastSubscriptionResult(); - if (! $subscription || ! $presenceUserId) { + if ($subscription === null || $presenceUserId === '') { return; } @@ -107,8 +107,8 @@ public function unsubscribe(Connection $connection, ?string $userId = null): voi $manager = app(DeferredWebhookManager::class); if ($delayMs > 0 && $connection->isDisconnecting() && ! $manager->isDraining()) { - app(SharedState::class)->setMemberSmoothingPending($app->id(), $this->name(), (string) $presenceUserId, $delayMs); - $manager->deferMemberRemoved($app, $this->name(), (string) $presenceUserId, $delayMs / 1000.0, $delayMs); + app(SharedState::class)->setMemberSmoothingPending($app->id(), $this->name(), $presenceUserId, $delayMs); + $manager->deferMemberRemoved($app, $this->name(), $presenceUserId, $delayMs / 1000.0, $delayMs); } else { app(WebhookDispatcher::class)->dispatch($app, 'member_removed', [ 'channel' => $this->name(), diff --git a/src/reverb/src/Protocols/Pusher/ClientEvent.php b/src/reverb/src/Protocols/Pusher/ClientEvent.php index 97bcdac9c..db9ac7afe 100644 --- a/src/reverb/src/Protocols/Pusher/ClientEvent.php +++ b/src/reverb/src/Protocols/Pusher/ClientEvent.php @@ -80,8 +80,10 @@ public static function handle(Connection $connection, array $event): void 'data' => $event['data'] ?? null, ]; - if ($userId = $channelConnection->data('user_id')) { - $rebroadcastEvent['user_id'] = $userId; + $userId = $channelConnection->data('user_id'); + + if ($userId !== null && $userId !== '') { + $rebroadcastEvent['user_id'] = (string) $userId; } } diff --git a/src/routing/src/AbstractRouteCollection.php b/src/routing/src/AbstractRouteCollection.php index 2bd466d70..a87382082 100644 --- a/src/routing/src/AbstractRouteCollection.php +++ b/src/routing/src/AbstractRouteCollection.php @@ -203,7 +203,7 @@ protected function addToSymfonyRoutesCollection(SymfonyRouteCollection $symfonyR $name = null; } - if (! $name) { + if ($name === null || $name === '') { $route->name($this->generateRouteName()); $this->add($route); diff --git a/src/sanctum/src/PersonalAccessToken.php b/src/sanctum/src/PersonalAccessToken.php index ec1197e2a..9f3e82b95 100644 --- a/src/sanctum/src/PersonalAccessToken.php +++ b/src/sanctum/src/PersonalAccessToken.php @@ -239,7 +239,10 @@ protected static function getCache(): CacheRepository { $cacheManager = Container::getInstance()->make('cache'); $store = config('sanctum.cache.store'); - return $store ? $cacheManager->store($store) : $cacheManager->store(); + + return $store !== null && $store !== '' + ? $cacheManager->store($store) + : $cacheManager->store(); } /** diff --git a/src/scout/src/Console/SyncIndexSettingsCommand.php b/src/scout/src/Console/SyncIndexSettingsCommand.php index 121f9f1a4..29a871978 100644 --- a/src/scout/src/Console/SyncIndexSettingsCommand.php +++ b/src/scout/src/Console/SyncIndexSettingsCommand.php @@ -34,7 +34,8 @@ class SyncIndexSettingsCommand extends Command */ public function handle(EngineManager $manager, Repository $config): int { - $driver = $this->option('driver') ?: $config->string('scout.driver'); + $driver = $this->option('driver'); + $driver = $driver === null || $driver === '' ? $config->string('scout.driver') : $driver; $engine = $manager->engine($driver); diff --git a/src/sentry/src/LogChannel.php b/src/sentry/src/LogChannel.php index 263042af7..2a61fd9e8 100644 --- a/src/sentry/src/LogChannel.php +++ b/src/sentry/src/LogChannel.php @@ -5,7 +5,6 @@ namespace Hypervel\Sentry; use Hypervel\Log\LogManager; -use Monolog\Handler\FingersCrossedHandler; use Monolog\Logger; use Sentry\State\HubInterface; @@ -24,14 +23,6 @@ public function __invoke(array $config = []): Logger isset($config['formatter']) && $config['formatter'] !== 'default' ); - if (isset($config['action_level'])) { - $handler = new FingersCrossedHandler($handler, $config['action_level']); - - // Consume the `action_level` config option since newer Laravel versions also support this option - // and will wrap the handler again in another `FingersCrossedHandler` if we leave the option set - unset($config['action_level']); - } - return new Logger( $this->parseChannel($config), [ diff --git a/src/sentry/src/Logs/LogChannel.php b/src/sentry/src/Logs/LogChannel.php index 1b44cbd8e..875cf8ce5 100644 --- a/src/sentry/src/Logs/LogChannel.php +++ b/src/sentry/src/Logs/LogChannel.php @@ -5,7 +5,6 @@ namespace Hypervel\Sentry\Logs; use Hypervel\Log\LogManager; -use Monolog\Handler\FingersCrossedHandler; use Monolog\Logger; class LogChannel extends LogManager @@ -20,14 +19,6 @@ public function __invoke(array $config = []): Logger $config['bubble'] ?? true ); - if (isset($config['action_level'])) { - $handler = new FingersCrossedHandler($handler, $config['action_level']); - - // Consume the `action_level` config option since newer Laravel versions also support this option - // and will wrap the handler again in another `FingersCrossedHandler` if we leave the option set - unset($config['action_level']); - } - return new Logger( $this->parseChannel($config), [ diff --git a/src/session/src/SessionManager.php b/src/session/src/SessionManager.php index 11b78f2d6..44c31bb6a 100644 --- a/src/session/src/SessionManager.php +++ b/src/session/src/SessionManager.php @@ -7,6 +7,9 @@ use Hypervel\Contracts\Encryption\Encrypter; use Hypervel\Support\Manager; use SessionHandlerInterface; +use UnitEnum; + +use function Hypervel\Support\enum_value; /** * @mixin \Hypervel\Session\Store @@ -119,7 +122,8 @@ protected function createCacheBased(string $driver): Store */ protected function createCacheHandler(string $driver): CacheBasedSessionHandler { - $store = $this->config->get('session.store') ?: $driver; + $store = $this->config->get('session.store'); + $store = $store === null || $store === '' ? $driver : $store; return new CacheBasedSessionHandler( clone $this->container->make('cache')->store($store), @@ -209,8 +213,12 @@ public function getDefaultDriver(): string * * Boot-only. Mutates process-global config; per-request use races across coroutines. */ - public function setDefaultDriver(string $name): void + public function setDefaultDriver(UnitEnum|string $name): void { + if ($name instanceof UnitEnum) { + $name = (string) enum_value($name); + } + $this->config->set('session.driver', $name); } } diff --git a/src/socialite/src/Contracts/Factory.php b/src/socialite/src/Contracts/Factory.php index 3f68978d5..4bd518dc2 100644 --- a/src/socialite/src/Contracts/Factory.php +++ b/src/socialite/src/Contracts/Factory.php @@ -4,10 +4,12 @@ namespace Hypervel\Socialite\Contracts; +use UnitEnum; + interface Factory { /** * Get a provider implementation. */ - public function driver(?string $driver = null): mixed; + public function driver(UnitEnum|string|null $driver = null): mixed; } diff --git a/src/socialite/src/Socialite.php b/src/socialite/src/Socialite.php index 651f5c60b..647c678b6 100644 --- a/src/socialite/src/Socialite.php +++ b/src/socialite/src/Socialite.php @@ -12,7 +12,7 @@ /** * @method static mixed with(string $driver) - * @method static mixed driver(string|null $driver = null) + * @method static mixed driver(\UnitEnum|string|null $driver = null) * @method static mixed buildOAuth2Provider(string $provider, array|null $config) * @method static array formatConfig(array $config) * @method static \Hypervel\Socialite\SocialiteManager forgetDrivers() diff --git a/src/socialite/src/SocialiteManager.php b/src/socialite/src/SocialiteManager.php index b0fda7bd7..b9f43d2fe 100644 --- a/src/socialite/src/SocialiteManager.php +++ b/src/socialite/src/SocialiteManager.php @@ -21,6 +21,7 @@ use Hypervel\Support\Manager; use Hypervel\Support\Str; use InvalidArgumentException; +use UnitEnum; class SocialiteManager extends Manager implements Contracts\Factory { @@ -38,7 +39,7 @@ public function with(string $driver): mixed * Refreshes the request on cached providers so each coroutine * gets the current request, not a stale one from first resolution. */ - public function driver(?string $driver = null): mixed + public function driver(UnitEnum|string|null $driver = null): mixed { $provider = parent::driver($driver); diff --git a/src/socialite/src/Testing/SocialiteFake.php b/src/socialite/src/Testing/SocialiteFake.php index 4655db233..4a9b51cc4 100644 --- a/src/socialite/src/Testing/SocialiteFake.php +++ b/src/socialite/src/Testing/SocialiteFake.php @@ -7,6 +7,9 @@ use Closure; use Hypervel\Socialite\Contracts\Factory; use Hypervel\Socialite\Contracts\User as UserContract; +use UnitEnum; + +use function Hypervel\Support\enum_value; class SocialiteFake implements Factory { @@ -28,8 +31,12 @@ public function __construct( /** * Get a provider implementation. */ - public function driver(?string $driver = null): mixed + public function driver(UnitEnum|string|null $driver = null): mixed { + if ($driver instanceof UnitEnum) { + $driver = (string) enum_value($driver); + } + return $this->providers[$driver] ?? $this->factory->driver($driver); } diff --git a/src/support/src/Facades/Broadcast.php b/src/support/src/Facades/Broadcast.php index cc60cb617..85fa6da35 100644 --- a/src/support/src/Facades/Broadcast.php +++ b/src/support/src/Facades/Broadcast.php @@ -16,13 +16,13 @@ * @method static \Hypervel\Broadcasting\AnonymousEvent presence(string $channel) * @method static \Hypervel\Broadcasting\PendingBroadcast event(mixed $event = null) * @method static void queue(mixed $event) - * @method static \Hypervel\Contracts\Broadcasting\Broadcaster connection(string|null $driver = null) - * @method static \Hypervel\Contracts\Broadcasting\Broadcaster driver(string|null $name = null) + * @method static \Hypervel\Contracts\Broadcasting\Broadcaster connection(\UnitEnum|string|null $driver = null) + * @method static \Hypervel\Contracts\Broadcasting\Broadcaster driver(\UnitEnum|string|null $name = null) * @method static \Pusher\Pusher pusher(array $config) * @method static \Ably\AblyRest ably(array $config) * @method static string getDefaultDriver() - * @method static void setDefaultDriver(string $name) - * @method static void purge(string|null $name = null) + * @method static void setDefaultDriver(\UnitEnum|string $name) + * @method static void purge(\UnitEnum|string|null $name = null) * @method static \Hypervel\Broadcasting\BroadcastManager extend(string $driver, \Closure $callback) * @method static \Hypervel\Contracts\Container\Container getApplication() * @method static \Hypervel\Broadcasting\BroadcastManager setApplication(\Hypervel\Contracts\Container\Container $app) diff --git a/src/support/src/Facades/Cache.php b/src/support/src/Facades/Cache.php index 4b02de42b..a2be8d736 100644 --- a/src/support/src/Facades/Cache.php +++ b/src/support/src/Facades/Cache.php @@ -35,14 +35,14 @@ * @method static bool touch(\UnitEnum|string $key, \DateInterval|\DateTimeInterface|int|null $ttl = null) * @method static bool forget(\UnitEnum|string $key) * @method static \Hypervel\Contracts\Cache\Store getStore() - * @method static mixed get(string $key, mixed $default = null) - * @method static bool set(string $key, mixed $value, null|int|\DateInterval $ttl = null) - * @method static bool delete(string $key) + * @method static mixed get(array|\UnitEnum|string $key, mixed $default = null) + * @method static bool set(\UnitEnum|string $key, mixed $value, null|int|\DateInterval $ttl = null) + * @method static bool delete(\UnitEnum|string $key) * @method static bool clear() - * @method static iterable getMultiple(iterable $keys, mixed $default = null) + * @method static iterable getMultiple(iterable<\UnitEnum|string> $keys, mixed $default = null) * @method static bool setMultiple(iterable $values, null|int|\DateInterval $ttl = null) - * @method static bool deleteMultiple(iterable $keys) - * @method static bool has(string $key) + * @method static bool deleteMultiple(iterable<\UnitEnum|string> $keys) + * @method static bool has(array|\UnitEnum|string $key) * @method static \Hypervel\Contracts\Cache\Lock lock(string $name, int $seconds = 0, string|null $owner = null) * @method static \Hypervel\Contracts\Cache\Lock restoreLock(string $name, string $owner) * @method static \Hypervel\Cache\TaggedCache tags(mixed $names) diff --git a/src/support/src/Facades/Concurrency.php b/src/support/src/Facades/Concurrency.php index 26900671f..1544f7ffc 100644 --- a/src/support/src/Facades/Concurrency.php +++ b/src/support/src/Facades/Concurrency.php @@ -7,7 +7,7 @@ use Hypervel\Concurrency\ConcurrencyManager; /** - * @method static mixed driver(string|null $name = null) + * @method static mixed driver(\UnitEnum|string|null $name = null) * @method static \Hypervel\Concurrency\CoroutineDriver createCoroutineDriver() * @method static \Hypervel\Concurrency\ProcessDriver createProcessDriver() * @method static \Hypervel\Concurrency\SyncDriver createSyncDriver() diff --git a/src/support/src/Facades/Hash.php b/src/support/src/Facades/Hash.php index 593f17d1b..ad570bddc 100644 --- a/src/support/src/Facades/Hash.php +++ b/src/support/src/Facades/Hash.php @@ -14,7 +14,7 @@ * @method static bool needsRehash(string $hashedValue, array $options = []) * @method static bool isHashed(string $value) * @method static string getDefaultDriver() - * @method static mixed driver(string|null $driver = null) + * @method static mixed driver(\UnitEnum|string|null $driver = null) * @method static \Hypervel\Hashing\HashManager extend(string $driver, \Closure $callback) * @method static array getDrivers() * @method static \Hypervel\Contracts\Container\Container getContainer() diff --git a/src/support/src/Facades/Jwt.php b/src/support/src/Facades/Jwt.php index d48ba8c69..df57221ea 100644 --- a/src/support/src/Facades/Jwt.php +++ b/src/support/src/Facades/Jwt.php @@ -12,7 +12,7 @@ * @method static string refresh(string $token, bool $forceForever = false, bool $resetClaims = false, array $customClaims = [], int|false|null $ttl = false) * @method static bool invalidate(string $token, bool $forceForever = false) * @method static bool hasBlacklistEnabled() - * @method static mixed driver(string|null $driver = null) + * @method static mixed driver(\UnitEnum|string|null $driver = null) * @method static \Hypervel\Jwt\JwtManager extend(string $driver, \Closure $callback) * @method static array getDrivers() * @method static \Hypervel\Contracts\Container\Container getContainer() diff --git a/src/support/src/Facades/Mail.php b/src/support/src/Facades/Mail.php index c7e500d9d..7fd97e0d4 100644 --- a/src/support/src/Facades/Mail.php +++ b/src/support/src/Facades/Mail.php @@ -7,13 +7,13 @@ use Hypervel\Support\Testing\Fakes\MailFake; /** - * @method static \Hypervel\Contracts\Mail\Mailer mailer(string|null $name = null) - * @method static \Hypervel\Contracts\Mail\Mailer driver(string|null $driver = null) + * @method static \Hypervel\Contracts\Mail\Mailer mailer(\UnitEnum|string|null $name = null) + * @method static \Hypervel\Contracts\Mail\Mailer driver(\UnitEnum|string|null $driver = null) * @method static \Hypervel\Mail\Mailer build(array $config) * @method static \Symfony\Component\Mailer\Transport\TransportInterface createSymfonyTransport(array $config) * @method static string getDefaultDriver() - * @method static void setDefaultDriver(string $name) - * @method static void purge(string|null $name = null) + * @method static void setDefaultDriver(\UnitEnum|string $name) + * @method static void purge(\UnitEnum|string|null $name = null) * @method static \Hypervel\Mail\MailManager extend(string $driver, \Closure $callback, bool $poolable = false) * @method static \Hypervel\Contracts\Container\Container getApplication() * @method static \Hypervel\Mail\MailManager setApplication(\Hypervel\Contracts\Container\Container $app) diff --git a/src/support/src/Facades/MaintenanceMode.php b/src/support/src/Facades/MaintenanceMode.php index 21e7ffd51..3c3c9a3ae 100644 --- a/src/support/src/Facades/MaintenanceMode.php +++ b/src/support/src/Facades/MaintenanceMode.php @@ -8,7 +8,7 @@ /** * @method static string getDefaultDriver() - * @method static mixed driver(string|null $driver = null) + * @method static mixed driver(\UnitEnum|string|null $driver = null) * @method static \Hypervel\Foundation\MaintenanceModeManager extend(string $driver, \Closure $callback) * @method static array getDrivers() * @method static \Hypervel\Contracts\Container\Container getContainer() diff --git a/src/support/src/Facades/Notification.php b/src/support/src/Facades/Notification.php index f148ca06b..52d796f91 100644 --- a/src/support/src/Facades/Notification.php +++ b/src/support/src/Facades/Notification.php @@ -11,14 +11,14 @@ /** * @method static void send(mixed $notifiables, mixed $notification) * @method static void sendNow(mixed $notifiables, mixed $notification, array|null $channels = null) - * @method static mixed channel(string|null $name = null) + * @method static mixed channel(\UnitEnum|string|null $name = null) * @method static \Hypervel\Notifications\ChannelManager extend(string $driver, \Closure $callback) * @method static string getDefaultDriver() * @method static string deliversVia() * @method static void deliverVia(string $channel) * @method static \Hypervel\Notifications\ChannelManager locale(string $locale) * @method static string|null getLocale() - * @method static mixed driver(string|null $driver = null) + * @method static mixed driver(\UnitEnum|string|null $driver = null) * @method static array getDrivers() * @method static \Hypervel\Contracts\Container\Container getContainer() * @method static \Hypervel\Notifications\ChannelManager setContainer(\Hypervel\Contracts\Container\Container $container) diff --git a/src/support/src/Facades/Queue.php b/src/support/src/Facades/Queue.php index 459eb037c..2a5d160a3 100644 --- a/src/support/src/Facades/Queue.php +++ b/src/support/src/Facades/Queue.php @@ -15,18 +15,18 @@ * @method static void failing(mixed $callback) * @method static void starting(mixed $callback) * @method static void stopping(mixed $callback) - * @method static void route(array|string $class, string|null $queue = null, string|null $connection = null) + * @method static void route(array|string $class, \UnitEnum|string|null $queue = null, \UnitEnum|string|null $connection = null) * @method static void pause(string $connection, string $queue) * @method static void pauseFor(string $connection, string $queue, \DateInterval|\DateTimeInterface|int $ttl) * @method static void resume(string $connection, string $queue) * @method static bool isPaused(string $connection, string $queue) * @method static void withoutInterruptionPolling() - * @method static bool connected(string|null $name = null) - * @method static \Hypervel\Contracts\Queue\Queue connection(string|null $name = null) + * @method static bool connected(\UnitEnum|string|null $name = null) + * @method static \Hypervel\Contracts\Queue\Queue connection(\UnitEnum|string|null $name = null) * @method static void extend(string $driver, \Closure $resolver) * @method static void addConnector(string $driver, \Closure $resolver) * @method static string getDefaultDriver() - * @method static void setDefaultDriver(string $name) + * @method static void setDefaultDriver(\UnitEnum|string $name) * @method static string getName(string|null $connection = null) * @method static void purge(string|null $name = null) * @method static \Hypervel\Contracts\Container\Container getApplication() diff --git a/src/support/src/Facades/Session.php b/src/support/src/Facades/Session.php index 8e9e14c94..99f2d3173 100644 --- a/src/support/src/Facades/Session.php +++ b/src/support/src/Facades/Session.php @@ -11,8 +11,8 @@ * @method static int defaultRouteBlockWaitSeconds() * @method static array getSessionConfig() * @method static string getDefaultDriver() - * @method static void setDefaultDriver(string $name) - * @method static mixed driver(string|null $driver = null) + * @method static void setDefaultDriver(\UnitEnum|string $name) + * @method static mixed driver(\UnitEnum|string|null $driver = null) * @method static \Hypervel\Session\SessionManager extend(string $driver, \Closure $callback) * @method static array getDrivers() * @method static \Hypervel\Contracts\Container\Container getContainer() diff --git a/src/support/src/Facades/Storage.php b/src/support/src/Facades/Storage.php index d44c59dfd..8ccfc6f7f 100644 --- a/src/support/src/Facades/Storage.php +++ b/src/support/src/Facades/Storage.php @@ -109,7 +109,14 @@ class Storage extends Facade */ public static function fake(UnitEnum|string|null $disk = null, array $config = []) { - $root = self::getRootPath($disk = enum_value($disk) ?: static::$app['config']->get('filesystems.default')); + if ($disk instanceof UnitEnum) { + $disk = (string) enum_value($disk); + } + + $disk = $disk === null || $disk === '' + ? static::$app['config']->get('filesystems.default') + : $disk; + $root = self::getRootPath($disk); if ($token = ParallelTesting::token()) { $root = "{$root}_test_{$token}"; @@ -143,7 +150,13 @@ public static function fake(UnitEnum|string|null $disk = null, array $config = [ */ public static function persistentFake(UnitEnum|string|null $disk = null, array $config = []) { - $disk = enum_value($disk) ?: static::$app['config']->get('filesystems.default'); + if ($disk instanceof UnitEnum) { + $disk = (string) enum_value($disk); + } + + $disk = $disk === null || $disk === '' + ? static::$app['config']->get('filesystems.default') + : $disk; static::set($disk, $fake = static::createLocalDriver( self::buildDiskConfiguration($disk, $config, root: self::getRootPath($disk)) diff --git a/src/support/src/Manager.php b/src/support/src/Manager.php index facbac58f..90fcdf2e1 100644 --- a/src/support/src/Manager.php +++ b/src/support/src/Manager.php @@ -8,9 +8,14 @@ use Hypervel\Config\Repository; use Hypervel\Contracts\Container\Container; use InvalidArgumentException; +use ReflectionException; +use RuntimeException; +use UnitEnum; abstract class Manager { + use RebindsCallbacksToSelf; + /** * The configuration repository instance. */ @@ -45,9 +50,15 @@ abstract public function getDefaultDriver(): string; * * @throws InvalidArgumentException */ - public function driver(?string $driver = null): mixed + public function driver(UnitEnum|string|null $driver = null): mixed { - $driver = $driver ?: $this->getDefaultDriver(); + if ($driver instanceof UnitEnum) { + $driver = (string) enum_value($driver); + } + + $driver = $driver === null || $driver === '' + ? $this->getDefaultDriver() + : $driver; // If the given driver has not been created before, we will create the instances // here and cache it so we can return it next time very quickly. If there is @@ -100,6 +111,13 @@ protected function callCustomCreator(string $driver): mixed */ public function extend(string $driver, Closure $callback): static { + try { + $callback = $this->bindCallbackToSelf($callback) + ?? throw new RuntimeException('Unable to bind custom driver callback'); + } catch (ReflectionException $e) { + throw new RuntimeException('Unable to bind custom driver callback', previous: $e); + } + $this->customCreators[$driver] = $callback; return $this; diff --git a/src/support/src/MultipleInstanceManager.php b/src/support/src/MultipleInstanceManager.php index e2cdfb845..96461027d 100644 --- a/src/support/src/MultipleInstanceManager.php +++ b/src/support/src/MultipleInstanceManager.php @@ -8,10 +8,13 @@ use Hypervel\Config\Repository; use Hypervel\Contracts\Foundation\Application; use InvalidArgumentException; +use ReflectionException; use RuntimeException; abstract class MultipleInstanceManager { + use RebindsCallbacksToSelf; + /** * The configuration repository instance. */ @@ -64,7 +67,9 @@ abstract public function getInstanceConfig(string $name): array; */ public function instance(?string $name = null): mixed { - $name = $name ?: $this->getDefaultInstance(); + $name = $name === null || $name === '' + ? $this->getDefaultInstance() + : $name; return $this->instances[$name] = $this->get($name); } @@ -169,7 +174,14 @@ public function purge(?string $name = null): void */ public function extend(string $name, Closure $callback): static { - $this->customCreators[$name] = $callback->bindTo($this, $this); + try { + $callback = $this->bindCallbackToSelf($callback) + ?? throw new RuntimeException('Unable to bind custom driver callback'); + } catch (ReflectionException $e) { + throw new RuntimeException('Unable to bind custom driver callback', previous: $e); + } + + $this->customCreators[$name] = $callback; return $this; } diff --git a/src/support/src/RebindsCallbacksToSelf.php b/src/support/src/RebindsCallbacksToSelf.php new file mode 100644 index 000000000..bb6448a0a --- /dev/null +++ b/src/support/src/RebindsCallbacksToSelf.php @@ -0,0 +1,35 @@ +isAnonymous()) { + if ($reflector->isStatic()) { + // Static functions are bound without $this. + $callback = $callback->bindTo(null, static::class); + } else { + // Non-static functions are bound to $this. + $callback = $callback->bindTo($this, static::class); + } + } + + return $callback; + } +} diff --git a/src/support/src/Testing/Fakes/MailFake.php b/src/support/src/Testing/Fakes/MailFake.php index 3fecf6628..7651ad5e5 100644 --- a/src/support/src/Testing/Fakes/MailFake.php +++ b/src/support/src/Testing/Fakes/MailFake.php @@ -21,6 +21,9 @@ use Hypervel\Support\Traits\ForwardsCalls; use Hypervel\Support\Traits\ReflectsClosures; use PHPUnit\Framework\Assert as PHPUnit; +use UnitEnum; + +use function Hypervel\Support\enum_value; class MailFake implements Factory, Fake, Mailer, MailQueue { @@ -360,13 +363,25 @@ protected function queuedMailablesOf(string $type): Collection /** * Get a mailer instance by name. */ - public function mailer(?string $name = null): Mailer + public function mailer(UnitEnum|string|null $name = null): Mailer { + if ($name instanceof UnitEnum) { + $name = (string) enum_value($name); + } + $this->currentMailer = $name; return $this; } + /** + * Get a mailer driver instance. + */ + public function driver(UnitEnum|string|null $driver = null): Mailer + { + return $this->mailer($driver); + } + /** * Begin the process of mailing a mailable class instance. */ diff --git a/src/support/src/Testing/Fakes/NotificationFake.php b/src/support/src/Testing/Fakes/NotificationFake.php index 814f3d1b4..fe2683ef3 100644 --- a/src/support/src/Testing/Fakes/NotificationFake.php +++ b/src/support/src/Testing/Fakes/NotificationFake.php @@ -16,6 +16,7 @@ use Hypervel\Support\Traits\Macroable; use Hypervel\Support\Traits\ReflectsClosures; use PHPUnit\Framework\Assert as PHPUnit; +use UnitEnum; class NotificationFake implements Fake, NotificationDispatcher, NotificationFactory { @@ -294,7 +295,7 @@ public function sendNow(mixed $notifiables, mixed $notification, ?array $channel /** * Get a channel instance by name. */ - public function channel(?string $name = null): mixed + public function channel(UnitEnum|string|null $name = null): mixed { return null; } diff --git a/src/telescope/src/Watchers/ClientRequestWatcher.php b/src/telescope/src/Watchers/ClientRequestWatcher.php index 4eddea269..db14b31a9 100644 --- a/src/telescope/src/Watchers/ClientRequestWatcher.php +++ b/src/telescope/src/Watchers/ClientRequestWatcher.php @@ -17,6 +17,7 @@ use Psr\Http\Message\ResponseInterface; use Symfony\Component\HttpFoundation\File\UploadedFile; use Throwable; +use UnitEnum; use function Hypervel\Support\enum_value; @@ -59,12 +60,8 @@ public function recordRequest(ProceedingJoinPoint $proceedingJoinPoint): mixed return $proceedingJoinPoint->process(); } - // Normalize enum cases (e.g. Hypervel\Contracts\Telescope\TelescopeTag) - // to their string values. Uses enum_value() for consistency with the - // framework's existing enum-handling idiom (Queueable, Translator, etc.); - // backed enums resolve to ->value, unit enums fall through to ->name. $customTags = array_map( - fn ($tag) => enum_value($tag), + fn ($tag) => $tag instanceof UnitEnum ? (string) enum_value($tag) : $tag, $options['telescope_tags'] ?? [], ); $recorded = false; diff --git a/src/telescope/src/Watchers/JobWatcher.php b/src/telescope/src/Watchers/JobWatcher.php index bc356c66f..12c6633c5 100644 --- a/src/telescope/src/Watchers/JobWatcher.php +++ b/src/telescope/src/Watchers/JobWatcher.php @@ -192,7 +192,7 @@ protected function defaultJobData(string $connection, ?string $queue, array $pay { return [ 'connection' => $connection, - 'queue' => $queue ?: 'default', + 'queue' => $queue === null || $queue === '' ? 'default' : $queue, 'name' => $payload['displayName'], 'tries' => $payload['maxTries'], 'timeout' => $payload['timeout'], diff --git a/src/testbench/src/Foundation/Console/CreateSqliteDbCommand.php b/src/testbench/src/Foundation/Console/CreateSqliteDbCommand.php index b5f9c4639..f681edff7 100644 --- a/src/testbench/src/Foundation/Console/CreateSqliteDbCommand.php +++ b/src/testbench/src/Foundation/Console/CreateSqliteDbCommand.php @@ -57,7 +57,7 @@ protected function databaseName(): string /** @var null|string $database */ $database = $this->option('database'); - if (empty($database)) { + if ($database === null || $database === '') { $database = 'database'; } diff --git a/src/testbench/src/Foundation/Console/DropSqliteDbCommand.php b/src/testbench/src/Foundation/Console/DropSqliteDbCommand.php index 85e57e367..985dc58d4 100644 --- a/src/testbench/src/Foundation/Console/DropSqliteDbCommand.php +++ b/src/testbench/src/Foundation/Console/DropSqliteDbCommand.php @@ -50,7 +50,7 @@ protected function databaseName(): string /** @var null|string $database */ $database = $this->option('database'); - if (empty($database)) { + if ($database === null || $database === '') { $database = 'database'; } diff --git a/src/translation/src/ArrayLoader.php b/src/translation/src/ArrayLoader.php index 7612b957f..a79c36b6a 100644 --- a/src/translation/src/ArrayLoader.php +++ b/src/translation/src/ArrayLoader.php @@ -18,7 +18,7 @@ class ArrayLoader implements Loader */ public function load(string $locale, string $group, ?string $namespace = null): array { - $namespace = $namespace ?: '*'; + $namespace = $namespace === null || $namespace === '' ? '*' : $namespace; return $this->messages[$namespace][$locale][$group] ?? []; } @@ -49,7 +49,7 @@ public function addPath(string $path): void */ public function addMessages(string $locale, string $group, array $messages, ?string $namespace = null): static { - $namespace = $namespace ?: '*'; + $namespace = $namespace === null || $namespace === '' ? '*' : $namespace; $this->messages[$namespace][$locale][$group] = $messages; diff --git a/tests/Broadcasting/InteractsWithBroadcastingTest.php b/tests/Broadcasting/InteractsWithBroadcastingTest.php index d081d9f2a..19138e6c8 100644 --- a/tests/Broadcasting/InteractsWithBroadcastingTest.php +++ b/tests/Broadcasting/InteractsWithBroadcastingTest.php @@ -7,10 +7,10 @@ use Hypervel\Broadcasting\BroadcastEvent; use Hypervel\Broadcasting\Channel; use Hypervel\Broadcasting\InteractsWithBroadcasting; +use Hypervel\Contracts\Broadcasting\Broadcaster; use Hypervel\Contracts\Broadcasting\Factory as BroadcastingFactory; +use Hypervel\Tests\TestCase; use Mockery as m; -use PHPUnit\Framework\TestCase; -use TypeError; enum InteractsWithBroadcastingTestConnectionStringEnum: string { @@ -32,11 +32,6 @@ enum InteractsWithBroadcastingTestConnectionUnitEnum class InteractsWithBroadcastingTest extends TestCase { - protected function tearDown(): void - { - parent::tearDown(); - } - public function testBroadcastViaAcceptsStringBackedEnum(): void { $event = new TestBroadcastingEvent; @@ -55,14 +50,26 @@ public function testBroadcastViaAcceptsUnitEnum(): void $this->assertSame(['redis'], $event->broadcastConnections()); } - public function testBroadcastViaWithIntBackedEnumStoresIntValue(): void + public function testBroadcastViaNormalizesIntegerBackedEnums(): void { $event = new TestBroadcastingEvent; $event->broadcastVia(InteractsWithBroadcastingTestConnectionIntEnum::Connection1); - // Int value is stored as-is (no cast to string) - will fail downstream if string expected - $this->assertSame([1], $event->broadcastConnections()); + $this->assertSame(['1'], $event->broadcastConnections()); + } + + public function testBroadcastViaNormalizesEveryArrayConnection(): void + { + $event = new TestBroadcastingEvent; + + $event->broadcastVia([ + InteractsWithBroadcastingTestConnectionIntEnum::Connection2, + InteractsWithBroadcastingTestConnectionUnitEnum::ably, + 'custom', + ]); + + $this->assertSame(['2', 'ably', 'custom'], $event->broadcastConnections()); } public function testBroadcastViaAcceptsNull(): void @@ -92,16 +99,18 @@ public function testBroadcastViaIsChainable(): void $this->assertSame($event, $result); } - public function testBroadcastWithIntBackedEnumThrowsTypeErrorAtBroadcastTime(): void + public function testBroadcastWithIntegerBackedEnumUsesNormalizedConnection(): void { $event = new TestBroadcastableEvent; $event->broadcastVia(InteractsWithBroadcastingTestConnectionIntEnum::Connection1); $broadcastEvent = new BroadcastEvent($event); $manager = m::mock(BroadcastingFactory::class); + $broadcaster = m::mock(Broadcaster::class); + + $manager->shouldReceive('connection')->once()->with('1')->andReturn($broadcaster); + $broadcaster->shouldReceive('broadcast')->once(); - // TypeError is thrown when BroadcastManager::connection() receives int instead of ?string - $this->expectException(TypeError::class); $broadcastEvent->handle($manager); } } diff --git a/tests/Broadcasting/PendingBroadcastTest.php b/tests/Broadcasting/PendingBroadcastTest.php index e1fb4aad1..2ff23bfcc 100644 --- a/tests/Broadcasting/PendingBroadcastTest.php +++ b/tests/Broadcasting/PendingBroadcastTest.php @@ -4,15 +4,11 @@ namespace Hypervel\Tests\Broadcasting; -use Hypervel\Broadcasting\BroadcastEvent; -use Hypervel\Broadcasting\Channel; use Hypervel\Broadcasting\InteractsWithBroadcasting; use Hypervel\Broadcasting\PendingBroadcast; -use Hypervel\Contracts\Broadcasting\Factory as BroadcastingFactory; use Hypervel\Contracts\Events\Dispatcher; +use Hypervel\Tests\TestCase; use Mockery as m; -use PHPUnit\Framework\TestCase; -use TypeError; enum PendingBroadcastTestConnectionStringEnum: string { @@ -61,17 +57,17 @@ public function testViaAcceptsUnitEnum(): void $this->assertSame(['redis'], $event->broadcastConnections()); } - public function testViaWithIntBackedEnumThrowsTypeErrorAtBroadcastTime(): void + public function testViaNormalizesIntegerBackedEnumImmediately(): void { - $event = new TestPendingBroadcastableEvent; - $event->broadcastVia(PendingBroadcastTestConnectionIntEnum::Connection1); + $dispatcher = m::mock(Dispatcher::class); + $dispatcher->shouldReceive('dispatch')->once(); + + $event = new TestPendingBroadcastEvent; + $pending = new PendingBroadcast($dispatcher, $event); - $broadcastEvent = new BroadcastEvent($event); - $manager = m::mock(BroadcastingFactory::class); + $pending->via(PendingBroadcastTestConnectionIntEnum::Connection1); - // TypeError is thrown when BroadcastManager::connection() receives int instead of ?string - $this->expectException(TypeError::class); - $broadcastEvent->handle($manager); + $this->assertSame(['1'], $event->broadcastConnections()); } public function testViaAcceptsNull(): void @@ -105,13 +101,3 @@ class TestPendingBroadcastEvent { use InteractsWithBroadcasting; } - -class TestPendingBroadcastableEvent -{ - use InteractsWithBroadcasting; - - public function broadcastOn(): Channel - { - return new Channel('test-channel'); - } -} diff --git a/tests/Bus/BusPendingBatchTest.php b/tests/Bus/BusPendingBatchTest.php index 892a003cc..ab92be018 100644 --- a/tests/Bus/BusPendingBatchTest.php +++ b/tests/Bus/BusPendingBatchTest.php @@ -7,8 +7,11 @@ use Hypervel\Bus\Batch; use Hypervel\Bus\Batchable; use Hypervel\Bus\BatchRepository; +use Hypervel\Bus\ChainedBatch; use Hypervel\Bus\PendingBatch; +use Hypervel\Bus\Queueable; use Hypervel\Container\Container; +use Hypervel\Contracts\Bus\Dispatcher as BusDispatcher; use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Support\Collection; use Hypervel\Tests\TestCase; @@ -18,6 +21,136 @@ class BusPendingBatchTest extends TestCase { + public function testPendingBatchNormalizesEnumConnectionAndQueueIdentifiers(): void + { + $job = new class { + use Batchable; + }; + + $pendingBatch = new PendingBatch(new Container, new Collection([$job])); + + $pendingBatch + ->onConnection(PendingBatchIntegerIdentifier::Zero) + ->onQueue(PendingBatchUnitIdentifier::Primary); + + $this->assertSame('0', $pendingBatch->connection()); + $this->assertSame('Primary', $pendingBatch->queue()); + } + + public function testChainedBatchPreservesZeroConnectionAndQueueIdentifiers(): void + { + $container = new Container; + Container::setInstance($container); + + $job = new class { + use Batchable; + }; + + $source = (new PendingBatch($container, new Collection([$job]))) + ->onConnection(PendingBatchIntegerIdentifier::Zero) + ->onQueue(PendingBatchIntegerIdentifier::Zero); + + $dispatcher = m::mock(BusDispatcher::class); + $dispatcher->shouldReceive('batch') + ->once() + ->andReturnUsing(fn ($jobs) => new PendingBatch($container, $jobs)); + $container->instance(BusDispatcher::class, $dispatcher); + + $pendingBatch = (new ChainedBatch($source))->toPendingBatch(); + + $this->assertSame('0', $pendingBatch->connection()); + $this->assertSame('0', $pendingBatch->queue()); + } + + public function testDirectlyRoutedChainedBatchPreservesZeroConnectionAndQueueIdentifiers(): void + { + $container = new Container; + Container::setInstance($container); + + $job = new class { + use Batchable; + }; + + $source = new PendingBatch($container, new Collection([$job])); + $chainedBatch = (new ChainedBatch($source)) + ->onConnection('0') + ->onQueue('0'); + + $dispatcher = m::mock(BusDispatcher::class); + $dispatcher->shouldReceive('batch') + ->once() + ->andReturnUsing(fn ($jobs) => new PendingBatch($container, $jobs)); + $container->instance(BusDispatcher::class, $dispatcher); + + $pendingBatch = $chainedBatch->toPendingBatch(); + + $this->assertSame('0', $pendingBatch->connection()); + $this->assertSame('0', $pendingBatch->queue()); + } + + public function testChainedBatchPreservesSourceEmptyConnectionAndQueueOptions(): void + { + $container = new Container; + Container::setInstance($container); + + $job = new class { + use Batchable; + }; + + $source = (new PendingBatch($container, new Collection([$job]))) + ->onConnection('') + ->onQueue(''); + + $dispatcher = m::mock(BusDispatcher::class); + $dispatcher->shouldReceive('batch') + ->once() + ->andReturnUsing(fn ($jobs) => new PendingBatch($container, $jobs)); + $container->instance(BusDispatcher::class, $dispatcher); + + $pendingBatch = (new ChainedBatch($source))->toPendingBatch(); + + $this->assertSame('', $pendingBatch->connection()); + $this->assertSame('', $pendingBatch->queue()); + } + + public function testChainedBatchRemainderInheritsEmptyIdentifiersAndPreservesZeroIdentifiers(): void + { + foreach ([['', 'chain-connection', 'chain-queue'], ['0', '0', '0']] as [$route, $expectedConnection, $expectedQueue]) { + $container = new Container; + Container::setInstance($container); + + $sourceJob = new BatchableJob; + $chainedBatch = new TestableChainedBatch( + new PendingBatch($container, new Collection([$sourceJob])) + ); + $chainedBatch->chain([ + (new ChainedBatchQueueableJob)->onConnection($route)->onQueue($route), + ]); + $chainedBatch->chainConnection = 'chain-connection'; + $chainedBatch->chainQueue = 'chain-queue'; + + $dispatcher = m::mock(BusDispatcher::class); + $dispatcher->shouldReceive('dispatch')->once()->with(m::on(function (ChainedBatchQueueableJob $job) use ($expectedConnection, $expectedQueue): bool { + $this->assertSame($expectedConnection, $job->connection); + $this->assertSame($expectedQueue, $job->queue); + + return true; + })); + $container->instance(BusDispatcher::class, $dispatcher); + + $pendingBatch = $chainedBatch->attachRemainder( + new PendingBatch($container, new Collection([$sourceJob])) + ); + + $callbacks = $pendingBatch->finallyCallbacks(); + $this->assertCount(1, $callbacks); + + $batch = m::mock(Batch::class); + $batch->shouldReceive('cancelled')->once()->andReturnFalse(); + $callbacks[0]($batch); + } + } + public function testPendingBatchMayBeConfiguredAndDispatched() { $container = new Container; @@ -387,7 +520,30 @@ public function testFailureCallbacksAccessorReturnsRegisteredCallbacks() } } +enum PendingBatchIntegerIdentifier: int +{ + case Zero = 0; +} + +enum PendingBatchUnitIdentifier +{ + case Primary; +} + class BatchableJob { use Batchable; } + +class ChainedBatchQueueableJob +{ + use Queueable; +} + +class TestableChainedBatch extends ChainedBatch +{ + public function attachRemainder(PendingBatch $batch): PendingBatch + { + return $this->attachRemainderOfChainToEndOfBatch($batch); + } +} diff --git a/tests/Bus/QueueableTest.php b/tests/Bus/QueueableTest.php index 589c93eb0..e95e80515 100644 --- a/tests/Bus/QueueableTest.php +++ b/tests/Bus/QueueableTest.php @@ -5,15 +5,17 @@ namespace Hypervel\Tests\Bus; use Hypervel\Bus\Queueable; +use Hypervel\Container\Container; +use Hypervel\Contracts\Bus\Dispatcher; use Hypervel\Tests\TestCase; use Laravel\SerializableClosure\SerializableClosure; +use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; -use TypeError; class QueueableTest extends TestCase { #[DataProvider('connectionDataProvider')] - public function testOnConnection(mixed $connection, ?string $expected) + public function testOnConnection(mixed $connection, ?string $expected): void { $job = new FakeJob; $job->onConnection($connection); @@ -22,7 +24,7 @@ public function testOnConnection(mixed $connection, ?string $expected) } #[DataProvider('connectionDataProvider')] - public function testAllOnConnection(mixed $connection, ?string $expected) + public function testAllOnConnection(mixed $connection, ?string $expected): void { $job = new FakeJob; $job->allOnConnection($connection); @@ -36,29 +38,16 @@ public static function connectionDataProvider(): array return [ 'uses string' => ['redis', 'redis'], 'uses string-backed enum' => [ConnectionEnum::Sqs, 'sqs'], + 'uses integer-backed enum' => [IntConnectionEnum::Redis, '2'], + 'uses zero-backed enum' => [IntConnectionEnum::Zero, '0'], 'uses unit enum' => [UnitConnectionEnum::Sync, 'Sync'], + 'uses empty string' => ['', ''], 'uses null' => [null, null], ]; } - public function testOnConnectionWithIntBackedEnumThrowsTypeError() - { - $job = new FakeJob; - - $this->expectException(TypeError::class); - $job->onConnection(IntConnectionEnum::Redis); - } - - public function testAllOnConnectionWithIntBackedEnumThrowsTypeError() - { - $job = new FakeJob; - - $this->expectException(TypeError::class); - $job->allOnConnection(IntConnectionEnum::Redis); - } - #[DataProvider('queuesDataProvider')] - public function testOnQueue(mixed $queue, ?string $expected) + public function testOnQueue(mixed $queue, ?string $expected): void { $job = new FakeJob; $job->onQueue($queue); @@ -67,7 +56,7 @@ public function testOnQueue(mixed $queue, ?string $expected) } #[DataProvider('queuesDataProvider')] - public function testAllOnQueue(mixed $queue, ?string $expected) + public function testAllOnQueue(mixed $queue, ?string $expected): void { $job = new FakeJob; $job->allOnQueue($queue); @@ -81,29 +70,16 @@ public static function queuesDataProvider(): array return [ 'uses string' => ['high', 'high'], 'uses string-backed enum' => [QueueEnum::High, 'high'], + 'uses integer-backed enum' => [IntQueueEnum::High, '2'], + 'uses zero-backed enum' => [IntQueueEnum::Zero, '0'], 'uses unit enum' => [UnitQueueEnum::Low, 'Low'], + 'uses empty string' => ['', ''], 'uses null' => [null, null], ]; } - public function testOnQueueWithIntBackedEnumThrowsTypeError() - { - $job = new FakeJob; - - $this->expectException(TypeError::class); - $job->onQueue(IntQueueEnum::High); - } - - public function testAllOnQueueWithIntBackedEnumThrowsTypeError() - { - $job = new FakeJob; - - $this->expectException(TypeError::class); - $job->allOnQueue(IntQueueEnum::High); - } - #[DataProvider('groupDataProvider')] - public function testOnGroup(mixed $group, string $expected) + public function testOnGroup(mixed $group, int|string $expected): void { $job = new FakeJob; $job->onGroup($group); @@ -116,11 +92,12 @@ public static function groupDataProvider(): array return [ 'uses string' => ['group-1', 'group-1'], 'uses string-backed enum' => [GroupEnum::Alpha, 'alpha'], + 'preserves integer-backed enum values' => [IntGroupEnum::One, 1], 'uses unit enum' => [UnitGroupEnum::Beta, 'Beta'], ]; } - public function testWithDeduplicatorClosure() + public function testWithDeduplicatorClosure(): void { $job = new FakeJob; $job->withDeduplicator(fn () => 'dedup-id'); @@ -128,7 +105,7 @@ public function testWithDeduplicatorClosure() $this->assertInstanceOf(SerializableClosure::class, $job->deduplicator); } - public function testWithDeduplicatorNull() + public function testWithDeduplicatorNull(): void { $job = new FakeJob; $job->withDeduplicator(null); @@ -138,7 +115,7 @@ public function testWithDeduplicatorNull() // REMOVED: testWithDeduplicatorRejectsNonClosureCallable - withDeduplicator() now accepts array|callable|null to match Laravel, which includes string callables - public function testPrependToChainWithMultipleJobs() + public function testPrependToChainWithMultipleJobs(): void { $job = new FakeJob; $job->chain([new FakeJob]); @@ -152,7 +129,7 @@ public function testPrependToChainWithMultipleJobs() $this->assertInstanceOf(FakeJob::class, unserialize($job->chained[2])); } - public function testAppendToChainWithMultipleJobs() + public function testAppendToChainWithMultipleJobs(): void { $job = new FakeJob; $job->chain([new FakeJob]); @@ -165,6 +142,44 @@ public function testAppendToChainWithMultipleJobs() $this->assertInstanceOf(FakeJob::class, unserialize($job->chained[1])); $this->assertInstanceOf(FakeJob::class, unserialize($job->chained[2])); } + + public function testDispatchNextJobPreservesExplicitZeroIdentifiers(): void + { + $job = new FakeJob; + $job->chain([(new FakeJob)->onConnection('0')->onQueue('0')]); + $job->chainConnection = 'default-connection'; + $job->chainQueue = 'default-queue'; + + $dispatcher = m::mock(Dispatcher::class); + $dispatcher->shouldReceive('dispatch')->once()->with(m::on(function (FakeJob $next): bool { + $this->assertSame('0', $next->connection); + $this->assertSame('0', $next->queue); + + return true; + })); + Container::getInstance()->instance(Dispatcher::class, $dispatcher); + + $job->dispatchNextJobInChain(); + } + + public function testDispatchNextJobInheritsChainIdentifiersForEmptyStrings(): void + { + $job = new FakeJob; + $job->chain([(new FakeJob)->onConnection('')->onQueue('')]); + $job->chainConnection = 'default-connection'; + $job->chainQueue = 'default-queue'; + + $dispatcher = m::mock(Dispatcher::class); + $dispatcher->shouldReceive('dispatch')->once()->with(m::on(function (FakeJob $next): bool { + $this->assertSame('default-connection', $next->connection); + $this->assertSame('default-queue', $next->queue); + + return true; + })); + Container::getInstance()->instance(Dispatcher::class, $dispatcher); + + $job->dispatchNextJobInChain(); + } } class FakeJob @@ -180,6 +195,7 @@ enum ConnectionEnum: string enum IntConnectionEnum: int { + case Zero = 0; case Sqs = 1; case Redis = 2; } @@ -198,6 +214,7 @@ enum QueueEnum: string enum IntQueueEnum: int { + case Zero = 0; case Default = 1; case High = 2; } @@ -214,6 +231,11 @@ enum GroupEnum: string case Beta = 'beta'; } +enum IntGroupEnum: int +{ + case One = 1; +} + enum UnitGroupEnum { case Alpha; diff --git a/tests/Cache/CacheManagerTest.php b/tests/Cache/CacheManagerTest.php index 22939679b..01eccba2d 100644 --- a/tests/Cache/CacheManagerTest.php +++ b/tests/Cache/CacheManagerTest.php @@ -562,6 +562,23 @@ public function testZeroStoreNameCanBeResolvedFromEnumAndString(): void $this->assertNotSame($store, $cacheManager->store()); } + public function testEmptyStoreNameRemainsExplicit(): void + { + $cacheManager = new CacheManager($this->getApp([ + 'cache' => [ + 'default' => 'array', + 'stores' => [ + 'array' => ['driver' => 'array'], + ], + ], + ])); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Cache store [] is not defined.'); + + $cacheManager->store(''); + } + public function testEnumDriverCanBeResolved(): void { $app = $this->getApp([ diff --git a/tests/Cache/CacheRepositoryEnumTest.php b/tests/Cache/CacheRepositoryEnumTest.php index 2ab87fe8c..7c55c8f5a 100644 --- a/tests/Cache/CacheRepositoryEnumTest.php +++ b/tests/Cache/CacheRepositoryEnumTest.php @@ -5,13 +5,15 @@ namespace Hypervel\Tests\Cache; use Hypervel\Cache\ArrayStore; +use Hypervel\Cache\Events\CacheHit; use Hypervel\Cache\Repository; use Hypervel\Cache\TaggedCache; use Hypervel\Contracts\Cache\Store; use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Tests\TestCase; +use InvalidArgumentException; use Mockery as m; -use TypeError; +use stdClass; enum CacheRepositoryEnumTestKeyBackedEnum: string { @@ -21,6 +23,7 @@ enum CacheRepositoryEnumTestKeyBackedEnum: string enum CacheRepositoryEnumTestKeyIntBackedEnum: int { + case Zero = 0; case Counter = 1; case Stats = 2; } @@ -43,6 +46,12 @@ enum CacheRepositoryEnumTestTagUnitEnum case Exports; } +enum CacheRepositoryEnumTestTagIntBackedEnum: int +{ + case Zero = 0; + case Reports = 1; +} + class CacheRepositoryEnumTest extends TestCase { public function testGetWithBackedEnum(): void @@ -61,13 +70,94 @@ public function testGetWithUnitEnum(): void $this->assertSame('dashboard-data', $repo->get(CacheRepositoryEnumTestKeyUnitEnum::Dashboard)); } - public function testGetWithIntBackedEnumThrowsTypeError(): void + public function testGetWithIntBackedEnum(): void { $repo = $this->getRepository(); + $repo->getStore()->shouldReceive('get')->once()->with('1')->andReturn('counter-value'); + + $this->assertSame('counter-value', $repo->get(CacheRepositoryEnumTestKeyIntBackedEnum::Counter)); + } + + public function testZeroBackedEnumAndStringInteroperability(): void + { + $repo = new Repository(new ArrayStore); + + $repo->put(CacheRepositoryEnumTestKeyIntBackedEnum::Zero, 'zero-value', 60); + + $this->assertSame('zero-value', $repo->get('0')); + $this->assertSame('zero-value', $repo->get(CacheRepositoryEnumTestKeyIntBackedEnum::Zero)); + } + + public function testIntegerBackedEnumIdentifiersWorkAcrossRepositoryOperations(): void + { + $repo = new Repository(new ArrayStore); + + $this->assertTrue($repo->add(CacheRepositoryEnumTestKeyIntBackedEnum::Counter, 1, 60)); + $this->assertSame(2, $repo->increment(CacheRepositoryEnumTestKeyIntBackedEnum::Counter)); + $this->assertSame(1, $repo->decrement(CacheRepositoryEnumTestKeyIntBackedEnum::Counter)); + $this->assertTrue($repo->touch(CacheRepositoryEnumTestKeyIntBackedEnum::Counter, 60)); + $this->assertSame(1, $repo->pull(CacheRepositoryEnumTestKeyIntBackedEnum::Counter)); + + $this->assertSame('remembered', $repo->remember( + CacheRepositoryEnumTestKeyIntBackedEnum::Stats, + 60, + fn () => 'remembered' + )); + + $this->assertSame('flexible', $repo->flexible( + CacheRepositoryEnumTestKeyIntBackedEnum::Zero, + [60, 120], + fn () => 'flexible' + )); + + $this->assertSame('locked', $repo->withoutOverlapping( + CacheRepositoryEnumTestKeyIntBackedEnum::Zero, + fn () => 'locked' + )); + } + + public function testIntegerBackedEnumTypedGetterThrowsTheIntendedDiagnostic(): void + { + $repo = new Repository(new ArrayStore); + $repo->put(CacheRepositoryEnumTestKeyIntBackedEnum::Counter, new stdClass, 60); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Cache value for key [1] must be a string, object given.'); + + $repo->string(CacheRepositoryEnumTestKeyIntBackedEnum::Counter); + } + + public function testGetMultiplePreservesNumericEnumIdentifiersAndDefaults(): void + { + $repo = new Repository(new ArrayStore); + $repo->put('1', 'stored', 60); + + $this->assertSame([ + 1 => 'stored', + 0 => 'fallback', + ], $repo->getMultiple([ + CacheRepositoryEnumTestKeyIntBackedEnum::Counter, + CacheRepositoryEnumTestKeyIntBackedEnum::Zero, + ], 'fallback')); + } + + public function testArrayStoreManyReadsNumericStringKeys(): void + { + $store = new ArrayStore; + $store->put('0', 'zero', 60); + $store->put('1', 'one', 60); - // Int-backed enum causes TypeError because store expects string key - $this->expectException(TypeError::class); - $repo->get(CacheRepositoryEnumTestKeyIntBackedEnum::Counter); + $this->assertSame([ + 0 => 'zero', + 1 => 'one', + ], $store->many(['0', '1'])); + } + + public function testCacheEventsNormalizeIntegerBackedEnumKeys(): void + { + $event = new CacheHit('array', CacheRepositoryEnumTestKeyIntBackedEnum::Zero, 'value'); + + $this->assertSame('0', $event->key); } public function testHasWithBackedEnum(): void @@ -324,6 +414,18 @@ public function testTagsWithUnitEnumArray(): void $this->assertEquals(['Reports', 'Exports'], $tagged->getTags()->getNames()); } + public function testTagsWithIntegerBackedEnumArray(): void + { + $repo = new Repository(new ArrayStore); + + $tagged = $repo->tags([ + CacheRepositoryEnumTestTagIntBackedEnum::Zero, + CacheRepositoryEnumTestTagIntBackedEnum::Reports, + ]); + + $this->assertSame(['0', '1'], $tagged->getTags()->getNames()); + } + public function testTagsWithMixedEnumsAndStrings(): void { $repo = new Repository(new ArrayStore); diff --git a/tests/Cache/CacheStackStoreTagsTest.php b/tests/Cache/CacheStackStoreTagsTest.php index 50ecc19bc..92a3d855b 100644 --- a/tests/Cache/CacheStackStoreTagsTest.php +++ b/tests/Cache/CacheStackStoreTagsTest.php @@ -193,13 +193,13 @@ public function testTaggedRememberReadsPlainAndWritesTaggedOnMiss(): void $taggable = $this->anyModeTaggableStore(); $taggedCache = m::mock(TaggedCache::class); - $taggable->shouldReceive('get')->once()->with('key')->andReturnNull(); + $taggable->shouldReceive('get')->once()->with('0')->andReturnNull(); $taggable->shouldReceive('tags')->once()->with(['tag'])->andReturn($taggedCache); - $taggedCache->shouldReceive('put')->once()->andReturnTrue(); + $taggedCache->shouldReceive('put')->once()->with('0', m::type('array'), 60)->andReturnTrue(); $stack = new StackStore([$taggable]); - $this->assertSame('computed', $stack->tags(['tag'])->remember('key', 60, fn () => 'computed')); + $this->assertSame('computed', $stack->tags(['tag'])->remember(StackTaggedCacheKey::Zero, 60, fn () => 'computed')); } public function testTaggedRememberHitReadsPlainWithoutTaggedWrite(): void @@ -272,3 +272,8 @@ private function nonTaggableStore(): Store|m\MockInterface return m::mock(Store::class); } } + +enum StackTaggedCacheKey: int +{ + case Zero = 0; +} diff --git a/tests/Cache/CacheTaggedCacheTest.php b/tests/Cache/CacheTaggedCacheTest.php index 636b70f04..d0035fafc 100644 --- a/tests/Cache/CacheTaggedCacheTest.php +++ b/tests/Cache/CacheTaggedCacheTest.php @@ -11,7 +11,6 @@ use Hypervel\Cache\Repository; use Hypervel\Cache\TaggedCache; use Hypervel\Tests\TestCase; -use TypeError; enum TaggedCacheTestKeyStringEnum: string { @@ -21,6 +20,7 @@ enum TaggedCacheTestKeyStringEnum: string enum TaggedCacheTestKeyIntEnum: int { + case Zero = 0; case Key1 = 1; case Key2 = 2; } @@ -313,14 +313,14 @@ public function testIncrementAcceptsUnitEnum(): void $this->assertSame(11, $value); } - public function testIncrementWithIntBackedEnumThrowsTypeError(): void + public function testIncrementAcceptsIntegerBackedEnum(): void { $store = new ArrayStore; $taggableStore = $store->tags('bop'); + $taggableStore->put(TaggedCacheTestKeyIntEnum::Zero, 5, 10); - // Int-backed enum causes TypeError because itemKey() expects string - $this->expectException(TypeError::class); - $taggableStore->increment(TaggedCacheTestKeyIntEnum::Key1); + $this->assertSame(6, $taggableStore->increment(TaggedCacheTestKeyIntEnum::Zero)); + $this->assertSame(6, $taggableStore->get('0')); } public function testDecrementAcceptsStringBackedEnum(): void @@ -348,14 +348,14 @@ public function testDecrementAcceptsUnitEnum(): void $this->assertSame(19, $value); } - public function testDecrementWithIntBackedEnumThrowsTypeError(): void + public function testDecrementAcceptsIntegerBackedEnum(): void { $store = new ArrayStore; $taggableStore = $store->tags('bop'); + $taggableStore->put(TaggedCacheTestKeyIntEnum::Key1, 10, 10); - // Int-backed enum causes TypeError because itemKey() expects string - $this->expectException(TypeError::class); - $taggableStore->decrement(TaggedCacheTestKeyIntEnum::Key1); + $this->assertSame(9, $taggableStore->decrement(TaggedCacheTestKeyIntEnum::Key1)); + $this->assertSame(9, $taggableStore->get('1')); } private function getTestCacheStoreWithTagValues(): ArrayStore diff --git a/tests/Cache/ConcurrencyLimiterTest.php b/tests/Cache/ConcurrencyLimiterTest.php index f68f80f51..407e8c16b 100644 --- a/tests/Cache/ConcurrencyLimiterTest.php +++ b/tests/Cache/ConcurrencyLimiterTest.php @@ -241,6 +241,23 @@ function () { $this->assertSame('failed', $result); } + public function testFunnelIntegerBackedEnumSharesKeyWithStringEquivalent(): void + { + (new ConcurrencyLimiterMockThatDoesntRelease($this->repository->getStore(), '0', 1, 5))->block(2, function () { + }); + + $result = $this->repository->funnel(ConcurrencyLimiterIntegerBackedEnum::Zero) + ->limit(1) + ->releaseAfter(5) + ->block(0) + ->then( + fn () => 'success', + fn () => 'failed' + ); + + $this->assertSame('failed', $result); + } + public function testFunnelThrowsExceptionWhenStoreDoesNotSupportLocks(): void { $store = $this->createStub(Store::class); @@ -445,6 +462,11 @@ enum ConcurrencyLimiterBackedEnum: string case TestFunnel = 'test-funnel'; } +enum ConcurrencyLimiterIntegerBackedEnum: int +{ + case Zero = 0; +} + enum ConcurrencyLimiterUnitEnum { case TestFunnel; diff --git a/tests/Cache/Redis/AllTaggedCacheTest.php b/tests/Cache/Redis/AllTaggedCacheTest.php index 8ff2b08b2..aa23f6e3b 100644 --- a/tests/Cache/Redis/AllTaggedCacheTest.php +++ b/tests/Cache/Redis/AllTaggedCacheTest.php @@ -679,7 +679,7 @@ public function testRememberNormalizesEnumKeyForRedisAndEvents(): void { $connection = $this->mockConnection(); - $key = hash('xxh128', '_all:tag:users:entries') . ':profile'; + $key = hash('xxh128', '_all:tag:users:entries') . ':0'; $expectedScore = now()->timestamp + 60; $connection->shouldReceive('get')->once()->with("prefix:{$key}")->andReturnNull(); @@ -707,9 +707,9 @@ public function testRememberNormalizesEnumKeyForRedisAndEvents(): void $keyWritten = array_values(array_filter($captured, fn (object $event) => $event instanceof KeyWritten))[0] ?? null; $this->assertNotNull($cacheMissed); - $this->assertSame('profile', $cacheMissed->key); + $this->assertSame('0', $cacheMissed->key); $this->assertNotNull($keyWritten); - $this->assertSame('profile', $keyWritten->key); + $this->assertSame('0', $keyWritten->key); } /** @@ -913,7 +913,7 @@ public function testRememberForeverNormalizesEnumKeyForRedisAndEvents(): void { $connection = $this->mockConnection(); - $key = hash('xxh128', '_all:tag:config:entries') . ':settings'; + $key = hash('xxh128', '_all:tag:config:entries') . ':1'; $connection->shouldReceive('get') ->once() @@ -938,7 +938,7 @@ public function testRememberForeverNormalizesEnumKeyForRedisAndEvents(): void $cacheHit = array_values(array_filter($captured, fn (object $event) => $event instanceof CacheHit))[0] ?? null; $this->assertNotNull($cacheHit); - $this->assertSame('settings', $cacheHit->key); + $this->assertSame('1', $cacheHit->key); } /** @@ -1139,8 +1139,8 @@ public function testFlexibleNullableReturnsNullOnFreshSentinelHit(): void } } -enum AllTaggedCacheTestKey: string +enum AllTaggedCacheTestKey: int { - case Profile = 'profile'; - case Settings = 'settings'; + case Profile = 0; + case Settings = 1; } diff --git a/tests/Cache/Redis/AnyTaggedCacheTest.php b/tests/Cache/Redis/AnyTaggedCacheTest.php index 1c865ff40..bf13994ed 100644 --- a/tests/Cache/Redis/AnyTaggedCacheTest.php +++ b/tests/Cache/Redis/AnyTaggedCacheTest.php @@ -646,14 +646,14 @@ public function testRememberNormalizesEnumKeyForRedisAndEvents(): void $connection->shouldReceive('get') ->once() - ->with('prefix:profile') + ->with('prefix:0') ->andReturnNull(); $connection->shouldReceive('evalWithShaCache') ->once() ->withArgs(function (string $script, array $keys, array $args): bool { - $this->assertSame('prefix:profile', $keys[0]); - $this->assertSame('profile', $args[5]); + $this->assertSame('prefix:0', $keys[0]); + $this->assertSame('0', $args[5]); return true; }) @@ -678,9 +678,9 @@ public function testRememberNormalizesEnumKeyForRedisAndEvents(): void $keyWritten = array_values(array_filter($captured, fn (object $event) => $event instanceof KeyWritten))[0] ?? null; $this->assertNotNull($cacheMissed); - $this->assertSame('profile', $cacheMissed->key); + $this->assertSame('0', $cacheMissed->key); $this->assertNotNull($keyWritten); - $this->assertSame('profile', $keyWritten->key); + $this->assertSame('0', $keyWritten->key); } public function testRememberNullableStoresAndReturnsNonNullValue(): void @@ -846,7 +846,7 @@ public function testRememberForeverNormalizesEnumKeyForRedisAndEvents(): void $connection->shouldReceive('get') ->once() - ->with('prefix:settings') + ->with('prefix:1') ->andReturn(serialize('cached_settings')); $captured = []; @@ -867,7 +867,7 @@ public function testRememberForeverNormalizesEnumKeyForRedisAndEvents(): void $cacheHit = array_values(array_filter($captured, fn (object $event) => $event instanceof CacheHit))[0] ?? null; $this->assertNotNull($cacheHit); - $this->assertSame('settings', $cacheHit->key); + $this->assertSame('1', $cacheHit->key); } /** @@ -1129,8 +1129,8 @@ public function testFlexibleNullableThrowsBadMethodCallException(): void } } -enum AnyTaggedCacheTestKey: string +enum AnyTaggedCacheTestKey: int { - case Profile = 'profile'; - case Settings = 'settings'; + case Profile = 0; + case Settings = 1; } diff --git a/tests/Cache/Redis/Console/BenchmarkCommandTest.php b/tests/Cache/Redis/Console/BenchmarkCommandTest.php new file mode 100644 index 000000000..1664a4d51 --- /dev/null +++ b/tests/Cache/Redis/Console/BenchmarkCommandTest.php @@ -0,0 +1,119 @@ +mockCacheStore('0'); + + $command = $this->createCommand(); + + $this->assertSame(Command::SUCCESS, $command->run( + new ArrayInput(['--store' => '0']), + new NullOutput, + )); + $this->assertSame('0', $command->storeName()); + } + + public function testSetupDetectsStoreForEmptyOption(): void + { + $this->app->make('config')->set('cache.stores', [ + 'redis' => ['driver' => 'redis'], + ]); + $this->mockCacheStore('redis'); + + $command = $this->createCommand(); + + $this->assertSame(Command::SUCCESS, $command->run( + new ArrayInput(['--store' => '']), + new NullOutput, + )); + $this->assertSame('redis', $command->storeName()); + } + + public function testSetupCastsDetectedNumericStoreNameToString(): void + { + $this->app->make('config')->set('cache.stores', [ + 0 => ['driver' => 'redis'], + ]); + $this->mockCacheStore('0'); + + $command = $this->createCommand(); + + $this->assertSame(Command::SUCCESS, $command->run( + new ArrayInput([]), + new NullOutput, + )); + $this->assertSame('0', $command->storeName()); + } + + public function testSetupFailsGracefullyWhenNoRedisStoreCanBeDetected(): void + { + $this->app->make('config')->set('cache.stores', [ + 'file' => ['driver' => 'file'], + ]); + + $cache = m::mock(CacheContract::class); + $cache->shouldNotReceive('store'); + $this->app->instance(CacheContract::class, $cache); + + $command = $this->createCommand(); + $output = new BufferedOutput; + + $this->assertSame(Command::FAILURE, $command->run(new ArrayInput([]), $output)); + $this->assertStringContainsString( + 'Could not detect a cache store using the "redis" driver.', + $output->fetch(), + ); + } + + private function mockCacheStore(string $name): void + { + $store = m::mock(RedisStore::class); + + $repository = m::mock(Repository::class); + $repository->shouldReceive('getStore')->once()->andReturn($store); + $repository->shouldReceive('get')->once()->with('test')->andReturnNull(); + + $cache = m::mock(CacheContract::class); + $cache->shouldReceive('store')->twice()->with($name)->andReturn($repository); + + $this->app->instance(CacheContract::class, $cache); + } + + private function createCommand(): TestableBenchmarkCommand + { + $command = new TestableBenchmarkCommand; + $command->setHypervel($this->app); + + return $command; + } +} + +class TestableBenchmarkCommand extends BenchmarkCommand +{ + public function handle(): int + { + return $this->setup() ? self::SUCCESS : self::FAILURE; + } + + public function storeName(): string + { + return $this->storeName; + } +} diff --git a/tests/Cache/Redis/Console/DoctorCommandTest.php b/tests/Cache/Redis/Console/DoctorCommandTest.php index e9a0b0d86..733416cd9 100644 --- a/tests/Cache/Redis/Console/DoctorCommandTest.php +++ b/tests/Cache/Redis/Console/DoctorCommandTest.php @@ -210,7 +210,7 @@ public function testDoctorDetectsRedisStoreFromConfig(): void $command = new DoctorCommand; $command->setHypervel($this->app); $output = new BufferedOutput; - $command->run(new ArrayInput([]), $output); + $command->run(new ArrayInput(['--store' => '']), $output); // Verify it detected the redis store (case-insensitive check) $outputText = $output->fetch(); @@ -218,7 +218,7 @@ public function testDoctorDetectsRedisStoreFromConfig(): void $this->assertStringContainsString('Tag Mode: any', $outputText); } - public function testDoctorUsesSpecifiedStore(): void + public function testDoctorUsesZeroNamedStore(): void { if (! extension_loaded('redis') || ! version_compare(phpversion('redis'), '6.3.0', '>=')) { @@ -233,7 +233,7 @@ public function testDoctorUsesSpecifiedStore(): void ->with('cache.default', 'file') ->andReturn('file'); $config->shouldReceive('get') - ->with('cache.stores.custom-redis.connection', 'default') + ->with('cache.stores.0.connection', 'default') ->andReturn('custom'); $this->app->instance('config', $config); @@ -256,9 +256,8 @@ public function testDoctorUsesSpecifiedStore(): void $repository->shouldReceive('getStore')->andReturn($store); $cacheManager = m::mock(CacheManager::class); - // Should use the specified store name (called multiple times during command) $cacheManager->shouldReceive('store') - ->with('custom-redis') + ->with('0') ->andReturn($repository); $this->app->instance(CacheContract::class, $cacheManager); @@ -280,11 +279,10 @@ protected function runCleanupVerification(DoctorContext $context): void }; $command->setHypervel($this->app); $output = new BufferedOutput; - $command->run(new ArrayInput(['--store' => 'custom-redis']), $output); + $command->run(new ArrayInput(['--store' => '0']), $output); - // Verify the custom store was used $outputText = $output->fetch(); - $this->assertStringContainsString('custom-redis', $outputText); + $this->assertStringContainsString('Testing cache store: 0', $outputText); } public function testDoctorRunsChecksWhileRedisConnectionIsBorrowed(): void diff --git a/tests/Console/Scheduling/EventTest.php b/tests/Console/Scheduling/EventTest.php index e81a8a495..dcfb17b33 100644 --- a/tests/Console/Scheduling/EventTest.php +++ b/tests/Console/Scheduling/EventTest.php @@ -19,7 +19,6 @@ use Hypervel\Tests\TestCase; use Mockery as m; use Symfony\Component\Process\Process; -use TypeError; enum EventTestTimezoneStringEnum: string { @@ -300,13 +299,13 @@ public function testTimezoneAcceptsUnitEnum(): void $this->assertSame('UTC', $event->timezone); } - public function testTimezoneWithIntBackedEnumThrowsTypeError(): void + public function testTimezoneAcceptsIntBackedEnum(): void { $event = new Event(m::mock(EventMutex::class), 'php -i'); - // Int-backed enum causes TypeError because $timezone property is DateTimeZone|string|null - $this->expectException(TypeError::class); $event->timezone(EventTestTimezoneIntEnum::Zone1); + + $this->assertSame('1', $event->timezone); } public function testTimezoneAcceptsDateTimeZoneObject(): void diff --git a/tests/Console/Scheduling/ScheduleTest.php b/tests/Console/Scheduling/ScheduleTest.php index a80e6ced8..c87248e68 100644 --- a/tests/Console/Scheduling/ScheduleTest.php +++ b/tests/Console/Scheduling/ScheduleTest.php @@ -22,7 +22,6 @@ use Mockery as m; use Mockery\MockInterface; use PHPUnit\Framework\Attributes\DataProvider; -use TypeError; enum ScheduleTestQueueStringEnum: string { @@ -152,12 +151,10 @@ public function testJobAcceptsUnitEnumForQueueAndConnection(): void self::assertSame(JobToTestWithSchedule::class, $scheduledJob->description); } - public function testJobWithIntBackedEnumStoresIntValue(): void + public function testJobAcceptsIntegerBackedEnumForQueueAndConnection(): void { $schedule = new Schedule; - // Int-backed enum values are stored as-is (no cast to string) - // TypeError will occur when the job is dispatched and dispatchToQueue() receives int $scheduledJob = $schedule->job( JobToTestWithSchedule::class, ScheduleTestQueueIntEnum::Priority1, @@ -182,18 +179,18 @@ public function testUseCacheAcceptsStringBackedEnum(): void $schedule->useCache(ScheduleTestCacheStoreEnum::Redis); } - public function testUseCacheWithIntBackedEnumThrowsTypeError(): void + public function testUseCacheAcceptsIntegerBackedEnum(): void { $eventMutex = m::mock(EventMutex::class, CacheAware::class); + $eventMutex->shouldReceive('useStore')->once()->with('1'); + $schedulingMutex = m::mock(SchedulingMutex::class, CacheAware::class); + $schedulingMutex->shouldReceive('useStore')->once()->with('1'); $this->container->instance(EventMutex::class, $eventMutex); $this->container->instance(SchedulingMutex::class, $schedulingMutex); $schedule = new Schedule; - - // TypeError is thrown when useStore() receives int instead of string - $this->expectException(TypeError::class); $schedule->useCache(ScheduleTestCacheStoreIntEnum::Store1); } diff --git a/tests/Container/ContextualAttributeBindingTest.php b/tests/Container/ContextualAttributeBindingTest.php index 4bc28d3c4..3f0fdb3d5 100644 --- a/tests/Container/ContextualAttributeBindingTest.php +++ b/tests/Container/ContextualAttributeBindingTest.php @@ -178,6 +178,7 @@ public function testCacheAttribute() $manager->shouldReceive('store')->with('bar')->andReturn(m::mock(CacheRepository::class)); $manager->shouldReceive('store')->with(CacheStoreUnitEnum::unit)->andReturn(m::mock(CacheRepository::class)); $manager->shouldReceive('store')->with(CacheStoreBackedEnum::Backed)->andReturn(m::mock(CacheRepository::class)); + $manager->shouldReceive('store')->with(CacheStoreIntegerBackedEnum::Zero)->andReturn(m::mock(CacheRepository::class)); $manager->shouldReceive('memo')->with('foo')->andReturn(m::mock(CacheRepository::class)); $manager->shouldReceive('memo')->with('bar')->andReturn(m::mock(CacheRepository::class)); @@ -208,6 +209,7 @@ public function testDatabaseAttribute() $manager = m::mock(DatabaseManager::class); $manager->shouldReceive('connection')->with('foo')->andReturn(m::mock(Connection::class)); $manager->shouldReceive('connection')->with('bar')->andReturn(m::mock(Connection::class)); + $manager->shouldReceive('connection')->with(DatabaseConnectionIntegerBackedEnum::Zero)->andReturn(m::mock(Connection::class)); return $manager; }); @@ -224,6 +226,7 @@ public function testAuthAttribute() $manager->shouldReceive('guard')->with('bar')->andReturn(m::mock(GuardContract::class)); $manager->shouldReceive('guard')->with(AuthGuardUnitEnum::unit)->andReturn(m::mock(GuardContract::class)); $manager->shouldReceive('guard')->with(AuthGuardBackedEnum::Backed)->andReturn(m::mock(GuardContract::class)); + $manager->shouldReceive('guard')->with(AuthGuardIntegerBackedEnum::Zero)->andReturn(m::mock(GuardContract::class)); return $manager; }); @@ -329,6 +332,7 @@ public function testStorageAttribute() $manager->shouldReceive('disk')->with('bar')->andReturn(m::mock(Filesystem::class)); $manager->shouldReceive('disk')->with(StorageDiskUnitEnum::unit)->andReturn(m::mock(Filesystem::class)); $manager->shouldReceive('disk')->with(StorageDiskBackedEnum::Backed)->andReturn(m::mock(Filesystem::class)); + $manager->shouldReceive('disk')->with(StorageDiskIntegerBackedEnum::Zero)->andReturn(m::mock(Filesystem::class)); return $manager; }); @@ -450,6 +454,11 @@ enum StorageDiskBackedEnum: string case Backed = 'backed'; } +enum StorageDiskIntegerBackedEnum: int +{ + case Zero = 0; +} + enum AuthGuardUnitEnum { case unit; @@ -460,6 +469,11 @@ enum AuthGuardBackedEnum: string case Backed = 'backed'; } +enum AuthGuardIntegerBackedEnum: int +{ + case Zero = 0; +} + enum CacheStoreUnitEnum { case unit; @@ -470,6 +484,16 @@ enum CacheStoreBackedEnum: string case Backed = 'backed'; } +enum CacheStoreIntegerBackedEnum: int +{ + case Zero = 0; +} + +enum DatabaseConnectionIntegerBackedEnum: int +{ + case Zero = 0; +} + enum LogChannelUnitEnum { case unit_channel; @@ -630,6 +654,8 @@ public function __construct( CacheRepository $unit, #[Cache(CacheStoreBackedEnum::Backed)] CacheRepository $backed, + #[Cache(CacheStoreIntegerBackedEnum::Zero)] + CacheRepository $integerBacked, #[Cache('foo', memo: true)] CacheRepository $fooMemoized, #[Cache('bar', memo: true)] @@ -661,8 +687,14 @@ public function __construct(#[Context('bar', hidden: true)] public string $foo) final class DatabaseTest { - public function __construct(#[Database('foo')] Connection $foo, #[Database('bar')] Connection $bar) - { + public function __construct( + #[Database('foo')] + Connection $foo, + #[Database('bar')] + Connection $bar, + #[Database(DatabaseConnectionIntegerBackedEnum::Zero)] + Connection $integerBacked, + ) { } } @@ -677,6 +709,8 @@ public function __construct( GuardContract $unit, #[Auth(AuthGuardBackedEnum::Backed)] GuardContract $backed, + #[Auth(AuthGuardIntegerBackedEnum::Zero)] + GuardContract $integerBacked, ) { } } @@ -732,6 +766,8 @@ public function __construct( Filesystem $unit, #[Storage(StorageDiskBackedEnum::Backed)] Filesystem $backed, + #[Storage(StorageDiskIntegerBackedEnum::Zero)] + Filesystem $integerBacked, ) { } } diff --git a/tests/Cookie/CookieJarTest.php b/tests/Cookie/CookieJarTest.php index 8725eff60..4849bf3ca 100644 --- a/tests/Cookie/CookieJarTest.php +++ b/tests/Cookie/CookieJarTest.php @@ -11,8 +11,8 @@ use Hypervel\Tests\TestCase; use InvalidArgumentException; use Mockery as m; +use PHPUnit\Framework\Attributes\TestWith; use Symfony\Component\HttpFoundation\Cookie; -use TypeError; enum CookieJarTestNameEnum: string { @@ -28,6 +28,7 @@ enum CookieJarTestNameUnitEnum enum CookieJarTestNameIntEnum: int { + case Zero = 0; case First = 1; } @@ -451,12 +452,33 @@ public function testForgetAcceptsUnitEnum() $this->assertNull($cookie->getValue()); } - public function testMakeWithIntBackedEnumThrowsTypeError() + public function testIntegerBackedEnumNamesAreNormalizedAcrossRequestCreationAndQueuePaths(): void { - $this->expectException(TypeError::class); + $request = m::mock(Request::class); + $request->shouldReceive('cookie')->once()->with('0', null)->andReturn('request-value'); + RequestContext::set($request); - $manager = new CookieJar; - $cookie = $manager->make(CookieJarTestNameIntEnum::First, 'value'); - $cookie->getName(); // TypeError thrown here + $jar = new CookieJar; + + $this->assertSame('request-value', $jar->get(CookieJarTestNameIntEnum::Zero)); + + $cookie = $jar->make(CookieJarTestNameIntEnum::First, 'queued-value'); + $this->assertSame('1', $cookie->getName()); + + $jar->queue($cookie); + $this->assertTrue($jar->hasQueued(CookieJarTestNameIntEnum::First)); + $this->assertSame($cookie, $jar->queued(CookieJarTestNameIntEnum::First)); + + $jar->unqueue(CookieJarTestNameIntEnum::First); + $this->assertFalse($jar->hasQueued(CookieJarTestNameIntEnum::First)); + } + + #[TestWith(['0'])] + #[TestWith([CookieJarTestNameIntEnum::Zero])] + public function testIntegerBackedEnumZeroHasTheSameCreationValidityAsStringZero(CookieJarTestNameIntEnum|string $name): void + { + $this->expectException(InvalidArgumentException::class); + + (new CookieJar)->make($name, 'value'); } } diff --git a/tests/Database/DatabaseConnectionTest.php b/tests/Database/DatabaseConnectionTest.php index 6a0d8ad84..65f7198cd 100755 --- a/tests/Database/DatabaseConnectionTest.php +++ b/tests/Database/DatabaseConnectionTest.php @@ -474,6 +474,17 @@ public function testFromCreatesNewQueryBuilder() $this->assertSame('users', $builder->from); } + public function testTableNormalizesIntegerBackedEnumName(): void + { + $connection = $this->getMockConnection(); + $connection->setQueryGrammar(m::mock(Grammar::class)); + $connection->setPostProcessor(m::mock(Processor::class)); + + $builder = $connection->table(DatabaseTableName::Zero); + + $this->assertSame('0', $builder->from); + } + public function testPrepareBindings() { $date = m::mock(DateTime::class); @@ -888,6 +899,11 @@ protected function getMockConnection($methods = [], $pdo = null) } } +enum DatabaseTableName: int +{ + case Zero = 0; +} + class PDOStub extends PDO { public function __construct() diff --git a/tests/Database/DatabaseEloquentFactoryTest.php b/tests/Database/DatabaseEloquentFactoryTest.php index 067534786..d9f7fb858 100644 --- a/tests/Database/DatabaseEloquentFactoryTest.php +++ b/tests/Database/DatabaseEloquentFactoryTest.php @@ -51,6 +51,13 @@ protected function setUp(): void Factory::expandRelationshipsByDefault(); } + public function testIntegerBackedConnectionEnumIsExposedAsAString(): void + { + $factory = (new UserFactory)->connection(FactoryConnectionName::Zero); + + $this->assertSame('0', $factory->getConnectionName()); + } + /** * Setup the database schema. */ @@ -1179,6 +1186,11 @@ public function definition(): array } } +enum FactoryConnectionName: int +{ + case Zero = 0; +} + class User extends Eloquent { use HasFactory; diff --git a/tests/Database/DatabaseEloquentModelAttributesTest.php b/tests/Database/DatabaseEloquentModelAttributesTest.php index 92e41d90e..f0221b79d 100644 --- a/tests/Database/DatabaseEloquentModelAttributesTest.php +++ b/tests/Database/DatabaseEloquentModelAttributesTest.php @@ -163,6 +163,13 @@ public function testConnectionAttributeWithBackedEnum(): void $this->assertSame('secondary', $model->getConnectionName()); } + public function testConnectionAttributeWithIntegerBackedEnum(): void + { + $model = new ModelWithIntegerBackedEnumConnectionAttribute; + + $this->assertSame('0', $model->getConnectionName()); + } + public function testTimestampsAttribute(): void { $model = new ModelWithTimestampsFalseAttribute; @@ -498,6 +505,11 @@ enum ConnectionBackedEnum: string case Secondary = 'secondary'; } +enum ConnectionIntegerBackedEnum: int +{ + case Zero = 0; +} + #[Table('custom_table_name')] class ModelWithTableAttribute extends Model { @@ -574,6 +586,11 @@ class ModelWithBackedEnumConnectionAttribute extends Model { } +#[Connection(ConnectionIntegerBackedEnum::Zero)] +class ModelWithIntegerBackedEnumConnectionAttribute extends Model +{ +} + #[Table(timestamps: false)] class ModelWithTimestampsFalseAttribute extends Model { diff --git a/tests/Database/DatabaseEloquentModelTest.php b/tests/Database/DatabaseEloquentModelTest.php index 157adde86..a6bab0474 100755 --- a/tests/Database/DatabaseEloquentModelTest.php +++ b/tests/Database/DatabaseEloquentModelTest.php @@ -1349,6 +1349,15 @@ public function testConnectionEnums(string|UnitEnum $connectionName) $this->assertSame($mockConnection, $model->getConnection()); } + public function testIntegerBackedConnectionEnumIsExposedAsAString(): void + { + $model = new ModelStub; + + $model->setConnection(IntegerBackedConnectionName::Zero); + + $this->assertSame('0', $model->getConnectionName()); + } + public function testToArray() { $model = new ModelStub; @@ -4987,3 +4996,8 @@ enum ConnectionNameBacked: string case Foo = 'Foo'; case Bar = 'Bar'; } + +enum IntegerBackedConnectionName: int +{ + case Zero = 0; +} diff --git a/tests/Database/DatabaseManagerTest.php b/tests/Database/DatabaseManagerTest.php index 257e45515..d3bbb779e 100644 --- a/tests/Database/DatabaseManagerTest.php +++ b/tests/Database/DatabaseManagerTest.php @@ -86,6 +86,63 @@ public function testDisconnectWithNamedNonPooledConnection() $this->assertNotNull($default->getRawPdo()); } + public function testIntegerBackedEnumConnectionNameIsNormalizedAcrossManagerLifecycle(): void + { + $this->db->addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ], '0'); + + $manager = $this->db->getDatabaseManager(); + $connection = $manager->connection(DatabaseManagerConnectionName::Zero); + + $this->assertSame('0', $connection->getName()); + + $manager->disconnect(DatabaseManagerConnectionName::Zero); + $this->assertNull($connection->getRawPdo()); + + $reconnected = $manager->reconnect(DatabaseManagerConnectionName::Zero); + $this->assertSame($connection, $reconnected); + $this->assertNotNull($reconnected->getRawPdo()); + + $manager->purge(DatabaseManagerConnectionName::Zero); + + $this->assertNotSame($connection, $manager->connection(DatabaseManagerConnectionName::Zero)); + } + + public function testEmptyConnectionNamesUseTheDefaultAcrossManagerLifecycle(): void + { + $manager = $this->db->getDatabaseManager(); + $connection = $manager->connection(); + + $this->assertSame($connection, $manager->connection('')); + + $manager->disconnect(''); + $this->assertNull($connection->getRawPdo()); + + $this->assertSame($connection, $manager->reconnect('')); + + $manager->purge(''); + + $this->assertNotSame($connection, $manager->connection()); + } + + public function testUsingConnectionNormalizesIntegerBackedEnumAndRestoresTheDefault(): void + { + $this->db->addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ], '0'); + + $manager = $this->db->getDatabaseManager(); + + $this->assertSame('0', $manager->usingConnection( + DatabaseManagerConnectionName::Zero, + fn (): ?string => $manager->connection()->getName() + )); + $this->assertSame('default', $manager->connection()->getName()); + } + public function testDisconnectWithNoExistingConnectionDoesNotError() { $manager = $this->db->getDatabaseManager(); @@ -368,3 +425,8 @@ protected function createSqliteUsersDatabase(string $path, string $name): void $statement->execute([$name]); } } + +enum DatabaseManagerConnectionName: int +{ + case Zero = 0; +} diff --git a/tests/Database/DatabaseMigratorConnectionRoutingTest.php b/tests/Database/DatabaseMigratorConnectionRoutingTest.php index e5aba7112..94cf52b74 100644 --- a/tests/Database/DatabaseMigratorConnectionRoutingTest.php +++ b/tests/Database/DatabaseMigratorConnectionRoutingTest.php @@ -234,7 +234,7 @@ public function testSetConnectionDoesNotSwapWhenNoMigrationsConnectionKey() ); } - public function testResolveConnectionUsesStoredConnectionWhenArgumentIsNull() + public function testResolveConnectionUsesStoredConnectionWhenArgumentIsNullOrEmpty() { $this->bindConfig([ 'pgsql-pooled' => ['driver' => 'pgsql', 'migrations_connection' => 'pgsql'], @@ -246,12 +246,34 @@ public function testResolveConnectionUsesStoredConnectionWhenArgumentIsNull() $resolvedConnection = m::mock(Connection::class); $repository->shouldReceive('setSource')->once()->with('pgsql'); - $resolver->shouldReceive('connection')->once()->with('pgsql')->andReturn($resolvedConnection); + $resolver->shouldReceive('connection')->twice()->with('pgsql')->andReturn($resolvedConnection); $migrator = new Migrator($repository, $resolver, new Filesystem); $migrator->setConnection('pgsql-pooled'); $this->assertSame($resolvedConnection, $migrator->resolveConnection(null)); + $this->assertSame($resolvedConnection, $migrator->resolveConnection('')); + } + + public function testResolveConnectionPreservesExplicitZeroConnection(): void + { + $this->bindConfig( + connections: [ + '0' => ['driver' => 'pgsql'], + 'pgsql' => ['driver' => 'pgsql'], + ], + default: 'pgsql', + ); + + $resolver = m::mock(Resolver::class); + $repository = m::mock(MigrationRepositoryInterface::class); + $resolvedConnection = m::mock(Connection::class); + + $resolver->shouldReceive('connection')->once()->with('0')->andReturn($resolvedConnection); + + $migrator = new Migrator($repository, $resolver, new Filesystem); + + $this->assertSame($resolvedConnection, $migrator->resolveConnection('0')); } public function testResolveConnectionSwapsPerMigrationConnectionOverride() diff --git a/tests/Database/DatabaseMonitorCommandTest.php b/tests/Database/DatabaseMonitorCommandTest.php index 2055459ac..413710ca5 100644 --- a/tests/Database/DatabaseMonitorCommandTest.php +++ b/tests/Database/DatabaseMonitorCommandTest.php @@ -31,4 +31,18 @@ public function testMonitorCommandFallsBackToDefaultConnectionNameWhenDatabaseDe $this->assertSame(0, $command->run(new ArrayInput([]), new NullOutput)); } + + public function testMonitorCommandPreservesConnectionNameZero(): void + { + $connection = m::mock(ConnectionInterface::class); + $connection->shouldReceive('threadCount')->once()->andReturn(1); + + $resolver = m::mock(ConnectionResolverInterface::class); + $resolver->shouldReceive('connection')->once()->with('0')->andReturn($connection); + + $command = new MonitorCommand($resolver, m::mock(Dispatcher::class)); + $command->setHypervel($this->app); + + $this->assertSame(0, $command->run(new ArrayInput(['--databases' => '0']), new NullOutput)); + } } diff --git a/tests/Database/DatabaseSchemaBlueprintTest.php b/tests/Database/DatabaseSchemaBlueprintTest.php index 53f365365..374710df7 100755 --- a/tests/Database/DatabaseSchemaBlueprintTest.php +++ b/tests/Database/DatabaseSchemaBlueprintTest.php @@ -87,6 +87,19 @@ public function testDropIndexDefaultNames() $this->assertSame('geo_coordinates_spatialindex', $commands[0]->index); } + public function testIndexCommandsPreserveExplicitIndexNameZero(): void + { + $blueprint = $this->getBlueprint(table: 'users'); + $blueprint->index('email', '0'); + + $this->assertSame('0', $blueprint->getCommands()[0]->index); + + $blueprint = $this->getBlueprint(table: 'users'); + $blueprint->dropMorphs('imageable', '0'); + + $this->assertSame('0', $blueprint->getCommands()[0]->index); + } + public function testDropIndexDefaultNamesWhenPrefixSupplied() { $blueprint = $this->getBlueprint(table: 'users', prefix: 'prefix_'); diff --git a/tests/Database/SeedCommandTest.php b/tests/Database/SeedCommandTest.php index df53fb062..0c052f35d 100644 --- a/tests/Database/SeedCommandTest.php +++ b/tests/Database/SeedCommandTest.php @@ -186,6 +186,63 @@ public function testHandleWithoutDatabaseOptionUsesResolverDefault() $command->run(new ArrayInput(['--force' => true]), new NullOutput); } + public function testHandlePreservesZeroDatabaseOption(): void + { + $seeder = m::mock(Seeder::class); + $seeder->shouldReceive('setContainer')->once()->andReturnSelf(); + $seeder->shouldReceive('setCommand')->once()->andReturnSelf(); + $seeder->shouldReceive('__invoke')->once()->andReturnUsing(function (): void { + Assert::assertSame( + '0', + CoroutineContext::get(ConnectionResolver::DEFAULT_CONNECTION_CONTEXT_KEY), + ); + }); + + $resolver = m::mock(ConnectionResolverInterface::class); + + $app = new ApplicationDatabaseSeedStub([ + ConnectionResolverInterface::class => $resolver, + 'DatabaseSeeder' => $seeder, + ]); + + $command = new SeedCommand($resolver); + $command->setHypervel($app); + + $command->run( + new ArrayInput(['--force' => true, '--database' => '0']), + new NullOutput, + ); + } + + public function testHandleWithEmptyDatabaseOptionUsesResolverDefault(): void + { + $seeder = m::mock(Seeder::class); + $seeder->shouldReceive('setContainer')->once()->andReturnSelf(); + $seeder->shouldReceive('setCommand')->once()->andReturnSelf(); + $seeder->shouldReceive('__invoke')->once()->andReturnUsing(function (): void { + Assert::assertSame( + 'tenant_reporting', + CoroutineContext::get(ConnectionResolver::DEFAULT_CONNECTION_CONTEXT_KEY), + ); + }); + + $resolver = m::mock(ConnectionResolverInterface::class); + $resolver->shouldReceive('getDefaultConnection')->once()->andReturn('tenant_reporting'); + + $app = new ApplicationDatabaseSeedStub([ + ConnectionResolverInterface::class => $resolver, + 'DatabaseSeeder' => $seeder, + ]); + + $command = new SeedCommand($resolver); + $command->setHypervel($app); + + $command->run( + new ArrayInput(['--force' => true, '--database' => '']), + new NullOutput, + ); + } + public function testHandleDoesNotMutateConfigDatabaseDefault() { // Regression: db:seed must use Context, not config mutation. config diff --git a/tests/Events/QueuedEventsTest.php b/tests/Events/QueuedEventsTest.php index 47cc614d7..fddf53661 100644 --- a/tests/Events/QueuedEventsTest.php +++ b/tests/Events/QueuedEventsTest.php @@ -114,6 +114,20 @@ public function testQueueIsSetByGetConnection() $d->dispatch('some.event', ['foo', 'bar']); } + public function testEnumQueueAndConnectionResultsAreNormalizedBeforeDispatch(): void + { + $dispatcher = new Dispatcher; + $factory = m::mock(QueueFactory::class); + $queue = m::mock(Queue::class); + + $factory->shouldReceive('connection')->once()->with('0')->andReturn($queue); + $queue->shouldReceive('pushOn')->once()->with('1', m::type(CallQueuedListener::class)); + + $dispatcher->setQueueResolver(fn () => $factory); + $dispatcher->listen('some.event', TestDispatcherGetEnumQueueAndConnection::class . '@handle'); + $dispatcher->dispatch('some.event', ['foo', 'bar']); + } + public function testDelayIsSetByWithDelay() { $d = new Dispatcher; @@ -734,6 +748,33 @@ public function viaConnection() } } +class TestDispatcherGetEnumQueueAndConnection implements ShouldQueue +{ + public function handle(): void + { + } + + public function viaConnection(): QueuedConnectionIdentifier + { + return QueuedConnectionIdentifier::Zero; + } + + public function viaQueue(): QueuedQueueIdentifier + { + return QueuedQueueIdentifier::Primary; + } +} + +enum QueuedConnectionIdentifier: int +{ + case Zero = 0; +} + +enum QueuedQueueIdentifier: int +{ + case Primary = 1; +} + class TestDispatcherGetDelay implements ShouldQueue { public int $delay = 10; diff --git a/tests/Foundation/FoundationAuthorizesRequestsTraitTest.php b/tests/Foundation/FoundationAuthorizesRequestsTraitTest.php index 8242cccab..af3dcf786 100644 --- a/tests/Foundation/FoundationAuthorizesRequestsTraitTest.php +++ b/tests/Foundation/FoundationAuthorizesRequestsTraitTest.php @@ -50,6 +50,17 @@ public function testAcceptsBackedEnumAsAbility() $this->assertTrue($_SERVER['_test.authorizes.trait.enum']); } + public function testAcceptsIntegerBackedEnumAsAbility(): void + { + $gate = $this->getBasicGate(); + + $gate->define('0', fn () => true); + + $response = (new AuthorizeTraitClass)->authorize(IntegerAbility::Zero); + + $this->assertInstanceOf(Response::class, $response); + } + public function testExceptionIsThrownIfGateCheckFails() { $this->expectException(AuthorizationException::class); @@ -182,3 +193,8 @@ enum Ability: string { case Baz = 'baz'; } + +enum IntegerAbility: int +{ + case Zero = 0; +} diff --git a/tests/Foundation/FoundationExceptionsHandlerTest.php b/tests/Foundation/FoundationExceptionsHandlerTest.php index d30e8929a..e33b255f2 100644 --- a/tests/Foundation/FoundationExceptionsHandlerTest.php +++ b/tests/Foundation/FoundationExceptionsHandlerTest.php @@ -20,8 +20,10 @@ use Hypervel\Contracts\Session\Session as SessionContract; use Hypervel\Contracts\Support\Responsable; use Hypervel\Contracts\View\Factory as ViewFactory; +use Hypervel\Coroutine\Coroutine; use Hypervel\Database\Eloquent\ModelNotFoundException; use Hypervel\Database\RecordsNotFoundException; +use Hypervel\Engine\Channel; use Hypervel\Foundation\Application; use Hypervel\Foundation\Exceptions\Handler; use Hypervel\Foundation\Testing\Concerns\InteractsWithExceptionHandling; @@ -101,6 +103,40 @@ public function testHandlerReportsExceptionAsContext() $this->handler->report(new RuntimeException('Exception message')); } + public function testReportingStateIsLocalToTheCurrentCoroutine(): void + { + $logger = m::mock(LoggerInterface::class); + $this->container->instance(LoggerInterface::class, $logger); + $exception = new RuntimeException('Exception message'); + $parentReporting = false; + $childReporting = true; + $completed = new Channel(1); + + $logger->shouldReceive('error')->once()->andReturnUsing( + function () use ( + $exception, + &$parentReporting, + &$childReporting, + $completed + ): void { + $parentReporting = $this->handler->isReporting($exception); + + Coroutine::fork(function () use ($exception, &$childReporting, $completed): void { + $childReporting = $this->handler->isReporting($exception); + $completed->push(true); + }); + + $completed->pop(); + } + ); + + $this->handler->report($exception); + + $this->assertTrue($parentReporting); + $this->assertFalse($childReporting); + $this->assertFalse($this->handler->isReporting($exception)); + } + public function testHandlerCallsContextMethodIfPresent() { $logger = m::mock(LoggerInterface::class); diff --git a/tests/Foundation/FoundationHelpersTest.php b/tests/Foundation/FoundationHelpersTest.php index 862c8d1ac..5d05bea15 100644 --- a/tests/Foundation/FoundationHelpersTest.php +++ b/tests/Foundation/FoundationHelpersTest.php @@ -13,11 +13,13 @@ use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Contracts\Support\Responsable; use Hypervel\Http\Exceptions\HttpResponseException; +use Hypervel\Log\LogManager; use Hypervel\Support\Defer\DeferredCallback; use Hypervel\Support\Defer\DeferredCallbackCollection; use Hypervel\Support\Facades\Event; use Hypervel\Testbench\TestCase; use Mockery as m; +use Psr\Log\LoggerInterface; use stdClass; use Symfony\Component\HttpFoundation\Response as SymfonyResponse; @@ -180,6 +182,17 @@ public function testCache() $this->assertSame('default', cache('baz', 'default')); } + public function testLogsResolvesAChannelNamedZero(): void + { + $manager = m::mock(LogManager::class); + $logger = m::mock(LoggerInterface::class); + $manager->shouldReceive('driver')->once()->with('0')->andReturn($logger); + $this->app->instance('log', $manager); + + $this->assertSame($logger, logs('0')); + $this->assertSame($manager, logs()); + } + public function testEvents() { $dispatcher = m::mock(Dispatcher::class); diff --git a/tests/Foundation/PendingChainTest.php b/tests/Foundation/PendingChainTest.php new file mode 100644 index 000000000..ff60d350d --- /dev/null +++ b/tests/Foundation/PendingChainTest.php @@ -0,0 +1,103 @@ +onConnection(PendingChainIntegerIdentifier::Zero) + ->onQueue(PendingChainUnitIdentifier::Primary); + + $this->assertSame('0', $chain->connection); + $this->assertSame('Primary', $chain->queue); + } + + public function testDispatchPreservesZeroIdentifiersOnTheChainAndFirstJob(): void + { + $dispatcher = m::mock(Dispatcher::class); + $dispatcher->shouldReceive('dispatch')->twice()->andReturnArg(0); + Container::getInstance()->instance(Dispatcher::class, $dispatcher); + + $fromChain = (new PendingChain(new PendingChainTestJob, [])) + ->onConnection(PendingChainIntegerIdentifier::Zero) + ->onQueue(PendingChainIntegerIdentifier::Zero) + ->dispatch(); + + $firstJob = (new PendingChainTestJob)->onConnection('0')->onQueue('0'); + $fromFirstJob = (new PendingChain($firstJob, [])) + ->onConnection('fallback-connection') + ->onQueue('fallback-queue') + ->dispatch(); + + $this->assertSame('0', $fromChain->connection); + $this->assertSame('0', $fromChain->queue); + $this->assertSame('0', $fromFirstJob->connection); + $this->assertSame('0', $fromFirstJob->queue); + } + + public function testDispatchUsesChainIdentifiersForEmptyFirstJobIdentifiers(): void + { + $dispatcher = m::mock(Dispatcher::class); + $dispatcher->shouldReceive('dispatch')->once()->andReturnArg(0); + Container::getInstance()->instance(Dispatcher::class, $dispatcher); + + $firstJob = (new PendingChainTestJob)->onConnection('')->onQueue(''); + + $dispatched = (new PendingChain($firstJob, [])) + ->onConnection('fallback-connection') + ->onQueue('fallback-queue') + ->dispatch(); + + $this->assertSame('fallback-connection', $dispatched->connection); + $this->assertSame('fallback-queue', $dispatched->queue); + } + + public function testDispatchIgnoresEmptyChainIdentifiers(): void + { + $dispatcher = m::mock(Dispatcher::class); + $dispatcher->shouldReceive('dispatch')->once()->andReturnArg(0); + Container::getInstance()->instance(Dispatcher::class, $dispatcher); + + $firstJob = (new PendingChainTestJob) + ->onConnection('job-connection') + ->onQueue('job-queue'); + + $dispatched = (new PendingChain($firstJob, [])) + ->onConnection('') + ->onQueue('') + ->dispatch(); + + $this->assertSame('job-connection', $dispatched->connection); + $this->assertSame('job-queue', $dispatched->queue); + $this->assertNull($dispatched->chainConnection); + $this->assertNull($dispatched->chainQueue); + } +} + +enum PendingChainIntegerIdentifier: int +{ + case Zero = 0; +} + +enum PendingChainUnitIdentifier +{ + case Primary; +} + +class PendingChainTestJob +{ + use Queueable; +} diff --git a/tests/Foundation/Testing/Concerns/InteractsWithDatabaseTest.php b/tests/Foundation/Testing/Concerns/InteractsWithDatabaseTest.php index d703f8b1a..3fab666f0 100644 --- a/tests/Foundation/Testing/Concerns/InteractsWithDatabaseTest.php +++ b/tests/Foundation/Testing/Concerns/InteractsWithDatabaseTest.php @@ -55,6 +55,14 @@ public function testAssertDatabaseEmpty() $this->assertDatabaseEmpty('foundation_test_users'); } + public function testDatabaseAssertionsPreserveAnExplicitZeroNamedConnection(): void + { + config()->set('database.connections.0', config('database.connections.testing')); + + $this->assertSame('0', $this->getConnection('0')->getName()); + $this->assertSame('testing', $this->getConnection('')->getName()); + } + public function testAssertModelExists() { $user = User::factory()->create(); diff --git a/tests/Foundation/Testing/DatabaseConnectionResolverTest.php b/tests/Foundation/Testing/DatabaseConnectionResolverTest.php index d12487674..5a5240043 100644 --- a/tests/Foundation/Testing/DatabaseConnectionResolverTest.php +++ b/tests/Foundation/Testing/DatabaseConnectionResolverTest.php @@ -83,6 +83,23 @@ public function testCachedWriteConnectionReappliesWriteReadRoutingAfterReset(): $this->assertSame('Taylor', $cachedConnection->selectOne('select name from users')->name); } + public function testIntegerBackedEnumConnectionNamesAreNormalizedInTestingAndPooledModes(): void + { + $config = $this->app->make('config'); + $testing = $config->get('database.connections.testing'); + + $config->set('database.connections.0', $testing); + $config->set('database.connections.1', array_replace_recursive($testing, [ + 'pool' => ['testing_enabled' => true], + ])); + + $resolver = $this->app->make(DatabaseConnectionResolver::class); + + $this->assertSame($resolver->connection(), $resolver->connection('')); + $this->assertSame('0', $resolver->connection(DatabaseTestingConnectionName::Zero)->getName()); + $this->assertSame('1', $resolver->connection(DatabaseTestingConnectionName::One)->getName()); + } + public function testNamedFlushDiscardsTheOwningWrapperAndRestoresPoolCapacity(): void { $this->app->make('config')->set('database.connections.testing.pool.max_connections', 1); @@ -147,3 +164,9 @@ public function testDiscardAndReconnectRollBackSharedSqliteTransactions(): void $reconnected->discard(); } } + +enum DatabaseTestingConnectionName: int +{ + case Zero = 0; + case One = 1; +} diff --git a/tests/Inertia/OncePropTest.php b/tests/Inertia/OncePropTest.php index c8cee6821..d6ba707f4 100644 --- a/tests/Inertia/OncePropTest.php +++ b/tests/Inertia/OncePropTest.php @@ -12,6 +12,11 @@ enum TestBackedEnum: string case Foo = 'foo-value'; } +enum TestIntBackedEnum: int +{ + case Zero = 0; +} + enum TestUnitEnum { case Baz; @@ -55,6 +60,9 @@ public function testCanSetCustomKey(): void $onceProp->as(TestBackedEnum::Foo); $this->assertSame('foo-value', $onceProp->getKey()); + $onceProp->as(TestIntBackedEnum::Zero); + $this->assertSame('0', $onceProp->getKey()); + $onceProp->as(TestUnitEnum::Baz); $this->assertSame('Baz', $onceProp->getKey()); } diff --git a/tests/Integration/Broadcasting/BroadcastManagerTest.php b/tests/Integration/Broadcasting/BroadcastManagerTest.php index 45ba4252a..c506fd75b 100644 --- a/tests/Integration/Broadcasting/BroadcastManagerTest.php +++ b/tests/Integration/Broadcasting/BroadcastManagerTest.php @@ -160,6 +160,45 @@ public function testThrowExceptionWhenUnknownStoreIsUsed(): void $broadcastManager->connection('alien_connection'); } + public function testEnumIdentifiersResolveSetDefaultsAndPurge(): void + { + $app = new Container; + $app->instance('config', new Repository([ + 'broadcasting' => [ + 'default' => 'default', + 'connections' => [ + 'default' => ['driver' => 'null'], + 'Primary' => ['driver' => 'null'], + 'primary' => ['driver' => 'null'], + '1' => ['driver' => 'null'], + '0' => ['driver' => 'null'], + ], + ], + ])); + + $manager = new BroadcastManager($app); + + $this->assertSame($manager->connection(BroadcastUnitIdentifier::Primary), $manager->connection('Primary')); + $this->assertSame($manager->connection(BroadcastStringIdentifier::Primary), $manager->connection('primary')); + $this->assertSame($manager->connection(BroadcastIntegerIdentifier::Primary), $manager->connection('1')); + $zero = $manager->connection(BroadcastIntegerIdentifier::Zero); + $this->assertSame($zero, $manager->connection('0')); + + $manager->setDefaultDriver(BroadcastIntegerIdentifier::Zero); + $this->assertSame($manager->connection('0'), $manager->connection()); + $this->assertSame($manager->connection('0'), $manager->connection('')); + + $manager->purge(''); + $this->assertSame($zero, $manager->connection('0')); + + $manager->purge(BroadcastIntegerIdentifier::Zero); + $replacement = $manager->connection('0'); + $this->assertNotSame($zero, $replacement); + + $manager->purge(null); + $this->assertNotSame($replacement, $manager->connection('0')); + } + public function testRoutesExcludesCsrfMiddleware(): void { $route = m::mock(Route::class); @@ -543,3 +582,19 @@ public function broadcast(array $channels, string $event, array $payload = []): { } } + +enum BroadcastUnitIdentifier +{ + case Primary; +} + +enum BroadcastStringIdentifier: string +{ + case Primary = 'primary'; +} + +enum BroadcastIntegerIdentifier: int +{ + case Primary = 1; + case Zero = 0; +} diff --git a/tests/Integration/Broadcasting/SendingBroadcastsViaAnonymousEventTest.php b/tests/Integration/Broadcasting/SendingBroadcastsViaAnonymousEventTest.php index ef74cde4b..a39163521 100644 --- a/tests/Integration/Broadcasting/SendingBroadcastsViaAnonymousEventTest.php +++ b/tests/Integration/Broadcasting/SendingBroadcastsViaAnonymousEventTest.php @@ -62,6 +62,28 @@ public function testDefaultNameIsSet() }); } + public function testZeroNameIsPreserved(): void + { + Event::fake(); + + Broadcast::on('test-channel')->as('0')->send(); + + Event::assertDispatched(AnonymousEvent::class, function ($event) { + return $event->broadcastAs() === '0'; + }); + } + + public function testEmptyNameUsesDefaultName(): void + { + Event::fake(); + + Broadcast::on('test-channel')->as('')->send(); + + Event::assertDispatched(AnonymousEvent::class, function ($event) { + return $event->broadcastAs() === 'AnonymousEvent'; + }); + } + public function testDefaultPayloadIsSet() { Event::fake(); diff --git a/tests/Integration/Cache/FailoverStoreTest.php b/tests/Integration/Cache/FailoverStoreTest.php index f32a4ab9e..d4b7fb520 100644 --- a/tests/Integration/Cache/FailoverStoreTest.php +++ b/tests/Integration/Cache/FailoverStoreTest.php @@ -140,6 +140,20 @@ public function testFailoverCacheSkipsFailedOverEventWhenThereAreNoListeners(): $this->assertSame('fallback', $store->get('test')); } + public function testGetRawNormalizesIntegerBackedEnumKeys(): void + { + $events = m::mock(Dispatcher::class); + $repository = m::mock(CacheRepository::class); + $repository->shouldReceive('getRaw')->once()->with('0')->andReturn('zero'); + + $cacheManager = m::mock(CacheManager::class); + $cacheManager->shouldReceive('store')->once()->with('primary')->andReturn($repository); + + $store = new FailoverStore($cacheManager, $events, ['primary']); + + $this->assertSame('zero', $store->getRaw(FailoverCacheKey::Zero)); + } + public function testPutManyWithEmptyInputReturnsSelectedStoreResult(): void { $events = m::mock(Dispatcher::class); @@ -285,3 +299,8 @@ public function __serialize(): array return []; } } + +enum FailoverCacheKey: int +{ + case Zero = 0; +} diff --git a/tests/Integration/Concurrency/ConcurrencyTest.php b/tests/Integration/Concurrency/ConcurrencyTest.php index 9f095f14d..4372fff5d 100644 --- a/tests/Integration/Concurrency/ConcurrencyTest.php +++ b/tests/Integration/Concurrency/ConcurrencyTest.php @@ -308,6 +308,22 @@ public function testManagerResolvesSyncDriver() $this->assertInstanceOf(SyncDriver::class, $manager->driver('sync')); } + public function testManagerResolvesEnumDriverIdentifiers(): void + { + $manager = $this->app->make(ConcurrencyManager::class); + + $manager->extend('Primary', fn () => new SyncDriver); + $manager->extend('1', fn () => new SyncDriver); + $manager->extend('0', fn () => new SyncDriver); + + $this->assertSame($manager->driver('Primary'), $manager->driver(ConcurrencyUnitIdentifier::Primary)); + $this->assertSame($manager->driver('1'), $manager->driver(ConcurrencyIntegerIdentifier::Primary)); + $this->assertSame($manager->driver('0'), $manager->driver(ConcurrencyIntegerIdentifier::Zero)); + $this->assertSame($manager->driver(), $manager->driver('')); + $this->assertInstanceOf(SyncDriver::class, $manager->driver('0')); + $this->assertInstanceOf(SyncDriver::class, $manager->driver(ConcurrencyIntegerIdentifier::Zero)); + } + public function testManagerCachesDriverInstances() { $manager = $this->app->make(ConcurrencyManager::class); @@ -411,3 +427,14 @@ public function __construct( parent::__construct("Request to {$uri} failed with status {$statusCode}"); } } + +enum ConcurrencyUnitIdentifier +{ + case Primary; +} + +enum ConcurrencyIntegerIdentifier: int +{ + case Primary = 1; + case Zero = 0; +} diff --git a/tests/Integration/Console/JobSchedulingTest.php b/tests/Integration/Console/JobSchedulingTest.php index c726809c1..c3fdcc9c8 100644 --- a/tests/Integration/Console/JobSchedulingTest.php +++ b/tests/Integration/Console/JobSchedulingTest.php @@ -83,6 +83,26 @@ public function testJobQueuingRespectsJobConnection(): void })->count()); } + public function testJobQueuingNormalizesIntegerBackedEnumQueueAndConnection(): void + { + Queue::fake(); + + /** @var Schedule $scheduler */ + $scheduler = $this->app->make(Schedule::class); + $scheduler->job( + JobWithoutDefaultConnection::class, + ScheduledJobIdentifier::Queue, + ScheduledJobIdentifier::Connection + )->name('')->everyMinute(); + + $scheduler->events()[0]->run($this->app); + + $this->assertSame(1, Queue::pushed( + JobWithoutDefaultConnection::class, + fn (JobWithoutDefaultConnection $job, $queue): bool => $job->connection === '1' && $queue === '0' + )->count()); + } + public function testJobQueuingRespectsQueueRoutes(): void { Queue::fake(); @@ -154,3 +174,9 @@ class JobWithoutDefaultConnection implements ShouldQueue use InteractsWithQueue; use Queueable; } + +enum ScheduledJobIdentifier: int +{ + case Queue = 0; + case Connection = 1; +} diff --git a/tests/Integration/Events/QueuedClosureListenerTest.php b/tests/Integration/Events/QueuedClosureListenerTest.php index bce707bc6..140c1c0bd 100644 --- a/tests/Integration/Events/QueuedClosureListenerTest.php +++ b/tests/Integration/Events/QueuedClosureListenerTest.php @@ -59,6 +59,20 @@ public function testAnonymousQueuedListenerAcceptsAnIntBackedEnumMessageGroup(): }); } + public function testAnonymousQueuedListenerNormalizesEnumConnectionAndQueueIdentifiers(): void + { + Bus::fake(); + + Event::listen(\Hypervel\Events\queueable(function (TestEvent $event): void { + })->onConnection(TestQueueIdentifier::Zero)->onQueue(TestQueueUnitIdentifier::Primary)); + + Event::dispatch(new TestEvent); + + Bus::assertDispatched(CallQueuedListener::class, function ($job): bool { + return $job->connection === '0' && $job->queue === 'Primary'; + }); + } + public function testAnonymousQueuedListenerIsQueuedWithDeduplicator() { $deduplicator = fn ($payload, $queue) => 'deduplicator-1'; @@ -106,6 +120,16 @@ enum TestMessageGroup: int case First = 1; } +enum TestQueueIdentifier: int +{ + case Zero = 0; +} + +enum TestQueueUnitIdentifier +{ + case Primary; +} + class QueuedClosureDeduplicator { public function resolve(string $payload, mixed $queue): string diff --git a/tests/Integration/Filesystem/StorageFakeTest.php b/tests/Integration/Filesystem/StorageFakeTest.php index 0ba7e1817..c953445c3 100644 --- a/tests/Integration/Filesystem/StorageFakeTest.php +++ b/tests/Integration/Filesystem/StorageFakeTest.php @@ -77,4 +77,37 @@ public function testPersistentFakePreservesOriginalDiskThrowConfig() /** @var FilesystemAdapter $fake */ $this->assertTrue($fake->getConfig()['throw']); } + + public function testIntegerBackedEnumDiskZeroDoesNotSelectTheDefaultDisk(): void + { + config([ + 'filesystems.default' => 'local', + 'filesystems.disks.local' => ['driver' => 'local', 'root' => storage_path('app')], + 'filesystems.disks.0' => ['driver' => 'local', 'root' => storage_path('zero'), 'throw' => true], + ]); + + $this->assertSame(Storage::disk(), Storage::disk('')); + $this->assertNotSame(Storage::disk(), Storage::disk(StorageFakeDisk::Zero)); + + $fake = Storage::fake(StorageFakeDisk::Zero); + $persistentFake = Storage::persistentFake(StorageFakeDisk::Zero); + $defaultFake = Storage::fake(''); + $testingSuffix = ($token = ParallelTesting::token()) ? "_test_{$token}" : ''; + + /** @var FilesystemAdapter $fake */ + $this->assertSame(storage_path('framework/testing/disks/0' . $testingSuffix), $fake->getConfig()['root']); + $this->assertTrue($fake->getConfig()['throw']); + + /** @var FilesystemAdapter $persistentFake */ + $this->assertSame(storage_path('framework/testing/disks/0'), $persistentFake->getConfig()['root']); + $this->assertTrue($persistentFake->getConfig()['throw']); + + /** @var FilesystemAdapter $defaultFake */ + $this->assertSame(storage_path('framework/testing/disks/local' . $testingSuffix), $defaultFake->getConfig()['root']); + } +} + +enum StorageFakeDisk: int +{ + case Zero = 0; } diff --git a/tests/Integration/Generators/PolicyMakeCommandTest.php b/tests/Integration/Generators/PolicyMakeCommandTest.php index 76d8c4082..8b20d02b4 100644 --- a/tests/Integration/Generators/PolicyMakeCommandTest.php +++ b/tests/Integration/Generators/PolicyMakeCommandTest.php @@ -41,4 +41,29 @@ public function testItCanGeneratePolicyFileWithModelOption() 'public function forceDelete(User $user, Post $post)', ], 'app/Policies/FooPolicy.php'); } + + public function testItCanGeneratePolicyFileForZeroNamedGuard(): void + { + $this->app->make('config')->set([ + 'auth.guards.0.provider' => 'zero-users', + 'auth.providers.zero-users.model' => 'App\Models\ZeroUser', + ]); + + $this->artisan('make:policy', ['name' => 'FooPolicy', '--guard' => '0']) + ->assertExitCode(0); + + $this->assertFileContains([ + 'use App\Models\ZeroUser;', + ], 'app/Policies/FooPolicy.php'); + } + + public function testEmptyGuardOptionUsesDefaultGuard(): void + { + $this->artisan('make:policy', ['name' => 'FooPolicy', '--guard' => '']) + ->assertExitCode(0); + + $this->assertFileContains([ + 'use Hypervel\Foundation\Auth\User;', + ], 'app/Policies/FooPolicy.php'); + } } diff --git a/tests/Integration/Horizon/Feature/ClearCommandTest.php b/tests/Integration/Horizon/Feature/ClearCommandTest.php new file mode 100644 index 000000000..d845ce68a --- /dev/null +++ b/tests/Integration/Horizon/Feature/ClearCommandTest.php @@ -0,0 +1,74 @@ +set('horizon.defaults', [ + 'supervisor-1' => ['connection' => 'redis'], + ]); + $app['config']->set('queue.connections.redis.queue', 'default'); + $app['config']->set('queue.connections.0.queue', 'zero-default'); + } + + #[DataProvider('queueIdentifierProvider')] + public function testCommandPreservesZeroAndDefaultsEmptyIdentifiers( + string $connection, + string $queue, + string $expectedConnection, + string $expectedQueue, + ): void { + $jobRepository = m::mock(RedisJobRepository::class); + $jobRepository->shouldReceive('purge')->once()->with($expectedQueue); + $this->app->instance(JobRepository::class, $jobRepository); + + $resolvedQueue = m::mock(Queue::class, ClearableQueue::class); + $resolvedQueue->shouldReceive('clear')->once()->with($expectedQueue)->andReturn(1); + + $manager = m::mock(QueueManager::class); + $manager->shouldReceive('connection')->once()->with($expectedConnection)->andReturn($resolvedQueue); + $this->app->instance('queue', $manager); + + $command = new ClearCommand; + $command->setHypervel($this->app); + + $this->assertSame(0, $command->run( + new ArrayInput([ + 'connection' => $connection, + '--queue' => $queue, + '--force' => true, + ]), + new BufferedOutput, + )); + } + + /** + * Provide queue identifiers and their resolved values. + */ + public static function queueIdentifierProvider(): array + { + return [ + 'zero connection' => ['0', '', '0', 'zero-default'], + 'zero queue' => ['redis', '0', 'redis', '0'], + 'empty identifiers' => ['', '', 'redis', 'default'], + ]; + } +} diff --git a/tests/Integration/Horizon/Feature/SupervisorCommandTest.php b/tests/Integration/Horizon/Feature/SupervisorCommandTest.php index d8d0fe6a0..9854eee52 100644 --- a/tests/Integration/Horizon/Feature/SupervisorCommandTest.php +++ b/tests/Integration/Horizon/Feature/SupervisorCommandTest.php @@ -68,6 +68,22 @@ public function testSupervisorCommandCanSetProcessNiceness() $this->assertSame(10, $this->myNiceness()); } + public function testSupervisorCommandPreservesZeroQueue(): void + { + $this->app->instance(SupervisorFactory::class, $factory = new FakeSupervisorFactory); + $this->artisan('horizon:supervisor', ['--queue' => '0'] + static::OPTIONS); + + $this->assertSame('0', $factory->supervisor->options->queue); + } + + public function testSupervisorCommandDefaultsEmptyQueue(): void + { + $this->app->instance(SupervisorFactory::class, $factory = new FakeSupervisorFactory); + $this->artisan('horizon:supervisor', ['--queue' => ''] + static::OPTIONS); + + $this->assertSame('default', $factory->supervisor->options->queue); + } + private function myNiceness() { $pid = getmypid(); diff --git a/tests/Integration/Horizon/Feature/SupervisorOptionsTest.php b/tests/Integration/Horizon/Feature/SupervisorOptionsTest.php index be2180607..b708c9617 100644 --- a/tests/Integration/Horizon/Feature/SupervisorOptionsTest.php +++ b/tests/Integration/Horizon/Feature/SupervisorOptionsTest.php @@ -9,9 +9,23 @@ class SupervisorOptionsTest extends IntegrationTestCase { - public function testDefaultQueueIsUsedWhenNullIsGiven() + public function testDefaultQueueIsUsedWhenNullIsGiven(): void { $options = new SupervisorOptions('name', 'redis'); $this->assertSame('default', $options->queue); } + + public function testDefaultQueueIsUsedWhenEmptyStringIsGiven(): void + { + $options = new SupervisorOptions('name', 'redis', queue: ''); + + $this->assertSame('default', $options->queue); + } + + public function testZeroQueueIsPreserved(): void + { + $options = new SupervisorOptions('name', 'redis', queue: '0'); + + $this->assertSame('0', $options->queue); + } } diff --git a/tests/Integration/Horizon/Feature/WaitTimeCalculatorTest.php b/tests/Integration/Horizon/Feature/WaitTimeCalculatorTest.php index 255d78fc8..c430ba373 100644 --- a/tests/Integration/Horizon/Feature/WaitTimeCalculatorTest.php +++ b/tests/Integration/Horizon/Feature/WaitTimeCalculatorTest.php @@ -111,6 +111,25 @@ public function testSingleQueueCanBeRetrievedForMultipleQueues() ); } + public function testQueueFilterDistinguishesZeroAndEmptyString(): void + { + $calculator = $this->with_scenario([ + 'test-supervisor' => (object) [ + 'processes' => [ + 'redis:test-queue' => 1, + ], + ], + ], [ + 'test-queue' => [ + 'size' => 10, + 'runtime' => 1000, + ], + ]); + + $this->assertSame([], $calculator->calculate('0')); + $this->assertSame(['redis:test-queue' => 10.0], $calculator->calculate('')); + } + public function testTimeToClearCanBeZero() { $calculator = $this->with_scenario([ diff --git a/tests/Integration/Queue/WorkCommandTest.php b/tests/Integration/Queue/WorkCommandTest.php index 49aa1203e..4a8653849 100644 --- a/tests/Integration/Queue/WorkCommandTest.php +++ b/tests/Integration/Queue/WorkCommandTest.php @@ -62,6 +62,47 @@ public function testRunningOneJob() $this->assertFalse(SecondJob::$ran); } + public function testQueueOptionPreservesZeroAndDefaultsEmptyString(): void + { + Queue::push(new FirstJob, queue: '0'); + Queue::push(new SecondJob); + + $this->artisan('queue:work', [ + '--once' => true, + '--memory' => 1024, + '--queue' => '0', + ])->assertExitCode(0); + + $this->assertTrue(FirstJob::$ran); + $this->assertFalse(SecondJob::$ran); + + $this->artisan('queue:work', [ + '--once' => true, + '--memory' => 1024, + '--queue' => '', + ])->assertExitCode(0); + + $this->assertTrue(SecondJob::$ran); + } + + public function testConnectionArgumentPreservesZero(): void + { + $this->app['config']->set( + 'queue.connections.0', + $this->app['config']->get('queue.connections.database'), + ); + + Queue::connection('0')->push(new FirstJob); + + $this->artisan('queue:work', [ + 'connection' => '0', + '--once' => true, + '--memory' => 1024, + ])->assertExitCode(0); + + $this->assertTrue(FirstJob::$ran); + } + public function testOnceDoesNotRunInMaintenanceModeUnlessForced() { Queue::push(new FirstJob); diff --git a/tests/Integration/Reverb/ServerTest.php b/tests/Integration/Reverb/ServerTest.php index 480fab60c..5a546e430 100644 --- a/tests/Integration/Reverb/ServerTest.php +++ b/tests/Integration/Reverb/ServerTest.php @@ -481,7 +481,8 @@ public function testNotifiesSubscribersWhenPresenceMemberLeaves() $this->assertNotNull($msg, 'Expected member_removed notification'); $data = json_decode($msg, associative: true); $this->assertSame('pusher_internal:member_removed', $data['event']); - $this->assertStringContainsString('"user_id":2', $data['data']); + $member = json_decode($data['data'], associative: true, flags: JSON_THROW_ON_ERROR); + $this->assertSame('2', $member['user_id']); $this->disconnect($clientOne); } diff --git a/tests/Jwt/JwtManagerTest.php b/tests/Jwt/JwtManagerTest.php index 2bb3ab5c6..bef2424eb 100644 --- a/tests/Jwt/JwtManagerTest.php +++ b/tests/Jwt/JwtManagerTest.php @@ -618,8 +618,9 @@ private function createManager(): JwtManager $this->config->shouldReceive('string')->with('jwt.driver', 'lcobucci')->andReturn('dummy'); $manager = new JwtManager($this->container, $this->claimFactory); + $provider = $this->provider; - $manager->extend('dummy', fn () => $this->provider); + $manager->extend('dummy', static fn () => $provider); return $manager; } diff --git a/tests/Log/ContextQueueTest.php b/tests/Log/ContextQueueTest.php index 07c857d33..93c1f5074 100644 --- a/tests/Log/ContextQueueTest.php +++ b/tests/Log/ContextQueueTest.php @@ -6,9 +6,11 @@ use Hypervel\Context\CoroutineContext; use Hypervel\Contracts\Queue\ShouldQueue; +use Hypervel\Engine\Channel; use Hypervel\Foundation\Bus\Dispatchable; use Hypervel\Foundation\Queue\Queueable; use Hypervel\Log\Context\Repository; +use Hypervel\Queue\BackgroundQueue; use Hypervel\Queue\Events\JobProcessing; use Hypervel\Queue\InteractsWithQueue; use Hypervel\Queue\SyncQueue; @@ -98,6 +100,22 @@ public function testHydrateSkipsWhenPayloadHasNoContext() $this->assertFalse(Repository::hasInstance()); } + public function testPayloadWithoutContextFlushesAnExistingRepository(): void + { + $repository = Repository::getInstance(); + $repository->add('stale', 'value'); + $repository->addHidden('secret', 'value'); + + $job = m::mock(\Hypervel\Contracts\Queue\Job::class); + $job->shouldReceive('payload')->andReturn(['job' => 'SomeJob']); + + $this->app['events']->dispatch(new JobProcessing('sync', $job)); + + $this->assertSame($repository, Repository::getInstance()); + $this->assertSame([], $repository->all()); + $this->assertSame([], $repository->allHidden()); + } + public function testDehydratingHookFiresBeforeJobDispatch() { $called = false; @@ -210,6 +228,23 @@ public function testEndToEndSyncJobReceivesContext() $this->assertSame('e2e-secret', ContextQueueTestJob::$receivedSecret); } + public function testBackgroundJobPayloadCapturesParentLogContextBeforeSpawning(): void + { + ContextQueueBackgroundJob::$receivedTraceId = null; + ContextQueueBackgroundJob::$completed = new Channel(1); + Repository::getInstance()->add('trace_id', 'parent-context'); + + $queue = new BackgroundQueue; + $queue->setContainer($this->app); + $queue->setConnectionName('background'); + $queue->push(new ContextQueueBackgroundJob); + + $this->assertTrue(ContextQueueBackgroundJob::$completed->pop(1)); + $this->assertSame('parent-context', ContextQueueBackgroundJob::$receivedTraceId); + + ContextQueueBackgroundJob::$completed = null; + } + /** * Create a SyncQueue for payload testing. */ @@ -250,3 +285,20 @@ public function handle(): void static::$receivedSecret = Repository::getInstance()->getHidden('secret'); } } + +class ContextQueueBackgroundJob implements ShouldQueue +{ + use Dispatchable; + use InteractsWithQueue; + use Queueable; + + public static ?Channel $completed = null; + + public static ?string $receivedTraceId = null; + + public function handle(): void + { + static::$receivedTraceId = Repository::getInstance()->get('trace_id'); + static::$completed?->push(true); + } +} diff --git a/tests/Log/FingersCrossedHandlerTest.php b/tests/Log/FingersCrossedHandlerTest.php new file mode 100644 index 000000000..2b1be6d06 --- /dev/null +++ b/tests/Log/FingersCrossedHandlerTest.php @@ -0,0 +1,78 @@ +handle($this->record(Level::Debug, 'first debug')); + $buffered->push(true); + $release->pop(); + }, + function () use ($handler, $buffered, $release): void { + $buffered->pop(); + $handler->handle($this->record(Level::Critical, 'second critical')); + $release->push(true); + }, + ]); + + $this->assertSame( + ['second critical'], + array_map( + static fn (LogRecord $record): string => $record->message, + $underlying->getRecords() + ) + ); + } + + public function testCopiedCoroutineStartsWithAFreshBuffer(): void + { + $underlying = new TestHandler; + $handler = new FingersCrossedHandler($underlying, Level::Critical); + $handler->handle($this->record(Level::Debug, 'parent debug')); + + parallel([ + fn () => $handler->handle($this->record(Level::Critical, 'child critical')), + ], copyContext: true); + + $this->assertSame( + ['child critical'], + array_map( + static fn (LogRecord $record): string => $record->message, + $underlying->getRecords() + ) + ); + } + + private function record(Level $level, string $message): LogRecord + { + return new LogRecord( + datetime: new DateTimeImmutable, + channel: 'test', + level: $level, + message: $message, + context: [], + extra: [], + ); + } +} diff --git a/tests/Log/JsonFormatterTest.php b/tests/Log/JsonFormatterTest.php new file mode 100644 index 000000000..1f8494714 --- /dev/null +++ b/tests/Log/JsonFormatterTest.php @@ -0,0 +1,328 @@ + 'testing']); + config(['logging.channels' => [ + 'testing' => [ + 'driver' => 'monolog', + 'handler' => TestHandler::class, + 'formatter' => JsonFormatter::class, + ], + ]]); + } + + public function testExceptionContextIsEnrichedOnDirectLogging(): void + { + Log::error('fail', ['exception' => new ContextProvidingException('Something went wrong')]); + + $formatted = $this->getFormattedJson(); + + $exceptionData = $formatted['context']['exception']; + $this->assertSame('bar', $exceptionData['foo']); + $this->assertSame(ContextProvidingException::class, $exceptionData['class']); + } + + public function testExceptionContextIsNotDuplicatedWhenGoingThroughReport(): void + { + $exception = new ContextProvidingException('Something went wrong'); + + $this->app->make(ExceptionHandlerContract::class)->report($exception); + + $formatted = $this->getFormattedJson(); + + // Context should be at the top level (from the handler) + $this->assertSame('bar', $formatted['context']['foo']); + + // But NOT enriched inside the normalized exception (formatter should skip) + $exceptionData = $formatted['context']['exception']; + $this->assertArrayNotHasKey('foo', $exceptionData); + } + + public function testStackDriverEnrichesBothHandlersOnDirectLogging(): void + { + $handlerA = new TestHandler; + $handlerB = new TestHandler; + + $monolog = new Monolog('test', [$handlerA, $handlerB]); + foreach ([$handlerA, $handlerB] as $h) { + $h->setFormatter(new JsonFormatter); + } + + $exception = new ContextProvidingException('Stack test'); + + $monolog->error('fail', ['exception' => $exception]); + + foreach (['handlerA' => $handlerA, 'handlerB' => $handlerB] as $name => $h) { + $formatted = $this->getFormattedJson($h); + $exceptionData = $formatted['context']['exception']; + + $this->assertSame('bar', $exceptionData['foo'], "Expected enriched context on {$name}"); + $this->assertSame(ContextProvidingException::class, $exceptionData['class']); + } + } + + public function testStackDriverSkipsEnrichmentOnBothHandlersWhenReporting(): void + { + $handlerA = new TestHandler; + $handlerB = new TestHandler; + + $monolog = new Monolog('test', [$handlerA, $handlerB]); + foreach ([$handlerA, $handlerB] as $h) { + $h->setFormatter(new JsonFormatter); + } + + $this->app->instance(LoggerInterface::class, new Logger($monolog)); + + $exceptionHandler = new Handler($this->app); + $this->app->instance(ExceptionHandlerContract::class, $exceptionHandler); + + $exception = new ContextProvidingException('Stack report test'); + + $exceptionHandler->report($exception); + + foreach (['handlerA' => $handlerA, 'handlerB' => $handlerB] as $name => $h) { + $formatted = $this->getFormattedJson($h); + + $this->assertSame('bar', $formatted['context']['foo'], "Context should be at top level on {$name}"); + + $exceptionData = $formatted['context']['exception']; + $this->assertArrayNotHasKey('foo', $exceptionData, "Formatter should not enrich on {$name}"); + } + } + + public function testPreviousExceptionContextIsAlsoEnriched(): void + { + $previous = new ContextProvidingException('Root cause'); + $outer = new RuntimeException('Wrapper', 0, $previous); + + Log::error('fail', ['exception' => $outer]); + + $formatted = $this->getFormattedJson(); + $exceptionData = $formatted['context']['exception']; + + $this->assertSame(RuntimeException::class, $exceptionData['class']); + $this->assertArrayHasKey('previous', $exceptionData); + + $previousData = $exceptionData['previous']; + $this->assertSame(ContextProvidingException::class, $previousData['class']); + $this->assertSame('bar', $previousData['foo']); + } + + public function testReportEnrichesPreviousExceptionContext(): void + { + $exception = new RuntimeException('Wrapper', 0, new ContextProvidingException('Root cause')); + + $this->app->make(ExceptionHandlerContract::class)->report($exception); + + $formatted = $this->getFormattedJson(); + + // The outer exception has no context() method, so nothing at the top level + $this->assertArrayNotHasKey('foo', $formatted['context']); + + $exceptionData = $formatted['context']['exception']; + + // Outer exception should NOT be enriched (isReporting matches it) + $this->assertArrayNotHasKey('foo', $exceptionData); + + // Previous exception SHOULD be enriched (isReporting does not match it) + $this->assertArrayHasKey('previous', $exceptionData); + $previousData = $exceptionData['previous']; + $this->assertSame(ContextProvidingException::class, $previousData['class']); + $this->assertSame('bar', $previousData['foo']); + } + + public function testExceptionWithoutContextMethodIsNotEnriched(): void + { + Log::error('fail', ['exception' => new RuntimeException('Plain exception')]); + + $formatted = $this->getFormattedJson(); + $exceptionData = $formatted['context']['exception']; + + $this->assertSame(RuntimeException::class, $exceptionData['class']); + $this->assertSame('Plain exception', $exceptionData['message']); + $this->assertArrayNotHasKey('foo', $exceptionData); + } + + public function testContextCallbacksAreIncludedInFormatterEnrichment(): void + { + $this->app->make(ExceptionHandlerContract::class)->buildContextUsing(function (Throwable $e) { + return ['callback_key' => 'callback_value']; + }); + + $exception = new ContextProvidingException('With callbacks'); + + Log::error('fail', ['exception' => $exception]); + + $formatted = $this->getFormattedJson(); + $exceptionData = $formatted['context']['exception']; + + $this->assertSame('bar', $exceptionData['foo']); + $this->assertSame('callback_value', $exceptionData['callback_key']); + } + + public function testNonScalarContextValuesAreNormalized(): void + { + $exception = new ObjectContextException('Has objects in context'); + + Log::error('fail', ['exception' => $exception]); + + $formatted = $this->getFormattedJson(); + $exceptionData = $formatted['context']['exception']; + + $this->assertIsArray($exceptionData['nested']); + $this->assertSame(ObjectContextException::class, $exceptionData['class']); + } + + public function testBothOuterAndPreviousContextEnrichedOnDirectLogging(): void + { + $previous = new ContextProvidingException('Root cause'); + $outer = new AnotherContextProvidingException('Wrapper', 0, $previous); + + Log::error('fail', ['exception' => $outer]); + + $formatted = $this->getFormattedJson(); + $exceptionData = $formatted['context']['exception']; + + // Outer exception should have its own context + $this->assertSame('outer_value', $exceptionData['outer_key']); + $this->assertSame(AnotherContextProvidingException::class, $exceptionData['class']); + + // Previous exception should have its own context + $this->assertArrayHasKey('previous', $exceptionData); + $previousData = $exceptionData['previous']; + $this->assertSame('bar', $previousData['foo']); + $this->assertSame(ContextProvidingException::class, $previousData['class']); + + // Context keys should not bleed between exceptions + $this->assertArrayNotHasKey('foo', $exceptionData); + $this->assertArrayNotHasKey('outer_key', $previousData); + } + + public function testBothOuterAndPreviousContextOnReport(): void + { + $previous = new ContextProvidingException('Root cause'); + $outer = new AnotherContextProvidingException('Wrapper', 0, $previous); + + $this->app->make(ExceptionHandlerContract::class)->report($outer); + + $formatted = $this->getFormattedJson(); + + // Outer's context should be at the top level (from the handler) + $this->assertSame('outer_value', $formatted['context']['outer_key']); + + $exceptionData = $formatted['context']['exception']; + + // Outer should NOT be enriched by the formatter (isReporting matches) + $this->assertArrayNotHasKey('outer_key', $exceptionData); + + // Previous SHOULD be enriched by the formatter (isReporting does not match) + $this->assertArrayHasKey('previous', $exceptionData); + $previousData = $exceptionData['previous']; + $this->assertSame('bar', $previousData['foo']); + $this->assertSame(ContextProvidingException::class, $previousData['class']); + } + + public function testFormatterHandlesNormalizationDepthLimit(): void + { + $formatter = new JsonFormatter; + $formatter->setMaxNormalizeDepth(3); + + $handler = new TestHandler; + $handler->setFormatter($formatter); + $monolog = new Monolog('test', [$handler]); + + $inner = new ContextProvidingException('inner'); + $outer = new ContextProvidingException('outer', 0, $inner); + + $monolog->error('fail', ['exception' => $outer]); + + $formatted = $this->getFormattedJson($handler); + $exceptionData = $formatted['context']['exception']; + + // Outermost exception at depth 2 — context normalize called at depth 3, + // within limit so the array is returned (values inside may be depth-truncated) + $this->assertArrayHasKey('foo', $exceptionData); + + // Previous exception at depth 3 — context normalize called at depth 4, + // exceeds limit so normalize returns a string. is_array() guard skips enrichment. + $this->assertArrayHasKey('previous', $exceptionData); + $this->assertArrayNotHasKey('foo', $exceptionData['previous']); + $this->assertSame(ContextProvidingException::class, $exceptionData['previous']['class']); + } + + public function testNoHandlerSetMergesExceptionContext(): void + { + $this->app->bind(ExceptionHandlerContract::class, function () { + throw new Exception('this never works'); + }); + Log::warning('fail', ['exception' => new ContextProvidingException('Oh no!')]); + + $formatted = $this->getFormattedJson(); + + $exceptionData = $formatted['context']['exception']; + $this->assertSame('bar', $exceptionData['foo']); + $this->assertSame(ContextProvidingException::class, $exceptionData['class']); + } + + private function getFormattedJson(?TestHandler $handler = null): array + { + $handler ??= $this->app->make('log')->driver()->getLogger()->getHandlers()[0]; + $records = $handler->getRecords(); + $this->assertNotEmpty($records, 'Expected at least one log record'); + + $formatted = $records[0]['formatted']; + + return json_decode($formatted, true, 512, JSON_THROW_ON_ERROR); + } +} + +class ContextProvidingException extends Exception +{ + public function context(): array + { + return ['foo' => 'bar']; + } +} + +class AnotherContextProvidingException extends Exception +{ + public function context(): array + { + return ['outer_key' => 'outer_value']; + } +} + +class ObjectContextException extends Exception +{ + public function context(): array + { + return [ + 'nested' => new stdClass, + ]; + } +} diff --git a/tests/Log/LogLoggerTest.php b/tests/Log/LogLoggerTest.php index 3f47be5ef..7be0009fa 100644 --- a/tests/Log/LogLoggerTest.php +++ b/tests/Log/LogLoggerTest.php @@ -4,26 +4,32 @@ namespace Hypervel\Tests\Log; +use Closure; use Hypervel\Context\CoroutineContext; use Hypervel\Contracts\Events\Dispatcher as DispatcherContract; use Hypervel\Contracts\Support\Arrayable; +use Hypervel\Engine\Channel; use Hypervel\Events\Dispatcher; use Hypervel\Log\Context\Repository as ContextRepository; use Hypervel\Log\Events\MessageLogged; use Hypervel\Log\Logger; use Hypervel\Tests\TestCase; use Mockery as m; +use Monolog\Handler\AbstractHandler; use Monolog\Handler\TestHandler; use Monolog\Level; use Monolog\Logger as Monolog; +use Monolog\LogRecord; use Psr\Log\LoggerInterface; use RuntimeException; +use function Hypervel\Coroutine\parallel; + class LogLoggerTest extends TestCase { public function testMethodsPassErrorAdditionsToMonolog() { - $writer = new Logger($monolog = m::mock(Monolog::class)); + $writer = new Logger($monolog = $this->mockMonolog()); $monolog->shouldReceive('isHandling')->with('error')->andReturn(true); $monolog->shouldReceive('error')->once()->with('foo', []); @@ -32,7 +38,7 @@ public function testMethodsPassErrorAdditionsToMonolog() public function testContextIsAddedToAllSubsequentLogs() { - $writer = new Logger($monolog = m::mock(Monolog::class)); + $writer = new Logger($monolog = $this->mockMonolog()); $writer->withContext(['bar' => 'baz']); $monolog->shouldReceive('isHandling')->with('error')->andReturn(true); @@ -43,7 +49,7 @@ public function testContextIsAddedToAllSubsequentLogs() public function testContextIsFlushed() { - $writer = new Logger($monolog = m::mock(Monolog::class)); + $writer = new Logger($monolog = $this->mockMonolog()); $writer->withContext(['bar' => 'baz']); $writer->withoutContext(); @@ -55,7 +61,7 @@ public function testContextIsFlushed() public function testContextKeysCanBeRemovedForSubsequentLogs() { - $writer = new Logger($monolog = m::mock(Monolog::class)); + $writer = new Logger($monolog = $this->mockMonolog()); $writer->withContext(['bar' => 'baz', 'forget' => 'me']); $writer->withoutContext(['forget']); @@ -67,7 +73,7 @@ public function testContextKeysCanBeRemovedForSubsequentLogs() public function testLoggerFiresEventsDispatcher() { - $writer = new Logger($monolog = m::mock(Monolog::class), $events = new Dispatcher); + $writer = new Logger($monolog = $this->mockMonolog(), $events = new Dispatcher); $monolog->shouldReceive('isHandling')->with('error')->andReturn(true); $monolog->shouldReceive('error')->once()->with('foo', []); @@ -93,14 +99,14 @@ public function testListenShortcutFailsWithNoDispatcher() $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Events dispatcher has not been set.'); - $writer = new Logger(m::mock(Monolog::class)); + $writer = new Logger($this->mockMonolog()); $writer->listen(function () { }); } public function testListenShortcut() { - $writer = new Logger(m::mock(Monolog::class), $events = m::mock(DispatcherContract::class)); + $writer = new Logger($this->mockMonolog(), $events = m::mock(DispatcherContract::class)); $callback = function () { return 'success'; @@ -112,7 +118,7 @@ public function testListenShortcut() public function testComplexContextManipulation() { - $writer = new Logger($monolog = m::mock(Monolog::class)); + $writer = new Logger($monolog = $this->mockMonolog()); $writer->withContext(['user_id' => 123, 'action' => 'login']); $writer->withContext(['ip' => '127.0.0.1', 'timestamp' => '1986-10-29']); @@ -213,6 +219,76 @@ public function testNamedLoggerPreservesWrapperBehaviorAndSharesChannelContext() $this->assertSame([], $other->getContext()); } + public function testDestroyedLoggerContextCannotAliasANewLogger(): void + { + $monolog = new Monolog('shared'); + $first = new Logger($monolog); + $firstObjectId = spl_object_id($first); + $first->withContext(['request_id' => 'first']); + + unset($first); + + $second = new Logger($monolog); + + $this->assertSame($firstObjectId, spl_object_id($second)); + $this->assertSame([], $second->getContext()); + } + + public function testConcurrentLogsDoNotTriggerMonologsSharedLoopDetector(): void + { + $release = new Channel(3); + $handler = new InterleavingLogHandler($release); + $logger = new Logger(new Monolog('concurrent', [$handler])); + + parallel([ + fn () => $logger->info('first'), + fn () => $logger->info('second'), + fn () => $logger->info('third'), + ]); + + $messages = array_map( + static fn (LogRecord $record): string => $record->message, + $handler->records + ); + sort($messages); + + $this->assertSame(['first', 'second', 'third'], $messages); + } + + public function testClearingContextDuringAWritePreservesLoopDetection(): void + { + $writer = null; + $handler = new ContextClearingRecursiveLogHandler(function () use (&$writer): void { + $writer->withoutContext(); + $writer->info('nested'); + }); + $writer = new Logger(new Monolog('recursive', [$handler])); + $writer->withContext(['request_id' => 'request-1']); + + $writer->info('initial'); + + $this->assertTrue($handler->loopWarningEmitted); + $this->assertSame([], $writer->getContext()); + } + + public function testMessageLoggedListenerRecursionUsesLoopDetection(): void + { + $handler = new TestHandler; + $events = new Dispatcher; + $writer = new Logger(new Monolog('recursive', [$handler]), $events); + $remainingRecursions = 6; + + $events->listen(MessageLogged::class, function () use ($writer, &$remainingRecursions): void { + if ($remainingRecursions-- > 0) { + $writer->info('nested'); + } + }); + + $writer->info('initial'); + + $this->assertTrue($handler->hasWarningThatContains('infinite logging loop')); + } + public function testNamedLoggerRejectsNonMonologDrivers(): void { $this->expectException(RuntimeException::class); @@ -225,7 +301,7 @@ public function testNamedLoggerRejectsNonMonologDrivers(): void public function testWithContext() { - $writer = new Logger($monolog = m::mock(Monolog::class)); + $writer = new Logger($monolog = $this->mockMonolog()); $writer->withContext(['foo' => 'bar']); $writer->withContext(['baz' => 'qux']); @@ -238,7 +314,7 @@ public function testWithContext() public function testLoggerSkipsEventDispatchWhenNoListenersAreRegistered() { - $writer = new Logger($monolog = m::mock(Monolog::class), $events = m::mock(DispatcherContract::class)); + $writer = new Logger($monolog = $this->mockMonolog(), $events = m::mock(DispatcherContract::class)); $monolog->shouldReceive('isHandling')->with('error')->andReturn(true); $monolog->shouldReceive('error')->once()->with('foo', []); $events->shouldReceive('hasListeners')->once()->with(MessageLogged::class)->andReturn(false); @@ -255,7 +331,7 @@ public function testMessageLoggedExtraContainsVisibleContextAndExcludesHidden() $repository->addHidden('api_key', 'secret-token'); CoroutineContext::set(ContextRepository::CONTEXT_KEY, $repository); - $writer = new Logger($monolog = m::mock(Monolog::class), $events); + $writer = new Logger($monolog = $this->mockMonolog(), $events); $monolog->shouldReceive('isHandling')->with('info')->andReturn(true); $monolog->shouldReceive('info')->once()->with('test', []); @@ -277,7 +353,7 @@ public function testMessageLoggedExtraIsEmptyWhenNoContextUsed() CoroutineContext::forget(ContextRepository::CONTEXT_KEY); $events = new Dispatcher; - $writer = new Logger($monolog = m::mock(Monolog::class), $events); + $writer = new Logger($monolog = $this->mockMonolog(), $events); $monolog->shouldReceive('isHandling')->with('info')->andReturn(true); $monolog->shouldReceive('info')->once()->with('test', []); @@ -290,4 +366,69 @@ public function testMessageLoggedExtraIsEmptyWhenNoContextUsed() $this->assertSame([], $captured->extra); } + + private function mockMonolog(): Monolog + { + $monolog = m::mock(Monolog::class); + $monolog->shouldReceive('useLoggingLoopDetection')->once()->with(false); + + return $monolog; + } +} + +class InterleavingLogHandler extends AbstractHandler +{ + /** @var list */ + public array $records = []; + + private int $entered = 0; + + public function __construct( + private readonly Channel $release + ) { + parent::__construct(); + } + + public function handle(LogRecord $record): bool + { + $this->records[] = $record; + + if (++$this->entered === 3) { + $this->release->push(true); + $this->release->push(true); + $this->release->push(true); + } + + $this->release->pop(); + + return false; + } +} + +class ContextClearingRecursiveLogHandler extends AbstractHandler +{ + public bool $loopWarningEmitted = false; + + private int $remainingRecursions = 6; + + public function __construct( + private readonly Closure $recurse + ) { + parent::__construct(); + } + + public function handle(LogRecord $record): bool + { + if ($record->level === Level::Warning && str_contains($record->message, 'infinite logging loop')) { + $this->loopWarningEmitted = true; + + return false; + } + + if ($this->remainingRecursions-- > 0) { + ($this->recurse)(); + } + + return false; + } } diff --git a/tests/Log/LogManagerTest.php b/tests/Log/LogManagerTest.php index 5fe9ff502..05ffeca5d 100644 --- a/tests/Log/LogManagerTest.php +++ b/tests/Log/LogManagerTest.php @@ -5,8 +5,12 @@ namespace Hypervel\Tests\Log; use Hypervel\Log\Context\ResolvedContextLogProcessor; +use Hypervel\Log\Handlers\FingersCrossedHandler as HypervelFingersCrossedHandler; +use Hypervel\Log\Handlers\RotatingFileHandler as HypervelRotatingFileHandler; +use Hypervel\Log\Handlers\StreamHandler as HypervelStreamHandler; use Hypervel\Log\Logger; use Hypervel\Log\LogManager; +use Hypervel\Log\Processors\UidProcessor as HypervelUidProcessor; use Hypervel\Testbench\TestCase; use Monolog\Formatter\HtmlFormatter; use Monolog\Formatter\LineFormatter; @@ -108,6 +112,15 @@ public function testStackChannel() $this->assertTrue($handlers[1]->getBubble()); } + public function testOnDemandStackUsesItsConfiguredName(): void + { + $manager = new LogManager($this->app); + + $logger = $manager->stack([], 'audit'); + + $this->assertSame('audit', $logger->getName()); + } + public function testParsingStackChannels() { $manager = new LogManager($this->app); @@ -285,6 +298,48 @@ public function testLogManagerCreatesMonologHandlerWithProcessors() $this->assertTrue($removeUsedContextFields->getValue($processors[2])); } + public function testExactVendorHandlersAndUidProcessorsUseCoroutineSafeImplementations(): void + { + $manager = new LogManager($this->app); + $config = $this->app->make('config'); + $config->set('logging.channels.safe', [ + 'driver' => 'monolog', + 'handler' => StreamHandler::class, + 'with' => ['stream' => 'php://memory'], + 'processors' => [ + ['processor' => UidProcessor::class, 'with' => ['length' => 16]], + ], + ]); + $config->set('logging.channels.custom', [ + 'driver' => 'monolog', + 'handler' => CustomStreamHandler::class, + 'with' => ['stream' => 'php://memory'], + 'processors' => [CustomUidProcessor::class], + ]); + + $safe = $manager->channel('safe')->getLogger(); + $custom = $manager->channel('custom')->getLogger(); + + $this->assertSame(HypervelStreamHandler::class, get_class($safe->getHandlers()[0])); + $this->assertSame(HypervelUidProcessor::class, get_class($safe->getProcessors()[1])); + $this->assertSame(16, strlen($safe->getProcessors()[1]->getUid())); + $this->assertSame(CustomStreamHandler::class, get_class($custom->getHandlers()[0])); + $this->assertSame(CustomUidProcessor::class, get_class($custom->getProcessors()[1])); + } + + public function testDailyDriverUsesCoroutineSafeRotatingHandler(): void + { + $manager = new LogManager($this->app); + $this->app->make('config')->set('logging.channels.daily-safe', [ + 'driver' => 'daily', + 'path' => __DIR__ . '/logs/daily-safe.log', + ]); + + $handler = $manager->channel('daily-safe')->getLogger()->getHandlers()[0]; + + $this->assertSame(HypervelRotatingFileHandler::class, get_class($handler)); + } + public function testItUtilisesTheNullDriverDuringTestsWhenNullDriverUsed() { $manager = new class($this->app) extends LogManager { @@ -486,6 +541,34 @@ public function testLogManagerCanBuildOnDemandChannel() $this->assertSame($path, $url->getValue($handler)); } + public function testOnDemandChannelsAreUncachedAndUseTheirRuntimeTaps(): void + { + $manager = new LogManager($this->app); + $config = [ + 'driver' => 'single', + 'tap' => [CustomizeFormatter::class], + 'path' => __DIR__ . '/logs/on-demand-tapped.log', + ]; + + $first = $manager->build($config); + $second = $manager->build($config); + $format = new ReflectionProperty( + get_class($first->getLogger()->getHandlers()[0]->getFormatter()), + 'format' + ); + + $this->assertNotSame($first, $second); + $this->assertSame([], $manager->getChannels()); + $this->assertSame( + '[%datetime%] %channel%.%level_name%: %message% %context% %extra%', + rtrim($format->getValue($first->getLogger()->getHandlers()[0]->getFormatter())) + ); + + $manager->shareContext(['request_id' => 'later']); + + $this->assertSame([], $first->getContext()); + } + public function testLogManagerCanUseOnDemandChannelInOnDemandStack() { $manager = new LogManager($this->app); @@ -547,6 +630,7 @@ public function testWrappingHandlerInFingersCrossedWhenActionLevelIsUsed() $expectedFingersCrossedHandler = $handlers[0]; $this->assertInstanceOf(FingersCrossedHandler::class, $expectedFingersCrossedHandler); + $this->assertSame(HypervelFingersCrossedHandler::class, get_class($expectedFingersCrossedHandler)); $activationStrategyProp = new ReflectionProperty(get_class($expectedFingersCrossedHandler), 'activationStrategy'); $activationStrategyValue = $activationStrategyProp->getValue($expectedFingersCrossedHandler); @@ -807,6 +891,29 @@ public function testCustomDriverClosureBoundObjectIsLogManager() $this->assertSame($manager, $manager->channel(__CLASS__)->getLogger()); } + public function testCustomDriverAcceptsStaticAnonymousClosure(): void + { + $this->app->make('config')->set('logging.channels.static', ['driver' => 'static']); + $manager = new LogManager($this->app); + $logger = new LoggerSpy; + + $manager->extend('static', static fn () => $logger); + + $this->assertSame($logger, $manager->channel('static')->getLogger()); + } + + public function testCustomDriverAcceptsFirstClassCallable(): void + { + $this->app->make('config')->set('logging.channels.callable', ['driver' => 'callable']); + $manager = new LogManager($this->app); + $logger = new LoggerSpy; + $factory = new LogCreator($logger); + + $manager->extend('callable', $factory->create(...)); + + $this->assertSame($logger, $manager->channel('callable')->getLogger()); + } + public function testLogManagerCanResolveBackedEnumChannel(): void { $manager = new LogManager($this->app); @@ -932,6 +1039,27 @@ public function log($level, Stringable|string $message, array $context = []): vo } } +class CustomStreamHandler extends StreamHandler +{ +} + +class CustomUidProcessor extends UidProcessor +{ +} + +class LogCreator +{ + public function __construct( + private readonly LoggerInterface $logger + ) { + } + + public function create(): LoggerInterface + { + return $this->logger; + } +} + enum LogChannelName: string { case Single = 'single'; diff --git a/tests/Log/StreamHandlerTest.php b/tests/Log/StreamHandlerTest.php new file mode 100644 index 000000000..1dcaa8f97 --- /dev/null +++ b/tests/Log/StreamHandlerTest.php @@ -0,0 +1,278 @@ +error('message'); + } catch (UnexpectedValueException $exception) { + $exceptionMessage = $exception->getMessage(); + } + }, + function (): void { + LogStreamWrapper::$entered->pop(); + trigger_error('warning from sibling coroutine', E_USER_WARNING); + LogStreamWrapper::$release->push(true); + }, + ]); + } finally { + restore_error_handler(); + } + + $this->assertContains([E_USER_WARNING, 'warning from sibling coroutine'], $warnings); + $this->assertStringContainsString(self::STREAM_SCHEME . '://failing', $exceptionMessage); + $this->assertStringNotContainsString('warning from sibling coroutine', $exceptionMessage); + } + + public function testOpenFailureIsNormalizedWithReturningAndThrowingErrorHandlers(): void + { + LogStreamWrapper::$failOpen = true; + $messages = []; + + set_error_handler(static fn (): bool => true); + + try { + $messages[] = $this->captureOpenFailureMessage('failed record'); + } finally { + restore_error_handler(); + } + + set_error_handler(static function (int $level, string $message, string $file, int $line): never { + throw new ErrorException($message, 0, $level, $file, $line); + }); + + try { + $messages[] = $this->captureOpenFailureMessage('failed record'); + } finally { + restore_error_handler(); + } + + $this->assertSame($messages[0], $messages[1]); + $this->assertStringStartsWith( + 'The stream or file "' . self::STREAM_SCHEME . '://open-failure" could not be opened using mode "a".', + $messages[0] + ); + } + + public function testFailedWriteReopensOnceAndRetries(): void + { + LogStreamWrapper::$failFirstWrite = true; + + (new Logger('test', [ + new StreamHandler(self::STREAM_SCHEME . '://retry'), + ]))->info('retry me'); + + $this->assertSame(2, LogStreamWrapper::$openCount); + $this->assertSame(2, LogStreamWrapper::$writeCount); + $this->assertStringContainsString('retry me', LogStreamWrapper::$written); + } + + public function testDirectoryCreationFailureThrowsDeterministically(): void + { + $file = tempnam(sys_get_temp_dir(), 'hypervel-log-parent-'); + $this->assertIsString($file); + set_error_handler(static fn (): bool => true); + + try { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('and it could not be created'); + + (new Logger('test', [new StreamHandler($file . '/child/app.log')]))->info('message'); + } finally { + restore_error_handler(); + @unlink($file); + } + } + + public function testRotatingHandlerWritesThroughTheSafeStreamBoundary(): void + { + $directory = sys_get_temp_dir() . '/hypervel-rotating-' . bin2hex(random_bytes(8)); + $path = $directory . '/app.log'; + $handler = new RotatingFileHandler($path, 1); + + try { + (new Logger('test', [$handler]))->info('rotating message'); + $handler->close(); + + $files = glob($directory . '/app-*.log'); + $this->assertIsArray($files); + $this->assertCount(1, $files); + $this->assertStringContainsString('rotating message', (string) file_get_contents($files[0])); + } finally { + foreach (glob($directory . '/*') ?: [] as $file) { + @unlink($file); + } + + @rmdir($directory); + } + } + + private function captureOpenFailureMessage(string $message): string + { + try { + (new Logger('test', [ + new StreamHandler(self::STREAM_SCHEME . '://open-failure'), + ]))->error($message); + } catch (UnexpectedValueException $exception) { + return $exception->getMessage(); + } + + $this->fail('Expected the stream open to fail.'); + } +} + +class LogStreamWrapper +{ + public static ?Channel $entered = null; + + public static ?Channel $release = null; + + public static bool $failOpenAfterYield = false; + + public static bool $failOpen = false; + + public static bool $failFirstWrite = false; + + public static int $openCount = 0; + + public static int $writeCount = 0; + + public static string $written = ''; + + public mixed $context; + + public static function reset(): void + { + self::$entered = null; + self::$release = null; + self::$failOpenAfterYield = false; + self::$failOpen = false; + self::$failFirstWrite = false; + self::$openCount = 0; + self::$writeCount = 0; + self::$written = ''; + } + + public function stream_open(string $path, string $mode, int $options, ?string &$openedPath): bool + { + ++self::$openCount; + + if (self::$failOpen) { + return false; + } + + if (self::$failOpenAfterYield) { + self::$entered?->push(true); + self::$release?->pop(); + + return false; + } + + return true; + } + + public function stream_write(string $data): int|false + { + ++self::$writeCount; + + if (self::$failFirstWrite && self::$writeCount === 1) { + return false; + } + + self::$written .= $data; + + return strlen($data); + } + + public function stream_close(): void + { + } + + public function stream_eof(): bool + { + return true; + } + + public function stream_flush(): bool + { + return true; + } + + public function stream_stat(): array + { + return $this->stat(); + } + + public function url_stat(string $path, int $flags): array + { + return $this->stat(); + } + + private function stat(): array + { + return [ + 'dev' => 0, + 'ino' => 1, + 'mode' => 0100666, + 'nlink' => 1, + 'uid' => 0, + 'gid' => 0, + 'rdev' => 0, + 'size' => strlen(self::$written), + 'atime' => time(), + 'mtime' => time(), + 'ctime' => time(), + 'blksize' => -1, + 'blocks' => -1, + ]; + } +} diff --git a/tests/Log/UidProcessorTest.php b/tests/Log/UidProcessorTest.php new file mode 100644 index 000000000..937367b94 --- /dev/null +++ b/tests/Log/UidProcessorTest.php @@ -0,0 +1,50 @@ + $processor->getUid(), + fn (): string => $processor->getUid(), + ]); + + $this->assertNotSame($uids[0], $uids[1]); + $this->assertSame(16, strlen($uids[0])); + $this->assertSame(16, strlen($uids[1])); + } + + public function testCopiedCoroutineKeepsTheRequestUid(): void + { + $processor = new UidProcessor; + $parentUid = $processor->getUid(); + $result = new Channel(1); + + go(fn () => $result->push($processor->getUid()), copyContext: true); + + $this->assertSame($parentUid, $result->pop()); + } + + public function testResetOnlyReplacesTheCurrentCoroutineUid(): void + { + $processor = new UidProcessor; + $first = $processor->getUid(); + + $processor->reset(); + + $this->assertNotSame($first, $processor->getUid()); + } +} diff --git a/tests/Mail/MailManagerTest.php b/tests/Mail/MailManagerTest.php index 04b79062e..b436e3565 100644 --- a/tests/Mail/MailManagerTest.php +++ b/tests/Mail/MailManagerTest.php @@ -5,10 +5,12 @@ namespace Hypervel\Tests\Mail; use Hypervel\Contracts\View\Factory as ViewFactory; +use Hypervel\Mail\Mailable; use Hypervel\Mail\MailManager; use Hypervel\Mail\TransportPoolProxy; use Hypervel\ObjectPool\Contracts\Factory as PoolFactory; use Hypervel\Support\ClassInvoker; +use Hypervel\Support\Testing\Fakes\MailFake; use Hypervel\Testbench\TestCase; use InvalidArgumentException; use Mockery as m; @@ -30,6 +32,38 @@ protected function setUp(): void $this->app->instance('view', m::mock(ViewFactory::class)); } + public function testIntegerEnumMailerNamesAreNormalizedWithoutTreatingZeroAsAbsent(): void + { + $this->app->make('config')->set('mail.mailers.0', ['transport' => 'array']); + + $manager = new MailManager($this->app); + $manager->setDefaultDriver(MailManagerTestIntIdentifier::Zero); + + $mailer = $manager->mailer(); + + $this->assertSame('0', $manager->getDefaultDriver()); + $this->assertSame($mailer, $manager->driver(MailManagerTestIntIdentifier::Zero)); + $this->assertSame($mailer, $manager->mailer('')); + + $manager->purge(''); + + $this->assertNotSame($mailer, $manager->mailer(MailManagerTestIntIdentifier::Zero)); + } + + public function testFakeDriverNormalizesIntegerEnumsWithoutEscapingToTheManager(): void + { + $this->app->make('config')->set('mail.default', 'array'); + + $fake = new MailFake(new MailManager($this->app)); + $mailable = new Mailable; + + $this->assertSame($fake, $fake->driver(MailManagerTestIntIdentifier::Zero)); + + $fake->send($mailable); + + $this->assertSame('0', $mailable->mailer); + } + #[DataProvider('emptyTransportConfigDataProvider')] public function testEmptyTransportConfig(mixed $transport): void { @@ -650,6 +684,11 @@ public function testOnDemandPoolCanBeInvalidatedThroughItsTransportProxy(): void } } +enum MailManagerTestIntIdentifier: int +{ + case Zero = 0; +} + class MailManagerTestTransport implements TransportInterface { public static int $sent = 0; diff --git a/tests/Mail/MailableQueuedTest.php b/tests/Mail/MailableQueuedTest.php index 282e27d94..09b9e139e 100644 --- a/tests/Mail/MailableQueuedTest.php +++ b/tests/Mail/MailableQueuedTest.php @@ -194,6 +194,32 @@ public function testDelayedQueuedMailableRespectsQueueAndConnectionAttributes(): $this->assertSame(30, $pushedJob->delay); } + public function testQueuedMailablePreservesZeroQueueAndDefaultsEmptyQueue(): void + { + $zeroQueue = new QueueFake($this->app); + (new MailableQueueableStub)->onQueue('0')->queue($zeroQueue); + + $zeroQueue->assertPushedOn('0', SendQueuedMailable::class); + + $defaultQueue = new QueueFake($this->app); + (new MailableQueueableStub)->onQueue('')->queue($defaultQueue); + + $defaultQueue->assertPushedOn(null, SendQueuedMailable::class); + } + + public function testDelayedMailablePreservesZeroQueueAndDefaultsEmptyQueue(): void + { + $zeroQueue = new QueueFake($this->app); + (new MailableQueueableStub)->onQueue('0')->later(10, $zeroQueue); + + $zeroQueue->assertPushedOn('0', SendQueuedMailable::class); + + $defaultQueue = new QueueFake($this->app); + (new MailableQueueableStub)->onQueue('')->later(10, $defaultQueue); + + $defaultQueue->assertPushedOn(null, SendQueuedMailable::class); + } + public function testQueuedMailableForwardsDeduplicationIdMethodToQueueJob() { $queueFake = new QueueFake($this->app); diff --git a/tests/Notifications/NotificationChannelManagerTest.php b/tests/Notifications/NotificationChannelManagerTest.php index 8f9a3999e..ff22b4b73 100644 --- a/tests/Notifications/NotificationChannelManagerTest.php +++ b/tests/Notifications/NotificationChannelManagerTest.php @@ -26,6 +26,7 @@ use Hypervel\Queue\InteractsWithQueue; use Hypervel\Queue\QueueRoutes; use Hypervel\Queue\SerializesModels; +use Hypervel\Support\Testing\Fakes\NotificationFake; use Hypervel\Tests\TestCase; use Laravel\SerializableClosure\SerializableClosure; use Mockery as m; @@ -54,6 +55,16 @@ public function testGetCustomChannelResolvesDirectly(): void $this->assertSame($channel, $manager->channel('test')); } + public function testIntegerEnumChannelNamesAreNormalizedByTheManagerAndFake(): void + { + $manager = new ChannelManager($this->getContainer()); + $channel = m::mock('customChannel'); + $manager->extend('0', fn () => $channel); + + $this->assertSame($channel, $manager->channel(NotificationChannelManagerTestIntIdentifier::Zero)); + $this->assertNull((new NotificationFake)->channel(NotificationChannelManagerTestIntIdentifier::Zero)); + } + public function testSlackChannelResolvesDirectly(): void { $container = $this->getContainer(); @@ -476,6 +487,11 @@ class TestSendQueuedNotifications implements ShouldQueue use SerializesModels; } +enum NotificationChannelManagerTestIntIdentifier: int +{ + case Zero = 0; +} + class NotificationChannelManagerTestNotifiable { use Notifiable; diff --git a/tests/Permission/Commands/CommandTest.php b/tests/Permission/Commands/CommandTest.php index 7face412d..f1e2dc0ef 100644 --- a/tests/Permission/Commands/CommandTest.php +++ b/tests/Permission/Commands/CommandTest.php @@ -113,6 +113,17 @@ public function testItCanShowPermissionsForGuard(): void $this->assertStringNotContainsString('Guard: admin', $output); } + public function testItCanShowPermissionsForGuardNamedZero(): void + { + Permission::create(['name' => 'zero-permission', 'guard_name' => '0']); + + Artisan::call('permission:show', ['guard' => '0']); + $output = Artisan::output(); + + $this->assertStringContainsString('Guard: 0', $output); + $this->assertStringNotContainsString('Guard: web', $output); + } + public function testItCanSetupTeamsUpgrade(): void { $this->app->make('config')->set('permission.teams', true); diff --git a/tests/Permission/GuardTest.php b/tests/Permission/GuardTest.php index 0d518ac42..0d4a3d45e 100644 --- a/tests/Permission/GuardTest.php +++ b/tests/Permission/GuardTest.php @@ -5,11 +5,47 @@ namespace Hypervel\Tests\Permission; use Hypervel\Permission\Guard; +use Hypervel\Support\Facades\Auth; use Hypervel\Tests\Permission\Fixtures\Models\Admin; use Hypervel\Tests\Permission\Fixtures\Models\User; +use Hypervel\Tests\Permission\Fixtures\PassportGuard; class GuardTest extends TestCase { + public function testZeroGuardNamesRemainStringIdentifiers(): void + { + $user = new User; + $user->setAttribute('guard_name', '0'); + + $this->assertSame(['0'], Guard::getNames($user)->all()); + $this->assertSame('0', Guard::getDefaultName($user)); + + $this->app->make('config')->set('auth.guards', [ + '0' => ['driver' => 'session', 'provider' => 'users'], + ]); + Guard::flushState(); + + $this->assertSame(['0'], Guard::getNames(User::class)->all()); + $this->assertSame('0', Guard::getDefaultName(User::class)); + } + + public function testZeroPassportGuardRunsTheClientCompatibilityCheck(): void + { + $this->setUpPassport(); + + $this->app->make('config')->set([ + 'auth.guards.api' => ['driver' => 'session', 'provider' => 'users'], + 'auth.guards.0' => ['driver' => 'passport', 'provider' => 'users'], + ]); + + $client = $this->testClient; + + Auth::extend('passport', fn (): PassportGuard => new PassportGuard($client)); + Auth::forgetGuards(); + + $this->assertNull(Guard::getPassportClient('0')); + } + public function testFlushStateClearsCachedGuardMetadata(): void { $this->assertSame(User::class, Guard::getModelForGuard('web')); diff --git a/tests/Permission/Middleware/PermissionMiddlewareTest.php b/tests/Permission/Middleware/PermissionMiddlewareTest.php index 7c212046d..cc3677c10 100644 --- a/tests/Permission/Middleware/PermissionMiddlewareTest.php +++ b/tests/Permission/Middleware/PermissionMiddlewareTest.php @@ -17,6 +17,12 @@ use Hypervel\Tests\Permission\TestCase; use InvalidArgumentException; +enum PermissionMiddlewareTestIntEnum: int +{ + case Zero = 0; + case One = 1; +} + class PermissionMiddlewareTest extends TestCase { protected PermissionMiddleware $permissionMiddleware; @@ -172,6 +178,16 @@ public function testItCanHandleEnumPermissionsWithStaticUsingMethod(): void ])); } + public function testItCanHandleIntegerEnumPermissionsWithStaticUsingMethod(): void + { + $this->assertSame(PermissionMiddleware::class . ':0', PermissionMiddleware::using(PermissionMiddlewareTestIntEnum::Zero)); + $this->assertSame(PermissionMiddleware::class . ':0,my-guard', PermissionMiddleware::using(PermissionMiddlewareTestIntEnum::Zero, 'my-guard')); + $this->assertSame(PermissionMiddleware::class . ':0|1', PermissionMiddleware::using([ + PermissionMiddlewareTestIntEnum::Zero, + PermissionMiddlewareTestIntEnum::One, + ])); + } + public function testItCanHandleEnumPermissionsWithHandleMethod(): void { $this->app->make(Permission::class)->create(['name' => TestRolePermissionsEnum::ViewArticles->value]); diff --git a/tests/Permission/Models/RoleTest.php b/tests/Permission/Models/RoleTest.php index 5ad20b300..a89c24fba 100644 --- a/tests/Permission/Models/RoleTest.php +++ b/tests/Permission/Models/RoleTest.php @@ -117,6 +117,22 @@ public function testItThrowsGuardMismatchWhenGivenAPermissionObjectFromAnotherGu $this->testUserRole->givePermissionTo($this->testAdminPermission); } + public function testGuardMismatchReportsAnExplicitZeroNamedGuard(): void + { + $this->expectException(GuardDoesNotMatch::class); + $this->expectExceptionMessage('should use guard `0` instead of `admin`'); + + $this->testUserRole->hasPermissionTo($this->testAdminPermission, '0'); + } + + public function testGuardMismatchTreatsAnEmptyGuardAsUnspecified(): void + { + $this->expectException(GuardDoesNotMatch::class); + $this->expectExceptionMessage('should use guard `web` instead of `admin`'); + + $this->testUserRole->hasPermissionTo($this->testAdminPermission, ''); + } + public function testItCanBeGivenMultiplePermissionsUsingAnArray(): void { $this->testUserRole->givePermissionTo(['edit-articles', 'edit-news']); diff --git a/tests/Permission/Traits/HasRolesTest.php b/tests/Permission/Traits/HasRolesTest.php index 5e8e15dd6..f89ebab71 100644 --- a/tests/Permission/Traits/HasRolesTest.php +++ b/tests/Permission/Traits/HasRolesTest.php @@ -585,6 +585,42 @@ public function testItCanScopeAgainstASpecificGuard(): void $this->assertCount(1, Admin::role('testAdminRole2', 'admin')->get()); } + public function testItCanScopeAgainstAZeroNamedGuard(): void + { + config()->set('auth.guards.0', ['driver' => 'session', 'provider' => 'users']); + + $user = User::create(['email' => 'zero-guard@test.com']); + $role = app(Role::class)->create(['name' => 'zeroGuardRole', 'guard_name' => '0']); + $webRole = app(Role::class)->create(['name' => 'emptyGuardFallbackRole', 'guard_name' => 'web']); + $user->assignRole($role, $webRole); + + $this->assertSame([$user->getKey()], User::role('zeroGuardRole', '0')->pluck('id')->all()); + $this->assertSame([$user->getKey()], User::role('emptyGuardFallbackRole', '')->pluck('id')->all()); + } + + public function testRoleChecksHonorAZeroNamedGuard(): void + { + config()->set('auth.guards.0', ['driver' => 'session', 'provider' => 'users']); + + $user = User::create(['email' => 'zero-role-checks@test.com']); + $zeroRole = app(Role::class)->create(['name' => 'zeroGuardRole', 'guard_name' => '0']); + $webRole = app(Role::class)->create(['name' => 'webOnlyRole', 'guard_name' => 'web']); + $user->assignRole($zeroRole, $webRole); + + $this->assertTrue($user->hasRole($zeroRole->getKey(), '0')); + $this->assertFalse($user->hasRole($webRole->getKey(), '0')); + $this->assertTrue($user->hasRole($zeroRole->name, '0')); + $this->assertFalse($user->hasRole($webRole->name, '0')); + $this->assertTrue($user->hasRole(collect([$user->roles->firstWhere('guard_name', '0')]), '0')); + $this->assertFalse($user->hasRole(collect([$user->roles->firstWhere('guard_name', 'web')]), '0')); + $this->assertTrue($user->hasAllRoles([$zeroRole->name], '0')); + $this->assertFalse($user->hasAllRoles([$zeroRole->name, $webRole->name], '0')); + $this->assertTrue($user->hasRole($webRole->getKey(), '')); + $this->assertTrue($user->hasRole($webRole->name, '')); + $this->assertTrue($user->hasRole(collect([$user->roles->firstWhere('guard_name', 'web')]), '')); + $this->assertTrue($user->hasAllRoles([$webRole->name], '')); + } + public function testItCanWithoutScopeAgainstASpecificGuard(): void { User::all()->each(fn ($item) => $item->delete()); diff --git a/tests/Pipeline/PipelineTest.php b/tests/Pipeline/PipelineTest.php index e2fcfc493..c1ba01242 100644 --- a/tests/Pipeline/PipelineTest.php +++ b/tests/Pipeline/PipelineTest.php @@ -6,14 +6,29 @@ use Exception; use Hypervel\Container\Container; +use Hypervel\Database\Connection; +use Hypervel\Database\DatabaseManager; +use Hypervel\Pipeline\Hub; use Hypervel\Pipeline\Pipeline; use Hypervel\Tests\Pipeline\Fixtures\FooPipeline; use Hypervel\Tests\TestCase; +use Mockery as m; use RuntimeException; use stdClass; class PipelineTest extends TestCase { + public function testHubPreservesZeroNamedPipelines(): void + { + $hub = new Hub(new Container); + $hub->defaults(fn (Pipeline $pipeline, string $value): string => 'default-' . $value); + $hub->pipeline('0', fn (Pipeline $pipeline, string $value): string => 'zero-' . $value); + + $this->assertSame('default-value', $hub->pipe('value')); + $this->assertSame('default-value', $hub->pipe('value', '')); + $this->assertSame('zero-value', $hub->pipe('value', '0')); + } + public function testPipelineBasicUsage() { $pipeTwo = function ($piped, $next) { @@ -270,6 +285,23 @@ public function testPipelineThrowsExceptionWhenUsingTransactionsWithoutContainer }); } + public function testPipelineDelegatesIntegerBackedEnumTransactionConnection(): void + { + $container = new Container; + $connection = m::mock(Connection::class); + $connection->shouldReceive('transaction')->once()->andReturnUsing(fn (callable $callback) => $callback()); + $manager = m::mock(DatabaseManager::class); + $manager->shouldReceive('connection')->once()->with(PipelineConnectionName::Zero)->andReturn($connection); + $container->instance('db', $manager); + + $result = (new Pipeline($container)) + ->send('data') + ->withinTransaction(PipelineConnectionName::Zero) + ->thenReturn(); + + $this->assertSame('data', $result); + } + public function testPipelineThenReturnMethodRunsPipelineThenReturnsPassable() { $result = (new Pipeline(new Container)) @@ -500,6 +532,11 @@ public function testPipelineMacroOverwrite() } } +enum PipelineConnectionName: int +{ + case Zero = 0; +} + class PipelineTestPipeOne { public function handle($piped, $next) diff --git a/tests/Queue/FailoverQueueTest.php b/tests/Queue/FailoverQueueTest.php index e658eefeb..75842a401 100644 --- a/tests/Queue/FailoverQueueTest.php +++ b/tests/Queue/FailoverQueueTest.php @@ -20,8 +20,10 @@ use Hypervel\Tests\TestCase; use Mockery as m; use RuntimeException; +use UnitEnum; use function Hypervel\Coroutine\parallel; +use function Hypervel\Support\enum_value; class FailoverQueueTest extends TestCase { @@ -102,8 +104,12 @@ public function __construct( ) { } - public function connection(?string $name = null): Queue + public function connection(UnitEnum|string|null $name = null): Queue { + if ($name instanceof UnitEnum) { + $name = (string) enum_value($name); + } + return $this->connections[$name]; } } diff --git a/tests/Queue/QueueBackgroundQueueTest.php b/tests/Queue/QueueBackgroundQueueTest.php index 6fe32ea3a..bf80af5ba 100644 --- a/tests/Queue/QueueBackgroundQueueTest.php +++ b/tests/Queue/QueueBackgroundQueueTest.php @@ -19,6 +19,7 @@ use Hypervel\Support\Carbon; use Hypervel\Tests\TestCase; use Mockery as m; +use RuntimeException; use function Hypervel\Coroutine\run; @@ -42,6 +43,42 @@ public function testPushShouldRunInBackground() $this->assertEquals(['foo' => 'bar'], $_SERVER['__background.test'][1]); } + public function testPushSnapshotsMutableJobBeforeBackgroundExecution(): void + { + BackgroundQueueSnapshotHandler::$receivedValue = null; + $data = (object) ['value' => 'before']; + $background = new BackgroundQueue; + $background->setConnectionName('background'); + $background->setContainer($this->getContainer()); + + run(function () use ($background, $data): void { + $background->push(BackgroundQueueSnapshotHandler::class, $data); + $data->value = 'after'; + }); + + $this->assertSame('before', BackgroundQueueSnapshotHandler::$receivedValue); + } + + public function testPushReportsSerializationFailuresSynchronously(): void + { + $exceptionHandled = false; + $background = new BackgroundQueue; + $background->setExceptionCallback(function () use (&$exceptionHandled): void { + $exceptionHandled = true; + }); + $background->setConnectionName('background'); + $background->setContainer($this->getContainer()); + + try { + $background->push(new UnserializableBackgroundQueueJob); + $this->fail('Expected job serialization to fail.'); + } catch (RuntimeException $exception) { + $this->assertStringContainsString('Failed to serialize job', $exception->getMessage()); + } + + $this->assertFalse($exceptionHandled); + } + public function testFailedJobGetsHandledWhenAnExceptionIsThrown() { unset($_SERVER['__background.failed']); @@ -159,6 +196,64 @@ public function testLaterSchedulesJobWithDelay() $this->assertEquals(['foo' => 'bar'], $_SERVER['__background.later.test'][1]); } + public function testLaterSnapshotsMutableJobBeforeTheTimerRuns(): void + { + $scheduled = null; + $timer = m::mock(Timer::class); + $timer->shouldReceive('after') + ->once() + ->with(5.0, m::type('Closure')) + ->andReturnUsing(function ($delay, $callback) use (&$scheduled) { + $scheduled = $callback; + + return 1; + }); + + BackgroundQueueSnapshotHandler::$receivedValue = null; + $data = (object) ['value' => 'before']; + $background = new BackgroundQueue(timer: $timer); + $background->setConnectionName('background'); + $background->setContainer($this->getContainer()); + + run(function () use ($background, $data, &$scheduled): void { + $background->later(5, BackgroundQueueSnapshotHandler::class, $data); + $data->value = 'after'; + $scheduled(); + }); + + $this->assertSame('before', BackgroundQueueSnapshotHandler::$receivedValue); + } + + public function testPushSnapshotsAfterCommitJobWhenTheTransactionCommits(): void + { + $afterCommit = null; + $container = $this->getContainer(); + $transactionManager = m::mock(DatabaseTransactionsManager::class); + $transactionManager->shouldReceive('addCallback') + ->once() + ->andReturnUsing(function ($callback) use (&$afterCommit) { + $afterCommit = $callback; + + return null; + }); + $container->instance('db.transactions', $transactionManager); + + BackgroundQueueSnapshotHandler::$receivedValue = null; + $data = (object) ['value' => 'before']; + $background = new BackgroundQueue(dispatchAfterCommit: true); + $background->setConnectionName('background'); + $background->setContainer($container); + + run(function () use ($background, $data, &$afterCommit): void { + $background->push(BackgroundQueueSnapshotHandler::class, $data); + $data->value = 'at-commit'; + $afterCommit(); + $data->value = 'after'; + }); + + $this->assertSame('at-commit', BackgroundQueueSnapshotHandler::$receivedValue); + } + public function testLaterWithDateInterval() { Carbon::setTestNow('2024-01-01 12:00:00'); @@ -514,3 +609,25 @@ public function fire(SyncJob $job, mixed $data): void $_SERVER['__background.later.test'] = func_get_args(); } } + +class BackgroundQueueSnapshotHandler +{ + public static ?string $receivedValue = null; + + public function fire(SyncJob $job, array $data): void + { + static::$receivedValue = $data['value']; + } +} + +class UnserializableBackgroundQueueJob +{ + public function __construct(public mixed $callback = null) + { + $this->callback = fn () => null; + } + + public function handle(): void + { + } +} diff --git a/tests/Queue/QueueBeanstalkdQueueTest.php b/tests/Queue/QueueBeanstalkdQueueTest.php index 4b47fcc3c..adae13ea3 100644 --- a/tests/Queue/QueueBeanstalkdQueueTest.php +++ b/tests/Queue/QueueBeanstalkdQueueTest.php @@ -32,6 +32,15 @@ class QueueBeanstalkdQueueTest extends TestCase */ private $container; + public function testQueueNamesPreserveZeroAndDefaultEmptyString(): void + { + $this->setQueue('default', 60); + + $this->assertSame('default', $this->queue->getQueue(null)); + $this->assertSame('default', $this->queue->getQueue('')); + $this->assertSame('0', $this->queue->getQueue('0')); + } + public function testPushProperlyPushesJobOntoBeanstalkd() { $now = Carbon::now(); diff --git a/tests/Queue/QueueCommandIdentifierTest.php b/tests/Queue/QueueCommandIdentifierTest.php new file mode 100644 index 000000000..b8b07ebb0 --- /dev/null +++ b/tests/Queue/QueueCommandIdentifierTest.php @@ -0,0 +1,96 @@ +set('queue.default', 'redis'); + $app['config']->set('queue.connections.redis.queue', 'default'); + $app['config']->set('queue.connections.0.queue', 'zero-default'); + } + + #[DataProvider('queueIdentifierProvider')] + public function testClearCommandPreservesZeroAndDefaultsEmptyIdentifiers( + string $connection, + string $queue, + string $expectedConnection, + string $expectedQueue, + ): void { + $resolvedQueue = m::mock(Queue::class, ClearableQueue::class); + $resolvedQueue->shouldReceive('clear')->once()->with($expectedQueue)->andReturn(1); + + $manager = m::mock(QueueManager::class); + $manager->shouldReceive('connection')->once()->with($expectedConnection)->andReturn($resolvedQueue); + $this->app->instance('queue', $manager); + + $command = new ClearCommand; + $command->setHypervel($this->app); + + $this->assertSame(0, $command->run( + new ArrayInput([ + 'connection' => $connection, + '--queue' => $queue, + '--force' => true, + ]), + new BufferedOutput, + )); + } + + #[DataProvider('queueIdentifierProvider')] + public function testListenCommandPreservesZeroAndDefaultsEmptyIdentifiers( + string $connection, + string $queue, + string $expectedConnection, + string $expectedQueue, + ): void { + $this->app['config']->set("queue.connections.{$expectedConnection}.queue", $expectedQueue); + + $listener = m::mock(Listener::class); + $listener->shouldReceive('setOutputHandler')->once(); + + $command = new QueueIdentifierListenCommand($this->app->make('config'), $listener); + + $this->assertSame($expectedQueue, $command->resolveQueue($connection, $queue)); + } + + /** + * Provide queue identifiers and their resolved values. + */ + public static function queueIdentifierProvider(): array + { + return [ + 'zero connection' => ['0', '', '0', 'zero-default'], + 'zero queue' => ['redis', '0', 'redis', '0'], + 'empty identifiers' => ['', '', 'redis', 'default'], + ]; + } +} + +class QueueIdentifierListenCommand extends ListenCommand +{ + /** + * Resolve the configured queue for the given command input. + */ + public function resolveQueue(string $connection, string $queue): string + { + $this->input = new ArrayInput(['--queue' => $queue], $this->getDefinition()); + + return $this->getQueue($connection); + } +} diff --git a/tests/Queue/QueueDatabaseQueueUnitTest.php b/tests/Queue/QueueDatabaseQueueUnitTest.php index b0096c8d1..94a5bcb3b 100644 --- a/tests/Queue/QueueDatabaseQueueUnitTest.php +++ b/tests/Queue/QueueDatabaseQueueUnitTest.php @@ -23,6 +23,21 @@ class QueueDatabaseQueueUnitTest extends TestCase { + public function testQueueNamesPreserveZeroAndDefaultEmptyString(): void + { + $queue = new TestDatabaseQueue( + resolver: m::mock(ConnectionResolverInterface::class), + connection: null, + table: 'table', + default: 'default', + currentTime: 1732502704, + ); + + $this->assertSame('default', $queue->getQueue(null)); + $this->assertSame('default', $queue->getQueue('')); + $this->assertSame('0', $queue->getQueue('0')); + } + #[DataProvider('pushJobsDataProvider')] public function testPushProperlyPushesJobOntoDatabase($uuid, $job, $displayNameStartsWith, $jobStartsWith) { diff --git a/tests/Queue/QueueDeferredQueueTest.php b/tests/Queue/QueueDeferredQueueTest.php index 02fa81f47..095be46ff 100644 --- a/tests/Queue/QueueDeferredQueueTest.php +++ b/tests/Queue/QueueDeferredQueueTest.php @@ -42,6 +42,22 @@ public function testPushShouldDefer() $this->assertEquals(['foo' => 'bar'], $_SERVER['__deferred.test'][1]); } + public function testPushSnapshotsMutableJobBeforeCoroutineEnd(): void + { + DeferredQueueSnapshotHandler::$receivedValue = null; + $data = (object) ['value' => 'before']; + $deferred = new DeferredQueue; + $deferred->setConnectionName('deferred'); + $deferred->setContainer($this->getContainer()); + + run(function () use ($deferred, $data): void { + $deferred->push(DeferredQueueSnapshotHandler::class, $data); + $data->value = 'after'; + }); + + $this->assertSame('before', DeferredQueueSnapshotHandler::$receivedValue); + } + public function testFailedJobGetsHandledWhenAnExceptionIsThrown() { unset($_SERVER['__deferred.failed']); @@ -514,3 +530,13 @@ public function fire(SyncJob $job, mixed $data): void $_SERVER['__deferred.later.test'] = func_get_args(); } } + +class DeferredQueueSnapshotHandler +{ + public static ?string $receivedValue = null; + + public function fire(SyncJob $job, array $data): void + { + static::$receivedValue = $data['value']; + } +} diff --git a/tests/Queue/QueueManagerTest.php b/tests/Queue/QueueManagerTest.php index 6b23cc118..ecdff7b94 100644 --- a/tests/Queue/QueueManagerTest.php +++ b/tests/Queue/QueueManagerTest.php @@ -20,6 +20,40 @@ class QueueManagerTest extends TestCase { + public function testIntegerEnumConnectionNamesAreNormalizedWithoutTreatingZeroAsAbsent(): void + { + $container = $this->getContainer(); + $config = $container->make('config'); + $config->set('queue.default', 'sync'); + $config->set('queue.connections.0', ['driver' => 'sync']); + + $manager = new QueueManager($container); + $connector = m::mock(ConnectorInterface::class); + $queue = m::mock(Queue::class); + $queue->shouldReceive('setConnectionName')->once()->with('0')->andReturnSelf(); + $queue->shouldReceive('setConfig')->once()->andReturnSelf(); + $queue->shouldReceive('setContainer')->once()->with($container)->andReturnSelf(); + $connector->shouldReceive('connect')->once()->with(['driver' => 'sync'])->andReturn($queue); + $manager->addConnector('sync', fn () => $connector); + + $this->assertSame('sync', $manager->getName()); + $this->assertSame('0', $manager->getName('0')); + $this->assertSame('sync', $manager->getName('')); + + $manager->setDefaultDriver(QueueManagerTestIntIdentifier::Zero); + + $this->assertSame('0', $manager->getDefaultDriver()); + $this->assertSame($queue, $manager->connection()); + $this->assertSame($queue, $manager->connection(QueueManagerTestIntIdentifier::Zero)); + $this->assertSame($queue, $manager->connection('')); + $this->assertTrue($manager->connected(QueueManagerTestIntIdentifier::Zero)); + $this->assertTrue($manager->connected('')); + + $manager->purge(''); + + $this->assertFalse($manager->connected(QueueManagerTestIntIdentifier::Zero)); + } + public function testDefaultConnectionCanBeResolved(): void { $container = $this->getContainer(); @@ -311,6 +345,11 @@ protected function getContainer(): Container } } +enum QueueManagerTestIntIdentifier: int +{ + case Zero = 0; +} + class QueueManagerTestJob extends Job { public function __construct(ContainerContract $container, string $connectionName) diff --git a/tests/Queue/QueuePauseResumeTest.php b/tests/Queue/QueuePauseResumeTest.php index 387274743..bce9a6c8a 100644 --- a/tests/Queue/QueuePauseResumeTest.php +++ b/tests/Queue/QueuePauseResumeTest.php @@ -197,6 +197,7 @@ public function parse(string $queue): array }; $this->assertSame(['redis', 'default'], $parser->parse('')); + $this->assertSame(['redis', '0'], $parser->parse('0')); $this->assertSame(['redis', 'emails'], $parser->parse('emails')); $this->assertSame(['database', 'notifications'], $parser->parse('database:notifications')); $this->assertSame(['redis', 'foo:bar'], $parser->parse('redis:foo:bar')); diff --git a/tests/Queue/QueueRedisQueueTest.php b/tests/Queue/QueueRedisQueueTest.php index 1a677e987..b12b55480 100644 --- a/tests/Queue/QueueRedisQueueTest.php +++ b/tests/Queue/QueueRedisQueueTest.php @@ -187,6 +187,8 @@ public function testGetQueueRemainsUnchangedForNonCluster(): void $queue = new RedisQueue(m::mock(Redis::class), 'default', 'default'); $this->assertSame('queues:default', $queue->getQueue(null)); + $this->assertSame('queues:default', $queue->getQueue('')); + $this->assertSame('queues:0', $queue->getQueue('0')); $this->assertSame('queues:emails', $queue->getQueue('emails')); } @@ -198,6 +200,8 @@ public function testGetQueueRemainsUnchangedForCluster(): void $redis->shouldReceive('connection')->never(); $this->assertSame('queues:default', $queue->getQueue(null)); + $this->assertSame('queues:default', $queue->getQueue('')); + $this->assertSame('queues:0', $queue->getQueue('0')); $this->assertSame('queues:emails', $queue->getQueue('emails')); } @@ -209,6 +213,8 @@ public function testGetRedisKeyReturnsPlainKeyForNonCluster(): void $redis->shouldReceive('connection')->once()->andReturn($redisProxy); $this->assertSame('queues:default', $queue->testGetQueueRedisKey(null)); + $this->assertSame('queues:default', $queue->testGetQueueRedisKey('')); + $this->assertSame('queues:0', $queue->testGetQueueRedisKey('0')); $this->assertSame('queues:emails', $queue->testGetQueueRedisKey('emails')); } @@ -220,6 +226,8 @@ public function testGetRedisKeyWrapsWithHashTagsForCluster(): void $redis->shouldReceive('connection')->once()->andReturn($redisProxy); $this->assertSame('queues:{default}', $queue->testGetQueueRedisKey(null)); + $this->assertSame('queues:{default}', $queue->testGetQueueRedisKey('')); + $this->assertSame('queues:{0}', $queue->testGetQueueRedisKey('0')); $this->assertSame('queues:{emails}', $queue->testGetQueueRedisKey('emails')); } @@ -401,6 +409,33 @@ public function testPopUsesClusterSafeRedisKeys(): void $this->assertNull($queue->pop()); } + public function testPoppedJobPreservesZeroQueueAndDefaultsEmptyQueue(): void + { + $payload = json_encode([ + 'id' => 'job-id', + 'job' => 'job', + 'attempts' => 0, + 'data' => [], + ], JSON_THROW_ON_ERROR); + + foreach ([['0', '0'], ['', 'default']] as [$requested, $expected]) { + $queue = $this->getMockBuilder(RedisQueue::class) + ->onlyMethods(['getQueueRedisKey', 'migrate', 'retrieveNextJob']) + ->setConstructorArgs([m::mock(Redis::class), 'default', 'default']) + ->getMock(); + $queue->setContainer(new Container); + $queue->setConnectionName('redis'); + $queue->expects($this->once())->method('getQueueRedisKey')->with($requested)->willReturn("queues:{$expected}"); + $queue->expects($this->once())->method('migrate')->with("queues:{$expected}"); + $queue->expects($this->once())->method('retrieveNextJob')->with("queues:{$expected}", true)->willReturn([$payload, $payload]); + + $job = $queue->pop($requested); + + $this->assertInstanceOf(RedisJob::class, $job); + $this->assertSame($expected, $job->getQueue()); + } + } + public function testDeleteReservedUsesClusterSafeRedisKey(): void { $queue = new RedisQueue($redis = m::mock(Redis::class), 'default', 'default'); diff --git a/tests/Queue/QueueRoutesTest.php b/tests/Queue/QueueRoutesTest.php index cef5f74f5..4d9d3da4f 100644 --- a/tests/Queue/QueueRoutesTest.php +++ b/tests/Queue/QueueRoutesTest.php @@ -10,7 +10,7 @@ class QueueRoutesTest extends TestCase { - public function testSet() + public function testSet(): void { $defaults = new QueueRoutes; @@ -38,7 +38,7 @@ public function testSet() ); } - public function testGetQueue() + public function testGetQueue(): void { $defaults = new QueueRoutes; @@ -56,7 +56,7 @@ public function testGetQueue() $this->assertNull($defaults->getQueue(new Payment)); } - public function testGetConnection() + public function testGetConnection(): void { $defaults = new QueueRoutes; @@ -72,6 +72,24 @@ public function testGetConnection() $this->assertSame('job-connection', $defaults->getConnection(new SomeJob)); $this->assertNull($defaults->getConnection(new Payment)); } + + public function testEnumRoutesAreNormalizedAndScalarRoutesRemainQueueOnly(): void + { + $defaults = new QueueRoutes; + + $defaults->set([ + SomeJob::class => QueueRouteIntegerIdentifier::Zero, + BaseNotification::class => [ + QueueRouteUnitIdentifier::Connection, + QueueRouteIntegerIdentifier::Queue, + ], + ]); + + $this->assertSame('0', $defaults->getQueue(new SomeJob)); + $this->assertNull($defaults->getConnection(new SomeJob)); + $this->assertSame('Connection', $defaults->getConnection(new FinanceNotification)); + $this->assertSame('1', $defaults->getQueue(new FinanceNotification)); + } } trait CustomTrait @@ -100,3 +118,14 @@ interface PaymentContract class Payment implements PaymentContract { } + +enum QueueRouteUnitIdentifier +{ + case Connection; +} + +enum QueueRouteIntegerIdentifier: int +{ + case Zero = 0; + case Queue = 1; +} diff --git a/tests/Queue/QueueSqsQueueTest.php b/tests/Queue/QueueSqsQueueTest.php index 3fc93fa5d..2cf3431ef 100644 --- a/tests/Queue/QueueSqsQueueTest.php +++ b/tests/Queue/QueueSqsQueueTest.php @@ -18,9 +18,10 @@ use Hypervel\Tests\Queue\Fixtures\FakeSqsJob; use Hypervel\Tests\Queue\Fixtures\FakeSqsJobWithDeduplication; use Hypervel\Tests\Queue\Fixtures\FakeSqsJobWithMessageGroup; +use Hypervel\Tests\TestCase; use Laravel\SerializableClosure\SerializableClosure; use Mockery as m; -use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; use Symfony\Component\Uid\Uuid; class QueueSqsQueueTest extends TestCase @@ -67,6 +68,8 @@ class QueueSqsQueueTest extends TestCase protected function setUp(): void { + parent::setUp(); + // Use Mockery to mock the SqsClient $this->sqs = m::mock(SqsClient::class); @@ -198,6 +201,60 @@ public function testPushProperlyPushesJobOntoSqs() $container->shouldHaveReceived('bound')->with('events')->twice(); } + #[DataProvider('queueDefaultingDataProvider')] + public function testPushPreservesZeroQueueAndDefaultsEmptyQueue(string $requestedQueue, string $logicalQueue): void + { + $queueUrl = $this->prefix . $logicalQueue; + $queue = $this->getMockBuilder(SqsQueue::class) + ->onlyMethods(['createPayload', 'getQueue']) + ->setConstructorArgs([$this->sqs, $this->queueName, $this->prefix]) + ->getMock(); + $queue->setContainer(m::spy(ContainerContract::class)); + $queue->expects($this->once())->method('createPayload')->with($this->mockedJob, $logicalQueue, $this->mockedData)->willReturn($this->mockedPayload); + $queue->expects($this->once())->method('getQueue')->with($requestedQueue)->willReturn($queueUrl); + $this->sqs->shouldReceive('sendMessage')->once()->with([ + 'QueueUrl' => $queueUrl, + 'MessageBody' => $this->mockedPayload, + ])->andReturn($this->mockedSendMessageResponseModel); + + $this->assertSame( + $this->mockedMessageId, + $queue->push($this->mockedJob, $this->mockedData, $requestedQueue) + ); + } + + #[DataProvider('queueDefaultingDataProvider')] + public function testLaterPreservesZeroQueueAndDefaultsEmptyQueue(string $requestedQueue, string $logicalQueue): void + { + $queueUrl = $this->prefix . $logicalQueue; + $queue = $this->getMockBuilder(SqsQueue::class) + ->onlyMethods(['createPayload', 'getQueue', 'secondsUntil']) + ->setConstructorArgs([$this->sqs, $this->queueName, $this->prefix]) + ->getMock(); + $queue->setContainer(m::spy(ContainerContract::class)); + $queue->expects($this->once())->method('createPayload')->with($this->mockedJob, $logicalQueue, $this->mockedData, $this->mockedDelay)->willReturn($this->mockedPayload); + $queue->expects($this->once())->method('getQueue')->with($requestedQueue)->willReturn($queueUrl); + $queue->expects($this->once())->method('secondsUntil')->with($this->mockedDelay)->willReturn($this->mockedDelay); + $this->sqs->shouldReceive('sendMessage')->once()->with([ + 'QueueUrl' => $queueUrl, + 'MessageBody' => $this->mockedPayload, + 'DelaySeconds' => $this->mockedDelay, + ])->andReturn($this->mockedSendMessageResponseModel); + + $this->assertSame( + $this->mockedMessageId, + $queue->later($this->mockedDelay, $this->mockedJob, $this->mockedData, $requestedQueue) + ); + } + + public static function queueDefaultingDataProvider(): array + { + return [ + 'preserves zero queue' => ['0', '0'], + 'defaults empty queue' => ['', 'emails'], + ]; + } + public function testSizeProperlyReadsSqsQueueSize() { $queue = $this->getMockBuilder(SqsQueue::class)->onlyMethods(['getQueue'])->setConstructorArgs([$this->sqs, $this->queueName, $this->account])->getMock(); @@ -227,6 +284,8 @@ public function testGetQueueProperlyResolvesUrlWithPrefix() { $queue = new SqsQueue($this->sqs, $this->queueName, $this->prefix); $this->assertEquals($this->queueUrl, $queue->getQueue(null)); + $this->assertEquals($this->queueUrl, $queue->getQueue('')); + $this->assertEquals($this->prefix . '0', $queue->getQueue('0')); $queueUrl = $this->baseUrl . '/' . $this->account . '/test'; $this->assertEquals($queueUrl, $queue->getQueue('test')); } diff --git a/tests/Queue/QueueWorkerTest.php b/tests/Queue/QueueWorkerTest.php index 49c7c8076..1f11d95be 100644 --- a/tests/Queue/QueueWorkerTest.php +++ b/tests/Queue/QueueWorkerTest.php @@ -42,6 +42,9 @@ use Mockery as m; use RuntimeException; use Throwable; +use UnitEnum; + +use function Hypervel\Support\enum_value; class QueueWorkerTest extends TestCase { @@ -1010,8 +1013,12 @@ public function __construct($name, $connection) $this->connections[$name] = $connection; } - public function connection(?string $name = null): Queue + public function connection(UnitEnum|string|null $name = null): Queue { + if ($name instanceof UnitEnum) { + $name = (string) enum_value($name); + } + return $this->connections[$name]; } } diff --git a/tests/Redis/RedisManagerTest.php b/tests/Redis/RedisManagerTest.php index 348875f1e..fa98e9c0e 100644 --- a/tests/Redis/RedisManagerTest.php +++ b/tests/Redis/RedisManagerTest.php @@ -64,11 +64,30 @@ public function testConnectionDefaultsToDefault() $withNull = $manager->connection(null); $withoutArg = $manager->connection(); + $withEmptyString = $manager->connection(''); $this->assertSame($withNull, $withoutArg); + $this->assertSame($withNull, $withEmptyString); $this->assertSame('default', $withNull->getName()); } + public function testIntegerBackedEnumConnectionNameIsNormalizedForResolutionAndPurge(): void + { + $poolFactory = m::mock(PoolFactory::class); + $poolFactory->shouldReceive('flushPool')->once()->with('0'); + + $manager = $this->createManager(['0'], poolFactory: $poolFactory); + $connection = $manager->connection(RedisConnectionName::Zero); + + $this->assertSame('0', $connection->getName()); + $this->assertSame($connection, $manager->connections()['0']); + + $manager->purge(RedisConnectionName::Zero); + + $this->assertSame([], $manager->connections()); + $this->assertFalse(CoroutineContext::has(RedisProxy::CONNECTION_CONTEXT_PREFIX . '0')); + } + public function testPurgeClearsProxyContextAndPool() { $poolFactory = m::mock(PoolFactory::class); @@ -78,7 +97,7 @@ public function testPurgeClearsProxyContextAndPool() $first = $manager->connection('default'); - $manager->purge('default'); + $manager->purge(''); // Context should be cleared $this->assertFalse(CoroutineContext::has(RedisProxy::CONNECTION_CONTEXT_PREFIX . 'default')); @@ -295,3 +314,8 @@ private function createRedisConfig(array $validNames): RedisConfig return new RedisConfig($repository); } } + +enum RedisConnectionName: int +{ + case Zero = 0; +} diff --git a/tests/Reverb/Protocols/Pusher/Channels/ChannelConnectionTest.php b/tests/Reverb/Protocols/Pusher/Channels/ChannelConnectionTest.php new file mode 100644 index 000000000..51934dcec --- /dev/null +++ b/tests/Reverb/Protocols/Pusher/Channels/ChannelConnectionTest.php @@ -0,0 +1,26 @@ + 'zero', + 'name' => 'Taylor', + ]); + + $this->assertSame('zero', $connection->data('0')); + $this->assertSame([ + 0 => 'zero', + 'name' => 'Taylor', + ], $connection->data()); + } +} diff --git a/tests/Reverb/Protocols/Pusher/Channels/PresenceCacheChannelTest.php b/tests/Reverb/Protocols/Pusher/Channels/PresenceCacheChannelTest.php index e4ea51797..93c59a65c 100644 --- a/tests/Reverb/Protocols/Pusher/Channels/PresenceCacheChannelTest.php +++ b/tests/Reverb/Protocols/Pusher/Channels/PresenceCacheChannelTest.php @@ -172,7 +172,7 @@ public function testSendsNotificationOfAnUnsubscribe() collect($connections)->each(fn ($connection) => $connection->assertReceived([ 'event' => 'pusher_internal:member_removed', - 'data' => json_encode(['user_id' => 1]), + 'data' => json_encode(['user_id' => '1']), 'channel' => 'presence-cache-test-channel', ])); } diff --git a/tests/Reverb/Protocols/Pusher/Channels/PresenceChannelTest.php b/tests/Reverb/Protocols/Pusher/Channels/PresenceChannelTest.php index 63761b165..85b8ce84b 100644 --- a/tests/Reverb/Protocols/Pusher/Channels/PresenceChannelTest.php +++ b/tests/Reverb/Protocols/Pusher/Channels/PresenceChannelTest.php @@ -175,11 +175,54 @@ public function testSendsNotificationOfAnUnsubscribe() collect($connections)->each(fn ($connection) => $connection->assertReceived([ 'event' => 'pusher_internal:member_removed', - 'data' => json_encode(['user_id' => 1]), + 'data' => json_encode(['user_id' => '1']), 'channel' => 'presence-test-channel', ])); } + public function testSubscriptionAndUnsubscriptionPreserveZeroUserId(): void + { + Queue::fake(); + + $this->app['config']->set('reverb.apps.apps.0.webhooks', [ + 'url' => 'https://example.com/webhook', + 'events' => ['member_added', 'member_removed'], + 'disconnect_smoothing_ms' => 0, + ]); + + $channel = $this->channels()->findOrCreate('presence-test-channel'); + $data = json_encode(['user_info' => ['name' => 'Zero'], 'user_id' => 0]); + + $channel->subscribe( + $this->connection, + static::validAuth($this->connection->id(), 'presence-test-channel', $data), + $data, + ); + + Queue::assertPushed(WebhookDeliveryJob::class, function (WebhookDeliveryJob $job) { + $event = $job->payload->events[0]; + + return $event['name'] === 'member_added' + && $event['user_id'] === '0'; + }); + + $this->channelConnectionManager->shouldReceive('find') + ->andReturn(new ChannelConnection($this->connection, ['user_info' => ['name' => 'Zero'], 'user_id' => 0])); + $this->channelConnectionManager->shouldReceive('all') + ->andReturn([]); + + Queue::fake(); + + $channel->unsubscribe($this->connection); + + Queue::assertPushed(WebhookDeliveryJob::class, function (WebhookDeliveryJob $job) { + $event = $job->payload->events[0]; + + return $event['name'] === 'member_removed' + && $event['user_id'] === '0'; + }); + } + public function testEnsuresTheMemberAddedEventIsOnlyFiredOnce() { $channel = new PresenceChannel('presence-test-channel'); diff --git a/tests/Reverb/Protocols/Pusher/ClientEventTest.php b/tests/Reverb/Protocols/Pusher/ClientEventTest.php index cfb02225c..793e7dc15 100644 --- a/tests/Reverb/Protocols/Pusher/ClientEventTest.php +++ b/tests/Reverb/Protocols/Pusher/ClientEventTest.php @@ -57,6 +57,50 @@ public function testCanForwardAClientMessage() ]); } + public function testClientMessagePreservesZeroUserIdInBroadcastAndWebhook(): void + { + Queue::fake(); + + $this->app['config']->set('reverb.apps.apps.0.webhooks', [ + 'url' => 'https://example.com/webhook', + 'events' => ['client_event'], + ]); + + $this->channels()->findOrCreate('presence-test-channel'); + + $connectionOne = collect(static::factory(data: ['user_info' => ['name' => 'Zero'], 'user_id' => 0]))->first(); + $connectionTwo = collect(static::factory(data: ['user_info' => ['name' => 'Two'], 'user_id' => '2']))->first(); + + $this->channelConnectionManager->shouldReceive('find') + ->andReturn($connectionOne); + $this->channelConnectionManager->shouldReceive('all') + ->andReturn([$connectionOne, $connectionTwo]); + + ClientEvent::handle( + $connectionOne->connection(), + [ + 'event' => 'client-test-message', + 'channel' => 'presence-test-channel', + 'data' => ['foo' => 'bar'], + ] + ); + + $connectionTwo->connection()->assertReceived([ + 'event' => 'client-test-message', + 'channel' => 'presence-test-channel', + 'data' => ['foo' => 'bar'], + 'user_id' => '0', + ]); + + Queue::assertPushed(WebhookDeliveryJob::class, function (WebhookDeliveryJob $job) { + $event = $job->payload->events[0]; + + return $event['name'] === 'client_event' + && $event['user_id'] === '0' + && $event['channel'] === 'presence-test-channel'; + }); + } + public function testRejectClientEventOnPublicChannelInMembersMode() { $this->channels()->findOrCreate('test-channel'); diff --git a/tests/Routing/RouteCollectionTest.php b/tests/Routing/RouteCollectionTest.php index 4f6a0536e..aeafde804 100644 --- a/tests/Routing/RouteCollectionTest.php +++ b/tests/Routing/RouteCollectionTest.php @@ -343,6 +343,18 @@ public function testToSymfonyRouteCollection() $this->assertInstanceOf('\Symfony\Component\Routing\RouteCollection', $this->routeCollection->toSymfonyRouteCollection()); } + public function testRouteNamedZeroRetainsItsNameInSymfonyRouteCollection(): void + { + $this->routeCollection->add( + $route = new Route('GET', 'users', ['uses' => 'UsersController@index', 'as' => '0']) + ); + + $symfonyRoutes = $this->routeCollection->toSymfonyRouteCollection(); + + $this->assertSame('0', $route->getName()); + $this->assertNotNull($symfonyRoutes->get('0')); + } + public function testOverlappingRoutesMatchesFirstRoute() { $this->routeCollection->add( diff --git a/tests/Sanctum/PersonalAccessTokenCacheTest.php b/tests/Sanctum/PersonalAccessTokenCacheTest.php index 845dd32b1..7e6895314 100644 --- a/tests/Sanctum/PersonalAccessTokenCacheTest.php +++ b/tests/Sanctum/PersonalAccessTokenCacheTest.php @@ -46,6 +46,10 @@ protected function defineEnvironment(ApplicationContract $app): void 'driver' => 'array', 'serialize' => false, ], + 'cache.stores.0' => [ + 'driver' => 'array', + 'serialize' => false, + ], 'sanctum.cache.enabled' => true, ]); } @@ -204,6 +208,30 @@ public function testClearTokenCacheForgetsTokenAndTokenableEntries(): void $this->assertNull($this->cacheRepository()->getRaw("sanctum:{$token->id}:tokenable")); } + public function testTokenCacheStorePreservesZeroAndEmptyFallback(): void + { + $defaultStore = $this->app->make('cache')->store(); + $zeroStore = $this->app->make('cache')->store('0'); + + $this->app->make('config')->set('sanctum.cache.store', '0'); + $defaultStore->put('sanctum:1', 'default', 60); + $zeroStore->put('sanctum:1', 'zero', 60); + + PersonalAccessToken::clearTokenCache(1); + + $this->assertSame('default', $defaultStore->get('sanctum:1')); + $this->assertNull($zeroStore->get('sanctum:1')); + + $this->app->make('config')->set('sanctum.cache.store', ''); + $defaultStore->put('sanctum:2', 'default', 60); + $zeroStore->put('sanctum:2', 'zero', 60); + + PersonalAccessToken::clearTokenCache(2); + + $this->assertNull($defaultStore->get('sanctum:2')); + $this->assertSame('zero', $zeroStore->get('sanctum:2')); + } + public function testUpdatingTokenForgetsTokenAndTokenableCacheEntries(): void { $token = $this->createToken(); diff --git a/tests/Scout/Unit/Console/SyncIndexSettingsCommandTest.php b/tests/Scout/Unit/Console/SyncIndexSettingsCommandTest.php index fef9c7265..0c8b35bae 100644 --- a/tests/Scout/Unit/Console/SyncIndexSettingsCommandTest.php +++ b/tests/Scout/Unit/Console/SyncIndexSettingsCommandTest.php @@ -143,6 +143,61 @@ public function testUsesDriverOptionWhenProvided(): void $this->assertSame(0, $result); } + public function testZeroDriverOptionIsNotReplacedByConfiguredDriver(): void + { + $engine = m::mock(Engine::class . ', ' . UpdatesIndexSettings::class); + + $manager = m::mock(EngineManager::class); + $manager->shouldReceive('engine') + ->with('0') + ->once() + ->andReturn($engine); + + $config = m::mock(Repository::class); + $config->shouldReceive('array') + ->with('scout.0.index-settings', []) + ->andReturn([]); + + $command = m::mock(SyncIndexSettingsCommand::class)->makePartial(); + $command->shouldReceive('option') + ->with('driver') + ->andReturn('0'); + $command->shouldReceive('info') + ->once() + ->with('No index settings found for the "0" engine.'); + + $this->assertSame(0, $command->handle($manager, $config)); + } + + public function testEmptyDriverOptionUsesConfiguredDriver(): void + { + $engine = m::mock(Engine::class . ', ' . UpdatesIndexSettings::class); + + $manager = m::mock(EngineManager::class); + $manager->shouldReceive('engine') + ->with('meilisearch') + ->once() + ->andReturn($engine); + + $config = m::mock(Repository::class); + $config->shouldReceive('string') + ->with('scout.driver') + ->andReturn('meilisearch'); + $config->shouldReceive('array') + ->with('scout.meilisearch.index-settings', []) + ->andReturn([]); + + $command = m::mock(SyncIndexSettingsCommand::class)->makePartial(); + $command->shouldReceive('option') + ->with('driver') + ->andReturn(''); + $command->shouldReceive('info') + ->once() + ->with('No index settings found for the "meilisearch" engine.'); + + $this->assertSame(0, $command->handle($manager, $config)); + } + public function testIndexNameResolutionPrependsPrefix(): void { $command = m::mock(SyncIndexSettingsCommand::class)->makePartial(); diff --git a/tests/Sentry/LogChannelTest.php b/tests/Sentry/LogChannelTest.php index b2641f708..aab9bbc09 100644 --- a/tests/Sentry/LogChannelTest.php +++ b/tests/Sentry/LogChannelTest.php @@ -4,10 +4,13 @@ namespace Hypervel\Tests\Sentry; +use Hypervel\Log\Handlers\FingersCrossedHandler; use Hypervel\Sentry\LogChannel; +use Hypervel\Sentry\Logs\LogChannel as LogsLogChannel; +use Hypervel\Sentry\Logs\LogsHandler; use Hypervel\Sentry\SentryHandler; -use Monolog\Handler\FingersCrossedHandler; use PHPUnit\Framework\Attributes\DataProvider; +use ReflectionProperty; use Sentry\Event; class LogChannelTest extends SentryTestCase @@ -25,12 +28,18 @@ public function testCreatingHandlerWithActionLevelConfig(): void { $logChannel = new LogChannel($this->app); - $logger = $logChannel(['action_level' => 'critical']); + $logger = $logChannel([ + 'action_level' => 'critical', + 'stop_buffering' => false, + ]); $this->assertContainsOnlyInstancesOf(FingersCrossedHandler::class, $logger->getHandlers()); $currentHandler = current($logger->getHandlers()); + $this->assertSame(FingersCrossedHandler::class, get_class($currentHandler)); + $this->assertFalse((new ReflectionProperty($currentHandler, 'stopBuffering'))->getValue($currentHandler)); + if (method_exists($currentHandler, 'getHandler')) { $this->assertInstanceOf(SentryHandler::class, $currentHandler->getHandler()); } @@ -40,6 +49,22 @@ public function testCreatingHandlerWithActionLevelConfig(): void $this->assertContainsOnlyInstancesOf(SentryHandler::class, $loggerWithoutActionLevel->getHandlers()); } + public function testCreatingLogsHandlerWithActionLevelConfig(): void + { + $logChannel = new LogsLogChannel($this->app); + + $logger = $logChannel([ + 'action_level' => 'critical', + 'stop_buffering' => false, + ]); + + $currentHandler = current($logger->getHandlers()); + + $this->assertSame(FingersCrossedHandler::class, get_class($currentHandler)); + $this->assertFalse((new ReflectionProperty($currentHandler, 'stopBuffering'))->getValue($currentHandler)); + $this->assertInstanceOf(LogsHandler::class, $currentHandler->getHandler()); + } + #[DataProvider('handlerDataProvider')] public function testHandlerWritingExpectedEventsAndContext(array $context, callable $asserter): void { diff --git a/tests/Session/SessionManagerTest.php b/tests/Session/SessionManagerTest.php index 9f9b3410e..6ef83570f 100644 --- a/tests/Session/SessionManagerTest.php +++ b/tests/Session/SessionManagerTest.php @@ -14,12 +14,31 @@ use Hypervel\Session\DatabaseSessionHandler; use Hypervel\Session\SessionManager; use Hypervel\Session\Store; +use Hypervel\Tests\TestCase; use Mockery as m; -use PHPUnit\Framework\TestCase; use ReflectionProperty; +use SessionHandlerInterface; class SessionManagerTest extends TestCase { + public function testEnumDefaultDriverIsNormalizedWithoutTreatingZeroAsAbsent(): void + { + $manager = new SessionManager($this->getContainer([ + 'session' => [ + 'driver' => 'array', + 'lifetime' => 120, + 'cookie' => 'session', + 'encrypt' => false, + ], + ])); + + $manager->extend('0', fn () => m::mock(SessionHandlerInterface::class)); + $manager->setDefaultDriver(SessionIntegerIdentifier::Zero); + + $this->assertSame('0', $manager->getDefaultDriver()); + $this->assertSame($manager->driver('0'), $manager->driver()); + } + public function testDatabaseDriverLeavesConnectionUnsetByDefault(): void { $manager = new SessionManager($this->getContainer([ @@ -64,6 +83,32 @@ public function testRedisDriverDefaultsToSessionConnectionWhenUnset(): void $this->assertInstanceOf(Store::class, $sessionStore); } + public function testCacheBackedSessionsPreserveZeroStoreAndEmptyFallback(): void + { + foreach ([['0', '0'], ['', 'redis']] as [$configuredStore, $expectedStore]) { + $store = m::mock(RedisStore::class); + $store->shouldReceive('setConnection')->once()->with('session'); + + $cacheManager = m::mock(); + $cacheManager->shouldReceive('store') + ->once() + ->with($expectedStore) + ->andReturn(new CacheRepository($store)); + + $container = $this->getContainer([ + 'session.driver' => 'redis', + 'session.connection' => null, + 'session.store' => $configuredStore, + 'session.lifetime' => 120, + 'session.cookie' => 'session', + 'session.encrypt' => false, + ]); + $container->instance('cache', $cacheManager); + + $this->assertInstanceOf(Store::class, (new SessionManager($container))->driver()); + } + } + public function testExplicitSessionConnectionOverridesBothDrivers(): void { $databaseManager = new SessionManager($this->getContainer([ @@ -131,3 +176,8 @@ protected function databaseConnectionFromHandler(DatabaseSessionHandler $handler return $property->getValue($handler); } } + +enum SessionIntegerIdentifier: int +{ + case Zero = 0; +} diff --git a/tests/Socialite/SocialiteFakeTest.php b/tests/Socialite/SocialiteFakeTest.php index f1a54f928..277bc43c8 100644 --- a/tests/Socialite/SocialiteFakeTest.php +++ b/tests/Socialite/SocialiteFakeTest.php @@ -13,6 +13,11 @@ use Hypervel\Socialite\Two\User as OAuth2User; use Hypervel\Testbench\TestCase; +enum SocialiteFakeTestIntIdentifier: int +{ + case Zero = 0; +} + class SocialiteFakeTest extends TestCase { protected function getPackageProviders($app): array @@ -65,6 +70,16 @@ public function testItCanFakeMultipleDrivers() $this->assertSame('google-456', Socialite::driver('google')->user()->getId()); } + public function testItCanResolveAFakedDriverUsingAnIntegerEnum(): void + { + Socialite::fake('0', (new OAuth2User)->map(['id' => 'enum-123'])); + + $provider = Socialite::driver(SocialiteFakeTestIntIdentifier::Zero); + + $this->assertInstanceOf(FakeProvider::class, $provider); + $this->assertSame('enum-123', $provider->user()->getId()); + } + public function testItReturnsFakeRedirectResponse() { Socialite::fake('github', (new OAuth2User)->map(['id' => '123'])); diff --git a/tests/Socialite/SocialiteManagerTest.php b/tests/Socialite/SocialiteManagerTest.php index b028073f2..648920521 100644 --- a/tests/Socialite/SocialiteManagerTest.php +++ b/tests/Socialite/SocialiteManagerTest.php @@ -6,6 +6,7 @@ use Hypervel\Config\Repository; use Hypervel\Context\RequestContext; +use Hypervel\Contracts\Container\Container; use Hypervel\Coroutine\Coroutine; use Hypervel\Http\Request; use Hypervel\Socialite\Exceptions\DriverMissingConfigurationException; @@ -260,13 +261,13 @@ public function testSameProviderClassWithDifferentDriversDoesNotCollide() $factory = $this->app->make(SocialiteManager::class); - $factory->extend('github_a', fn () => $factory->buildOAuth2Provider( + $factory->extend('github_a', static fn (Container $container) => $factory->buildOAuth2Provider( GithubProvider::class, - $this->app->make('config')->get('services.github_a') + $container->make('config')->get('services.github_a') )); - $factory->extend('github_b', fn () => $factory->buildOAuth2Provider( + $factory->extend('github_b', static fn (Container $container) => $factory->buildOAuth2Provider( GithubProvider::class, - $this->app->make('config')->get('services.github_b') + $container->make('config')->get('services.github_b') )); $driverA = $factory->driver('github_a'); @@ -318,8 +319,8 @@ public function testGenericProviderGetsRequestRefreshed() $factory = $this->app->make(SocialiteManager::class); - $factory->extend('generic', fn () => new GenericTestProviderStub( - $this->app->make('request') + $factory->extend('generic', static fn (Container $container) => new GenericTestProviderStub( + $container->make('request') )); $provider = $factory->driver('generic'); diff --git a/tests/Support/ManagerTest.php b/tests/Support/ManagerTest.php new file mode 100644 index 000000000..ae137b806 --- /dev/null +++ b/tests/Support/ManagerTest.php @@ -0,0 +1,72 @@ +createManager(); + + $manager->extend('Primary', fn (): string => 'unit'); + $manager->extend('primary', fn (): string => 'string'); + $manager->extend('1', fn (): string => 'integer'); + $manager->extend('0', fn (): string => 'zero'); + + $this->assertSame('unit', $manager->driver(ManagerUnitIdentifier::Primary)); + $this->assertSame('string', $manager->driver(ManagerStringIdentifier::Primary)); + $this->assertSame('integer', $manager->driver(ManagerIntegerIdentifier::Primary)); + $this->assertSame('zero', $manager->driver(ManagerIntegerIdentifier::Zero)); + } + + public function testNullAndEmptyStringSelectTheDefaultDriver(): void + { + $manager = $this->createManager(); + + $manager->extend('default', fn (): string => 'default'); + $manager->extend('0', fn (): string => 'zero'); + + $this->assertSame('default', $manager->driver()); + $this->assertSame('default', $manager->driver('')); + $this->assertSame('zero', $manager->driver(ManagerIntegerIdentifier::Zero)); + } + + protected function createManager(): EnumIdentifierManager + { + $container = new Container; + $container->instance('config', new Repository); + + return new EnumIdentifierManager($container); + } +} + +class EnumIdentifierManager extends Manager +{ + public function getDefaultDriver(): string + { + return 'default'; + } +} + +enum ManagerUnitIdentifier +{ + case Primary; +} + +enum ManagerStringIdentifier: string +{ + case Primary = 'primary'; +} + +enum ManagerIntegerIdentifier: int +{ + case Primary = 1; + case Zero = 0; +} diff --git a/tests/Support/RebindsCallbacksToSelfTest.php b/tests/Support/RebindsCallbacksToSelfTest.php new file mode 100644 index 000000000..0f66855c5 --- /dev/null +++ b/tests/Support/RebindsCallbacksToSelfTest.php @@ -0,0 +1,68 @@ +createManager(); + + $manager->extend('anonymous', function (): object { + return $this; + }); + + $this->assertSame($manager, $manager->driver('anonymous')); + } + + public function testManagerSupportsStaticAnonymousCallbacks(): void + { + $manager = $this->createManager(); + + $manager->extend('static', static fn (ContainerContract $container): object => $container); + + $this->assertSame($manager->getContainer(), $manager->driver('static')); + } + + public function testManagerPreservesFirstClassCallableReceiver(): void + { + $manager = $this->createManager(); + $creator = new RebindingManagerCreator; + + $manager->extend('first-class', $creator->create(...)); + + $this->assertSame($creator, $manager->driver('first-class')); + } + + protected function createManager(): RebindingManager + { + $container = new Container; + $container->instance('config', new Repository); + + return new RebindingManager($container); + } +} + +class RebindingManager extends Manager +{ + public function getDefaultDriver(): string + { + return 'anonymous'; + } +} + +class RebindingManagerCreator +{ + public function create(): object + { + return $this; + } +} diff --git a/tests/Support/SupportMaintenanceModeTest.php b/tests/Support/SupportMaintenanceModeTest.php index a1a1d238b..fb4813f12 100644 --- a/tests/Support/SupportMaintenanceModeTest.php +++ b/tests/Support/SupportMaintenanceModeTest.php @@ -21,6 +21,32 @@ public function testExtend() $this->assertInstanceOf(TestMaintenanceMode::class, $driver); } + + public function testCacheDriverPreservesZeroStoreAndEmptyFallback(): void + { + $this->app->config->set([ + 'app.maintenance.driver' => 'cache', + 'cache.default' => 'array', + 'cache.stores.0' => ['driver' => 'array'], + 'cache.stores.array' => ['driver' => 'array'], + ]); + + $this->app->make('cache')->store('0')->put('hypervel:foundation:down', ['store' => 'zero']); + $this->app->config->set('app.maintenance.store', '0'); + + $this->assertSame( + ['store' => 'zero'], + (new MaintenanceModeManager($this->app))->driver()->data(), + ); + + $this->app->make('cache')->store('array')->put('hypervel:foundation:down', ['store' => 'default']); + $this->app->config->set('app.maintenance.store', ''); + + $this->assertSame( + ['store' => 'default'], + (new MaintenanceModeManager($this->app))->driver()->data(), + ); + } } class TestMaintenanceMode implements MaintenanceModeContract diff --git a/tests/Telescope/Watchers/ClientRequestWatcherTest.php b/tests/Telescope/Watchers/ClientRequestWatcherTest.php index d1bab0a15..922a90314 100644 --- a/tests/Telescope/Watchers/ClientRequestWatcherTest.php +++ b/tests/Telescope/Watchers/ClientRequestWatcherTest.php @@ -22,6 +22,11 @@ use Hypervel\Tests\Telescope\FeatureTestCase; use Psr\Http\Message\RequestInterface; +enum ClientRequestWatcherTestIntTag: int +{ + case Zero = 0; +} + #[WithConfig('telescope.watchers', [ ClientRequestWatcher::class => true, ])] @@ -749,6 +754,26 @@ public function testBackedEnumTelescopeTagsAreNormalizedToStrings() $this->assertContains('algolia', $tags); } + public function testIntegerBackedEnumTelescopeTagsAreNormalizedToStrings(): void + { + $client = $this->makeClient([new Response(200, [], 'OK')]); + + $this->executeTransfer( + $client, + new Request('GET', 'https://example.com/api'), + ['telescope_tags' => [ClientRequestWatcherTestIntTag::Zero]], + ); + + $entry = $this->loadTelescopeEntries()->first(); + + $this->assertNotNull($entry); + $tags = DB::table('telescope_entries_tags') + ->where('entry_uuid', $entry->uuid) + ->pluck('tag') + ->all(); + $this->assertContains('0', $tags); + } + public function testExistingOnStatsCallbackIsPreserved() { $callbackFired = false; diff --git a/tests/Telescope/Watchers/JobWatcherTest.php b/tests/Telescope/Watchers/JobWatcherTest.php index 2462f6e5c..d2fe7a728 100644 --- a/tests/Telescope/Watchers/JobWatcherTest.php +++ b/tests/Telescope/Watchers/JobWatcherTest.php @@ -247,6 +247,7 @@ public function testJobPreservesFalsyFields() $this->assertArrayHasKey('queue', $entry->content); $this->assertArrayHasKey('tries', $entry->content); $this->assertArrayHasKey('timeout', $entry->content); + $this->assertSame('0', $entry->content['queue']); $this->assertSame(0, $entry->content['tries']); $this->assertSame(0, $entry->content['timeout']); } @@ -386,6 +387,8 @@ class MockedZeroValuesJob implements ShouldQueue { use Dispatchable; + public string $queue = '0'; + public int $tries = 0; public int $timeout = 0; diff --git a/tests/Testbench/Foundation/Console/CreateSqliteDbCommandTest.php b/tests/Testbench/Foundation/Console/CreateSqliteDbCommandTest.php index 587532cf0..6cc0f8efe 100644 --- a/tests/Testbench/Foundation/Console/CreateSqliteDbCommandTest.php +++ b/tests/Testbench/Foundation/Console/CreateSqliteDbCommandTest.php @@ -5,6 +5,7 @@ namespace Hypervel\Tests\Testbench\Foundation\Console; use Hypervel\Contracts\Foundation\Application as ApplicationContract; +use Hypervel\Filesystem\Filesystem; use Hypervel\Testbench\Concerns\Database\InteractsWithSqliteDatabaseFile; use Hypervel\Testbench\TestbenchServiceProvider; use Hypervel\Tests\Testbench\TestCase; @@ -50,4 +51,22 @@ public function itCannotGenerateDatabaseUsingCommandWhenDatabaseAlreadyExists(): ->assertOk(); }); } + + #[Test] + public function itCanGenerateDatabaseNamedZero(): void + { + $filesystem = new Filesystem; + $database = database_path('0.sqlite'); + $filesystem->delete($database); + + try { + $this->artisan('package:create-sqlite-db', ['--database' => '0']) + ->expectsOutputToContain('File [@hypervel/database/0.sqlite] generated') + ->assertOk(); + + $this->assertTrue($filesystem->exists($database)); + } finally { + $filesystem->delete($database); + } + } } diff --git a/tests/Testbench/Foundation/Console/DropSqliteDbCommandTest.php b/tests/Testbench/Foundation/Console/DropSqliteDbCommandTest.php index 114b4d17a..9e11971a3 100644 --- a/tests/Testbench/Foundation/Console/DropSqliteDbCommandTest.php +++ b/tests/Testbench/Foundation/Console/DropSqliteDbCommandTest.php @@ -5,6 +5,7 @@ namespace Hypervel\Tests\Testbench\Foundation\Console; use Hypervel\Contracts\Foundation\Application as ApplicationContract; +use Hypervel\Filesystem\Filesystem; use Hypervel\Testbench\Concerns\Database\InteractsWithSqliteDatabaseFile; use Hypervel\Testbench\TestbenchServiceProvider; use Hypervel\Tests\Testbench\TestCase; @@ -50,4 +51,22 @@ public function itCannotDropDatabaseUsingCommandWhenDatabaseDoesntExists(): void ->assertOk(); }); } + + #[Test] + public function itCanDropDatabaseNamedZero(): void + { + $filesystem = new Filesystem; + $database = database_path('0.sqlite'); + $filesystem->put($database, ''); + + try { + $this->artisan('package:drop-sqlite-db', ['--database' => '0']) + ->expectsOutputToContain('File [@hypervel/database/0.sqlite] has been deleted') + ->assertOk(); + + $this->assertFalse($filesystem->exists($database)); + } finally { + $filesystem->delete($database); + } + } } diff --git a/tests/Translation/TranslationArrayLoaderTest.php b/tests/Translation/TranslationArrayLoaderTest.php new file mode 100644 index 000000000..35ed7a1bd --- /dev/null +++ b/tests/Translation/TranslationArrayLoaderTest.php @@ -0,0 +1,29 @@ +addMessages('en', 'messages', ['value' => 'zero'], '0'); + + $this->assertSame(['value' => 'zero'], $loader->load('en', 'messages', '0')); + $this->assertSame([], $loader->load('en', 'messages')); + } + + public function testEmptyNamespaceUsesWildcardNamespace(): void + { + $loader = new ArrayLoader; + $loader->addMessages('en', 'messages', ['value' => 'wildcard'], ''); + + $this->assertSame(['value' => 'wildcard'], $loader->load('en', 'messages')); + $this->assertSame(['value' => 'wildcard'], $loader->load('en', 'messages', '')); + } +}