From 0dd7d4aad74b4f7a6041471087d7c8e1b4931248 Mon Sep 17 00:00:00 2001 From: Nikolay Strikhar Date: Thu, 13 Aug 2026 15:38:18 +0200 Subject: [PATCH] Prove the notice was there before asserting it is gone Three clear-then-assertFalse tests never established the row existed, so a store that wrote nothing passed them. The renderer's not-called assertion had its control in a sibling test. The replacement-store test overrode all(), put() and clear() but not option_name(), so a writer composing the name itself -- instead of delegating, which is the documented seam -- passed. Adds the multisite scope neither option had: the queue is written, a second site is created, and switch_to_blog() finds it still there. Swapping the store to get_option()/update_option() now fails the multisite leg, where before it was green on both. Also pins that the capability is checked before the store is read. The check guards the clearing as much as the drawing, and nothing said so. --- tests/unit/Notices/PresenterTest.php | 74 +++++++++++++++- tests/unit/Notices/RendererTest.php | 22 +++++ tests/unit/Notices/StoreTest.php | 123 +++++++++++++++++++++++++++ tests/unit/Notices/WriterTest.php | 38 ++++++++- 4 files changed, 254 insertions(+), 3 deletions(-) diff --git a/tests/unit/Notices/PresenterTest.php b/tests/unit/Notices/PresenterTest.php index 0348c57..4bb9c2d 100644 --- a/tests/unit/Notices/PresenterTest.php +++ b/tests/unit/Notices/PresenterTest.php @@ -132,6 +132,11 @@ public function test_render_keeps_a_link_but_not_an_event_handler(): void { public function test_render_clears_the_queue(): void { $this->queue_notice( 'queue_merge_notice' ); + // The queue has to be there before rendering can be what took it away: a writer that never + // wrote, or an option name the two halves disagree about, would satisfy the assertion below + // without a presenter having cleared anything. + $this->assertTrue( $this->queue_exists(), 'The queue must exist before it is rendered.' ); + $presenter = $this->make_presenter(); $this->render_to_string( $presenter ); @@ -202,16 +207,83 @@ public function render( array $queue ): void { } }; + $presenter = $this->make_presenter( null, $renderer ); + + // The control belongs in this test rather than in a sibling: a presenter that reached no + // renderer at all, or a renderer this one never received, would satisfy the assertion at the + // end for a reason that has nothing to do with the capability. + $this->queue_notice( 'queue_merge_notice' ); + + $this->render_to_string( $presenter ); + + $this->assertTrue( $renderer->called, 'This renderer must be reachable for someone who may consume the queue.' ); + + $renderer->called = false; + $this->queue_notice( 'queue_merge_notice' ); wp_set_current_user( $this->create_user( 'subscriber' ) ); - $this->render_to_string( $this->make_presenter( null, $renderer ) ); + $this->render_to_string( $presenter ); $this->assertFalse( $renderer->called ); $this->assertTrue( $this->queue_exists() ); } + /** + * The capability is asked before the queue is read, not after. + * + * The gate gets its authority from being in front of both halves: reading is what the clearing + * follows from, so a presenter that read the queue and then decided who may see it would already + * have taken the notice out of the store by the time it turned the subscriber away. Asserting on + * the output alone cannot see that difference, so the store here counts the calls it receives. + */ + public function test_the_capability_is_checked_before_the_queue_is_read(): void { + $store = new class() extends Store { + /** + * @var int + */ + public $reads = 0; + + /** + * @var int + */ + public $clears = 0; + + /** + * @return array + */ + public function all(): array { + ++$this->reads; + + return [ 'give-recurring:merge' => 'Bundled now.' ]; + } + + /** + * @return void + */ + public function clear(): void { + ++$this->clears; + } + }; + + $presenter = $this->make_presenter( $store ); + + wp_set_current_user( $this->create_user( 'subscriber' ) ); + + $this->assertSame( '', $this->render_to_string( $presenter ) ); + $this->assertSame( 0, $store->reads, 'The queue must not be read at all for a user who may not consume it.' ); + $this->assertSame( 0, $store->clears, 'And it must certainly not be cleared.' ); + + // The recorder has to be shown working, or "never read" and "never wired to anything" are the + // same result: the same store, in the same presenter, for a user who does have the capability. + $this->become_plugin_administrator(); + + $this->assertStringContainsString( 'Bundled now.', $this->render_to_string( $presenter ) ); + $this->assertSame( 1, $store->reads ); + $this->assertSame( 1, $store->clears ); + } + /** * Rendering consumes the queue, so a user who cannot act on the notice must neither see it * nor destroy it. The merge notice is raised once and never re-queued. diff --git a/tests/unit/Notices/RendererTest.php b/tests/unit/Notices/RendererTest.php index a1d516a..04f69a7 100644 --- a/tests/unit/Notices/RendererTest.php +++ b/tests/unit/Notices/RendererTest.php @@ -3,6 +3,8 @@ * @package Nexcess\PluginAbsorber */ +declare( strict_types=1 ); + namespace Nexcess\PluginAbsorber\Tests\Unit\Notices; use Codeception\TestCase\WPTestCase; @@ -163,6 +165,26 @@ public static function empty_messages(): Generator { yield 'a message that is only disallowed markup' => [ '' ]; } + /** + * Skipping is per message, not for the rest of the queue: an entry a host left empty — or one + * `wp_kses_post()` emptied for it — must not take the notices behind it off the screen with it. + * That is the difference between a `continue` and a `return`, and every case above holds a single + * message, so none of them can see it. + */ + public function test_an_empty_message_does_not_stop_the_ones_behind_it(): void { + $output = $this->render( + [ + 'a:merge' => 'First.', + 'b:merge' => '', + 'c:dependency' => 'Second.', + ] + ); + + $this->assertStringContainsString( 'First.', $output ); + $this->assertStringContainsString( 'Second.', $output ); + $this->assertSame( 2, substr_count( $output, '