diff --git a/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md b/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md index 30da5a91f..b6aa8861b 100644 --- a/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md +++ b/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md @@ -1158,6 +1158,12 @@ Add one row only for a shared finding or changed lower-level assumption that ano | `database-21` | `database` | `database` and `scout` (revalidation complete) | `Complete Scout current parity, queue, and search lifecycles`; finding `database-21` | | `database-22` | `database` | `database` (revalidation complete) | `Complete Scout current parity, queue, and search lifecycles`; finding `database-22` | | `database-23` | `database` | `database` and `scout` (revalidation complete) | `Complete Scout current parity, queue, and search lifecycles`; finding `database-23` | +| `mail-17` | `mail` | `mail` and `support` (revalidation complete) | `Complete Mail correctness, current parity, and package boundaries`; finding `mail-17` | +| `support-28` | `support` | `support` and `mail` (revalidation complete) | `Complete Mail correctness, current parity, and package boundaries`; finding `support-28` | +| `support-29` | `support` | `support`, `mail`, and `validation` (targeted revalidation complete); later full `validation` audit | `Complete Mail correctness, current parity, and package boundaries`; finding `support-29` | +| `contracts-10` | `contracts` | `contracts` and `mail` (revalidation complete) | `Complete Mail correctness, current parity, and package boundaries`; finding `contracts-10` | +| `contracts-11` | `contracts` | `contracts`, `mail`, and `console` (revalidation complete) | `Complete Mail correctness, current parity, and package boundaries`; finding `contracts-11` | +| `filesystem-14` | `filesystem` | `filesystem` and `mail` (revalidation complete) | `Complete Mail correctness, current parity, and package boundaries`; finding `filesystem-14` | ## Package checklist @@ -1237,7 +1243,7 @@ The order is lower-level first where practical. Hypervel has cross-cutting depen - [ ] `api-client` - [x] `grpc` - [x] `broadcasting` -- [ ] `mail` +- [x] `mail` - [ ] `notifications` ### Application and domain packages diff --git a/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md b/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md index ced6515e1..5f64b6991 100644 --- a/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md +++ b/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md @@ -87,12 +87,15 @@ Append package entries in checklist order. Keep each entry compact but complete | `contracts-07` | Userland footgun | Minor | High | Several concrete worker-singleton mutators lack lifecycle warnings | Add warnings at concrete lifetime boundaries; keep generic contracts implementation-neutral except for universal native/process invariants | | `contracts-08` | Improvement | Minor | High | The package lacks required upstream references, accurate nullable model documentation, method docs, and several native test/doc return types | Complete the package documentation while preserving concise title-only method docs | | `contracts-09` | Contract defect | Minor | High | The core Broadcaster contract requires optional channel enumeration that only concrete Hypervel broadcasters provide | Remove the optional capability from the contract while retaining it on concrete broadcasters, the proxy, and facade | +| `contracts-10` | Contract defect | Minor | High | The Mail queue contract rejects enum identifiers supported by the framework's queue boundary | Accept `UnitEnum|string|null` where queue selection is optional and `UnitEnum|string` on `queueOn()` / `laterOn()` across the contract, concrete mailer, fake, and facade | +| `contracts-11` | Contract defect | Minor | High | The Mail contract describes message callbacks as `mixed` even though the supported runtime boundary is `Closure|string` | Use the truthful callback union and revalidate Mail plus Console's scheduled-output consumer | | `notifications-07` | Contract defect | Major | High | The Notification Factory rejects a supported single notifiable even though every implementation and the Dispatcher accepts it | Widen Factory notifiables to `mixed`, retain its two-parameter `sendNow()`, and keep channels on the Dispatcher contract | - **Important rejected concerns:** Do not rewrite validation around only `ValidationRule`; Laravel's deprecated contracts remain its live internal execution protocol, userland already receives the modern API, and a rewrite would add permanent synchronization cost without behavior gain. Do not remove `Console\Application::add()`: Symfony deprecated its underlying method, but Laravel deliberately retains, uses, and tests a non-deprecated wrapper over `addCommand()`. Do not add orphan Image contracts before Hypervel has a coherent Image package. Do not relocate the Monolog-specific context contract without a separately approved API redesign. Do not add optional dependencies merely because they appear only in lazy parameter/return types. Do not make public `View::share()` request-dependent or add a public request-sharing API. Do not keep a throwing `Request::get()` tombstone; record the intentional omission so static analysis rejects it. -- **Cross-package implications:** `validation-01` affects `validation`; `view-01` affects `view` and `foundation`; `filesystem-01` affects `filesystem`; `queue-01` affects `queue`; `contracts-04` affects `server`, `server-process`, `websocket-server`, and `reverb`; `contracts-05` affects `http`, `routing`, `foundation`, `console`, and `database`; `contracts-09` affects Foundation and Broadcasting; and `notifications-07` affects Notifications. Queue, Foundation, Broadcasting, and HTTP revalidation is complete; the later HTTP work also completed the Contracts-owned part of `routing-01` by restoring the URL generator's open parameter-normalization contract. The remaining package checkboxes stay open until their own complete audits. +- **Cross-package implications:** `validation-01` affects `validation`; `view-01` affects `view` and `foundation`; `filesystem-01` affects `filesystem`; `queue-01` affects `queue`; `contracts-04` affects `server`, `server-process`, `websocket-server`, and `reverb`; `contracts-05` affects `http`, `routing`, `foundation`, `console`, and `database`; `contracts-09` affects Foundation and Broadcasting; `contracts-10` affects Mail; `contracts-11` affects Mail and Console; and `notifications-07` affects Notifications. Queue, Foundation, Broadcasting, HTTP, Mail, and the affected Console consumer revalidation are complete; the later HTTP work also completed the Contracts-owned part of `routing-01` by restoring the URL generator's open parameter-normalization contract. The remaining package checkboxes stay open until their own complete audits. - **Implementation:** Declared every external parent-interface dependency in the split package; added evidence-backed contract and implementation types; ported `ShouldBeDiscovered` with current upstream source, fixtures, tests, and docs; corrected the handshake/process spellings without aliases; removed only APIs directly deprecated by Laravel plus dead migration wiring; retained Laravel's live `Application::add()` wrapper; normalized every statically valid `resolveCommands()` argument shape; corrected Engine diagnostics; completed contract docs and upstream references; and added lifecycle warnings to concrete worker-state mutators. The later Queue work removed the optional broadcaster capability and widened Factory notifiables without changing Factory `sendNow()` arity or the Dispatcher channel boundary. The validation, view, filesystem, and queue corrections below were implemented at their owning boundaries, with superseded state writes, loose construction types, unrestorable identifier conversion, raw morph-alias restoration, dead dependencies, obsolete tests, and stale comments removed. - **Regression tests:** Added split-package dependency-presence coverage, discovery opt-out fixtures, exact Engine diagnostic coverage inside and outside a coroutine, custom-filesystem result validation, configured-rule clone/isolation coverage, deterministic concurrent view-error isolation, ordinary serialization for non-Eloquent queue contracts, morph-mapped Eloquent collection restoration, and all supported scalar/array `resolveCommands()` forms. Updated every affected contract implementation and test double, including integration-only caster fixtures; the later Queue gate verifies the broadcaster command and Notification implementers against the corrected contracts. +- **Later Mail revalidation:** `contracts-10` aligns every Mail queue entry point with the framework's enum-capable identifier boundary while retaining non-nullable `queueOn()` and `laterOn()` aliases. `contracts-11` replaces unsupported `mixed` callback metadata with `Closure|string`; focused Mail and Console scheduling coverage proves the narrowed contract through the real consumer path without changing runtime behavior. - **Performance and complexity:** Owner approved one clone per configured validation-default resolution and one coroutine-context lookup per rendered View object. The View path retains one array merge. Custom filesystem validation runs only on construction/cache miss; discovery is boot-only; all other accepted changes add no meaningful runtime work. No locks, registries, compatibility shims, or speculative abstractions are introduced. - **Validation and review:** Pre-implementation second-opinion consensus and owner approval complete. Focused affected-package tests and the complete `composer fix` gate pass. Fresh full-diff self-review completed; its focused second-opinion corrections are implemented and revalidated. Independent code review is signed off; owner pre-commit approval is complete. - **Assessment:** The result establishes truthful public contracts and isolates the verified shared-state failures without locks, request-scoped manager clones, compatibility aliases, recursive graph machinery, or speculative abstractions. Runtime work is limited to the explicitly approved clone/context operations and constant-time boundary checks. @@ -469,6 +472,10 @@ Append package entries in checklist order. Keep each entry compact but complete | `support-25` | Defect | Minor | High | ExceptionHandlerFake can suppress `throwOnReport()` when `withoutExceptionHandling()` is active depending on setup order | Treat the active without-exception-handling marker as reportable while preserving normal fake filtering | | `support-26` | Defect | Minor | High | BusFake's three count assertions report grammatically incorrect singular counts and lag current Laravel's fake diagnostics | Pluralize the actual and expected counts at all three existing assertion boundaries and pin each public method | | `support-27` | Defect | Major | High | `SafeCaller` lets exception-handler resolution or reporting failure replace the protected failure and prevent its promised default | Contain reporter failure, log both failures through a no-throw fallback, and return the exact default | +| `support-28` | Fake/facade defect | Major | High | MailFake forwards several side-effecting APIs to the real mailer and snapshots or leaks selected-mailer state across operations | Intercept every delivery entry point, consume selection exactly once on success or failure, preserve recipients and queues, and regenerate concrete facade metadata | +| `support-29` | Parity defect | Minor | High | `Str::isUrl()` rejects valid current-Laravel single-label hosts used by internal HTTP services | Port the current domain branch and revalidate Stringable, Mail URL attachments, and Validation's `url` rule | +| `support-30` | Fake assertion type defect | Major | High | MailFake's shared assertion helper rejects count and address shapes accepted by its public methods | Describe the complete callable, count, and address domain and route integer counts explicitly | +| `support-31` | Fake assertion type defect | Minor | High | NotificationFake promises arbitrary strings as callbacks even though non-callable strings fail at the delegated boundary | Narrow to callable or integer, regenerate facade metadata, and port the complete current upstream fake tests | | `auth-01` | Defect | Major | High | Worker-cached SessionGuard and PasswordBroker instances share one mutable Timebox whose early-return state persists and races across authentication operations | Clone the configured Timebox for each of the five timed operations so every operation has isolated mutable timing state | - **Intentional omissions:** Laravel deferred-provider APIs remain omitted because their per-request bootstrap optimization conflicts with Hypervel's long-lived provider architecture; record the difference in Support's README and at the natural source boundary. `loadFactoriesFrom()` is directly deprecated by current Laravel and receives only a concise source `REMOVED:` marker. `Auth::routes()` exists solely for `laravel/ui`, which Hypervel does not integrate; retain the documented manual/Fortify route approach and record the omission in Auth's README plus the natural facade source location. Do not invent placeholder tests for omitted APIs. @@ -482,6 +489,7 @@ Append package entries in checklist order. Keep each entry compact but complete - **Later Reverb revalidation:** `support-27` is complete at Support's exception-conversion boundary. WebSocket Server's sole production consumer retains the exact default and both diagnostics after a reporter failure; Reverb remains unaffected because Foundation's WebSocket kernel overrides the base handler. - **Later Queue revalidation:** `queue-36` regenerated truthful concrete inspection facade metadata, while `queue-37` gave QueueFake one disjoint pending/delayed/reserved history, delay-faithful pass-through, and ordered instance-owned push hooks. `redis-22` regenerated the Redis facade from corrected underlying command declarations. Focused Support coverage and the full gate revalidated the completed package without adding static fake state or a testing cleanup hook. - **Later HTTP revalidation:** HTTP regenerated truthful client facade metadata. `routing-01` widened URL/redirect facade forwarding through the existing Routing owner and restored required expiration on temporary signed `Uri` routes. Focused Support, Routing, and URL-signing coverage plus the full gate revalidated the completed package without adding normalization or compatibility machinery to Support. +- **Later Mail revalidation:** `support-28` makes MailFake a faithful one-shot delivery boundary and refreshes the Mail facade from its concrete forwarded surface. `support-29` restores current single-label URL support across Str, Stringable, Mail, and Validation. `support-30` and `support-31` correct fake assertion types and regenerate the affected Notification facade metadata without adding runtime state. Current upstream MailFake and NotificationFake coverage passes; the separately missing EventFake test port remains recorded in `docs/todo.md`. - **Validation and review:** Every changed Support, Auth, Foundation-testing, Queue, Testbench, and cleanup regression is green. PHP CS Fixer, both PHPStan configurations, the complete parallel suite, both Testbench suites, `git diff --check`, broad stale-reference/API/parity scans, a fresh full-diff caller/callee and lifecycle review, and independent code review are complete. The final review added strict PendingBatchFake comparisons, confirmed the three local file-mode checks are preferable to a Laravel-divergent public Filesystem helper, and signed off with no remaining finding. - **Laravel-facing result:** Public API and configuration remain compatible. The work restores current Laravel fake, string, validated-input, Carbon metadata, and edge-case behavior; adds the matching facade metadata and documentation; and retains only the documented Hypervel omissions for deferred providers, `laravel/ui`, and Laravel's directly deprecated `loadFactoriesFrom()` surface. - **Assessment:** The final work fixes demonstrated utility, file-publication, fake, reflection, cleanup, and authentication-timing defects at their owning boundaries. Ordinary request paths are unchanged; the only new per-operation cost is one Timebox clone when an authentication or password-reset operation is actually timed. No lock, retry, coroutine context, registry, worker cache, general transaction/callable/serialization abstraction, or speculative compatibility machinery was added. The design is complete without overengineering. @@ -823,6 +831,7 @@ Append package entries in checklist order. Keep each entry compact but complete | `filesystem-09` | Defect | Minor | High | `LeasedStream::__destruct()` can reach native pooled cleanup during unsafe destructor timing even though explicit `stream_close` already owns release | Remove destructor cleanup and retain deterministic wrapper-close and construction-rollback ownership | | `filesystem-10` | Improvement | Improvement | High | Cloud `readStream()` defaults spool complete remote objects before consumers can read, increasing peak memory and delaying first-byte delivery under concurrent Swoole workloads | With owner approval, default S3 and GCS `stream_reads` to true, document the connection-reuse tradeoff and false opt-out, and prove lazy coroutine-safe consumption and cleanup deterministically | | `filesystem-11` | Improvement | Improvement | High | Hypervel lacks current Laravel's public `assertEmpty()` storage testing assertion and the scoped/pooled forwarding and metadata needed for consistent use | With owner approval, port the current Laravel API, tests, facade metadata, forwarding, and concise testing documentation without adding it to the Filesystem contract | +| `filesystem-14` | Static-analysis type defect | Minor | High | Mail and ServeFile falsely narrow supported disks to `FilesystemAdapter`, excluding pooled adapters that provide the required dynamic methods | Keep the truthful Filesystem contract and use exact local suppressions for the adapter metadata methods every shipped disk provides | | `foundation-04` | Defect | Major | High | Kernel and application termination stop after the first listener, middleware, application, duration, or context failure, allowing independent request cleanup to be skipped | Run each fixed termination phase independently, preserve the earliest failure, and keep context removal as final belt-and-suspenders cleanup | - **Native lock design:** Under Swoole 6.2.2, any `LOCK_NB` request goes directly to one native `flock` attempt; only bare `LOCK_EX` and `LOCK_SH` enter Swoole's 1-to-100-millisecond coroutine backoff. Current locked `put()` and `sharedGet()` therefore wait through Hypervel's fixed one-millisecond polling for both same-worker and cross-process contention. Checked blocking native calls move those waits to Swoole's adaptive backoff, while nonblocking `LockableFile` and Cache FileStore paths remain immediate and no longer queue behind a worker-local gate. This reduces wakeups under sustained contention; the owner accepted that a lone waiter behind a long-held lock can observe release up to roughly 100 milliseconds later. No timeout, configurable polling policy, replacement gate, or compatibility path is added. `append(lock: true)` and bare blocking `LockableFile` calls were already native. @@ -836,6 +845,7 @@ Append package entries in checklist order. Keep each entry compact but complete - **Implementation:** Removed the worker-local lock wrapper and moved Filesystem, LockableFile, and FileStore onto checked native lock ownership. Hardened local metadata, writes, replacement, links, deletion, modes, MIME database/file failure handling, adapter reads, upload streams, JSON types, signed paths, facades, contracts, scoped and pooled proxies, and leased-stream teardown. Added direct iterable streaming from generator and SSE producers through the Swoole bridge, framed every multiline SSE data field, stopped production on failed writes, preserved committed responses, and made Foundation and HTTP Server finalization exhaustive. S3 and GCS streamed reads now default to lazy coroutine-cooperative PHP-stream transport with a documented opt-out; ordinary cloud reads remain unchanged. Ported Laravel's current `assertEmpty()` API without expanding the generic Filesystem contract and kept its testing examples internally coherent. - **Regression tests:** Deterministic coverage proves native lock serialization, immediate nonblocking failure, cancellation safety, partial and zero-progress writes, checked native and MIME failures, cleanup precedence, cache add and refresh behavior, exhaustive deletion without iterator-order assumptions, zero modes, link and replacement failures, metadata false results, adapter read protection, upload-stream ownership, JSON unions, signed nested paths, assertion forwarding, lazy remote reads, scheduler progress, disconnect termination, stream and lease release, exact CRLF/CR/LF SSE framing, committed failures, exhaustive Foundation and HTTP Server termination, receive-file failure, and exact `stream_close` ownership. - **Cross-package revalidation:** `filesystem-01` retains its typed custom-creator boundary; `filesystem-02` retains checked directory creation; `filesystem-03` and `encryption-03` retain sensitive atomic replacement; `support-02` retains enum disk normalization. The obsolete Filesystem acquisition booleans from `coroutine-05` were removed with their worker-local lock layer, while the corrected Coroutine Locker primitive remains unchanged. Cache's nonblocking FileStore behavior, HTTP's response construction, and Foundation and HTTP Server response lifecycles pass their affected coverage. The later full HTTP audit revalidated `http-02` and `filesystem-07` against current request, response, event-stream, and Swoole bridge coverage without changing Filesystem ownership. +- **Later Mail revalidation:** `filesystem-14` removes runtime-false concrete annotations from Mail storage attachments and ServeFile while retaining the intentionally narrow Filesystem contract. Exact local PHPStan suppressions cover only adapter methods available on every shipped disk; storage integration and ServeFile regressions verify the dynamic boundary without reflection, a capability interface, or runtime branching. - **Validation and review:** Every changed test file and all affected Filesystem, Cache, HTTP, Routing, Foundation, HTTP Server, contract, facade, environment, session, and streaming groups pass. PHP CS Fixer changed none of 5,574 files; both PHPStan configurations pass; the complete components suite passes with 23,282 tests, 66,332 assertions, and 1,600 expected skips; Testbench passes with 346 tests, 1,029 assertions, and 3 expected skips; dogfood passes with 4 tests and 7 assertions; `git diff --check` and package-checklist parity are clean. Fresh full-diff caller/callee, resource-lifecycle, API, performance, stale-code, and overengineering review is complete, and independent code review signed off on the final MIME, SSE, documentation, test, and ledger corrections. - **Laravel-facing result:** Current Laravel filesystem APIs, configuration structure, and conventional extension shapes remain compatible. The owner approved the safer native-failure behavior, the Swoole-specific lazy remote-streaming default, the bounded native lock-detection tail, and protocol-correct multiline SSE framing where current Laravel emits invalid subsequent data lines; `stream_reads=false` preserves the documented eager transport option. No public API was removed or renamed, and the added `assertEmpty()` surface restores current Laravel parity. - **Assessment:** The result fixes verified native-boundary, ownership, streaming, disconnect, and cleanup defects at their lowest owners while bounding remote-read and response-stream memory. Ordinary non-streaming requests and ordinary cloud reads gain no new runtime work; single-line SSE events add one native scan without normalized-data allocation, while multiline events incur only the bounded native string work required for correct framing. The design adds no registry, replacement lock, retry loop, timeout policy, context state, custom cURL bridge, resource state machine, compatibility shim, or speculative Image surface; every accepted mechanism has a demonstrated consumer and the completed work is free of overengineering. @@ -1033,6 +1043,7 @@ Append package entries in checklist order. Keep each entry compact but complete - **Migration publication:** Database MigrationCreator remains the sole existence-check, stub-read, populate, publish, and hook owner. Its optional path parameter avoids mutable singleton configuration and a duplicate public transaction. Current Laravel collision-free numbering is retained in coroutine context rather than a singleton property, and touched glob boundaries are checked through one shared helper used by the affected generator siblings. - **Important rejected concerns:** Do not add a per-day schedule cache, cron compiler, universal timezone converter, prompt snapshot registry, output registry, generic finalizer, callback timeout, Termwind lock, group exception manager, per-event mutex-owner object, Queue transport commit protocol, mutable MigrationCreator configuration, generic native-boundary wrapper, or isolation for the single-writer `lastChecked` state and public compatibility snapshots. Do not add an upper repeat interval guard without a supported caller or fabricate native coroutine-run false handling. Retain Laravel's `ScheduledTaskFinished` then `ScheduledTaskFailed` ordering and dispatch-before-report sequence: reordering or shielding one throwable merely trades one reporting hole for another and changes established Sentry and Telescope sequencing. - **Cross-package implications and revalidation:** Console revalidated the carried `console-01`, `reflection-02`, `support-02`, and `console-02` assumptions. The migration work is owned by `database-04` and requires the later full Database audit; deterministic deferred-webhook timer ownership is recorded as `reverb-04` for the later full Reverb audit. +- **Later Mail revalidation:** `contracts-11` narrows raw Mail callbacks to the supported `Closure|string` boundary. Console's scheduled-output callback remains compatible, and a focused `emailOutputTo()` regression proves captured output, subject, and recipients reach the Mailer contract unchanged. - **Upstream and documentation:** Originating Laravel implementation and documentation pull requests supplied feature discovery, while current local Laravel source, tests, and docs supplied the porting reference. Hypervel retains its Swoole coroutine execution and background-scheduling ownership. Artisan and scheduling guides now document CommandInput, environment filters, expression timezones, event callback injection, and command setup traits without exposing internal lifecycle choreography. - **Implementation:** Schedule and CallbackEvent state is isolated per run; Event and Command cleanup is exhaustive and owns exact mutexes and processes; scheduler status, same-Event registration, unique-job locks, output, and prompt restoration reflect the real invocation. Current CommandInput, environment filtering, callback injection, iterable progress, IO, and completion surfaces are complete. Generators publish atomically, MigrationCreator owns final collision-free publication through the framework clock, Reverb timer tests use exact injected ownership, and Console's split manifest and provenance are complete. Dead prompt restoration, stale schedule-test mutation, shallow-clone logic, eager output buffering, and obsolete teardown code are removed. - **Performance and complexity:** No HTTP or server hot path changes. Correct interval filters perform three Carbon operations only when evaluated; callback/process runs use a small exact-object coroutine slot; running-event cleanup adds one integer count; optional-event guards normally remove allocations and dispatch; and exact output checks occur beside existing I/O. OutputStyle removes eager iterable buffering. There is no new lock, retry, polling, sleep, network call, unbounded retention, compatibility shim, or speculative abstraction. @@ -1537,12 +1548,14 @@ Append package entries in checklist order. Keep each entry compact but complete | `http-24` | Worker-lifetime footgun | Major | High | Global Factory mutators and default JSON decode flags can be changed during request handling without exposing cross-request effects | Add concise boot/test lifecycle warnings and keep instance-scoped decode configuration unchanged | | `http-25` | Metadata and documentation defect | Major | High | The split package omits a runtime extension, optional image capability, provenance, and current task-focused guidance | Correct root/split metadata, pin it with tests, add provenance, and update public HTTP documentation without exposing internals | | `http-26` | Current Laravel test parity | Improvement | High | Large current request, response, client, middleware, resource, and JSON:API surfaces lack upstream regression coverage | Port and merge current unit/integration tests and fixtures while retaining every Hypervel-specific Swoole/coroutine regression | +| `http-27` | Documentation defect | Minor | High | The package README puts provenance before differences and omits the canonical Requests guide | Restore the package badge, canonical guide link, approved ordering, and final provenance link | - **Approved owner gates and intentional differences:** The owner approved request-local dynamic CORS resolution before path matching, fail-closed textual proxy headers, removal of the obsolete trusted-proxy config fallback, strict PJAX protocol handling, Laravel bug corrections for body `"0"`, malformed hosts, missing JSON:API classes, fake transport failure, signed-URI expiration, and every source-proven constant-time boundary check. Swoole continues to own request capture and response emission. Pool/Batch and the incomplete Image component remain outside HTTP; Image is a separately recorded todo rather than a placeholder or false omission record. - **Important rejected concerns:** Do not add shared CORS mutation, a coroutine config slot, resolver cache, transport recorder registry, deduplication state, sink retry/stream wrapper, fallback recording around fully custom clients or stray-request rejection, a second JSON:API relationship map, collection-content validator, response coercion framework, host parser, guessed URL-parameter union, compatibility proxy fallback, broad strictness sweep, partial Image bridge, or README inventory of ordinary bug fixes. Retain the protected reusable-client extension surface. - **Tracked follow-up:** The remaining pre-existing untyped methods in `HttpClientTest`, `HttpRequestTrustedStateTest`, and `HttpRequestTrustedStateCoroutineTest` are recorded in `docs/todo.md` for owner-directed mechanical conversion. Every test method added or modified by this work uses the required native `void` type. - **Implementation:** PendingRequest, Factory, Request, Response, response traits, file helpers, middleware, JSON:API owners, metadata, facades, and docs now expose the supported current surface with exact native/value contracts. Recording is centralized at the Guzzle middleware owner; fake promises and sinks preserve settlement and failure. CORS config and service state remain request-local, while proxy bootstrap is exact and fail-closed. JSON:API uses one relationship traversal and validates identifiable configuration. URL forwarding reaches the canonical Routing normalizer unchanged. Dead duplicate relationship compilation, redirect-only cookie code, legacy proxy fallback, duplicate defaults, stale imports/comments, and the completed JSON:API todo are removed. - **Cross-package revalidation:** Carried `http-01` through `http-06`, `http-server-03`, `contracts-05`, and Foundation's `http-03` remain valid against the complete package. `routing-01` is implemented across Contracts, Foundation, Routing, and Support; the completed Contracts, Foundation, and Support packages were revalidated, while Routing retains later full-audit revalidation. Testbench owns `testbench-03` and retains later full-audit revalidation. Socialite's config-rebinding fixture now replaces only its intended service setting without fabricating an incomplete application repository; no Socialite production behavior changed. +- **Later Mail revalidation:** `http-27` corrects only HTTP's package README: the DeepWiki badge and approved differences remain, the canonical Requests guide is linked, and provenance is last. No HTTP source, API, configuration, or runtime behavior changed. - **Regression tests:** Current unit and integration coverage now spans requests, responses, redirects, JSON, MIME/files, cache and proxy middleware, the HTTP client, fakes, streams/sinks, recording, throw behavior, resources, JSON:API fixtures, URL forwarding/signing, CORS timing and host paths, metadata, config ownership, facades, Swoole response/request boundaries, and prior identity/fieldset/include corrections. Deterministic CORS concurrency forces an interleaving after resolver execution and fails against a model shared-state leak. Counterfactual exact-value cases cover zero, null, empty, malformed, callable, asynchronous, failure, and partial-configuration paths. - **Performance and complexity:** Without a custom CORS resolver, applicable requests use one typed config read instead of two; with one, the approved callback runs once before matching so resolver-owned paths work, and no config or service is retained. JSON:API removes duplicate relationship resolution and queries. Recording adds work only to failed attempts that were previously invisible and removes duplicate response recording. Other hot paths gain bounded local branches, casts, native lookups, or lifecycle-free type checks; none gains a lock, retry, network round trip, container loop, serialization layer, yield, registry, context slot, or worker-retained request state. Test, metadata, docs, and config validation are cold. - **Laravel-facing result:** Supported current Laravel HTTP APIs, names, named arguments, protected client extensions, tests, fixtures, and task-focused documentation are restored or preserved. Hypervel retains its Swoole capture/emission ownership and dynamic middleware extensions. Observable differences are limited to approved architecture requirements, intentional unsupported surfaces, and fail-fast or exact-value corrections for verified Laravel defects. @@ -1611,3 +1624,28 @@ Append package entries in checklist order. Keep each entry compact but complete - **Laravel-facing result:** Current supported Laravel broadcasting APIs, protected extension points, creator shapes, enum names, and channel forms are restored or preserved. Hypervel retains worker-wide channel/auth registries and formatter callbacks, explicit custom pooling, direct built-in clients, and no deferred-provider marker. Invalid Redis connection configuration now fails at construction through the manager's established `RuntimeException`, preserving the helper's `InvalidArgumentException` message and cause. - **Validation and review:** Every changed test file and affected package group passed during implementation. The final `composer fix` gate changed no formatting, both PHPStan configurations passed, and the complete parallel components, Testbench package, and dogfood suites passed. `git diff --check`, stale-reference scans, and fresh caller/callee, API, coroutine, SDK ownership, retained-state, hot-path, dead-code, and overengineering review are complete. Independent review read every changed source and test file, verified the amendments, and signed off with no remaining findings. - **Assessment:** Broadcasting is current, coroutine-safe, failure-truthful, and bounded for long-lived workers. Every accepted finding is fixed at its owning boundary without a workaround, speculative abstraction, Laravel API break, meaningful hot-path regression, or stale superseded path. + +### Complete Mail correctness, current parity, and package boundaries + +- **Status and inspected surface:** Complete; implementation, focused validation, the authoritative gate, fresh self-review, and independent code review are signed off. The audit covered every Mail source, unit, and integration file; Contracts, Support fakes/facades, Filesystem, Console, HTTP documentation, split/root metadata, and the public Mail guide; current Laravel source/tests and originating PRs; installed Symfony Mailer/Mime/HttpFoundation behavior; and pooled transport ownership. The detailed design is recorded in [`2026-08-03-1909-mail-correctness-current-parity-and-package-boundaries.md`](2026-08-03-1909-mail-correctness-current-parity-and-package-boundaries.md). + +| Findings | Final decision | +|---|---| +| `mail-01`, `mail-03`, `contracts-10` | Apply explicit queue names before delayed dispatch, pass the owning queue factory, and accept enums across real, fake, contract, and facade entry points while keeping `queueOn()` / `laterOn()` non-nullable. | +| `mail-02`, `support-29` | Restrict remote attachments to HTTP(S) while porting current single-label URL support at the shared Str owner and revalidating Stringable and Validation consumers. | +| `mail-04`, `filesystem-14` | Resolve storage once, preserve explicit MIME, omit failed MIME detection, and retain truthful Filesystem contracts with exact local suppressions for shipped adapter metadata methods. | +| `mail-05`, `mail-06`, `mail-09`, `mail-16`, `mail-17` | Port current no-attachment assertions, exact non-empty SES v2 tenant forwarding including `"0"`, missing Mail regressions, render-before-callback ordering, and concrete Mail facade metadata. | +| `mail-07`, `mail-08`, `mail-11`–`mail-15`, `http-27` | Remove stale transport fallbacks and false dependencies/suppressions, correct split metadata and native-resource docs, document SES v2-only and pooled transport behavior, and restore concise canonical README ordering. | +| `mail-10` | Move Mail tests onto framework bases and worker-safe temporary directories, and complete truthful native test typing without duplicate cleanup. | +| `support-28`, `support-30`, `support-31` | Make MailFake intercept every delivery path with one-shot selection and truthful queue/recipient/assertion behavior; narrow NotificationFake's callback domain and port current fake coverage. | +| `contracts-11` | Replace unsupported `mixed` callbacks on `raw()`, `html()`, and `plain()` with the actual `Closure|string` boundary and revalidate Console's scheduled-output consumer. | + +- **Upstream inventory:** Current Laravel supplied the final source and tests. Originating framework PRs `#60865`, `#59443`, `#60886`, `#58686`, and `#51990` were used only to identify the complete delayed-queue, no-attachment, SES tenant, URL predicate, and callback-ordering surfaces. +- **Important rejected concerns:** Do not add transport retries, health checks, poisoned-resource policy, attachment streaming machinery, Filesystem capability/reflection wrappers, pool-proxy magic forwarding, fake delay state, callback-order modes, a notification-specific duplicate ordering test, a Support package-graph redesign, or SES v1 compatibility. Retain intentional Markdown fallbacks and the protected Laravel provider extension point. Failed attachment-data reads have no valid fallback and continue to fail at the existing typed/Symfony boundary. +- **Implementation and boundaries:** Mail now renders before callbacks, owns explicit delayed queue selection, resolves storage adapters once, handles unknown MIME without violating string contracts, forwards every non-empty SES v2 tenant name exactly, and constructs on-demand transports only from supplied configuration. MailFake owns every side-effecting fake entry point and consumes explicit selection once, including failure paths. Current assertion, facade, callback, URL, queue, attachment, and dependency surfaces are restored at their existing owners. Dead fallback reads, false concrete annotations, broad suppressions, direct Notifications/Testing dependencies, and stale documentation were removed. +- **Cross-package revalidation:** `support-28` through `support-31` are complete in Support; the remaining EventFake test-parity gap is recorded in `docs/todo.md`. `support-29` also revalidates Validation's existing URL rule without completing its later package audit. `contracts-10` is complete across Contracts and Mail; `contracts-11` is complete across Contracts, Mail, and Console. `filesystem-14` is complete across storage attachments and ServeFile. `http-27` changes documentation only. No consumer-local workaround was added. +- **Regression tests:** Counterfactual coverage proves explicit delayed and enum queue names at every alias; direct and mailable callbacks observe and may replace rendered bodies; HTTP(S) and single-label attachment URLs; single storage resolution and false MIME fallback; exact SES tenant behavior including `"0"`; one-shot fake selection across ordinary, queued, invalid, and failing calls; named recipients; zero real transport or queue leakage; and concrete Mail facade exposure. Additional validation covers split metadata, generated Notification facade metadata, worker-safe temporary-file ownership, and Console's scheduled-output consumer. Current MailFake, NotificationFake, attachment, message, queued-mailable, SES, and all existing Mail integration suites were ported or revalidated. +- **Performance and complexity:** Callback ordering moves existing work without adding work. Storage attachments remove duplicate disk resolution and metadata I/O when MIME is explicit. Fake, metadata, docs, tests, and SES option copying are cold or test-only; queue and URL fixes add only constant-time normalization/validation at their existing boundaries. No request-time lock, retry, registry, cache, context slot, new container loop, network preflight, retained request state, or meaningful hot-path overhead is introduced. +- **Laravel-facing result:** Supported current Laravel Mail APIs, named arguments, queue identifier forms, callbacks, assertions, facade forwarding, and protected extension points are preserved or restored. Hypervel intentionally supports Amazon SES through SES v2 only and keeps pooled mailers behind `TransportPoolProxy`; both differences are documented without a compatibility shim. Type corrections reject only values outside the real supported Laravel behavior. +- **Validation and review:** Every changed test file and affected Mail, Integration Mail, Support, Notification, Console, Validation, Filesystem, facade, and metadata group passed during implementation. The authoritative `composer fix` gate passed formatting, both PHPStan configurations, the complete parallel suite, Testbench package mode, and dogfood. `git diff --check`, facade regeneration, stale-reference scans, and fresh caller/callee, queue, storage, fake-state, API, coroutine, retained-state, hot-path, dead-code, and overengineering review are complete. Independent review verified every correction and signed off with no remaining finding. +- **Assessment:** Mail is current, coroutine-safe, queue- and fake-truthful, bounded for long-lived workers, and complete at its package boundaries. The result removes more stale behavior and false metadata than it adds, introduces no workaround or speculative abstraction, and has no meaningful performance regression or unintended Laravel API break. diff --git a/docs/plans/2026-08-03-1909-mail-correctness-current-parity-and-package-boundaries.md b/docs/plans/2026-08-03-1909-mail-correctness-current-parity-and-package-boundaries.md new file mode 100644 index 000000000..ec57fb6bb --- /dev/null +++ b/docs/plans/2026-08-03-1909-mail-correctness-current-parity-and-package-boundaries.md @@ -0,0 +1,492 @@ +# Mail Correctness, Current Parity, and Package Boundaries + +## Goal and boundary + +Complete the Mail package audit by fixing the verified delivery, queue, attachment, fake, metadata, and test defects; porting the identified current Laravel changes; documenting the intentional SES v2-only and pooled-transport behavior; and closing routed Support, Contracts, and HTTP findings. Discovery is complete. Implementation, same-family findings exposed while editing, validation, final self-review, and code review complete the package audit. + +## Audit principles + +### 1. Verify before changing + +A suspicious pattern is not an actionable finding until the audit establishes: + +- the exact file and symbol; +- every relevant caller and callee across `src/` and `tests/`; +- the state or resource owner; +- the initialization, commit, use, and cleanup boundaries; +- a realistic production or test failure schedule; +- why current guards and tests do not prevent it; +- sibling implementations and same-family sites; +- relevant upstream behavior; +- the lowest correct fix boundary; +- a regression strategy; +- the performance and complexity effect of the proposed fix. + +Use a focused probe when source reasoning cannot settle native or scheduler behavior. Do not repeatedly run the full suite hoping to reproduce a rare flake. + +### 2. Fix the lowest inconsistent contract + +Do not add local compensation when a shared lower-level contract is wrong. A caller catch is not enough when a typed filesystem method can return `false`; a per-consumer spawn catch is not enough when Engine exposes an ambiguous spawn contract; a proxy workaround is not enough when pool ownership is undefined. + +After changing a lower-level contract, re-audit every affected caller and revisit completed packages that depend on it. Record cross-references in both the owning package and each affected package ledger entry. + +### 3. Make ownership explicit + +The component that acquires or registers a resource records the exact handle and releases that exact handle. Cleanup must not reconstruct identity from mutable state when the original handle can be retained. + +Examples include coroutine IDs, timer IDs, process IDs plus incarnation checks, listener callbacks, pool leases, subscriber objects, stream handles, temporary filenames, signal watcher IDs, and channel tokens. + +### 4. Make creation transactional + +If code reserves capacity or publishes state before a later operation can fail, it must either finish creation or roll back every earlier change. Do not expose half-initialized objects, registered-but-dead pools, leaked wait-group counts, or published runtime paths without their cleanup owner. + +### 5. Make cleanup exhaustive + +Independent cleanup steps run even when an earlier step fails. The earliest operation or cleanup failure remains primary. Cleanup failures must not corrupt bookkeeping, skip unrelated cleanup, or turn a successful ownership transfer into a reported failure. + +### 6. Bound only external progress + +Use deadlines where progress depends on a process, socket peer, lock owner, IPC child, or external service that can disappear. Do not add arbitrary timeouts to ordinary internal coroutine joins once successful creation and ownership guarantee completion. + +### 7. Preserve hot-path quality + +For every fix, inspect: + +- additional allocations; +- container or facade resolutions; +- locking and atomics; +- hashing and serialization; +- new yields or sleeps; +- retries and polling; +- logging or exception construction; +- retained worker memory; +- cache invalidation and eviction. + +A correctness guard on a cold failure path has a different cost from a new lock or resolver on every request. State the difference explicitly. + +Any proposed change with a measured or source-proven hot-path regression requires explicit owner approval before implementation, even when it fixes a defect. Present the expected frequency and magnitude, the evidence, and the viable alternatives. Do not hide an unavoidable tradeoff inside a general correctness claim. + +Performance improvements must provide a meaningful practical benefit after accounting for code complexity and divergence from upstream. Measure representative behavior where practical. Always surface an evidence-backed opportunity to the owner, but do not implement it without approval; a micro-optimization within measurement noise is neither a reason to diverge nor an actionable finding. + +### 8. Remove superseded design completely + +When a fix changes the owning model, delete obsolete helpers, callbacks, properties, config keys, comments, tests, and documentation. Do not leave a compatibility path or comment describing behavior that no longer exists. Preserve intentional upstream comments unless the new design makes them incorrect. + +### 9. Treat remediation patterns as candidates + +The established patterns later in this plan are a vocabulary, not a lookup table. Choose among per-call parameters, immutable values, scoped bindings, cloning, CoroutineContext, factories, explicit ownership, static reset, or resource teardown only after proving the real lifetime and owner. + +### 10. Reject speculative complexity + +Record low-confidence concerns under rejected or unresolved analysis. Do not implement them. Surface every evidence-backed, meaningful non-defect improvement to the owner with its benefit, cost, and alternatives, then stop for explicit approval. This requirement exists to keep worthwhile opportunities visible, not to discourage finding them. + +### 11. Preserve useful Laravel parity + +Keep current Laravel public APIs, named arguments, protected extension points, method order, and conventional behavior unless a concrete Hypervel benefit justifies an approved difference. Historical PRs identify the complete changed-file surface; current Laravel `13.x` is the implementation and test reference. + +## Evidence and references + +- Hypervel source, tests, split/root metadata, Mail guide, and package README in this worktree. +- Current Laravel Framework `13.x` at `2c410561c21452de2f164caea64ab0fcac692a5d`. +- Originating Laravel changes, used for file discovery only: + - PR #60865 / `b921032562`: `Mailer::later()` queue name (`Mailer.php`, `MailableQueuedTest.php`). + - PR #59443 / `9c888ac1f9`: `assertHasNoAttachments()` (`Mailable.php`, `MailMailableTest.php`). + - PR #60886 / `b79e2d621b`: SES v2 tenant name (`SesV2Transport.php`, `MailSesV2TransportTest.php`). + - PR #58686 / `8aded48ec7`: single-label URLs (`Str.php`, `SupportStrTest.php`). + - PR #51990 / `213a370b70`: render view content before Mailer callbacks (`Mailer.php`). +- Installed Symfony Mailer, Mime, and HttpFoundation source for sendmail defaults, MIME types, response constants, and transport behavior. +- Current Laravel Mail tests, including `AttachmentTest`, `MailableQueuedTest`, `MailMailableTest`, `MailMessageTest`, `MailSesV2TransportTest`, and `SupportTestingMailFakeTest`. + +## State and ownership + +- `MailManager` is a worker-lifetime singleton and caches named `Mailer` wrappers; its public registry/configuration mutators remain boot/test-only. +- Poolable transports remain lease-owned behind `TransportPoolProxy`; this work does not change borrowing, release, invalidation, or pool identity. +- A `Mailable` and its attachment resolvers are operation/job objects. Queue and MIME choices belong on those objects, not manager-global or coroutine context state. +- `MailFake` is tests-only facade state. Its selected mailer is a one-operation override, not a worker/request cache; the manager remains the owner of the current default. +- No new static, singleton-held mutable, coroutine-context, or external-resource state is introduced. + +## Final finding set + +| ID | Category | Severity | Decision | +|---|---|---:|---| +| `mail-01` | Queue defect | Major | Apply explicit queue names to delayed mailables and pass the queue factory to `Mailable::later()`. | +| `mail-02` | Security/parity defect | Major | Restrict URL attachments to HTTP(S). | +| `mail-03` | Queue contract defect | Minor | Accept `UnitEnum` on primary queue-selection APIs and normalize at Queueable. | +| `mail-04` | Attachment defect/performance | Major | Resolve storage once, preserve explicit MIME, and omit failed MIME detection. | +| `mail-05` | Current parity | Minor | Port `assertHasNoAttachments()`. | +| `mail-06` | SES v2 parity | Minor | Forward every non-empty `X-SES-TENANT-NAME`, including `"0"`, as `TenantName`. | +| `mail-07` | Configuration defect | Minor | Remove stale on-demand sendmail/log fallback reads without coupling to named mailers. | +| `mail-08` | Package boundary defect | Major | Remove unnecessary direct Notifications and Testing requirements; declare actual direct and optional dependencies. | +| `mail-09` | Test parity defect | Minor | Port and merge the missing current attachment, fake, embed, queue, assertion, and SES regressions. | +| `mail-10` | Test isolation/type defect | Minor | Use framework test bases, truthful return types, and worker-safe temporary directories. | +| `mail-11` | Dead/stale code | Minor | Remove an empty `try/finally`, use container `make()`, narrow suppressions, and retain the protected provider extension point with truthful documentation. | +| `mail-12` | Documentation defect | Minor | Add canonical docs/provenance and concise public guidance. | +| `mail-13` | Intentional difference | Minor | Record SES v2-only support in README, source, and tests. | +| `mail-14` | Pooled API documentation | Minor | Explain why pooled mailers expose a proxy rather than one stable concrete transport. | +| `mail-15` | Native type documentation | Minor | Document the supported string and resource data shapes where PHP requires native `mixed`. | +| `mail-16` | Callback ordering parity defect | Major | Render message content before callbacks so direct sends, mailables, and notifications can inspect or replace the final body. | +| `mail-17` | Facade metadata defect | Minor | Point `MailManager`'s mixin at its concrete `Mailer` so facade-documenter exposes the complete forwarded API. | +| `support-28` | Fake/facade defect | Major | Make `MailFake` intercept every side-effecting mail method, consume selection once, preserve queue and recipient metadata, and refresh facade annotations. | +| `support-29` | URL predicate parity defect | Minor | Port current single-label-domain support in `Str::isUrl()` for Mail, Stringable, and the framework `url` validation rule. | +| `support-30` | Fake assertion type defect | Major | Make MailFake's shared assertion helper accept the count and address shapes its public methods support. | +| `support-31` | Fake assertion type defect | Minor | Remove NotificationFake's non-callable string callback promise and port its complete current upstream unit suite. | +| `contracts-10` | Queue type consistency | Minor | Widen the Mail queue contract to the framework's `UnitEnum|string|null` identifier boundary. | +| `contracts-11` | Callback type consistency | Minor | Replace broad `mixed` callback parameters with Laravel's actual `Closure|string` boundary across the Mail contract, concrete implementation, fake, and facade. | +| `http-27` | Completed-package docs defect | Minor | Correct HTTP README ordering and add its canonical guide link. | +| `filesystem-14` | Static-analysis type defect | Minor | Remove false `FilesystemAdapter` narrowing where supported pooled disks expose adapter methods outside the intentionally narrow contract. | + +## Implementation design + +### 1. Queue selection and delayed delivery + +Widen the primary APIs only: + +```php +public function queue(array|MailableContract|string $view, UnitEnum|string|null $queue = null): mixed +public function onQueue(UnitEnum|string|null $queue, MailableContract $view): mixed +public function later(DateInterval|DateTimeInterface|int $delay, array|MailableContract|string $view, UnitEnum|string|null $queue = null): mixed +``` + +Apply a non-null queue to the mailable, then always pass the queue factory to the queued mailable: + +```php +if ($queue !== null) { + $view->onQueue($queue); +} + +return $view->mailer($this->name)->later($delay, $this->queue); +``` + +Use the same queue assignment in `queue()`. Accept `UnitEnum|string` in `queueOn()` and `laterOn()` as well so every public alias forwards the same identifier domain instead of rejecting values its delegate supports. Do not add `onQueue()` to the Mailable contract: generated queueable mailables receive it from `Bus\Queueable`, which owns `UnitEnum` normalization. Widen `Contracts\Mail\MailQueue` and facade/fake declarations consistently. At each deliberate contract boundary, use only the exact identifier-scoped suppression with a concise explanation; do not preselect an identifier or broaden the Mailable contract to silence analysis. + +### 2. Render content before message callbacks + +Port Laravel PR #51990 from the current `Mailer::send()` implementation exactly: + +- remove the `First we need to parse the view...` comment from `send()` only; +- move the `Once we have retrieved the view content...` comment directly above `parseView()`; +- call `addContent()` before invoking the callback. + +Do not change `render()`, where the original parse-view comment remains correct. The reorder fixes direct `send()`, `raw()`, `html()`, and `plain()` callbacks, `Mailable::send()` callbacks registered through `withSymfonyMessage()` or `Envelope(using: [...])`, and the notification `MailChannel` callback path. Every object involved is operation-local; the change adds no work or shared state. + +Add two counterfactual regressions: + +- in `MailMailerTest`, record the rendered HTML observed inside the callback, replace it, and assert both the observation and final replacement after sending; +- in `MailMailableTest`, exercise `Envelope(using: [...])` through a real mailable send, record the rendered body, replace it, and assert the final message. + +Do not add a notification-specific ordering test: `MailChannel::buildMessage()` never sets a body, so it cannot distinguish the old and new order. Record the two accepted upstream consequences: attachments embedded while rendering now precede attachments added by mailable/notification callbacks in MIME order, and views now receive a bare message before envelope state and attachments are applied. Existing view embed behavior and attachment-index assertions remain valid and must be revalidated. + +### 3. URL attachment safety and Support URL parity + +At construction, accept only current HTTP(S) URLs: + +```php +if (! Str::isUrl($url, ['http', 'https'])) { + throw new InvalidArgumentException('Attachment URLs must use the http or https scheme.'); +} +``` + +Port Laravel's current `Str::isUrl()` domain branch at the Support owner so valid single-label internal hosts are not rejected: + +```php +(?: + (?: + (?:[\pL\pN\pS\pM\-\_]++\.)+ + (?: + (?:xn--[a-z0-9-]++) # punycode in tld + | + (?:[\pL\pN\pM]++) # no punycode in tld + ) + ) # a multi-level domain name + | + [a-z0-9\-\_]++ # a single-level domain name +)\.? +``` + +Copy this upstream branch verbatim, including its grouping and comments. Add the upstream Support cases and a Mail attachment case for `http://l/...`. Revalidate that the two Validation consumers now accept single-label hosts, which is the intended user-visible current-Laravel behavior. Do not add a Mail-local hostname exception or perform network preflight. + +### 4. Storage attachment consistency + +Resolve one adapter inside the modern resolver and reuse it: + +```php +$storage = static::getStorageDisk($disk); + +// The contract deliberately omits adapter metadata methods, which every shipped disk provides. +// @phpstan-ignore method.notFound +$mime = $attachment->mime ?? $storage->mimeType($path); + +$attachment->as($attachment->as ?? basename($path)); + +if ($mime !== false) { + $attachment->withMime($mime); +} + +return $dataStrategy(fn () => $storage->get($path), $attachment); +``` + +In legacy `Mailable::buildDiskAttachments()`, use `options['mime']` when present and skip metadata I/O. Otherwise detect once and include `mime` only when detection returns a string. Preserve one content read and Symfony's fallback when MIME is unknown. Keep the truthful Filesystem contract type at both storage boundaries. The contract deliberately omits adapter metadata while every shipped disk implementation provides it, so place an exact `method.notFound` suppression on each `mimeType()` call with that concise WHY; do not restore the runtime-false `FilesystemAdapter` annotation. The suppression cannot prove the `false|string` return, making the false-MIME regressions load-bearing. + +Do not widen the generic Filesystem contract, add adapter capabilities/reflection, catch unrelated failures, or redesign attachment streaming. + +### 5. MailFake fidelity and one-shot selection + +The fake stores only an explicit one-shot override; null means “resolve the manager's current default when an operation occurs.” Normalize enum names in `mailer()`, convert null/empty to null, and preserve `"0"`. + +Use one helper because all side-effecting entry points consume the same invariant: + +```php +protected function pullCurrentMailer(): string +{ + $mailer = $this->currentMailer ?? $this->manager->getDefaultDriver(); + $this->currentMailer = null; + + return $mailer; +} +``` + +Do not snapshot the default in the constructor. Route `ShouldQueue` before pulling so queued sends pull exactly once in `queue()`: + +```php +protected function sendMail(array|Mailable|string $view, bool $shouldQueue = false): mixed +{ + if ($shouldQueue) { + return $this->queue($view); + } + + $mailer = $this->pullCurrentMailer(); + + if (! $view instanceof Mailable) { + return null; + } + + $view->mailer($mailer); + $this->mailables[] = $view; + + return null; +} +``` + +`queue()` pulls before its type check, then mirrors the real mailer by throwing `InvalidArgumentException` for non-mailables. It applies the selected mailer, applies every non-null queue through Queueable, and records it. This consumes invalid calls and is failure-safe when validation or `onQueue()` throws, without `try/finally`. + +Declare side-effecting methods so `__call()` cannot reach the real transport or queue: + +```php +public function html(string $html, Closure|string $callback): ?SentMessage +public function plain(string $view, array $data, Closure|string $callback): ?SentMessage +public function onQueue(UnitEnum|string|null $queue, Mailable $view): mixed +public function queueOn(UnitEnum|string $queue, Mailable $view): mixed +public function laterOn(UnitEnum|string $queue, DateInterval|DateTimeInterface|int $delay, Mailable $view): mixed +``` + +`raw()`, `html()`, and `plain()` pull and return null. Queue helpers delegate to the fake's `queue()`/`later()`. Leave read-only `render()` and `getSymfonyTransport()` forwarding; do not intercept configuration-only `always*` methods or invent delay state. + +Add optional `?string $name` to fake `to/cc/bcc` and construct the same `Mailables\Address` as the real Mailer. Keep the one-argument Mailer contract unchanged. + +Make the assertion helper accept the aggregate shape of all six callers: + +```php +protected function prepareMailableAndCallback( + Closure|string $mailable, + array|callable|int|string|null $callback, +): array +``` + +Use `is_int()` for `assertSent()` and `assertQueued()` count routing because strings are addresses and the target methods require `int`. Keep the negative assertion unions unchanged. Repository PHPStan does not report this union mismatch; current upstream public-overload tests are the regression guard. + +Regressions must cover dynamic manager defaults, null/empty/`"0"`/enum selection, ordinary and ShouldQueue sends, invalid send and queue consumption, both string and enum queue preservation across every queue alias, failure consumption, named recipients, and zero real transport/queue calls from all five previously forwarded methods. Relax the existing `getDefaultDriver()->once()` mock expectation: it pins the constructor snapshot being removed, while behavioral assertions must prove the one-shot selection contract and ShouldQueue's single pull. + +Narrow `NotificationFake::assertSentTo()` from `callable|int|string|null` to `callable|int|null`. Callable strings remain accepted by `callable`; non-callable strings have no supported notification meaning and currently fail later at `sent(..., ?callable)`. Leave its `is_numeric()` check unchanged because the narrowed domain makes it equivalent to `is_int()`. Regenerate the Notification facade from that underlying signature. Port current Laravel's complete `SupportTestingNotificationFakeTest.php`; do not add a TypeError-only test that also passes before the fix. Record the separately missing `SupportTestingEventFakeTest.php` port in `docs/todo.md` rather than mixing unrelated Event work into this finding. + +### 6. Current Mailable and SES v2 APIs + +Insert `assertHasNoAttachments()` immediately before `assertHasAttachment()` in current upstream order. + +For SES v2, copy options locally and add only a non-empty tenant name: + +```php +if (($tenantName = $this->tenantName($message)) !== null) { + $options['TenantName'] = $tenantName; +} +``` + +The helper reads `X-SES-TENANT-NAME`, maps only the empty string to null, and preserves every other valid value exactly. Cover present, `"0"`, absent, and empty headers. No shared transport options mutate. + +### 7. Transport construction and source cleanup + +On-demand sendmail/log transports read only their supplied config: + +```php +'path' => $config['path'] ?? null, +'channel' => $config['channel'] ?? null, +``` + +Do not inherit another named mailer's configuration. Symfony owns the default sendmail command; log channel remains nullable. Retain Markdown's `'default'` and `[]` fallbacks because the inner settings are intentionally optional in a replaceable nested config array. + +Replace six MailManager container array reads with `make()`, using an evidence-based local `@var` only where a canonical string key obscures the runtime type. Collapse `sendSymfonyMessage()` to its direct return. Replace broad PHPStan suppression only with a correct `@var` or identifier-scoped ignore. + +Remove the same false `FilesystemAdapter` annotation from `ServeFile::__invoke()`. Keep the truthful disk contract, leave contract-owned `exists()` unsuppressed, and place the same concise WHY plus exact `method.notFound` suppression on `serve()`. Do not change `Storage::fake()`'s accurate local-adapter annotation. + +Document the intentionally native-`mixed` data boundaries on `Message::attachData()`, `Message::embedData()`, and `TextMessage::embedData()` with Laravel's `@param resource|string $data`. PHP cannot express `resource` natively; do not widen the public contract to Symfony's tolerated `File`, narrow the native parameters, or add runtime conversion. + +Keep the protected provider extension point and explain its retained name truthfully: + +```php +/** + * Register the mailer instance. + * + * The method name is retained for compatibility with Laravel's protected extension point. + */ +protected function registerIlluminateMailer(): void +``` + +### 8. Split-package dependencies + +Change `Attachment::attachTo()` to native `object` with the precise supported PHPDoc union: + +```php +/** + * Attach the attachment to a built-in mail type. + * + * @param Mailable|MailMessage|Message $mail + */ +public function attachTo(object $mail, array $options = []): mixed +``` + +Runtime dispatch remains the existing `attach()` / `attachData()` structural API. Type the path strategy parameter as `string` to document the built-in resolver contract, not as an analysis workaround. Preserve the data resolver's existing runtime contract and remove both the method-wide suppression and false `@var Mailable` narrowing. At the repository's PHPStan level the truthful union typechecks without suppression because the data and filename strategy expressions remain `mixed`; do not claim that the three target parameter signatures are identical. Add no target interface, reflection, duplicated per-target dispatch, or suppression absent a newly reproduced error. + +Use `Closure|string` for `raw()`, `html()`, and `plain()` callbacks in the concrete Mailer and fake, and for `raw()` in `Contracts\Mail\Mailer`. These are the complete supported callback shapes passed to `send()` and match Laravel; propagate them to facade metadata rather than copying `mixed` into new declarations. + +Add one focused `tests/Console/Scheduling/EventTest.php` case for the contract's only external consumer: register `emailOutputTo()`, invoke the after callback with mocked Mailer and Filesystem dependencies, and assert captured output reaches `raw()` with the expected subject and recipients. This closes an existing public-behavior coverage gap; it is not a counterfactual regression for the type correction. The source trace showing `Event::emailOutput()` passes a Closure, together with repo-wide PHPStan, verifies compatibility with `Closure|string`. + +In `src/mail/composer.json`: + +- remove direct `hypervel/notifications` after the source reference is gone; +- remove hard `hypervel/testing`; +- require `symfony/mime` and `symfony/http-foundation` because shipped source dereferences them; +- suggest `hypervel/http` for UploadedFile attachments; +- suggest `hypervel/filesystem` for storage attachments; +- suggest `hypervel/testing` for ordered Mailable assertions; +- suggest `phpunit/phpunit` for Mailable assertion methods; +- retain the Symfony HTTP client and transport suggestions. + +Do not claim the full repository graph is acyclic: Support intentionally aggregates core providers and currently creates broader transitive cycles. Do not redesign that package graph in this Mail correction. + +Add `tests/Mail/PackageMetadataTest.php` for direct split/root consistency, provider metadata, required dependencies, optional suggestions, absence of Mail's direct Notifications/Testing requirements, and the existing root replacements. Root Symfony/PHPUnit entries already exist; do not run Composer for unchanged root constraints. + +### 9. Facade metadata and current tests + +Change `MailManager`'s mixin from the narrow Mailer contract to the concrete `Mailer`, matching Laravel and the manager's actual resolver. Unlike arbitrary Filesystem extensions, Mail extensions create transports and cannot replace the concrete mailer. Then refresh the Mail facade from current Laravel in upstream order while retaining Hypervel manager pooling methods, fake assertions, and accurate contract return types. Include `always*`, optional names on `to/cc/bcc`, `html`, `plain`, `render`, queue methods, transport/view/queue accessors, and macros. Regenerate the Notification facade after narrowing `NotificationFake::assertSentTo()`. Underlying signatures remain authoritative for facade-documenter output; do not add a redundant concrete `@see` route. Pin the concrete forwarded Mail surface in `PackageMetadataTest` so a future narrow mixin cannot silently remove it. + +Port or merge, one file at a time: + +- current `tests/Mail/AttachmentTest.php`; +- PR/current delayed-queue regressions in `MailableQueuedTest.php`; +- `assertHasNoAttachments()` regressions in `MailMailableTest.php`; +- SES tenant regressions in `MailSesV2TransportTest.php`; +- current `SupportTestingMailFakeTest.php`, preserving Hypervel's `sendNow` and public `assertQueuedTimes` coverage; +- current `SupportTestingNotificationFakeTest.php` for `support-31`; +- the missing `testItEmbedsFilesViaAttachableContractFromData()` in `MailMessageTest.php`. + +Read and retain all ten `tests/Integration/Mail/` files and their fixtures: `AttachingFromStorageTest`, `MailableTestCase`, `MailableWithSecuredEncodingTest`, `MailableWithoutSecuredEncodingTest`, `MarkdownParserTest`, `RenderingMailWithLocaleTest`, `SendingMailWithLocaleTest`, `SendingMarkdownMailTest`, `SendingQueuedMailTest`, and `SentMessageMailTest`. `AttachingFromStorageTest` is mandatory revalidation for the storage resolver and the widened `attachTo()` target; the remaining integration files retain rendering, locale, queued delivery, sent-message, secured encoding, and Markdown behavior. + +Do not duplicate the already-present data-attachment test, legacy six-config compatibility tests, rejected duplicate address-control-character tests, or behavior already covered more strongly by Hypervel enum/pooling tests. + +### 10. Test hygiene + +Change the seven raw PHPUnit Mail tests to `Hypervel\Tests\TestCase`. Give test methods `: void`; type helpers/providers with their actual return contracts. Do not type providers `void`. + +The seven base-class conversions are: + +- `AttachableTest.php`; +- `MailMailableAssertionsTest.php`; +- `MailMailableDataTest.php`; +- `MailMailableHeadersTest.php`; +- `MailMarkdownTest.php`; +- `MailMessageTest.php`; +- `MailableAlternativeSyntaxTest.php`. + +Apply the same truthful typing pass to all ten `tests/Integration/Mail/` files: add `: void` to test methods, and type lifecycle hooks, test parameters, helpers, and data providers according to their real contracts. Type `MailableTestCase::defineEnvironment(ApplicationContract $app): void` to match its parent. Retain `MailableTestCase`; its shared environment setup justifies the package base class. + +Apply the same rule to every additional test file this work edits. In particular, complete the existing partial typing in `MailMailerTest`, `MailMailableTest`, and `MailableQueuedTest`, including truthful helper return types; do not leave a touched test file internally inconsistent. + +Before marking Mail complete, add `: void` to the remaining ten untyped test methods in `MailLogTransportTest`, `MailResendTransportTest`, `MailFailoverTransportTest`, and `MailRoundRobinTransportTest`. + +Move `MailMessageTest`'s image into a per-worker `ParallelTesting::tempDir()` created in `setUp()` and removed in `tearDown()`. Remove per-test `unlink()` calls once that directory owns cleanup. Give `MarkdownCoroutineSafetyTest` the same owned-directory lifecycle. Cleanup is exception-safe and does not duplicate framework static resets. + +### 11. Public documentation and intentional differences + +Keep the README minimal and ordered: + +1. package header; +2. existing package badge; +3. `Documentation: https://hypervel.org/docs/mail`; +4. Differences From Laravel; +5. `Ported from: https://github.com/laravel/framework`. + +Retain the cloud-storage-helper difference and add one concise SES statement: Hypervel supports Amazon SES through SES v2 only; the `ses` mailer uses `ses-v2`. Add the matching concise source insertion comment and `REMOVED:` test marker. Do not port SES v1. + +Update the Boost Mail guide at natural sections only: + +- `fromUrl()` accepts HTTP(S); use `fromPath()` for local paths; +- `assertHasNoAttachments()`; +- `X-SES-TENANT-NAME`; +- primary queue methods accept enum identifiers; +- pooled named mailers expose `TransportPoolProxy`; configure through `mail.php`, or use `pool => false` only when stable concrete transport inspection is required. + +Do not duplicate the guide in README or document internal construction/state machinery. + +Correct the completed HTTP README separately: restore its package badge, link to the canonical Requests guide, retain approved differences, and move `Ported from:` to the final position. + +## Rejected concerns + +- No duplicate address control-character validator; Symfony Mime owns the active boundary. +- No transport retries, health checks, or poisoned-resource policy without a demonstrated broken-resource lifecycle. +- No array/log history redesign. +- No attachment streaming redesign or Filesystem contract expansion. +- No pool-proxy magic forwarding: a proxy cannot expose one stable concrete transport after releasing a lease. +- No SES v1 or compatibility wrapper. +- No protected provider-method rename. +- No removal of intentional Markdown fallbacks. +- No speculative interception of fake configuration methods or fake delay storage. +- No Support package-graph redesign inside the Mail slice. +- No pre-guard for unreadable storage attachment data: modern Mail targets already reject null at Hypervel's typed boundary and `Message` reaches Symfony's explicit body-type exception. Unlike failed MIME detection, there is no valid fallback result to preserve. +- No notification-specific callback-ordering test, selectable callback-order mode, re-render, or body snapshot; none can add evidence beyond the two discriminating regressions without extra machinery. + +## Validation plan + +Run each changed/new test file immediately after editing it. Then run: + +1. all `tests/Mail/`, `tests/Integration/Mail/`, `tests/Support/SupportTestingMailFakeTest.php`, and `tests/Support/SupportTestingNotificationFakeTest.php` tests; +2. `tests/Support/SupportStrTest.php`, `tests/Support/SupportStringableTest.php`, the focused Validation URL tests, and the new Console scheduling email-output test to revalidate existing consumers; +3. `tests/Integration/Filesystem/ServeFileTest.php`, facade generation and scoped lint for `Hypervel\Support\Facades\Mail` and `Hypervel\Support\Facades\Notification`, and package metadata tests for Mail and HTTP; +4. `composer fix`, which runs PHP CS Fixer, both PHPStan configurations, the full parallel suite, the Testbench package suite, and Testbench dogfood. + +Counterfactual assertions must fail against the old implementation: explicit delayed queues and every enum queue alias do not type-error; direct and `Envelope(using: [...])` callbacks observe and replace rendered content; short internal HTTP hosts validate while non-HTTP schemes fail; false MIME does not reach a string boundary; disk resolution occurs once; fake side-effect methods never call real transports/queues; invalid fake queue calls throw while consuming selection; explicit fake queues and selected mailers survive recording; ShouldQueue selection pulls once; SES tenant names, including `"0"`, are exact; the Mail facade exposes the concrete forwarded surface. + +Additional validation keeps Mail and Notification facade metadata synchronized and revalidates worker-safe temporary-file ownership. + +## Completion records + +After implementation and final review: + +- add the Mail package ledger entry with every accepted/rejected concern, upstream PR inventory, performance/API/coroutine conclusion, tests, and review result; +- mark Mail complete in the core checklist; +- add completed-package amendments for `support-28`, `support-29`, `support-30`, `support-31`, `contracts-10`, `contracts-11`, `http-27`, and `filesystem-14`, including Console's use of the narrowed Mailer callback contract and Support's remaining EventFake test-parity todo; +- record Validation's completed consumer revalidation under `support-29` in the new Mail work unit; Validation has no completed package-ledger entry to amend and remains pending its later full audit; +- add dependency-index rows only for the shared lower-level findings: `mail-17` affects Mail and Support, `support-28` affects Support and Mail, `support-29` affects Support, Mail, and Validation, `contracts-10` affects Contracts and Mail, `contracts-11` affects Contracts, Mail, and Console, and `filesystem-14` affects Filesystem and Mail; `support-30`, `support-31`, and the HTTP README-only correction change no assumption consumed by another package; +- update Support's ledger summary for MailFake/facade and `Str::isUrl()`, Contracts for the queue and callback types, Console for its callback consumer revalidation, HTTP for its README correction, and Filesystem for the truthful dynamic adapter-method boundary; +- update the routing index with every newly assigned finding and verify IDs do not collide. + +## Final self-review + +Trace every changed caller/callee and recheck current Laravel source order. Confirm: + +- no supported Laravel public API or protected extension point was removed or narrowed; the broad native `mixed` callbacks are corrected to Laravel's documented `Closure|string` boundary, so unsupported values do not count as compatibility; +- callback ordering matches current Laravel across direct sends, mailables, and notifications, including the accepted view-state and MIME attachment-order consequences; +- SES v2-only is the sole new documented intentional difference; +- fake state is operation-local and no selected mailer survives a completed or failing side-effect call; +- no worker/coroutine state, lock, registry, retry, cache, network call, or hot-path resolution was added; +- storage resolution and production dependency load are reduced; +- no runtime-false filesystem concrete annotations or broad dynamic-method suppressions remain; +- no dead helper, stale suppression, misleading comment, duplicate documentation, or unowned test file remains. diff --git a/docs/todo.md b/docs/todo.md index 6aceed8db..27f441ca8 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -26,6 +26,7 @@ ## Testing +- Port current Laravel's complete `tests/Support/SupportTestingEventFakeTest.php`, preserving Hypervel-specific EventFake coverage and coroutine-safe test behavior. - Complete Testing assertion coverage: port the remaining current Laravel `TestResponseTest` cases through the incremental upstream-update workflow, and add focused coverage for `TestView`'s public assertion and string surface where Laravel has no equivalent suite. - Add the repository-required `: void` return type to the remaining untyped HTTP test methods: 176 in `tests/Http/HttpClientTest.php`, 30 in `tests/Http/HttpRequestTrustedStateTest.php`, and 4 in `tests/Http/HttpRequestTrustedStateCoroutineTest.php`. Verify each file after the mechanical conversion. diff --git a/src/boost/docs/mail.md b/src/boost/docs/mail.md index 77986639e..ddf2477a8 100644 --- a/src/boost/docs/mail.md +++ b/src/boost/docs/mail.md @@ -225,6 +225,8 @@ public function headers(): Headers } ``` +To send through an SES tenant, add an `X-SES-TENANT-NAME` text header containing the tenant name. + If you would like to define [additional options](https://docs.aws.amazon.com/aws-sdk-php/v3/api/api-sesv2-2019-09-27.html#sendemail) that Hypervel should pass to the AWS SDK's `SendEmail` method when sending an email, you may define an `options` array within your `ses` mailer configuration: ```php @@ -313,6 +315,8 @@ Named mailers use pooling by default when their transport is poolable. On-demand Any other value is rejected. Explicitly requesting pooling for a transport that is not poolable is also rejected instead of being silently ignored. +For a pooled mailer, `getSymfonyTransport()` returns a `TransportPoolProxy` rather than one concrete transport. Configure the transport through `config/mail.php`, or set `pool` to `false` if stable concrete transport inspection is required. + Pool identity is derived from the transport's resolved construction input, including fallback credentials from `config/services.php`. Equivalent named mailers and explicitly pooled equivalent mailers created through `Mail::build()` converge on one pool. Credential changes create a different identity. Failover and round-robin identities include each resolved child transport in order, so changing a child or its credentials also changes the composite pool. The default pool settings are suitable for most applications. If your application sends a high volume of concurrent mail, or if you encounter pool exhaustion errors, you may tune the pool for any mailer by adding a `pool` configuration option to the mailer's configuration array: @@ -604,6 +608,8 @@ public function attachments(): array } ``` +`Attachment::fromUrl()` accepts HTTP and HTTPS URLs. Use `fromPath()` for local filesystem paths. + When attaching files to a message, you may also specify the display name and / or MIME type for the attachment using the `as` and `withMime` methods: ```php @@ -1093,6 +1099,8 @@ Mail::to($request->user()) ->queue($message); ``` +The `queue()`, `later()`, `onQueue()`, `queueOn()`, and `laterOn()` methods accept queue names as strings or PHP enum cases. + #### Queueing by Default @@ -1323,6 +1331,8 @@ public function test_mailable_content(): void As you might expect, the "HTML" assertions assert that the HTML version of your mailable contains a given string, while the "text" assertions assert that the plain-text version of your mailable contains a given string. +Use `$mailable->assertHasNoAttachments()` when a message should not contain any attachments. + ### Testing Mailable Sending diff --git a/src/contracts/src/Mail/MailQueue.php b/src/contracts/src/Mail/MailQueue.php index 5bb459f51..0fc4b7f55 100644 --- a/src/contracts/src/Mail/MailQueue.php +++ b/src/contracts/src/Mail/MailQueue.php @@ -6,16 +6,17 @@ use DateInterval; use DateTimeInterface; +use UnitEnum; interface MailQueue { /** * Queue a new e-mail message for sending. */ - public function queue(array|Mailable|string $view, ?string $queue = null): mixed; + public function queue(array|Mailable|string $view, UnitEnum|string|null $queue = null): mixed; /** * Queue a new e-mail message for sending after (n) seconds. */ - public function later(DateInterval|DateTimeInterface|int $delay, array|Mailable|string $view, ?string $queue = null): mixed; + public function later(DateInterval|DateTimeInterface|int $delay, array|Mailable|string $view, UnitEnum|string|null $queue = null): mixed; } diff --git a/src/contracts/src/Mail/Mailer.php b/src/contracts/src/Mail/Mailer.php index 6f95066ba..7f8574432 100644 --- a/src/contracts/src/Mail/Mailer.php +++ b/src/contracts/src/Mail/Mailer.php @@ -28,7 +28,7 @@ public function bcc(mixed $users): PendingMail; /** * Send a new message with only a raw text part. */ - public function raw(string $text, mixed $callback): ?SentMessage; + public function raw(string $text, Closure|string $callback): ?SentMessage; /** * Send a new message using a view. diff --git a/src/filesystem/src/ServeFile.php b/src/filesystem/src/ServeFile.php index a6c865312..25596270a 100644 --- a/src/filesystem/src/ServeFile.php +++ b/src/filesystem/src/ServeFile.php @@ -31,7 +31,6 @@ public function __invoke(Request $request, string $path): Response $this->isProduction ? 404 : 403 ); try { - /** @var FilesystemAdapter $disk */ $disk = Storage::disk($this->disk); abort_unless($disk->exists($path), 404); @@ -42,6 +41,8 @@ public function __invoke(Request $request, string $path): Response ]; return tap( + // The contract omits adapter response methods, which every shipped disk provides. + // @phpstan-ignore method.notFound $disk->serve($request, $path, headers: $headers), function ($response) use ($headers) { if (! $response->headers->has('Content-Security-Policy')) { diff --git a/src/http/README.md b/src/http/README.md index 88393fa21..a5631b04a 100644 --- a/src/http/README.md +++ b/src/http/README.md @@ -3,7 +3,7 @@ Http for Hypervel [](https://deepwiki.com/hypervel/http) -Ported from: https://github.com/laravel/framework +Documentation: https://hypervel.org/docs/requests ## Differences From Laravel @@ -16,3 +16,5 @@ Laravel's `Http::pool()` and `Http::batch()` APIs are intentionally not ported. `TrustHosts` fails closed when no trusted host patterns resolve. If the middleware is enabled and no resolver, `at()` list, or valid `app.url` host provides a trusted pattern, Hypervel rejects all hosts using a never-matching sentinel. Laravel and Symfony leave the trusted host list empty in this case, which accepts every host. Configure a valid `app.url`, `TrustHosts::at()`, or `TrustHosts::resolveHostsUsing()` when enabling the middleware. Configure trusted proxies through `$middleware->trustProxies(...)` in `bootstrap/app.php`. Hypervel does not read the legacy `trustedproxy.proxies` configuration key or include Laravel's Cloud, Forge, and Vapor host-specific proxy behavior. + +Ported from: https://github.com/laravel/framework diff --git a/src/mail/README.md b/src/mail/README.md index 070754c2c..10c752c16 100644 --- a/src/mail/README.md +++ b/src/mail/README.md @@ -3,6 +3,12 @@ Mail for Hypervel [](https://deepwiki.com/hypervel/mail) +Documentation: https://hypervel.org/docs/mail + ## Differences From Laravel Hypervel omits Laravel's legacy `Attachment::fromCloudStorage()` helper. Use `Attachment::fromStorageDisk(...)` with a named disk instead. + +Hypervel supports Amazon SES through SES v2 only. The `ses` mailer uses the `ses-v2` transport. + +Ported from: https://github.com/laravel/framework diff --git a/src/mail/composer.json b/src/mail/composer.json index 6f953c2ca..4051c3c88 100644 --- a/src/mail/composer.json +++ b/src/mail/composer.json @@ -32,7 +32,9 @@ "php": "^8.4", "league/commonmark": "^2.7", "psr/log": "^3.0", + "symfony/http-foundation": "^8.1", "symfony/mailer": "^8.1", + "symfony/mime": "^8.1", "tijsverkoyen/css-to-inline-styles": "^2.2.5", "hypervel/bus": "^0.4", "hypervel/collections": "^0.4", @@ -42,11 +44,9 @@ "hypervel/contracts": "^0.4", "hypervel/log": "^0.4", "hypervel/macroable": "^0.4", - "hypervel/notifications": "^0.4", "hypervel/object-pool": "^0.4", "hypervel/queue": "^0.4", "hypervel/support": "^0.4", - "hypervel/testing": "^0.4", "hypervel/view": "^0.4" }, "config": { @@ -64,9 +64,13 @@ }, "suggest": { "aws/aws-sdk-php": "Required to use the SES mail driver (^3.235.5).", + "hypervel/filesystem": "Required to attach files from storage (^0.4).", + "hypervel/http": "Required to attach uploaded files (^0.4).", + "hypervel/testing": "Required to use ordered Mailable assertions (^0.4).", + "phpunit/phpunit": "Required to use Mailable assertion methods (^13.0).", + "resend/resend-php": "Required to enable support for the Resend mail transport (^1.0).", "symfony/http-client": "Required to use the Symfony API mail transports (^8.1).", "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^8.1).", - "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^8.1).", - "resend/resend-php": "Required to enable support for the Resend mail transport (^1.0)." + "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^8.1)." } -} \ No newline at end of file +} diff --git a/src/mail/src/Attachment.php b/src/mail/src/Attachment.php index 94c085ead..83b217693 100644 --- a/src/mail/src/Attachment.php +++ b/src/mail/src/Attachment.php @@ -10,7 +10,9 @@ use Hypervel\Contracts\Filesystem\Filesystem; use Hypervel\Http\UploadedFile; use Hypervel\Notifications\Messages\MailMessage; +use Hypervel\Support\Str; use Hypervel\Support\Traits\Macroable; +use InvalidArgumentException; use RuntimeException; use function with; @@ -52,6 +54,10 @@ public static function fromPath(string $path): static */ public static function fromUrl(string $url): static { + if (! Str::isUrl($url, ['http', 'https'])) { + throw new InvalidArgumentException('Attachment URLs must use the http or https scheme.'); + } + return static::fromPath($url); } @@ -93,15 +99,26 @@ public static function fromStorage(string $path): static public static function fromStorageDisk(?string $disk, string $path): static { return new static(function ($attachment, $pathStrategy, $dataStrategy) use ($disk, $path) { - $attachment - ->as($attachment->as ?? basename($path)) - ->withMime($attachment->mime ?? static::getStorageDisk($disk)->mimeType($path)); // @phpstan-ignore-line + $storage = static::getStorageDisk($disk); + $mime = $attachment->mime; + + if ($mime === null) { + // The contract omits adapter metadata methods, which every shipped disk provides. + // @phpstan-ignore method.notFound + $mime = $storage->mimeType($path); + } - return $dataStrategy(fn () => static::getStorageDisk($disk)->get($path), $attachment); + $attachment->as($attachment->as ?? basename($path)); + + if ($mime !== false) { + $attachment->withMime($mime); + } + + return $dataStrategy(fn () => $storage->get($path), $attachment); }); } - // Laravel's fromCloudStorage() helper is intentionally not ported. + // REMOVED: Laravel's fromCloudStorage() helper is intentionally not ported. // Use fromStorageDisk('s3', $path) or another named disk instead. /** @@ -145,13 +162,14 @@ public function attachWith(Closure $pathStrategy, Closure $dataStrategy): mixed /** * Attach the attachment to a built-in mail type. * - * @phpstan-ignore-next-line + * @param Mailable|MailMessage|Message $mail + * + * @throws RuntimeException */ - public function attachTo(Mailable|MailMessage|Message $mail, array $options = []): mixed + public function attachTo(object $mail, array $options = []): mixed { - /** @var Mailable $mail */ return $this->attachWith( - fn ($path) => $mail->attach($path, [ + fn (string $path) => $mail->attach($path, [ 'as' => $options['as'] ?? $this->as, 'mime' => $options['mime'] ?? $this->mime, ]), diff --git a/src/mail/src/MailManager.php b/src/mail/src/MailManager.php index 579a0edd3..e79d2e89d 100644 --- a/src/mail/src/MailManager.php +++ b/src/mail/src/MailManager.php @@ -8,8 +8,11 @@ use Closure; use Hypervel\Config\Repository; use Hypervel\Contracts\Container\Container; +use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Contracts\Mail\Factory as FactoryContract; use Hypervel\Contracts\Mail\Mailer as MailerContract; +use Hypervel\Contracts\Queue\Factory as QueueFactory; +use Hypervel\Contracts\View\Factory as ViewFactory; use Hypervel\Log\LogManager; use Hypervel\Mail\Transport\ArrayTransport; use Hypervel\Mail\Transport\CloudflareTransport; @@ -42,7 +45,7 @@ use function Hypervel\Support\enum_value; /** - * @mixin \Hypervel\Contracts\Mail\Mailer + * @mixin \Hypervel\Mail\Mailer */ class MailManager implements FactoryContract { @@ -134,18 +137,22 @@ protected function resolve(string $name): MailerContract throw new InvalidArgumentException("Mailer [{$name}] is not defined."); } - // Once we have created the mailer instance we will set a container instance - // on the mailer. This allows us to resolve mailer classes via containers - // for maximum testability on said classes instead of passing Closures. + /** @var ViewFactory $views */ + $views = $this->app->make('view'); + /** @var Dispatcher $events */ + $events = $this->app->make('events'); + $mailer = new Mailer( $name, - $this->app['view'], + $views, $this->createMailerTransport($config, [$name], poolByDefault: true), - $this->app['events'] + $events ); if ($this->app->bound('queue')) { - $mailer->setQueue($this->app['queue']); + /** @var QueueFactory $queue */ + $queue = $this->app->make('queue'); + $mailer->setQueue($queue); } // Next we will set all of the global addresses on this mailer, which allows @@ -163,15 +170,22 @@ protected function resolve(string $name): MailerContract */ public function build(array $config): Mailer { + /** @var ViewFactory $views */ + $views = $this->app->make('view'); + /** @var Dispatcher $events */ + $events = $this->app->make('events'); + $mailer = new Mailer( $config['name'] ?? 'ondemand', - $this->app['view'], + $views, $this->createMailerTransport($config), - $this->app['events'] + $events ); if ($this->app->bound('queue')) { - $mailer->setQueue($this->app['queue']); + /** @var QueueFactory $queue */ + $queue = $this->app->make('queue'); + $mailer->setQueue($queue); } return $mailer; @@ -288,7 +302,7 @@ protected function transportConstructionConfig(array $config, array $mailerStack return match ($transport) { 'sendmail' => [ 'transport' => $transport, - 'path' => $config['path'] ?? $this->config->get('mail.sendmail'), + 'path' => $config['path'] ?? null, ], 'ses-v2' => array_merge( $this->config->array('services.ses', []), @@ -328,7 +342,7 @@ protected function transportConstructionConfig(array $config, array $mailerStack ), 'log' => [ 'transport' => $transport, - 'channel' => $config['channel'] ?? $this->config->get('mail.log_channel'), + 'channel' => $config['channel'] ?? null, ], 'mail', 'array' => ['transport' => $transport], default => $config, @@ -463,6 +477,8 @@ protected function createSendmailTransport(array $config): SendmailTransport return new SendmailTransport($config['path']); } + // REMOVED: Hypervel supports Amazon SES through the SES v2 API only. + /** * Create an instance of the Symfony Amazon SES V2 Transport driver. */ diff --git a/src/mail/src/MailServiceProvider.php b/src/mail/src/MailServiceProvider.php index 6591c3e67..0df1e0cbd 100644 --- a/src/mail/src/MailServiceProvider.php +++ b/src/mail/src/MailServiceProvider.php @@ -18,7 +18,9 @@ public function register(): void } /** - * Register the Illuminate mailer instance. + * Register the mailer instance. + * + * The method name is retained for compatibility with Laravel's protected extension point. */ protected function registerIlluminateMailer(): void { diff --git a/src/mail/src/Mailable.php b/src/mail/src/Mailable.php index 44f238f1c..f8910126c 100644 --- a/src/mail/src/Mailable.php +++ b/src/mail/src/Mailable.php @@ -469,15 +469,26 @@ protected function buildAttachments(Message $message): static protected function buildDiskAttachments(Message $message): void { foreach ($this->diskAttachments as $attachment) { - /** @var \Hypervel\Filesystem\FilesystemAdapter $storage */ $storage = Container::getInstance()->make( FilesystemFactory::class )->disk($attachment['disk']); + $options = $attachment['options']; + + if (! isset($options['mime'])) { + // The contract omits adapter metadata methods, which every shipped disk provides. + // @phpstan-ignore method.notFound + $mime = $storage->mimeType($attachment['path']); + + if ($mime !== false) { + $options['mime'] = $mime; + } + } + $message->attachData( $storage->get($attachment['path']), $attachment['name'] ?? basename($attachment['path']), - array_merge(['mime' => $storage->mimeType($attachment['path'])], $attachment['options']) + $options ); } } @@ -1276,6 +1287,31 @@ public function assertSeeInOrderInText(array $strings): static return $this; } + /** + * Assert the mailable has no attachments. + */ + public function assertHasNoAttachments(): static + { + $this->renderForAssertions(); + + PHPUnit::assertEmpty( + $this->attachments, + 'Expected no attachments, but found [' . count($this->attachments) . '] file attachment(s).' + ); + + PHPUnit::assertEmpty( + $this->rawAttachments, + 'Expected no attachments, but found [' . count($this->rawAttachments) . '] raw data attachment(s).' + ); + + PHPUnit::assertEmpty( + $this->diskAttachments, + 'Expected no attachments, but found [' . count($this->diskAttachments) . '] storage attachment(s).' + ); + + return $this; + } + /** * Assert the mailable has the given attachment. */ diff --git a/src/mail/src/Mailer.php b/src/mail/src/Mailer.php index c20c59d57..546403c4b 100644 --- a/src/mail/src/Mailer.php +++ b/src/mail/src/Mailer.php @@ -8,7 +8,6 @@ use DateInterval; use DateTimeInterface; use Hypervel\Contracts\Events\Dispatcher; -use Hypervel\Contracts\Mail\Mailable; use Hypervel\Contracts\Mail\Mailable as MailableContract; use Hypervel\Contracts\Mail\Mailer as MailerContract; use Hypervel\Contracts\Mail\MailQueue as MailQueueContract; @@ -26,6 +25,7 @@ use Symfony\Component\Mailer\SentMessage as SymfonySentMessage; use Symfony\Component\Mailer\Transport\TransportInterface; use Symfony\Component\Mime\Email; +use UnitEnum; use function value; @@ -161,7 +161,7 @@ public function bcc(mixed $users, ?string $name = null): PendingMail /** * Send a new message with only an HTML part. */ - public function html(string $html, mixed $callback): ?SentMessage + public function html(string $html, Closure|string $callback): ?SentMessage { return $this->send(['html' => new HtmlString($html)], [], $callback); } @@ -169,7 +169,7 @@ public function html(string $html, mixed $callback): ?SentMessage /** * Send a new message with only a raw text part. */ - public function raw(string $text, mixed $callback): ?SentMessage + public function raw(string $text, Closure|string $callback): ?SentMessage { return $this->send(['raw' => $text], [], $callback); } @@ -177,7 +177,7 @@ public function raw(string $text, mixed $callback): ?SentMessage /** * Send a new message with only a plain part. */ - public function plain(string $view, array $data, mixed $callback): ?SentMessage + public function plain(string $view, array $data, Closure|string $callback): ?SentMessage { return $this->send(['text' => $view], $data, $callback); } @@ -227,7 +227,7 @@ protected function replaceEmbeddedAttachments(string $renderedView, array $attac /** * Send a new message using a view. */ - public function send(array|Mailable|string $view, array $data = [], Closure|string|null $callback = null): ?SentMessage + public function send(array|MailableContract|string $view, array $data = [], Closure|string|null $callback = null): ?SentMessage { if ($view instanceof MailableContract) { return $this->sendMailable($view); @@ -235,22 +235,19 @@ public function send(array|Mailable|string $view, array $data = [], Closure|stri $data['mailer'] = $this->name; - // First we need to parse the view, which could either be a string or an array - // containing both an HTML and plain text versions of the view which should - // be used when sending an e-mail. We will extract both of them out here. + // Once we have retrieved the view content for the e-mail we will set the body + // of this message using the HTML type, which will provide a simple wrapper + // to creating view based emails that are able to receive arrays of data. [$view, $plain, $raw] = $this->parseView($view); $data['message'] = $message = $this->createMessage(); - // Once we have retrieved the view content for the e-mail we will set the body - // of this message using the HTML type, which will provide a simple wrapper - // to creating view based emails that are able to receive arrays of data. + $this->addContent($message, $view, $plain, $raw, $data); + if (! is_null($callback)) { $callback($message); } - $this->addContent($message, $view, $plain, $raw, $data); - // If a global "to" address has been set, we will set that address on the mail // message. This is primarily useful during local development in which each // message should be delivered into a single mail address for inspection. @@ -379,14 +376,15 @@ protected function setGlobalToAndRemoveCcAndBcc(Message $message): void * * @throws InvalidArgumentException */ - public function queue(array|MailableContract|string $view, ?string $queue = null): mixed + public function queue(array|MailableContract|string $view, UnitEnum|string|null $queue = null): mixed { if (! $view instanceof MailableContract) { throw new InvalidArgumentException('Only mailables may be queued.'); } - if (is_string($queue)) { - $view->onQueue($queue); // @phpstan-ignore-line + if ($queue !== null) { + // Queueable owns identifier normalization, so it is intentionally absent from the Mailable contract. + $view->onQueue($queue); // @phpstan-ignore method.notFound } return $view->mailer($this->name)->queue($this->queue); @@ -395,7 +393,7 @@ public function queue(array|MailableContract|string $view, ?string $queue = null /** * Queue a new mail message for sending on the given queue. */ - public function onQueue(?string $queue, MailableContract $view): mixed + public function onQueue(UnitEnum|string|null $queue, MailableContract $view): mixed { return $this->queue($view, $queue); } @@ -405,7 +403,7 @@ public function onQueue(?string $queue, MailableContract $view): mixed * * This method didn't match rest of framework's "onQueue" phrasing. Added "onQueue". */ - public function queueOn(string $queue, MailableContract $view): mixed + public function queueOn(UnitEnum|string $queue, MailableContract $view): mixed { return $this->onQueue($queue, $view); } @@ -415,22 +413,24 @@ public function queueOn(string $queue, MailableContract $view): mixed * * @throws InvalidArgumentException */ - public function later(DateInterval|DateTimeInterface|int $delay, array|MailableContract|string $view, ?string $queue = null): mixed + public function later(DateInterval|DateTimeInterface|int $delay, array|MailableContract|string $view, UnitEnum|string|null $queue = null): mixed { if (! $view instanceof MailableContract) { throw new InvalidArgumentException('Only mailables may be queued.'); } - return $view->mailer($this->name)->later( - $delay, - is_null($queue) ? $this->queue : $queue - ); + if ($queue !== null) { + // Queueable owns identifier normalization, so it is intentionally absent from the Mailable contract. + $view->onQueue($queue); // @phpstan-ignore method.notFound + } + + return $view->mailer($this->name)->later($delay, $this->queue); } /** * Queue a new mail message for sending after (n) seconds on the given queue. */ - public function laterOn(string $queue, DateInterval|DateTimeInterface|int $delay, MailableContract $view): mixed + public function laterOn(UnitEnum|string $queue, DateInterval|DateTimeInterface|int $delay, MailableContract $view): mixed { return $this->later($delay, $view, $queue); } @@ -468,10 +468,7 @@ protected function createMessage(): Message */ protected function sendSymfonyMessage(Email $message): ?SymfonySentMessage { - try { - return $this->transport->send($message, Envelope::create($message)); - } finally { - } + return $this->transport->send($message, Envelope::create($message)); } /** diff --git a/src/mail/src/Message.php b/src/mail/src/Message.php index d8d9f7845..ba09abab8 100644 --- a/src/mail/src/Message.php +++ b/src/mail/src/Message.php @@ -249,6 +249,8 @@ public function attach(Attachable|Attachment|string $file, array $options = []): /** * Attach in-memory data as an attachment. + * + * @param resource|string $data */ public function attachData(mixed $data, string $name, array $options = []): static { @@ -296,6 +298,8 @@ function ($data) use ($file) { /** * Embed in-memory data in the message and get the CID. + * + * @param resource|string $data */ public function embedData(mixed $data, string $name, ?string $contentType = null): string { diff --git a/src/mail/src/TextMessage.php b/src/mail/src/TextMessage.php index e55cceb96..b37e3108d 100644 --- a/src/mail/src/TextMessage.php +++ b/src/mail/src/TextMessage.php @@ -32,6 +32,8 @@ public function embed(Attachable|Attachment|string $file): string /** * Embed in-memory data in the message and get the CID. + * + * @param resource|string $data */ public function embedData(mixed $data, string $name, ?string $contentType = null): string { diff --git a/src/mail/src/Transport/SesV2Transport.php b/src/mail/src/Transport/SesV2Transport.php index 67321827f..720c9eda5 100644 --- a/src/mail/src/Transport/SesV2Transport.php +++ b/src/mail/src/Transport/SesV2Transport.php @@ -37,6 +37,10 @@ protected function doSend(SentMessage $message): void $options['ListManagementOptions'] = $listManagementOptions; } + if (($tenantName = $this->tenantName($message)) !== null) { + $options['TenantName'] = $tenantName; + } + foreach ($message->getOriginalMessage()->getHeaders()->all() as $header) { if ($header instanceof MetadataHeader) { $options['EmailTags'][] = ['Name' => $header->getKey(), 'Value' => $header->getValue()]; @@ -98,6 +102,21 @@ protected function listManagementOptions(SentMessage $message): ?array return null; } + /** + * Extract the SES tenant name, if applicable. + */ + protected function tenantName(SentMessage $message): ?string + { + // SES transports receive an Email even though Symfony exposes RawMessage here. + if ($header = $message->getOriginalMessage()->getHeaders()->get('X-SES-TENANT-NAME')) { // @phpstan-ignore method.notFound + $tenantName = $header->getBodyAsString(); + + return $tenantName === '' ? null : $tenantName; + } + + return null; + } + /** * Get the Amazon SES V2 client for the SesV2Transport instance. */ diff --git a/src/support/src/Facades/Mail.php b/src/support/src/Facades/Mail.php index a586c9110..6d86abfea 100644 --- a/src/support/src/Facades/Mail.php +++ b/src/support/src/Facades/Mail.php @@ -24,12 +24,33 @@ * @method static \Hypervel\Mail\MailManager removePoolable(string $driver) * @method static array getPoolables() * @method static \Hypervel\Mail\MailManager setPoolables(array $poolables) - * @method static \Hypervel\Mail\PendingMail to(mixed $users) - * @method static \Hypervel\Mail\PendingMail cc(mixed $users) - * @method static \Hypervel\Mail\PendingMail bcc(mixed $users) - * @method static \Hypervel\Mail\SentMessage|null raw(string $text, mixed $callback) + * @method static void alwaysFrom(string $address, string|null $name = null) + * @method static void alwaysReplyTo(string $address, string|null $name = null) + * @method static void alwaysReturnPath(string $address) + * @method static void alwaysTo(string $address, string|null $name = null) + * @method static \Hypervel\Mail\PendingMail to(mixed $users, string|null $name = null) + * @method static \Hypervel\Mail\PendingMail cc(mixed $users, string|null $name = null) + * @method static \Hypervel\Mail\PendingMail bcc(mixed $users, string|null $name = null) + * @method static \Hypervel\Mail\SentMessage|null html(string $html, \Closure|string $callback) + * @method static \Hypervel\Mail\SentMessage|null raw(string $text, \Closure|string $callback) + * @method static \Hypervel\Mail\SentMessage|null plain(string $view, array $data, \Closure|string $callback) + * @method static string render(\Closure|array|string $view, array $data = []) * @method static \Hypervel\Mail\SentMessage|null send(\Hypervel\Contracts\Mail\Mailable|array|string $view, array $data = [], \Closure|string|null $callback = null) * @method static \Hypervel\Mail\SentMessage|null sendNow(\Hypervel\Contracts\Mail\Mailable|array|string $mailable, array $data = [], \Closure|string|null $callback = null) + * @method static mixed queue(\Hypervel\Contracts\Mail\Mailable|array|string $view, \UnitEnum|string|null $queue = null) + * @method static mixed onQueue(\UnitEnum|string|null $queue, \Hypervel\Contracts\Mail\Mailable $view) + * @method static mixed queueOn(\UnitEnum|string $queue, \Hypervel\Contracts\Mail\Mailable $view) + * @method static mixed later(\DateInterval|\DateTimeInterface|int $delay, \Hypervel\Contracts\Mail\Mailable|array|string $view, \UnitEnum|string|null $queue = null) + * @method static mixed laterOn(\UnitEnum|string $queue, \DateInterval|\DateTimeInterface|int $delay, \Hypervel\Contracts\Mail\Mailable $view) + * @method static \Symfony\Component\Mailer\Transport\TransportInterface getSymfonyTransport() + * @method static \Hypervel\Contracts\View\Factory getViewFactory() + * @method static void setSymfonyTransport(\Symfony\Component\Mailer\Transport\TransportInterface $transport) + * @method static \Hypervel\Mail\Mailer setQueue(\Hypervel\Contracts\Queue\Factory $queue) + * @method static void flushState() + * @method static void macro(string $name, callable|object $macro) + * @method static void mixin(object $mixin, bool $replace = true) + * @method static bool hasMacro(string $name) + * @method static void flushMacros() * @method static void assertSent(\Closure|string $mailable, callable|array|string|int|null $callback = null) * @method static void assertSentTimes(string $mailable, int $times = 1) * @method static void assertNotOutgoing(\Closure|string $mailable, callable|null $callback = null) @@ -47,8 +68,6 @@ * @method static bool hasSent(string $mailable) * @method static \Hypervel\Support\Collection queued(\Closure|string $mailable, callable|null $callback = null) * @method static bool hasQueued(string $mailable) - * @method static mixed queue(\Hypervel\Contracts\Mail\Mailable|array|string $view, string|null $queue = null) - * @method static mixed later(\DateInterval|\DateTimeInterface|int $delay, \Hypervel\Contracts\Mail\Mailable|array|string $view, string|null $queue = null) * * @see \Hypervel\Mail\MailManager * @see \Hypervel\Support\Testing\Fakes\MailFake diff --git a/src/support/src/Facades/Notification.php b/src/support/src/Facades/Notification.php index 52d796f91..6c1d30469 100644 --- a/src/support/src/Facades/Notification.php +++ b/src/support/src/Facades/Notification.php @@ -12,13 +12,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(\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 void flushState() * @method static mixed driver(\UnitEnum|string|null $driver = null) + * @method static \Hypervel\Notifications\ChannelManager extend(string $driver, \Closure $callback) * @method static array getDrivers() * @method static \Hypervel\Contracts\Container\Container getContainer() * @method static \Hypervel\Notifications\ChannelManager setContainer(\Hypervel\Contracts\Container\Container $container) @@ -30,7 +31,7 @@ * @method static string|null resolveConnectionFromQueueRoute(object $queueable) * @method static string|null resolveQueueFromQueueRoute(object $queueable) * @method static void assertSentOnDemand(\Closure|string $notification, callable|null $callback = null) - * @method static void assertSentTo(mixed $notifiable, \Closure|string $notification, callable|string|int|null $callback = null) + * @method static void assertSentTo(mixed $notifiable, \Closure|string $notification, callable|int|null $callback = null) * @method static void assertSentOnDemandTimes(string $notification, int $times = 1) * @method static void assertSentToTimes(mixed $notifiable, string $notification, int $times = 1) * @method static void assertNotSentTo(mixed $notifiable, \Closure|string $notification, callable|null $callback = null) diff --git a/src/support/src/Str.php b/src/support/src/Str.php index c7321dd8b..1d5b83db9 100644 --- a/src/support/src/Str.php +++ b/src/support/src/Str.php @@ -512,10 +512,21 @@ public static function isUrl(mixed $value, array $protocols = []): bool (HYPERVEL_PROTOCOLS):// # protocol (((?:[\_\.\pL\pN-]|%[0-9A-Fa-f]{2})+:)?((?:[\_\.\pL\pN-]|%[0-9A-Fa-f]{2})+)@)? # basic auth ( - ([\pL\pN\pS\-\_\.])+(\.?([\pL\pN]|xn\-\-[\pL\pN-]+)+\.?) # a domain name - | # or - \d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3} # an IP address - | # or + (?: + (?: + (?:[\pL\pN\pS\pM\-\_]++\.)+ + (?: + (?:xn--[a-z0-9-]++) # punycode in tld + | + (?:[\pL\pN\pM]++) # no punycode in tld + ) + ) # a multi-level domain name + | + [a-z0-9\-\_]++ # a single-level domain name + )\.? + | # or + \d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3} # an IP address + | # or \[ (?:(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){6})(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:::(?:(?:(?:[0-9a-f]{1,4})):){5})(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:[0-9a-f]{1,4})))?::(?:(?:(?:[0-9a-f]{1,4})):){4})(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){0,1}(?:(?:[0-9a-f]{1,4})))?::(?:(?:(?:[0-9a-f]{1,4})):){3})(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){0,2}(?:(?:[0-9a-f]{1,4})))?::(?:(?:(?:[0-9a-f]{1,4})):){2})(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){0,3}(?:(?:[0-9a-f]{1,4})))?::(?:(?:[0-9a-f]{1,4})):)(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){0,4}(?:(?:[0-9a-f]{1,4})))?::)(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){0,5}(?:(?:[0-9a-f]{1,4})))?::)(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){0,6}(?:(?:[0-9a-f]{1,4})))?::)))) \] # an IPv6 address diff --git a/src/support/src/Testing/Fakes/MailFake.php b/src/support/src/Testing/Fakes/MailFake.php index fe00b46b6..2dd90dca5 100644 --- a/src/support/src/Testing/Fakes/MailFake.php +++ b/src/support/src/Testing/Fakes/MailFake.php @@ -12,6 +12,7 @@ use Hypervel\Contracts\Mail\Mailer; use Hypervel\Contracts\Mail\MailQueue; use Hypervel\Contracts\Queue\ShouldQueue; +use Hypervel\Mail\Mailables\Address; use Hypervel\Mail\MailManager; use Hypervel\Mail\PendingMail; use Hypervel\Mail\SentMessage; @@ -20,6 +21,7 @@ use Hypervel\Support\Str; use Hypervel\Support\Traits\ForwardsCalls; use Hypervel\Support\Traits\ReflectsClosures; +use InvalidArgumentException; use PHPUnit\Framework\Assert as PHPUnit; use UnitEnum; @@ -51,7 +53,6 @@ class MailFake implements Factory, Fake, Mailer, MailQueue public function __construct( public MailManager $manager ) { - $this->currentMailer = $manager->getDefaultDriver(); } /** @@ -61,7 +62,7 @@ public function assertSent(Closure|string $mailable, array|callable|int|string|n { [$mailable, $callback] = $this->prepareMailableAndCallback($mailable, $callback); - if (is_numeric($callback)) { + if (is_int($callback)) { $this->assertSentTimes($mailable, $callback); return; } @@ -170,7 +171,7 @@ public function assertQueued(Closure|string $mailable, array|callable|int|string { [$mailable, $callback] = $this->prepareMailableAndCallback($mailable, $callback); - if (is_numeric($callback)) { + if (is_int($callback)) { $this->assertQueuedTimes($mailable, $callback); return; } @@ -369,7 +370,7 @@ public function mailer(UnitEnum|string|null $name = null): Mailer $name = (string) enum_value($name); } - $this->currentMailer = $name; + $this->currentMailer = $name === '' ? null : $name; return $this; } @@ -385,32 +386,66 @@ public function driver(UnitEnum|string|null $driver = null): Mailer /** * Begin the process of mailing a mailable class instance. */ - public function to(mixed $users): PendingMail + public function to(mixed $users, ?string $name = null): PendingMail { + if (! is_null($name) && is_string($users)) { + $users = new Address($users, $name); + } + return (new PendingMailFake($this))->to($users); } /** * Begin the process of mailing a mailable class instance. */ - public function cc(mixed $users): PendingMail + public function cc(mixed $users, ?string $name = null): PendingMail { + if (! is_null($name) && is_string($users)) { + $users = new Address($users, $name); + } + return (new PendingMailFake($this))->cc($users); } /** * Begin the process of mailing a mailable class instance. */ - public function bcc(mixed $users): PendingMail + public function bcc(mixed $users, ?string $name = null): PendingMail { + if (! is_null($name) && is_string($users)) { + $users = new Address($users, $name); + } + return (new PendingMailFake($this))->bcc($users); } + /** + * Send a new message with only an HTML part. + */ + public function html(string $html, Closure|string $callback): ?SentMessage + { + $this->pullCurrentMailer(); + + return null; + } + /** * Send a new message with only a raw text part. */ - public function raw(string $text, mixed $callback): ?SentMessage + public function raw(string $text, Closure|string $callback): ?SentMessage { + $this->pullCurrentMailer(); + + return null; + } + + /** + * Send a new message with only a plain part. + */ + public function plain(string $view, array $data, Closure|string $callback): ?SentMessage + { + $this->pullCurrentMailer(); + return null; } @@ -439,17 +474,17 @@ public function sendNow(array|Mailable|string $mailable, array $data = [], Closu */ protected function sendMail(array|Mailable|string $view, bool $shouldQueue = false): mixed { - if (! $view instanceof Mailable) { - return null; + if ($shouldQueue) { + return $this->queue($view); } - $view->mailer($this->currentMailer); + $mailer = $this->pullCurrentMailer(); - if ($shouldQueue) { - return $this->queue($view); + if (! $view instanceof Mailable) { + return null; } - $this->currentMailer = null; + $view->mailer($mailer); $this->mailables[] = $view; @@ -458,16 +493,23 @@ protected function sendMail(array|Mailable|string $view, bool $shouldQueue = fal /** * Queue a new message for sending. + * + * @throws InvalidArgumentException */ - public function queue(array|Mailable|string $view, ?string $queue = null): mixed + public function queue(array|Mailable|string $view, UnitEnum|string|null $queue = null): mixed { + $mailer = $this->pullCurrentMailer(); + if (! $view instanceof Mailable) { - return null; + throw new InvalidArgumentException('Only mailables may be queued.'); } - $view->mailer($this->currentMailer); + $view->mailer($mailer); - $this->currentMailer = null; + if ($queue !== null) { + // Queueable owns identifier normalization, so it is intentionally absent from the Mailable contract. + $view->onQueue($queue); // @phpstan-ignore method.notFound + } $this->queuedMailables[] = $view; @@ -476,16 +518,42 @@ public function queue(array|Mailable|string $view, ?string $queue = null): mixed /** * Queue a new e-mail message for sending after (n) seconds. + * + * @throws InvalidArgumentException + */ + public function later(DateInterval|DateTimeInterface|int $delay, array|Mailable|string $view, UnitEnum|string|null $queue = null): mixed + { + return $this->queue($view, $queue); + } + + /** + * Queue a new mail message for sending on the given queue. */ - public function later(DateInterval|DateTimeInterface|int $delay, array|Mailable|string $view, ?string $queue = null): mixed + public function onQueue(UnitEnum|string|null $queue, Mailable $view): mixed { return $this->queue($view, $queue); } + /** + * Queue a new mail message for sending on the given queue. + */ + public function queueOn(UnitEnum|string $queue, Mailable $view): mixed + { + return $this->onQueue($queue, $view); + } + + /** + * Queue a new mail message for sending after (n) seconds on the given queue. + */ + public function laterOn(UnitEnum|string $queue, DateInterval|DateTimeInterface|int $delay, Mailable $view): mixed + { + return $this->later($delay, $view, $queue); + } + /** * Infer mailable class using reflection if a typehinted closure is passed to assertion. */ - protected function prepareMailableAndCallback(Closure|string $mailable, callable|string|null $callback): array + protected function prepareMailableAndCallback(Closure|string $mailable, array|callable|int|string|null $callback): array { if ($mailable instanceof Closure) { return [$this->firstClosureParameterType($mailable), $mailable]; @@ -494,6 +562,17 @@ protected function prepareMailableAndCallback(Closure|string $mailable, callable return [$mailable, $callback]; } + /** + * Get and clear the mailer for the current operation. + */ + protected function pullCurrentMailer(): string + { + $mailer = $this->currentMailer ?? $this->manager->getDefaultDriver(); + $this->currentMailer = null; + + return $mailer; + } + /** * Forget all of the resolved mailer instances. */ @@ -507,7 +586,7 @@ public function forgetMailers(): static /** * Handle dynamic method calls to the mailer. */ - public function __call(string $method, array $parameters) + public function __call(string $method, array $parameters): mixed { return $this->forwardCallTo($this->manager, $method, $parameters); } diff --git a/src/support/src/Testing/Fakes/NotificationFake.php b/src/support/src/Testing/Fakes/NotificationFake.php index fe2683ef3..60d33055b 100644 --- a/src/support/src/Testing/Fakes/NotificationFake.php +++ b/src/support/src/Testing/Fakes/NotificationFake.php @@ -53,7 +53,7 @@ public function assertSentOnDemand(Closure|string $notification, ?callable $call * * @throws Exception */ - public function assertSentTo(mixed $notifiable, Closure|string $notification, callable|int|string|null $callback = null): void + public function assertSentTo(mixed $notifiable, Closure|string $notification, callable|int|null $callback = null): void { if (is_array($notifiable) || $notifiable instanceof Collection) { if (count($notifiable) === 0) { diff --git a/tests/Console/Scheduling/EventTest.php b/tests/Console/Scheduling/EventTest.php index 8c3d1334e..5b2020c85 100644 --- a/tests/Console/Scheduling/EventTest.php +++ b/tests/Console/Scheduling/EventTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Console\Scheduling; +use Closure; use DateTimeZone; use Hypervel\Console\Scheduling\CallbackEvent; use Hypervel\Console\Scheduling\Event; @@ -14,14 +15,17 @@ use Hypervel\Contracts\Container\Container as ContainerContract; use Hypervel\Contracts\Filesystem\FileNotFoundException; use Hypervel\Contracts\Foundation\Application as ApplicationContract; +use Hypervel\Contracts\Mail\Mailer; use Hypervel\Filesystem\Filesystem; use Hypervel\Foundation\Application; +use Hypervel\Mail\Message; use Hypervel\Support\CarbonImmutable; use Hypervel\Support\Str; use Hypervel\Support\Stringable; use Hypervel\Tests\TestCase; use Mockery as m; use RuntimeException; +use Symfony\Component\Mime\Email; use Symfony\Component\Process\Process; use Throwable; @@ -56,7 +60,7 @@ protected function setUp(): void $this->container->instance(Filesystem::class, new Filesystem); } - public function testSendOutputToWithIsNotFile() + public function testSendOutputToWithIsNotFile(): void { $event = new Event(m::mock(EventMutex::class), 'php -v'); @@ -71,7 +75,7 @@ public function testSendOutputToWithIsNotFile() $event->writeOutput($this->container); } - public function testSendOutputTo() + public function testSendOutputTo(): void { $event = new Event(m::mock(EventMutex::class), 'php -v'); @@ -98,7 +102,7 @@ public function testSendOutputTo() $event->writeOutput($this->container); } - public function testSendOutputToWithSystemProcess() + public function testSendOutputToWithSystemProcess(): void { $event = new Event(m::mock(EventMutex::class), 'php -v'); $event->isSystem = true; @@ -124,7 +128,7 @@ public function testSendOutputToWithSystemProcess() CoroutineContext::forget($key); } - public function testDaysOfMonthMethod() + public function testDaysOfMonthMethod(): void { $event = new Event(m::mock(EventMutex::class), 'php -i'); @@ -136,14 +140,14 @@ public function testDaysOfMonthMethod() $this->assertSame('0 0 1,10,20,30 * *', $event->getExpression()); } - public function testEventDoesNotRunWhenPausedByDefault() + public function testEventDoesNotRunWhenPausedByDefault(): void { $event = new Event(m::mock(EventMutex::class), 'php -i'); $this->assertFalse($event->runsWhenPaused()); } - public function testEventRunsWhenMarkedAsEvenWhenPaused() + public function testEventRunsWhenMarkedAsEvenWhenPaused(): void { $event = new Event(m::mock(EventMutex::class), 'php -i'); $event->evenWhenPaused(); @@ -151,7 +155,7 @@ public function testEventRunsWhenMarkedAsEvenWhenPaused() $this->assertTrue($event->runsWhenPaused()); } - public function testEventMarksSkippedWhenMutexAlreadyExists() + public function testEventMarksSkippedWhenMutexAlreadyExists(): void { $eventMutex = m::mock(EventMutex::class); $eventMutex->shouldReceive('create')->once()->andReturnFalse(); @@ -166,7 +170,7 @@ public function testEventMarksSkippedWhenMutexAlreadyExists() $this->assertTrue($event->skippedBecauseOverlapping); } - public function testEventResetsSkippedBecauseOverlappingWhenItRuns() + public function testEventResetsSkippedBecauseOverlappingWhenItRuns(): void { $eventMutex = m::mock(EventMutex::class); $eventMutex->shouldReceive('create')->andReturnFalse(); @@ -197,7 +201,7 @@ public function testEventResetsSkippedBecauseOverlappingWhenItRuns() $this->assertFalse($event->skippedBecauseOverlapping); } - public function testReleaseMutexOnTerminationSignalReleasesOwnedMutex() + public function testReleaseMutexOnTerminationSignalReleasesOwnedMutex(): void { $eventMutex = m::mock(EventMutex::class); $eventMutex->shouldReceive('create')->once()->andReturnTrue(); @@ -211,7 +215,7 @@ public function testReleaseMutexOnTerminationSignalReleasesOwnedMutex() $event->releaseMutexOnTerminationSignal(); } - public function testReleaseMutexOnTerminationSignalDoesNotReleaseUnownedMutex() + public function testReleaseMutexOnTerminationSignalDoesNotReleaseUnownedMutex(): void { $eventMutex = m::mock(EventMutex::class); $eventMutex->shouldNotReceive('forget'); @@ -222,7 +226,7 @@ public function testReleaseMutexOnTerminationSignalDoesNotReleaseUnownedMutex() $event->releaseMutexOnTerminationSignal(); } - public function testReleaseMutexOnTerminationSignalHonorsReleaseFlag() + public function testReleaseMutexOnTerminationSignalHonorsReleaseFlag(): void { $eventMutex = m::mock(EventMutex::class); $eventMutex->shouldReceive('create')->once()->andReturnTrue(); @@ -329,7 +333,7 @@ public function testExitPublicationFailureStillRunsCallbacksAndReleasesMutex(): $this->assertTrue($afterCalled); } - public function testAppendOutput() + public function testAppendOutput(): void { $event = new Event(m::mock(EventMutex::class), 'php -v'); @@ -418,6 +422,41 @@ public function testOutputCallbackSurfacesReadFailureAfterFileCheck(): void $event->finish($this->container, 0); } + public function testEmailOutputUsesTheMailContractCallback(): void + { + $event = new Event(m::mock(EventMutex::class), 'php -v'); + $event->sendOutputTo($output = 'test.log'); + $event->description('Daily report'); + $event->emailOutputTo($addresses = ['taylor@example.com']); + + $filesystem = m::mock(Filesystem::class); + $filesystem->shouldReceive('isFile')->once()->with($output)->andReturnTrue(); + $filesystem->shouldReceive('get')->once()->with($output)->andReturn('captured output'); + + $mailer = m::mock(Mailer::class); + $mailer->shouldReceive('raw') + ->once() + ->with('captured output', m::type(Closure::class)) + ->andReturnUsing(function (string $text, Closure $callback) use ($addresses) { + $message = new Message(new Email); + $callback($message); + + $this->assertSame('captured output', $text); + $this->assertSame($addresses, array_map( + static fn ($address) => $address->getAddress(), + $message->getSymfonyMessage()->getTo() + )); + $this->assertSame('Daily report', $message->getSymfonyMessage()->getSubject()); + + return null; + }); + + $this->container->instance(Filesystem::class, $filesystem); + $this->container->instance(Mailer::class, $mailer); + + $event->callAfterCallbacks($this->container); + } + public function testProcessIsRetainedThroughAfterCallbacksAndReleasedAfterSuccess(): void { $outputDuringCallback = null; @@ -495,7 +534,7 @@ public function testProcessIsReleasedAfterAfterCallbackFailure(): void $this->assertFalse($event->hasRetainedProcess()); } - public function testNextRunDate() + public function testNextRunDate(): void { $event = new Event(m::mock(EventMutex::class), 'php -i'); $event->dailyAt('10:15'); @@ -503,7 +542,7 @@ public function testNextRunDate() $this->assertSame('10:15:00', $event->nextRunDate()->toTimeString()); } - public function testCustomMutexName() + public function testCustomMutexName(): void { $event = new Event(m::mock(EventMutex::class), 'php -i'); $event->description('Fancy command description'); @@ -513,7 +552,7 @@ public function testCustomMutexName() $event->mutexName() ); - $event->createMutexNameUsing(function (Event $event) { + $event->createMutexNameUsing(function (Event $event): string { return Str::slug($event->description); }); @@ -651,7 +690,7 @@ public function testTimezoneAcceptsDateTimeZoneObject(): void $this->assertSame($tz, $event->timezone); } - public function testBasicCronCompilation() + public function testBasicCronCompilation(): void { $app = m::mock(ApplicationContract::class); $app->shouldReceive('isDownForMaintenance')->andReturn(false); @@ -698,7 +737,7 @@ public function testBasicCronCompilation() ); } - public function testEventIsDueCheck() + public function testEventIsDueCheck(): void { $app = m::mock(ApplicationContract::class); $app->shouldReceive('isDownForMaintenance')->andReturn(false); @@ -714,7 +753,7 @@ public function testEventIsDueCheck() $this->assertTrue($event->isDue($app)); } - public function testEventIsDueAtUsesGivenTime() + public function testEventIsDueAtUsesGivenTime(): void { $app = m::mock(ApplicationContract::class); $app->shouldReceive('isDownForMaintenance')->andReturn(false); @@ -733,7 +772,7 @@ public function testEventIsDueAtUsesGivenTime() } } - public function testEventIsDueAtUsesEventTimezone() + public function testEventIsDueAtUsesEventTimezone(): void { $app = m::mock(ApplicationContract::class); $app->shouldReceive('isDownForMaintenance')->andReturn(false); @@ -746,7 +785,7 @@ public function testEventIsDueAtUsesEventTimezone() $this->assertFalse($event->isDueAt($app, CarbonImmutable::parse('2026-05-29 12:59:59', 'UTC'))); } - public function testTimeBetweenChecks() + public function testTimeBetweenChecks(): void { $app = m::mock(ApplicationContract::class); $app->shouldReceive('isDownForMaintenance')->andReturn(false); @@ -808,7 +847,7 @@ public function testTimeBetweenUsesTimezoneConfiguredAfterTheConstraint(): void $this->assertTrue($event->filtersPass($app)); } - public function testTimeUnlessBetweenChecks() + public function testTimeUnlessBetweenChecks(): void { $app = m::mock(ApplicationContract::class); $app->shouldReceive('isDownForMaintenance')->andReturn(false); diff --git a/tests/Integration/Mail/AttachingFromStorageTest.php b/tests/Integration/Mail/AttachingFromStorageTest.php index 11ef2545b..cda569b6e 100644 --- a/tests/Integration/Mail/AttachingFromStorageTest.php +++ b/tests/Integration/Mail/AttachingFromStorageTest.php @@ -9,10 +9,11 @@ use Hypervel\Notifications\Messages\MailMessage; use Hypervel\Support\Facades\Storage; use Hypervel\Testbench\TestCase; +use League\Flysystem\Local\FallbackMimeTypeDetector; class AttachingFromStorageTest extends TestCase { - public function testItCanAttachFromStorage() + public function testItCanAttachFromStorage(): void { Storage::disk('local')->put('/dir/foo.png', 'expected body contents'); $mail = new MailMessage; @@ -33,7 +34,7 @@ public function testItCanAttachFromStorage() Storage::disk('local')->delete('/dir/foo.png'); } - public function testItCanAttachFromStorageAndFallbackToStorageNameAndMime() + public function testItCanAttachFromStorageAndFallbackToStorageNameAndMime(): void { Storage::disk()->put('/dir/foo.png', 'expected body contents'); $mail = new MailMessage; @@ -48,7 +49,7 @@ public function testItCanAttachFromStorageAndFallbackToStorageNameAndMime() // when using "prefer-lowest" the local filesystem driver will // not detect the mime type based on the extension and will // instead fallback to "text/plain". - 'mime' => class_exists(\League\Flysystem\Local\FallbackMimeTypeDetector::class) + 'mime' => class_exists(FallbackMimeTypeDetector::class) ? 'image/png' : 'text/plain', ], @@ -57,7 +58,7 @@ public function testItCanAttachFromStorageAndFallbackToStorageNameAndMime() Storage::disk('local')->delete('/dir/foo.png'); } - public function testItCanChainAttachWithMailMessage() + public function testItCanChainAttachWithMailMessage(): void { Storage::disk('local')->put('/dir/foo.png', 'expected body contents'); $message = new MailMessage; @@ -72,7 +73,7 @@ public function testItCanChainAttachWithMailMessage() // REMOVED: testItCanAttachFromCloudStorage - Hypervel omits Laravel's legacy // default-cloud filesystem shortcut. Use Attachment::fromStorageDisk() with a named disk. - public function testItCanCheckForStorageBasedAttachments() + public function testItCanCheckForStorageBasedAttachments(): void { Storage::disk()->put('/dir/foo.png', 'expected body contents'); $mailable = new Mailable; diff --git a/tests/Integration/Mail/MailableTestCase.php b/tests/Integration/Mail/MailableTestCase.php index 03191e454..31db82bfb 100644 --- a/tests/Integration/Mail/MailableTestCase.php +++ b/tests/Integration/Mail/MailableTestCase.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Integration\Mail; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Mail\Mailable; use Hypervel\Mail\Mailables\Content; use Hypervel\Mail\Mailables\Envelope; @@ -12,27 +13,27 @@ abstract class MailableTestCase extends TestCase { - protected function defineEnvironment($app): void + protected function defineEnvironment(ApplicationContract $app): void { - $app['view']->addLocation(__DIR__ . '/Fixtures'); + $app->make('view')->addLocation(__DIR__ . '/Fixtures'); } #[DataProvider('markdownEncodedDataProvider')] - public function testItCanAssertMarkdownEncodedString($given, $expected) + public function testItCanAssertMarkdownEncodedString(string $given, string $expected): void { $mailable = new class($given) extends Mailable { public function __construct(public string $message) { } - public function envelope() + public function envelope(): Envelope { return new Envelope( subject: 'My basic title', ); } - public function content() + public function content(): Content { return new Content( markdown: 'message', @@ -43,7 +44,7 @@ public function content() $mailable->assertSeeInHtml($expected, false); } - public static function markdownEncodedDataProvider() + public static function markdownEncodedDataProvider(): iterable { yield ['[Hypervel](https://hypervel.org)', 'My message is: [Hypervel](https://hypervel.org)']; diff --git a/tests/Integration/Mail/MailableWithSecuredEncodingTest.php b/tests/Integration/Mail/MailableWithSecuredEncodingTest.php index 781fb7406..3fb163c1d 100644 --- a/tests/Integration/Mail/MailableWithSecuredEncodingTest.php +++ b/tests/Integration/Mail/MailableWithSecuredEncodingTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Integration\Mail; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Foundation\Auth\User; use Hypervel\Foundation\Testing\LazilyRefreshDatabase; use Hypervel\Mail\Mailable; @@ -16,7 +17,7 @@ class MailableWithSecuredEncodingTest extends MailableTestCase { use LazilyRefreshDatabase; - protected function defineEnvironment($app): void + protected function defineEnvironment(ApplicationContract $app): void { parent::defineEnvironment($app); @@ -25,7 +26,7 @@ protected function defineEnvironment($app): void #[WithMigration] #[DataProvider('markdownEncodedTemplateDataProvider')] - public function testItCanAssertMarkdownEncodedStringUsingTemplate($given, $expected) + public function testItCanAssertMarkdownEncodedStringUsingTemplate(string $given, string $expected): void { $user = UserFactory::new()->create([ 'name' => $given, @@ -38,7 +39,7 @@ public function __construct(public User $user) { } - public function build() + public function build(): static { return $this->markdown('message-with-template'); } @@ -49,7 +50,7 @@ public function build() #[WithMigration] #[DataProvider('markdownEncodedTemplateDataProvider')] - public function testItCanAssertMarkdownEncodedStringUsingTemplateWithTable($given, $expected) + public function testItCanAssertMarkdownEncodedStringUsingTemplateWithTable(string $given, string $expected): void { $user = UserFactory::new()->create([ 'name' => $given, @@ -62,7 +63,7 @@ public function __construct(public User $user) { } - public function build() + public function build(): static { return $this->markdown('table-with-template'); } @@ -95,7 +96,7 @@ public function build() TABLE, false); } - public static function markdownEncodedTemplateDataProvider() + public static function markdownEncodedTemplateDataProvider(): iterable { yield ['[Hypervel](https://hypervel.org)', 'Hi [Hypervel](https://hypervel.org)']; diff --git a/tests/Integration/Mail/MailableWithoutSecuredEncodingTest.php b/tests/Integration/Mail/MailableWithoutSecuredEncodingTest.php index abb47180d..87332e0d5 100644 --- a/tests/Integration/Mail/MailableWithoutSecuredEncodingTest.php +++ b/tests/Integration/Mail/MailableWithoutSecuredEncodingTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Integration\Mail; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Foundation\Auth\User; use Hypervel\Foundation\Testing\LazilyRefreshDatabase; use Hypervel\Mail\Mailable; @@ -16,7 +17,7 @@ class MailableWithoutSecuredEncodingTest extends MailableTestCase { use LazilyRefreshDatabase; - protected function defineEnvironment($app): void + protected function defineEnvironment(ApplicationContract $app): void { parent::defineEnvironment($app); @@ -25,7 +26,7 @@ protected function defineEnvironment($app): void #[WithMigration] #[DataProvider('markdownEncodedTemplateDataProvider')] - public function testItCanAssertMarkdownEncodedStringUsingTemplate($given, $expected) + public function testItCanAssertMarkdownEncodedStringUsingTemplate(string $given, string $expected): void { $user = UserFactory::new()->create([ 'name' => $given, @@ -38,7 +39,7 @@ public function __construct(public User $user) { } - public function build() + public function build(): static { return $this->markdown('message-with-template'); } @@ -49,7 +50,7 @@ public function build() #[WithMigration] #[DataProvider('markdownEncodedTemplateDataProvider')] - public function testItCanAssertMarkdownEncodedStringUsingTemplateWithTable($given, $expected) + public function testItCanAssertMarkdownEncodedStringUsingTemplateWithTable(string $given, string $expected): void { $user = UserFactory::new()->create([ 'name' => $given, @@ -62,7 +63,7 @@ public function __construct(public User $user) { } - public function build() + public function build(): static { return $this->markdown('table-with-template'); } @@ -95,7 +96,7 @@ public function build() TABLE, false); } - public static function markdownEncodedTemplateDataProvider() + public static function markdownEncodedTemplateDataProvider(): iterable { yield ['[Hypervel](https://hypervel.org)', '
Hi Hypervel
']; diff --git a/tests/Integration/Mail/MarkdownParserTest.php b/tests/Integration/Mail/MarkdownParserTest.php index 538fa7733..ba90bedc4 100644 --- a/tests/Integration/Mail/MarkdownParserTest.php +++ b/tests/Integration/Mail/MarkdownParserTest.php @@ -8,22 +8,17 @@ use Hypervel\Support\EncodedHtmlString; use Hypervel\Support\HtmlString; use Hypervel\Testbench\TestCase; +use League\CommonMark\Extension\ExtensionInterface; +use League\CommonMark\Extension\Strikethrough\StrikethroughExtension; +use League\CommonMark\Extension\TaskList\TaskListExtension; use PHPUnit\Framework\Attributes\DataProvider; class MarkdownParserTest extends TestCase { - protected function tearDown(): void - { - Markdown::flushState(); - EncodedHtmlString::flushState(); - - parent::tearDown(); - } - #[DataProvider('markdownDataProvider')] - public function testItCanParseMarkdownString($given, $expected) + public function testItCanParseMarkdownString(string $given, string $expected): void { - tap(Markdown::parse($given), function ($html) use ($expected) { + tap(Markdown::parse($given), function (HtmlString $html) use ($expected): void { $this->assertInstanceOf(HtmlString::class, $html); $this->assertStringEqualsStringIgnoringLineEndings($expected . PHP_EOL, (string) $html); @@ -31,7 +26,7 @@ public function testItCanParseMarkdownString($given, $expected) }); } - public static function markdownDataProvider() + public static function markdownDataProvider(): iterable { yield ['[Hypervel](https://hypervel.org)', '']; yield ['\[Hypervel](https://hypervel.org)', '[Hypervel](https://hypervel.org)
']; @@ -43,16 +38,16 @@ public static function markdownDataProvider() } #[DataProvider('markdownEncodedDataProvider')] - public function testItCanParseMarkdownEncodedString($given, $expected) + public function testItCanParseMarkdownEncodedString(EncodedHtmlString|string $given, string $expected): void { - tap(Markdown::parse($given, encoded: true), function ($html) use ($expected) { + tap(Markdown::parse($given, encoded: true), function (HtmlString $html) use ($expected): void { $this->assertInstanceOf(HtmlString::class, $html); $this->assertStringEqualsStringIgnoringLineEndings($expected . PHP_EOL, (string) $html); }); } - public static function markdownEncodedDataProvider() + public static function markdownEncodedDataProvider(): iterable { yield [new EncodedHtmlString('[Hypervel](https://hypervel.org)'), '[Hypervel](https://hypervel.org)
']; @@ -90,10 +85,10 @@ public static function markdownEncodedDataProvider() public function testItCanParseMarkdownWithCustomExtensionsViaConfig(): void { $this->configureMarkdownExtensions([ - \League\CommonMark\Extension\Strikethrough\StrikethroughExtension::class, + StrikethroughExtension::class, ]); - tap(Markdown::parse('~~strikethrough text~~'), function ($html) { + tap(Markdown::parse('~~strikethrough text~~'), function (HtmlString $html): void { $this->assertInstanceOf(HtmlString::class, $html); $expected = 'strikethrough text
~~strikethrough text~~
'; @@ -120,11 +115,11 @@ public function testItCanParseMarkdownWithoutCustomExtensionsDoesNotApplyThem(): public function testItCanParseMarkdownWithMultipleCustomExtensions(): void { $this->configureMarkdownExtensions([ - \League\CommonMark\Extension\Strikethrough\StrikethroughExtension::class, - \League\CommonMark\Extension\TaskList\TaskListExtension::class, + StrikethroughExtension::class, + TaskListExtension::class, ]); - tap(Markdown::parse('~~strikethrough~~'), function ($html) { + tap(Markdown::parse('~~strikethrough~~'), function (HtmlString $html): void { $this->assertInstanceOf(HtmlString::class, $html); $expected = 'strikethrough
strikethrough text
Hello World
', $sentMessage->toString()); } - public function testMailerSendSendsMessageWithProperPlainViewContent() + public function testMailerSendSendsMessageWithProperPlainViewContent(): void { $viewInterface = m::mock(ViewContract::class); $viewInterface->shouldReceive('render') @@ -166,7 +184,7 @@ public function testMailerSendSendsMessageWithProperPlainViewContent() $this->assertStringContainsString($expected, $sentMessage->toString()); } - public function testMailerSendSendsMessageWithProperPlainViewContentWhenExplicit() + public function testMailerSendSendsMessageWithProperPlainViewContentWhenExplicit(): void { $viewInterface = m::mock(ViewContract::class); $viewInterface->shouldReceive('render') @@ -204,7 +222,7 @@ public function testMailerSendSendsMessageWithProperPlainViewContentWhenExplicit $this->assertStringContainsString($expected, $sentMessage->toString()); } - public function testToAllowsEmailAndName() + public function testToAllowsEmailAndName(): void { $view = $this->mockView(); $mailer = new Mailer('array', $view, new ArrayTransport); @@ -217,7 +235,7 @@ public function testToAllowsEmailAndName() $this->assertSame('Taylor Otwell', $recipients[0]->getName()); } - public function testGlobalFromIsRespectedOnAllMessages() + public function testGlobalFromIsRespectedOnAllMessages(): void { $view = $this->mockView(); $mailer = new Mailer('array', $view, new ArrayTransport); @@ -231,7 +249,7 @@ public function testGlobalFromIsRespectedOnAllMessages() $this->assertSame('hello@hypervel.org', $sentMessage->getEnvelope()->getSender()->getAddress()); } - public function testGlobalReplyToIsRespectedOnAllMessages() + public function testGlobalReplyToIsRespectedOnAllMessages(): void { $view = $this->mockView(); $mailer = new Mailer('array', $view, new ArrayTransport); @@ -245,7 +263,7 @@ public function testGlobalReplyToIsRespectedOnAllMessages() $this->assertStringContainsString('Reply-To: Taylor Otwellhello world
\n", Str::markdown('*hello world*')); $this->assertSame("html
', $callback); + $htmlNext = new MailableStub; + $this->fake->send($htmlNext); + + $this->fake->mailer('ses')->plain('mail.view', [], $callback); + $plainNext = new MailableStub; + $this->fake->send($plainNext); + + $this->assertTrue($rawNext->usesMailer('smtp')); + $this->assertTrue($htmlNext->usesMailer('smtp')); + $this->assertTrue($plainNext->usesMailer('smtp')); + } + + public function testQueueHelpersDoNotCallTheRealManager(): void + { + $this->mailManager->shouldNotReceive('onQueue'); + $this->mailManager->shouldNotReceive('queueOn'); + $this->mailManager->shouldNotReceive('laterOn'); + + $onQueueString = new QueueableMailableStub; + $onQueueEnum = new QueueableMailableStub; + $queueOnString = new QueueableMailableStub; + $queueOnEnum = new QueueableMailableStub; + $laterOnString = new QueueableMailableStub; + $laterOnEnum = new QueueableMailableStub; + + $this->fake->onQueue('on-queue', $onQueueString); + $this->fake->onQueue(MailFakeQueueName::Transactional, $onQueueEnum); + $this->fake->queueOn('queue-on', $queueOnString); + $this->fake->queueOn(MailFakeQueueName::Transactional, $queueOnEnum); + $this->fake->laterOn('later-on', 30, $laterOnString); + $this->fake->laterOn(MailFakeQueueName::Transactional, 30, $laterOnEnum); + + $this->assertSame('on-queue', $onQueueString->queue); + $this->assertSame('transactional-mail', $onQueueEnum->queue); + $this->assertSame('queue-on', $queueOnString->queue); + $this->assertSame('transactional-mail', $queueOnEnum->queue); + $this->assertSame('later-on', $laterOnString->queue); + $this->assertSame('transactional-mail', $laterOnEnum->queue); + $this->fake->assertQueuedCount(6); + } + + public function testNamedPendingRecipientsMatchTheRealMailer(): void + { + $to = new MailableStub; + $cc = new MailableStub; + $bcc = new MailableStub; + + $this->fake->to('to@laravel.com', 'To Name')->send($to); + $this->fake->cc('cc@laravel.com', 'Cc Name')->send($cc); + $this->fake->bcc('bcc@laravel.com', 'Bcc Name')->send($bcc); + + $this->assertTrue($to->hasTo('to@laravel.com', 'To Name')); + $this->assertTrue($cc->hasCc('cc@laravel.com', 'Cc Name')); + $this->assertTrue($bcc->hasBcc('bcc@laravel.com', 'Bcc Name')); + } + + public function testShouldQueueSendResolvesTheDefaultMailerOnce(): void { $manager = m::mock(MailManager::class); $manager->shouldReceive('getDefaultDriver')->once()->andReturn('smtp'); + $fake = new MailFake($manager); + $mailable = new QueueableMailableStub; - return new MailFake($manager); + $fake->send($mailable); + + $this->assertTrue($mailable->usesMailer('smtp')); + $fake->assertQueued(QueueableMailableStub::class); + } +} + +class MailableStub extends Mailable +{ + public string $framework = 'Hypervel'; + + protected string $version = '6.0'; + + /** + * Build the message. + */ + public function build(): void + { + $this->with('first_name', 'Taylor') + ->withLastName('Otwell'); } } class QueueableMailableStub extends Mailable implements ShouldQueue { + use Queueable; + + public string $framework = 'Hypervel'; + + protected string $version = '6.0'; + + /** + * Build the message. + */ + public function build(): void + { + $this->with('first_name', 'Taylor') + ->withLastName('Otwell'); + } +} + +class LocalizedRecipientStub implements HasLocalePreference +{ + public string $email = 'taylor@laravel.com'; + + public function preferredLocale(): string + { + return 'au'; + } +} + +class FailingQueueMailableStub extends Mailable +{ + use Queueable; + + public function onQueue(UnitEnum|string|null $queue): static + { + throw new LogicException('Queue selection failed.'); + } +} + +enum MailFakeMailerName: string +{ + case Transactional = 'transactional'; +} + +enum MailFakeQueueName: string +{ + case Transactional = 'transactional-mail'; } diff --git a/tests/Support/SupportTestingNotificationFakeTest.php b/tests/Support/SupportTestingNotificationFakeTest.php new file mode 100644 index 000000000..0a5f0ad21 --- /dev/null +++ b/tests/Support/SupportTestingNotificationFakeTest.php @@ -0,0 +1,281 @@ +fake = new NotificationFake; + $this->notification = new NotificationStub; + $this->user = new UserStub; + } + + public function testAssertSentTo(): void + { + try { + $this->fake->assertSentTo($this->user, NotificationStub::class); + $this->fail(); + } catch (ExpectationFailedException $e) { + $this->assertStringContainsString('The expected [Hypervel\Tests\Support\NotificationStub] notification was not sent.', $e->getMessage()); + } + + $this->fake->send($this->user, new NotificationStub); + + $this->fake->assertSentTo($this->user, NotificationStub::class); + } + + public function testAssertSentToClosure(): void + { + $this->fake->send($this->user, new NotificationStub); + + $this->fake->assertSentTo($this->user, function (NotificationStub $notification): bool { + return true; + }); + } + + public function testAssertSentOnDemand(): void + { + $this->fake->send(new AnonymousNotifiable, new NotificationStub); + + $this->fake->assertSentOnDemand(NotificationStub::class); + } + + public function testAssertSentOnDemandClosure(): void + { + $this->fake->send(new AnonymousNotifiable, new NotificationStub); + + $this->fake->assertSentOnDemand(NotificationStub::class, function (NotificationStub $notification): bool { + return true; + }); + } + + public function testAssertNotSentTo(): void + { + $this->fake->assertNotSentTo($this->user, NotificationStub::class); + + $this->fake->send($this->user, new NotificationStub); + + try { + $this->fake->assertNotSentTo($this->user, NotificationStub::class); + $this->fail(); + } catch (ExpectationFailedException $e) { + $this->assertStringContainsString('The unexpected [Hypervel\Tests\Support\NotificationStub] notification was sent.', $e->getMessage()); + } + } + + public function testAssertNotSentToClosure(): void + { + $this->fake->send($this->user, new NotificationStub); + + try { + $this->fake->assertNotSentTo($this->user, function (NotificationStub $notification): bool { + return true; + }); + $this->fail(); + } catch (ExpectationFailedException $e) { + $this->assertStringContainsString('The unexpected [Hypervel\Tests\Support\NotificationStub] notification was sent.', $e->getMessage()); + } + } + + public function testAssertNothingSent(): void + { + $this->fake->assertNothingSent(); + $this->fake->send($this->user, new NotificationStub); + + try { + $this->fake->assertNothingSent(); + $this->fail(); + } catch (ExpectationFailedException $e) { + $this->assertStringContainsString("The following notifications were sent unexpectedly:\n\n- " . get_class(new NotificationStub), $e->getMessage()); + } + } + + public function testAssertNothingSentTo(): void + { + $this->fake->assertNothingSentTo($this->user); + $this->fake->send($this->user, new NotificationStub); + + try { + $this->fake->assertNothingSentTo($this->user); + $this->fail(); + } catch (ExpectationFailedException $e) { + $this->assertStringContainsString('Notifications were sent unexpectedly.', $e->getMessage()); + } + } + + public function testAssertSentToFailsForEmptyArray(): void + { + $this->expectException(Exception::class); + + $this->fake->assertSentTo([], NotificationStub::class); + } + + public function testAssertSentToFailsForEmptyCollection(): void + { + $this->expectException(Exception::class); + + $this->fake->assertSentTo(new Collection, NotificationStub::class); + } + + public function testResettingNotificationId(): void + { + $this->fake->send($this->user, $this->notification); + + $id = $this->notification->id; + + $this->fake->send($this->user, $this->notification); + + $this->assertSame($id, $this->notification->id); + + $this->notification->id = null; + + $this->fake->send($this->user, $this->notification); + + $this->assertNotNull($this->notification->id); + $this->assertNotSame($id, $this->notification->id); + } + + public function testAssertSentTimes(): void + { + $this->fake->assertSentTimes(NotificationStub::class, 0); + + $this->fake->send($this->user, new NotificationStub); + + $this->fake->send($this->user, new NotificationStub); + + $this->fake->send(new UserStub, new NotificationStub); + + $this->fake->assertSentTimes(NotificationStub::class, 3); + } + + public function testAssertSentToTimes(): void + { + $this->fake->assertSentToTimes($this->user, NotificationStub::class, 0); + + $this->fake->send($this->user, new NotificationStub); + + $this->fake->send($this->user, new NotificationStub); + + $this->fake->send($this->user, new NotificationStub); + + $this->fake->assertSentToTimes($this->user, NotificationStub::class, 3); + } + + public function testAssertSentOnDemandTimes(): void + { + $this->fake->assertSentOnDemandTimes(NotificationStub::class, 0); + + $this->fake->send(new AnonymousNotifiable, new NotificationStub); + + $this->fake->send(new AnonymousNotifiable, new NotificationStub); + + $this->fake->send(new AnonymousNotifiable, new NotificationStub); + + $this->fake->assertSentOnDemandTimes(NotificationStub::class, 3); + } + + public function testAssertSentToWhenNotifiableHasPreferredLocale(): void + { + $user = new LocalizedUserStub; + + $this->fake->send($user, new NotificationStub); + + $this->fake->assertSentTo($user, NotificationStub::class, function (NotificationStub $notification, array $channels, LocalizedUserStub $notifiable, ?string $locale) use ($user): bool { + return $notifiable === $user && $locale === 'au'; + }); + } + + public function testAssertSentToWhenNotifiableHasFalsyShouldSend(): void + { + $user = new LocalizedUserStub; + + $this->fake->send($user, new NotificationWithFalsyShouldSendStub); + + $this->fake->assertNotSentTo($user, NotificationWithFalsyShouldSendStub::class); + } + + public function testAssertItCanSerializeAndRestoreNotifications(): void + { + $this->fake->serializeAndRestore(); + $this->fake->send($this->user, new NotificationWithSerialization('hello')); + + $this->fake->assertSentTo($this->user, NotificationWithSerialization::class, function (NotificationWithSerialization $notification): bool { + return $notification->value === 'hello-serialized-unserialized'; + }); + } +} + +class NotificationStub extends Notification +{ + public function via(mixed $notifiable): array + { + return ['mail']; + } +} + +class NotificationWithFalsyShouldSendStub extends Notification +{ + public function via(mixed $notifiable): array + { + return ['mail']; + } + + public function shouldSend(mixed $notifiable, string $channel): bool + { + return false; + } +} + +class UserStub extends User +{ +} + +class LocalizedUserStub extends User implements HasLocalePreference +{ + public function preferredLocale(): string + { + return 'au'; + } +} + +class NotificationWithSerialization extends NotificationStub implements ShouldQueue +{ + use Queueable; + + public function __construct(public string $value) + { + } + + public function __serialize(): array + { + return ['value' => $this->value . '-serialized']; + } + + public function __unserialize(array $data): void + { + $this->value = $data['value'] . '-unserialized'; + } +}