88namespace Nexcess \PluginAbsorber \Tests \Unit \Registry ;
99
1010use Codeception \TestCase \WPTestCase ;
11+ use Generator ;
12+ use LogicException ;
1113use Nexcess \PluginAbsorber \Absorber ;
14+ use Nexcess \PluginAbsorber \Boot \Scheduler ;
1215use Nexcess \PluginAbsorber \Config ;
1316use Nexcess \PluginAbsorber \Registry \Contracts \Registrar_Interface ;
1417use Nexcess \PluginAbsorber \Registry \Reader ;
1922use Nexcess \PluginAbsorber \Tests \Support \Test_Container ;
2023use Nexcess \PluginAbsorber \Tests \Support \Traits \WithContainer ;
2124use Nexcess \PluginAbsorber \Tests \Support \Traits \WithIncorrectUsage ;
25+ use ReflectionClass ;
2226use RuntimeException ;
2327use Throwable ;
2428
@@ -53,6 +57,17 @@ class ReaderTest extends WPTestCase {
5357 */
5458 private $ report_recorder = null ;
5559
60+ /**
61+ * plugins_loaded callbacks these tests added, as [ callback, priority ] pairs.
62+ *
63+ * Tracked so tearDown can take back exactly what a test put there. `remove_all_actions()` would
64+ * strip the hook bare instead, discarding every callback WordPress and the rest of the suite have
65+ * on it for the remainder of the process.
66+ *
67+ * @var array<int,array{0:callable,1:int}>
68+ */
69+ private $ added_actions = [];
70+
5671 public function setUp (): void {
5772 parent ::setUp ();
5873
@@ -62,6 +77,13 @@ public function setUp(): void {
6277 }
6378
6479 public function tearDown (): void {
80+ // In tearDown rather than at the end of a test body: a failed assertion would otherwise leave
81+ // a callback that registers a sub-plugin on plugins_loaded for the rest of the process.
82+ foreach ( $ this ->added_actions as [ $ callback , $ priority ] ) {
83+ remove_action ( 'plugins_loaded ' , $ callback , $ priority );
84+ }
85+ $ this ->added_actions = [];
86+
6587 $ this ->stop_recording_reports ();
6688 $ this ->stop_expecting_incorrect_usage ();
6789 Absorber_State::reset ();
@@ -332,6 +354,166 @@ static function (): Registrar_Interface {
332354 );
333355 }
334356
357+ /**
358+ * The mistake with no symptom: a registration made after the load pass has gone by is read by
359+ * nothing, so the sub-plugin is simply absent — no notice, no skip, no missing file, and nothing
360+ * for a support engineer to pull on.
361+ *
362+ * `Absorber::boot()` has had a barrier for this since it was written. Registration is the call a
363+ * host is likelier to misplace, because a service provider is where a WordPress plugin usually
364+ * puts it and a provider runs whenever the host's bootstrap happens to run it.
365+ */
366+ public function test_a_registration_past_the_load_pass_is_reported (): void {
367+ $ this ->set_up_container ();
368+ $ this ->expect_incorrect_usage ();
369+
370+ $ this ->register_from_plugins_loaded ( self ::load_priority () + 1 );
371+
372+ $ this ->assert_the_library_reported_incorrect_usage_saying (
373+ '"give-recurring" ' ,
374+ 'The report has to name the sub-plugin that will not load, or the host cannot find it. '
375+ );
376+ $ this ->assert_the_library_reported_incorrect_usage_saying (
377+ 'after plugins_loaded had gone past the load pass ' ,
378+ 'A registration read by nothing is what failed, and the report has to say so rather than '
379+ . ' name some other gate. '
380+ );
381+ }
382+
383+ /**
384+ * Reported, not refused. The registration is buffered like any other, so a host reading
385+ * `Absorber::all()` still sees what it registered — and a `boot()` late enough to run the
386+ * sequence inline still has something to load.
387+ */
388+ public function test_a_registration_past_the_load_pass_is_still_buffered (): void {
389+ $ this ->set_up_container ();
390+ $ this ->expect_incorrect_usage ();
391+
392+ $ this ->register_from_plugins_loaded ( self ::load_priority () + 1 );
393+
394+ $ this ->assertSame (
395+ [ 'give-recurring ' ],
396+ array_keys ( $ this ->reader ()->all () ),
397+ 'The guard reports a registration; it must not throw one away. '
398+ );
399+ $ this ->assert_the_library_reported_incorrect_usage ();
400+ }
401+
402+ /**
403+ * The other side of the barrier, and the reason it is measured where it is. A host module
404+ * registering from its own `plugins_loaded` callback at the conflict pass's priority is a
405+ * documented shape — the load pass reads a priority later and loads it — and so is a
406+ * registration in the load pass's own priority, where whether the pass has run yet is the
407+ * position within that priority and nothing exposes it.
408+ *
409+ * @dataProvider priorities_the_load_pass_may_still_read
410+ *
411+ * @param int $priority plugins_loaded priority the host registers from.
412+ */
413+ public function test_a_registration_the_load_pass_may_still_read_is_left_alone ( int $ priority ): void {
414+ $ this ->set_up_container ();
415+ $ this ->expect_incorrect_usage ();
416+ $ this ->record_reports ();
417+
418+ $ this ->register_from_plugins_loaded ( $ priority );
419+
420+ $ this ->assertSame ( [], $ this ->reports , 'A registration this early is not a mistake to report. ' );
421+
422+ // The recorder has to be shown to work, or a guard that never ran at all satisfies the
423+ // assertion above however it had behaved.
424+ $ this ->register_from_plugins_loaded ( self ::load_priority () + 1 , 'give-fee-recovery ' );
425+
426+ $ this ->assertCount (
427+ 1 ,
428+ $ this ->reports ,
429+ 'The recorder must catch a registration that really did arrive too late. '
430+ );
431+ }
432+
433+ /**
434+ * @return Generator<string,array{0:int}>
435+ */
436+ public static function priorities_the_load_pass_may_still_read (): Generator {
437+ yield 'while the conflict pass is dispatching ' => [ self ::load_priority () - 1 ];
438+ yield 'in the load pass own priority ' => [ self ::load_priority () ];
439+ }
440+
441+ /**
442+ * The deliberate limit of the guard: outside a `plugins_loaded` dispatch it says nothing.
443+ *
444+ * Not an oversight, and not for want of knowing the hook is over. A host that has not booted yet
445+ * is not late — `Absorber::boot()` finds the wiring window shut and runs the whole sequence
446+ * inline, and that pass reads the buffer like any other — and from a static call that resolves
447+ * nothing there is no telling that host from one whose load pass ran already. A report that
448+ * fired on both would be wrong on the shape this library documents a rescue for.
449+ */
450+ public function test_a_registration_made_outside_the_dispatch_is_left_alone (): void {
451+ $ this ->set_up_container ();
452+ $ this ->expect_incorrect_usage ();
453+ $ this ->record_reports ();
454+
455+ $ this ->register ( 'give-recurring ' );
456+
457+ $ this ->assertSame (
458+ [],
459+ $ this ->reports ,
460+ 'Outside the dispatch a late boot can still rescue the registration, so nothing is said. '
461+ );
462+
463+ $ this ->register_from_plugins_loaded ( self ::load_priority () + 1 , 'give-fee-recovery ' );
464+
465+ $ this ->assertCount (
466+ 1 ,
467+ $ this ->reports ,
468+ 'The recorder must catch a registration that really did arrive too late. '
469+ );
470+ }
471+
472+ /**
473+ * The priority the load pass is wired at, read from the scheduler rather than restated, so that
474+ * "one past it" goes on meaning that if the number ever moves.
475+ *
476+ * @throws LogicException When the constant is missing or not an int, rather than registering at
477+ * priority zero and passing for the wrong reason.
478+ *
479+ * @return int
480+ */
481+ private static function load_priority (): int {
482+ $ priority = ( new ReflectionClass ( Scheduler::class ) )->getConstant ( 'LOAD_PRIORITY ' );
483+
484+ if ( ! is_int ( $ priority ) ) {
485+ throw new LogicException ( 'Boot\Scheduler::LOAD_PRIORITY must be an int. ' );
486+ }
487+
488+ return $ priority ;
489+ }
490+
491+ /**
492+ * Register one sub-plugin from a `plugins_loaded` callback at the given priority, and dispatch.
493+ *
494+ * The callback comes back off the hook as soon as the dispatch is over: a test that dispatches
495+ * twice would otherwise register the same sub-plugin again on the second pass, from a priority
496+ * it is no longer about.
497+ *
498+ * @param int $priority plugins_loaded priority to register from.
499+ * @param string $slug Slug to register under.
500+ *
501+ * @return void
502+ */
503+ private function register_from_plugins_loaded ( int $ priority , string $ slug = 'give-recurring ' ): void {
504+ $ callback = function () use ( $ slug ): void {
505+ $ this ->register ( $ slug );
506+ };
507+
508+ $ this ->added_actions [] = [ $ callback , $ priority ];
509+
510+ add_action ( 'plugins_loaded ' , $ callback , $ priority );
511+
512+ do_action ( 'plugins_loaded ' );
513+
514+ remove_action ( 'plugins_loaded ' , $ callback , $ priority );
515+ }
516+
335517 /**
336518 * Count the library's reports for this test, so "reported once" can be told from "reported at
337519 * every read".
0 commit comments