Skip to content

[269] implement method to assert Notifications sent in tests - #270

Open
vitgrams wants to merge 43 commits into
masterfrom
269-implement-method-to-assert-notifications-in-tests
Open

[269] implement method to assert Notifications sent in tests#270
vitgrams wants to merge 43 commits into
masterfrom
269-implement-method-to-assert-notifications-in-tests

Conversation

@vitgrams

@vitgrams vitgrams commented Mar 26, 2026

Copy link
Copy Markdown
Contributor

refs: #269

Improvements over the reference implementation:

  • Fail loudly instead of silently returning null. The reference sets $value = null; break; when a chain step can't be resolved, silently masking misspelled methods/properties. The implementation calls fail(...) with a descriptive message instead.
  • Pass $notifiable into chain method calls. This makes options work correctly against real notification channel methods, not just no-arg accessors.
  • Validate options keys against reserved keys.

@vitgrams vitgrams self-assigned this Mar 26, 2026
@vitgrams
vitgrams force-pushed the 269-implement-method-to-assert-notifications-in-tests branch from f255898 to 6677d9e Compare March 26, 2026 13:23
@vitgrams
vitgrams marked this pull request as draft April 2, 2026 08:11
@vitgrams
vitgrams force-pushed the 269-implement-method-to-assert-notifications-in-tests branch from 5c67a9f to c51430d Compare April 17, 2026 09:17
@vitgrams
vitgrams force-pushed the 269-implement-method-to-assert-notifications-in-tests branch from c51430d to 24267cc Compare April 17, 2026 09:20
@vitgrams
vitgrams marked this pull request as ready for review April 17, 2026 09:34

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 24267cca3e

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/Traits/NotificationsMockTrait.php Outdated
Comment thread src/Traits/NotificationsMockTrait.php Outdated
@vitgrams

Copy link
Copy Markdown
Contributor Author

@yburlakov Assigned to myself. Some improvements are required

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f7259ea0d4

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/Traits/NotificationsMockTrait.php Outdated
protected function prepareNotification(array $notification, array $options): array
{
foreach ($options as $key => $chain) {
$notification[$key] = $this->resolveNotificationChain($notification['notification'], $chain);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Pass the notifiable when resolving payload methods

When an option targets a channel payload method that needs the notifiable, such as Laravel's usual toArray(object $notifiable) or toMail(object $notifiable), this invokes the chain with only the notification object even though the fake entry still contains $notification['notifiable']. The documented ['toArray()']/push-notification helper use case will therefore throw an ArgumentCountError for normal notifications instead of exporting or comparing the payload; the resolver needs a way to pass the stored notifiable before the notification is replaced with attributes.

Useful? React with 👍 / 👎.

@vitgrams vitgrams May 19, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this may make sense because $notifiable is not an arbitrary argument, but part of the contract of any Laravel notification: all payload methods (toMail, toArray, toBroadcast, toExpoPush, etc.) accept it as the first parameter by framework convention.

@DenTray What do you think this should look like?

Option 1. Pass $notifiable automatically when $value instanceof Notification, no API changes (no control over what gets passed)

'message' => ['toExpoPush()', 'toArray()']

Option 2. Custom args per step (full control over args)
Step can carry explicit arguments:

  'message' => [['toExpoPush()', [$notifiable]], 'toArray()']

Or via callable:

  'message' => [fn ($notification, $notifiable) => $notification->toExpoPush($notifiable)->toArray()]

But then we need to decide: should callable and string syntax coexist, or should callable fully replace chains? The first option seems more reasonable - simple cases remain strings, while complex ones are implemented as callables.

@vitgrams vitgrams May 31, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@DenTray I believe we can force the $notifiable arg. This solution will cover above 99% of real use-cases.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e756fc6bf2

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/Traits/NotificationsMockTrait.php Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b2c098805f

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/Traits/ReflectionTrait.php

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 97bb55e00e

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/Traits/NotificationsMockTrait.php Outdated
}
}

$preparedActualData = json_decode(json_encode($actualData), true);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject failed notification JSON normalization

When a captured notification attribute or option result is not JSON-encodable—for example, a non-backed enum, malformed UTF-8 string, resource, or INFjson_encode() returns false and json_decode() silently converts that failure into null. Export mode can consequently create a null fixture, and later assertions against it can pass while discarding every sent notification; use JSON_THROW_ON_ERROR or explicitly fail/normalize unsupported values.

Useful? React with 👍 / 👎.

@DenTray DenTray left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The feature is not documented anywhere. NotificationsMockTrait and ReflectionTrait are new public API of the package, but neither readme.md nor documentation/traits.md mentions them — so assertNotificationsSent() ships invisible to anyone who does not read the diff.

What is missing:

  1. documentation/traits.md — a ## NotificationsMockTrait section. The $options chain DSL is the part that genuinely needs prose: the 'method()' vs 'property' step forms, the fact that $notifiable is passed as the first argument to every method call, the reserved keys (notification, channels, notifiable, locale), and a worked example. The wrapper pattern from #269 (assertPushNotificationSent, assertBroadcastNotificationSent) is the most useful example to show, since that is the intended consumption path.
  2. documentation/traits.md — a ## ReflectionTrait section, or a note that it is an internal helper not meant for direct use. Right now it is a public trait in RonasIT\Support\Traits with a single consumer and no description, so its contract is undefined for users — in particular that it skips static and uninitialized properties, and that inherited private properties are not captured.
  3. readme.md — the ## Usage list links to the documentation sections; a new trait belongs in whatever index the project keeps up to date. MailsMockTrait is undocumented too, but that is an existing gap, not a reason to add a second one.
  4. documentation/migration.md — an entry for the global Notification::fake() added in src/Testing/TestCase.php: it stops notification channels from executing in consuming projects' tests, so tests asserting rows in the notifications table, or side effects of custom channels, would start failing. (For the record on what is not affected: direct Mail::to(...)->queue(...) still reaches MailFake untouched, and mail sent through notifications was never captured by Mail::fake() anyway — MailChannel hands Mailer::send() a view rather than the Mailable, and MailFake::sendMail() early-returns for non-Mailable input.)

Convention going forward: the readme and the documentation files are expected to describe every feature of the package, and a PR that adds or changes public API updates them in the same PR. Docs that were made stale by a change count the same as docs that are missing.

Comment thread src/Traits/NotificationsMockTrait.php Outdated
$this->fail("Notification {$notificationClass} doesn't have method '{$method}' required by options step '{$step}'.");
}

$value = $value->{$method}($notifiable);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

$notifiable is passed unconditionally. Userland methods silently ignore the extra argument, but internal ones throw. Reproduction — a notification with getDate(): DateTimeImmutable:

options: ['ts' => ['getDate()', 'getTimestamp()']]
ArgumentCountError: DateTimeImmutable::getTimestamp() expects exactly 0 arguments, 1 given
at src/Traits/NotificationsMockTrait.php:107

Chaining into DateTimeImmutable, ArrayObject or SplStack is realistic. Consider checking ReflectionMethod::getNumberOfParameters() and only passing $notifiable when the method accepts at least one argument.


$value = $value->{$method}($notifiable);
} elseif (property_exists($value, $step)) {
$value = $value->$step;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

property_exists() also returns true for private/protected members, so this line throws a raw PHP Error instead of the descriptive failure the docblock promises:

options: ['private_step' => ['channels']] // TestNotification::$channels is private readonly
Error: Cannot access private property RonasIT\...\TestNotification::$channels
at src/Traits/NotificationsMockTrait.php:109

I agree with the decision that non-public access is out of scope — but then it should fail with a clear message rather than a fatal. Worth noting the inconsistency too: getObjectAttributes() does read private properties.

Comment on lines +90 to +113
$notificationClass = $notification::class;
$value = $notification;

foreach ($chain as $step) {
if (!is_object($value)) {
$type = get_debug_type($value);

$this->fail("Notification {$notificationClass} cannot resolve options step '{$step}' because the previous step returned a non-object value of type '{$type}'.");
}

if (str_ends_with($step, '()')) {
$method = substr($step, 0, -2);

if (!method_exists($value, $method)) {
$this->fail("Notification {$notificationClass} doesn't have method '{$method}' required by options step '{$step}'.");
}

$value = $value->{$method}($notifiable);
} elseif (property_exists($value, $step)) {
$value = $value->$step;
} else {
$this->fail("Notification {$notificationClass} doesn't have property '{$step}' required by options step '{$step}'.");
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

$notificationClass is captured once from the root notification but substituted into all three failure messages, including failures on nested objects further down the chain. With getModel(): TestNotifiable in the chain, options: ['name' => ['getModel()', 'nope']] currently reports:

Notification RonasIT\...\ClsNotification doesn't have property 'nope' required by options step 'nope'.

The property is missing on TestNotifiable, not on the notification — the message points at the wrong class. Taking the class from $value at the point of failure fixes it:

Suggested change
$notificationClass = $notification::class;
$value = $notification;
foreach ($chain as $step) {
if (!is_object($value)) {
$type = get_debug_type($value);
$this->fail("Notification {$notificationClass} cannot resolve options step '{$step}' because the previous step returned a non-object value of type '{$type}'.");
}
if (str_ends_with($step, '()')) {
$method = substr($step, 0, -2);
if (!method_exists($value, $method)) {
$this->fail("Notification {$notificationClass} doesn't have method '{$method}' required by options step '{$step}'.");
}
$value = $value->{$method}($notifiable);
} elseif (property_exists($value, $step)) {
$value = $value->$step;
} else {
$this->fail("Notification {$notificationClass} doesn't have property '{$step}' required by options step '{$step}'.");
}
}
$notificationClass = get_class($notification);
$value = $notification;
foreach ($chain as $step) {
if (!is_object($value)) {
$type = get_debug_type($value);
$this->fail("Notification {$notificationClass} cannot resolve options step '{$step}' because the previous step returned a non-object value of type '{$type}'.");
}
$stepClass = get_class($value);
if (str_ends_with($step, '()')) {
$method = substr($step, 0, -2);
if (!method_exists($value, $method)) {
$this->fail("Class {$stepClass} doesn't have method '{$method}' required by options step '{$step}' of notification {$notificationClass}.");
}
$value = $value->{$method}($notifiable);
} elseif (property_exists($value, $step)) {
$value = $value->$step;
} else {
$this->fail("Class {$stepClass} doesn't have property '{$step}' required by options step '{$step}' of notification {$notificationClass}.");
}
}

Now both classes are named — where the member is missing, and which notification the chain belongs to:

Class RonasIT\...\Models\TestNotifiable doesn't have property 'nope'
required by options step 'nope' of notification RonasIT\...\ClsNotification.

Notes on the diff:

  • The !is_object($value) branch intentionally keeps the root $notificationClass — a non-object has no class, and the type is already reported via get_debug_type(), so referring to the notification is the useful context there.
  • "Class {$stepClass} doesn't have method ..." matches the existing precedent in MailsMockTrait.php:219.
  • $notification::class is also the only occurrence of that form in src/; the other nine call sites all use get_class(), so this aligns with the codebase.
  • All three existing expectations ("doesn't have method 'nonExistentMethod'", "doesn't have property 'nonExistentProperty'", 'returned a non-object value') remain substrings — verified green, OK (14 tests, 24 assertions), no test changes needed.

Separately, since the property branch only checks property_exists(), magic __get properties (Eloquent attributes) are never resolvable, so any chain reaching a model fails regardless of the message. Worth either supporting or stating explicitly in the docblock if it's intentional.

Comment thread src/Traits/NotificationsMockTrait.php Outdated
{
if ($notifiable instanceof Model) {
return [
$notifiable->getKeyName() => $notifiable->getKey(),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reducing the notifiable to [keyName => key] discards the model class, while the outer aggregation is keyed only by the notification class. Two different notifiable classes sharing a key then produce indistinguishable entries. Reproduction with TestNotifiable#1 and a second OtherNotifiable#1:

{"RonasIT\\...\\ProbeNotification":[
  {"notification":{"locale":null},"channels":["database"],"notifiable":{"id":1},"locale":null},
  {"notification":{"locale":null},"channels":["database"],"notifiable":{"id":1},"locale":null}
]}

A fixture written for User#1 will pass when the code actually notifies Admin#1. Including the notifiable class in the prepared data would close this.

Comment thread src/Traits/NotificationsMockTrait.php Outdated
}
}

$preparedActualData = json_decode(json_encode($actualData), true);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

json_decode(json_encode(...)) silently converts an encoding failure into null. Reproduction with a non-UTF-8 string returned by an option step:

ENCODED: false  ERR: Malformed UTF-8 characters, possibly incorrectly encoded
DECODED: NULL

The assertion then compares null against the fixture with an unhelpful message, and in export mode a literal null is written to the fixture file. JSON_THROW_ON_ERROR or an explicit fail() would surface the real cause. Non-backed enums, resources and INF hit the same path.

foreach ($notificationsByNotifiable as $notificationsByClass) {
foreach ($notificationsByClass as $notificationClass => $notifications) {
foreach ($notifications as $notification) {
$actualData[$notificationClass][] = $this->prepareNotificationFixtureData($notification, $options);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because the fake groups by notifiable class and then by notifiable key, the order of entries under a notification class is grouping order, not send order. Sending A to user1, B to user2, then C to user1 produces A, C, B in the fixture (verified).

That is a surprising footgun for anyone asserting a sequence of notifications — worth documenting in the docblock, since a fixture that looks chronological is not.

Comment thread tests/NotificationsMockTraitTest.php Outdated
exportMode: true,
);

$this->assertFileExists($this->getFixturePath('assert_notifications_sent_with_export.json'));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two gaps compared to MailsMockTraitTest::testMailWithGlobalExportMode:

  1. The fixture is not unlinked first, so this assertion can pass on a file left over from an earlier run.
  2. Nothing verifies the exported content — assertEqualsFixture compares the data against the fixture it just exported, so the assertion is self-fulfilling. The mail test compares against a committed test_mail_with_global_export_example.html; an equivalent committed reference here would make both export tests meaningful.

Same applies to testAssertNotificationsSentWithGlobalExportMode below.

Comment thread tests/NotificationsMockTraitTest.php Outdated

public function testAssertNotificationsSentWithAnonymousNotifiable(): void
{
Notification::route('mail', 'test@example.com')->notify(new TestNotification());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Notification::route('mail', ...) is combined with a notification whose via() returns ['database']. On a real dispatch AnonymousNotifiable::route('database') throws InvalidArgumentException — the database channel does not support on-demand notifications. The fake does not enforce it, but the fixture then encodes a state that cannot occur in production. Using a mail-channel notification would keep the case realistic.

Comment thread src/Traits/NotificationsMockTrait.php Outdated
Comment on lines +65 to +71
$reservedKeys = ['notification', 'channels', 'notifiable', 'locale'];

foreach (array_keys($options) as $key) {
if (in_array($key, $reservedKeys, true)) {
$this->fail("Options field '{$key}' collides with a reserved key. Reserved keys are: " . implode(', ', $reservedKeys) . '.');
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

foreach + in_array here can be a single key intersection. Besides being shorter, it reports all colliding keys at once instead of failing on the first one — with ['locale' => ..., 'channels' => ...] the developer currently needs two runs to learn about both.

Suggested change
$reservedKeys = ['notification', 'channels', 'notifiable', 'locale'];
foreach (array_keys($options) as $key) {
if (in_array($key, $reservedKeys, true)) {
$this->fail("Options field '{$key}' collides with a reserved key. Reserved keys are: " . implode(', ', $reservedKeys) . '.');
}
}
$reservedKeys = ['notification', 'channels', 'notifiable', 'locale'];
$collisions = array_keys(array_intersect_key($options, array_flip($reservedKeys)));
if (!empty($collisions)) {
$this->fail(sprintf(
"Options field '%s' collides with a reserved key. Reserved keys are: %s.",
implode("', '", $collisions),
implode(', ', $reservedKeys),
));
}

array_intersect_key is preferable to array_intersect(array_keys($options), $reservedKeys): it expresses the intent directly (intersecting option keys with reserved keys) and compares keys exactly, whereas array_intersect falls back to loose string comparison. Verified equivalent on edge cases — no collision, one, several, numeric-string keys, integer key 0.

The single-collision message is byte-identical to the current one, so testAssertNotificationsSentWithReservedOptionKey stays green. Two optional follow-ups, both out of scope for a one-click suggestion:

  • move the list into a typed constant (protected const array RESERVED_OPTION_KEYS = [...], matching the style of PostgresDBTypeResolver and TestCase::REDIS_COUNT_DATABASES) so the docblock and the validation cannot drift apart;
  • use proper plural wording (Options fields '...' collide with reserved keys) — that requires updating the expectation on line 141.

@DenTray DenTray assigned vitgrams and unassigned DenTray Aug 24, 2026
vitgrams and others added 11 commits August 24, 2026 12:32
- resolve options chain via `ReflectionMethod::getNumberOfParameters()`
  so parameterless methods are called without arguments, chaining into
  internal classes like `DateTimeImmutable::getTimestamp()` no longer
  throws `ArgumentCountError`
- cover the case with a `getDate()` step on `TestChainableNotification`

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- check `ReflectionMethod::isPublic()` and resolve properties through
  `get_object_vars()` so private and protected members, as well as
  uninitialized typed ones, fail the assertion instead of raising a raw
  PHP `Error`, dynamic properties stay resolvable
- document that only public members are resolvable while the
  `notification` fixture key still exposes all the properties
- cover non-public method, non-public property and uninitialized
  property steps

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- take the class from the current chain value instead of always naming
  the root notification, so a failure on a nested object points at that
  object while the message still names the notification being resolved
- cover an unresolvable step on a nested object

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- include the class next to the primary key of a model notifiable and
  next to the public properties of any other one, so notifiables of
  different classes sharing a key no longer produce indistinguishable
  entries under the same notification class
- document the shape of the `notifiable` key and the
  `prepareNotifiableFixtureData()` extension point
- cover two notifiable classes sharing the primary key, update the
  existing fixtures

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- add `castToJsonStructure()` to `FixturesTrait`, it casts the data to
  the structure of a read back fixture with `JSON_THROW_ON_ERROR` and
  fails with the encoding error instead of silently comparing null and
  writing null to the fixture in export mode
- assert the sent notifications through it, so non-UTF-8 strings,
  non-backed enums, resources and INF report the real cause
- cover the cast itself and an unencodable option step value

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- state that the fake groups entries by notifiable class and key, so a
  notification sent to one notifiable, another one and the first one
  again lands out of the send order and the fixture is not a timeline
- cover the grouping order with a fixture that locks it in

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- remove the exported fixture before the export tests, so a file left
  over from an earlier run can no longer make them pass
- compare the export against a committed example fixture instead of the
  file it has just written, and turn the global export mode off before
  that comparison to keep the example intact

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- match the notification channel with the on-demand route, the database
  channel rejects anonymous notifiables on a real dispatch, so the
  previous fixture encoded a state unreachable in production

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- intersect the options with the reserved fields instead of failing on
  the first match, so all colliding fields are named in one run
- move the reserved fields to the `RESERVED_NOTIFICATION_FIELDS`
  constant and name the check after the notification options it
  validates
- cover a collision on two fields

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- add a `NotificationsMockTrait` section: the fixture structure, the
  order of the entries, the options chain steps with their resolution
  rules and reserved field names, and the per channel assertions a
  project declares in its own `TestCase`
- add a `ReflectionTrait` section marking it an internal helper and
  stating that it skips static and uninitialized properties and does not
  capture the private properties of parent classes

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- describe the feature next to the other package features and link it to
  the `NotificationsMockTrait` section of the traits documentation

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ee481d42c1

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/Traits/NotificationsMockTrait.php
vitgrams and others added 7 commits August 25, 2026 16:28
- describe that `TestCase` now fakes the notifications, so the channels
  stop executing and the tests asserting the `notifications` table rows
  or the custom channel side effects have to assert the notifications
  themselves, with a way to restore the real dispatch when needed
- link the new section from the readme migration guides

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- keep the options shape, a chain example covering both step forms and
  the resolution rules, and point to the traits documentation for the
  fixture structure, the entry order and the channel wrappers
- refer to `RESERVED_NOTIFICATION_FIELDS` instead of listing the
  reserved names, they had already drifted apart once

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- send a notification with an explicit id and assert the fixture without
  it, the fake assigns a random uuid to every notification, so keeping
  the id would make every fixture unstable

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- accept a step exposed through `__isset()` or held in the attributes of
  an Eloquent model, a chain into a model attribute used to fail with a
  missing property message while the same access works in the code
- keep the loud failures for a misspelled, non-public and uninitialized
  step
- cover a chain into a model attribute and a misspelled one

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- inline the json round trip into `assertNotificationsSent()` instead of
  exposing it as a `FixturesTrait` method, the assertion is its only
  consumer and the trait is mixed into every project `TestCase`
- keep the failure message specific to the sent notifications, its
  coverage stays in `NotificationsMockTraitTest`

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- nest the notifiable data under an `attributes` key next to the class,
  a primary key or a public property named `class` used to overwrite the
  class and make notifiables of different types indistinguishable again
- update the fixtures and the traits documentation to the new shape

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vitgrams
vitgrams force-pushed the 269-implement-method-to-assert-notifications-in-tests branch from ee481d4 to 34d3e81 Compare August 25, 2026 11:51
vitgrams and others added 2 commits August 26, 2026 12:28
- keep in the phpdoc only what the call site needs, move the details of
  the step resolution to the traits documentation, they were described
  in both places and had already drifted
- replace the outdated claim that only public members are resolvable,
  magic properties and model attributes resolve as well

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- give the assertion its own heading with a typed signature, so the
  phpdoc can link straight at it, and point that link at the rendered
  documentation instead of an unresolvable relative path
- say why the notification id never reaches the fixture, unfold the
  sentence about the notifiable attributes and drop the bullet repeating
  what the paragraph below already says about non-public members

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vitgrams

Copy link
Copy Markdown
Contributor Author

@DenTray All the review comments above have been addressed.

Added beyond the review scope: magic property steps in the options chain

resolveNotificationChain() used to resolve a property step only through get_object_vars(), so a step landing on an object with __get() failed. The most common case is a notification holding a model:

public function __construct(public readonly Order $order) {}
options: ['order_status' => ['order', 'status']]

order is a real public property, but status is an Eloquent attribute, so the chain failed with Order doesn't have property 'status' — a message that sends the reader looking for a problem in the model, while $order->status works everywhere else in the code.

The check now lives in isNotificationChainPropertyAccessible() and accepts a step that is a public property, exposed through __isset(), or present in the attributes of an Eloquent model. The last branch is not redundant: Eloquent's __isset() returns false for a null attribute, so without it the step would resolve or fail depending on the data.

The loud failures are unchanged — a misspelled step, a non-public member and an uninitialized typed property still fail with a message naming the class the step was resolved on, both covered by tests. Documented in traits.md; the phpdoc keeps only what the call site needs.

@vitgrams vitgrams assigned DenTray and unassigned vitgrams Aug 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants