Add link-target and auto-redirect options to booking/login blocks
CI / Coding Standards (pull_request) Successful in 51s
CI / No Debug Code (pull_request) Successful in 3s
CI / Tests (PHP 8.2) (pull_request) Successful in 50s
CI / Tests (PHP 8.3) (pull_request) Successful in 1m2s
CI / Build Plugin Zip (pull_request) Has been skipped
CI / Tests (PHP 8.1) (pull_request) Successful in 53s
CI / PHPStan (pull_request) Successful in 1m24s

The booking block gains a loginPageId attribute choosing which page its
logged-out "log in to book a lesson" link points to (default remains the
WordPress login screen), and the student-login block gains a
bookingPageId attribute controlling the logged-in "View available
lessons" link and the post-login redirect target (default remains the
current page). Both blocks also gain an autoRedirect toggle, off by
default, that sends the visitor straight to the target page; block
rendering starts after output, so the redirect runs on
template_redirect by parsing the queried page's content for the block,
with a self-target guard against redirect loops. The link targets are
also available to the shortcodes as login_page_id/booking_page_id.

Also fixes a pre-existing fatal: WordPress passes an empty string (not
an array) to shortcode callbacks when a shortcode is used without
attributes, so bare [us_booking] etc. threw a TypeError against the
strictly-typed render(array $atts) methods. ShortcodeRegistrar now
wraps each callback to normalize non-array attribute values.

Closes #51

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
2026-07-05 16:16:52 -03:00
co-authored by Claude Fable 5
parent 43497503b9
commit 9d89bc6d0e
13 changed files with 848 additions and 53 deletions
+94
View File
@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Auth;
use Brain\Monkey\Functions;
use Unsupervised\Schedular\Auth\LoginPage;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class LoginPageTest extends TestCase
{
private LoginPage $page;
protected function setUp(): void
{
parent::setUp();
$this->page = new LoginPage();
}
protected function tearDown(): void
{
unset($_POST['us_login']);
parent::tearDown();
}
public function testLoggedInVisitorIsLinkedToTheCurrentPageByDefault(): void
{
Functions\when('is_user_logged_in')->justReturn(true);
Functions\when('get_permalink')->alias(
static fn(int $id = 0): string|false => 0 === $id ? 'https://example.com/login/' : false
);
$html = $this->page->render([]);
self::assertStringContainsString('href="https://example.com/login/"', $html);
self::assertStringContainsString('View available lessons', $html);
}
public function testLoggedInVisitorIsLinkedToTheChosenBookingPage(): void
{
Functions\when('is_user_logged_in')->justReturn(true);
Functions\when('get_permalink')->alias(
static fn(int $id = 0): string|false => 9 === $id ? 'https://example.com/book/' : 'https://example.com/login/'
);
$html = $this->page->render(['bookingPageId' => 9]);
self::assertStringContainsString('href="https://example.com/book/"', $html);
}
public function testShortcodeStyleAttributeSelectsTheBookingPageToo(): void
{
Functions\when('is_user_logged_in')->justReturn(true);
Functions\when('get_permalink')->alias(
static fn(int $id = 0): string|false => 9 === $id ? 'https://example.com/book/' : 'https://example.com/login/'
);
$html = $this->page->render(['booking_page_id' => '9']);
self::assertStringContainsString('href="https://example.com/book/"', $html);
}
public function testLoggedOutVisitorSeesTheLoginForm(): void
{
Functions\when('is_user_logged_in')->justReturn(false);
Functions\when('get_permalink')->justReturn('https://example.com/login/');
Functions\when('sanitize_url')->returnArg();
Functions\when('wp_nonce_field')->justReturn('');
$html = $this->page->render([]);
self::assertStringContainsString('us-login-form', $html);
self::assertStringContainsString('name="us_login"', $html);
}
public function testBookingUrlIsNullWithoutAChosenPage(): void
{
self::assertNull($this->page->bookingUrl(0));
}
public function testBookingUrlIsNullWhenTheChosenPageIsGone(): void
{
Functions\when('get_permalink')->justReturn(false);
self::assertNull($this->page->bookingUrl(9));
}
public function testBookingUrlIsThePagePermalink(): void
{
Functions\when('get_permalink')->justReturn('https://example.com/book/');
self::assertSame('https://example.com/book/', $this->page->bookingUrl(9));
}
}
+232 -9
View File
@@ -13,18 +13,26 @@ use Unsupervised\Schedular\Booking\BookingPage;
use Unsupervised\Schedular\GroupClass\GroupClassPage;
/**
* Test double exposing editor-preview mode as a switch, since the real
* detection relies on the REST_REQUEST constant which cannot be toggled
* within a single PHP process.
* Test double exposing editor-preview mode as a switch (the real detection
* relies on the REST_REQUEST constant, which cannot be toggled within a
* single PHP process) and recording redirects instead of exiting.
*/
class TestableBlockRegistrar extends BlockRegistrar
{
public bool $preview = false;
/** @var list<string> */
public array $redirects = [];
protected function isEditorPreview(): bool
{
return $this->preview;
}
protected function redirect(string $url): void
{
$this->redirects[] = $url;
}
}
class BlockRegistrarTest extends TestCase
@@ -52,9 +60,10 @@ class BlockRegistrarTest extends TestCase
);
}
public function testRegisterHooksBlockRegistrationOntoInit(): void
public function testRegisterHooksBlockRegistrationAndAutoRedirect(): void
{
Actions\expectAdded('init')->once()->with([$this->registrar, 'registerBlocks']);
Actions\expectAdded('template_redirect')->once()->with([$this->registrar, 'maybeAutoRedirect']);
$this->registrar->register();
}
@@ -105,6 +114,19 @@ class BlockRegistrarTest extends TestCase
self::assertSame(BlockRegistrar::STYLE_HANDLE, $args['style']);
self::assertIsCallable($args['render_callback']);
}
// The link-target and auto-redirect options must be declared
// server-side or the block-renderer preview rejects them.
self::assertSame(
['loginPageId', 'autoRedirect'],
array_keys($registered['us-scheduler/booking']['attributes'])
);
self::assertSame(
['bookingPageId', 'autoRedirect'],
array_keys($registered['us-scheduler/student-login']['attributes'])
);
self::assertSame([], $registered['us-scheduler/student-register']['attributes']);
self::assertSame([], $registered['us-scheduler/group-classes']['attributes']);
}
public function testRegisterBlocksDoesNotReRegisterAnAlreadyRegisteredStyle(): void
@@ -117,17 +139,19 @@ class BlockRegistrarTest extends TestCase
$this->registrar->registerBlocks();
}
public function testFrontEndRenderDelegatesToThePageObjects(): void
public function testFrontEndRenderDelegatesToThePageObjectsPassingAttributes(): void
{
$this->registrar->preview = false;
$this->bookingPage->shouldReceive('render')->once()->with([])->andReturn('booking-html');
$this->loginPage->shouldReceive('render')->once()->with([])->andReturn('login-html');
$this->bookingPage->shouldReceive('render')
->once()->with(['loginPageId' => 5])->andReturn('booking-html');
$this->loginPage->shouldReceive('render')
->once()->with(['bookingPageId' => 9])->andReturn('login-html');
$this->registrationPage->shouldReceive('render')->once()->with([])->andReturn('register-html');
$this->groupClassPage->shouldReceive('render')->once()->with([])->andReturn('group-html');
self::assertSame('booking-html', $this->registrar->renderBooking());
self::assertSame('login-html', $this->registrar->renderLogin());
self::assertSame('booking-html', $this->registrar->renderBooking(['loginPageId' => 5]));
self::assertSame('login-html', $this->registrar->renderLogin(['bookingPageId' => 9]));
self::assertSame('register-html', $this->registrar->renderRegistration());
self::assertSame('group-html', $this->registrar->renderGroupClasses());
}
@@ -164,4 +188,203 @@ class BlockRegistrarTest extends TestCase
self::assertSame('live', $registrar->renderBooking());
}
/**
* Stubs the front-end request context for maybeAutoRedirect: a singular
* page whose content parses to the given blocks.
*
* @param list<array<string, mixed>> $parsedBlocks
*/
private function stubSingularRequest(int $postId, array $parsedBlocks, bool $loggedIn): void
{
$post = new \WP_Post();
$post->ID = $postId;
$post->post_content = 'serialized-block-content';
Functions\when('is_admin')->justReturn(false);
Functions\when('is_singular')->justReturn(true);
Functions\when('get_post')->justReturn($post);
Functions\when('is_user_logged_in')->justReturn($loggedIn);
Functions\when('has_block')->alias(
static function (string $blockName) use ($parsedBlocks): bool {
foreach ($parsedBlocks as $block) {
if (($block['blockName'] ?? null) === $blockName) {
return true;
}
foreach ((array) ($block['innerBlocks'] ?? []) as $inner) {
if (is_array($inner) && ($inner['blockName'] ?? null) === $blockName) {
return true;
}
}
}
return false;
}
);
Functions\when('parse_blocks')->justReturn($parsedBlocks);
}
public function testAutoRedirectSendsLoggedOutVisitorToTheLoginPage(): void
{
$this->stubSingularRequest(
10,
[
[
'blockName' => 'us-scheduler/booking',
'attrs' => ['autoRedirect' => true, 'loginPageId' => 7],
'innerBlocks' => [],
],
],
false
);
$this->bookingPage->shouldReceive('loginUrl')->once()->with(7)->andReturn('https://example.com/login/');
$this->registrar->maybeAutoRedirect();
self::assertSame(['https://example.com/login/'], $this->registrar->redirects);
}
public function testAutoRedirectFindsTheBookingBlockNestedInsideAnotherBlock(): void
{
$this->stubSingularRequest(
10,
[
[
'blockName' => 'core/group',
'attrs' => [],
'innerBlocks' => [
[
'blockName' => 'us-scheduler/booking',
'attrs' => ['autoRedirect' => true],
'innerBlocks' => [],
],
],
],
],
false
);
// loginPageId omitted from the serialized block (defaults are not
// stored) — falls back to the WordPress login screen.
$this->bookingPage->shouldReceive('loginUrl')->once()->with(0)->andReturn('https://example.com/wp-login.php');
$this->registrar->maybeAutoRedirect();
self::assertSame(['https://example.com/wp-login.php'], $this->registrar->redirects);
}
public function testNoRedirectWhenTheBookingBlockDoesNotOptIn(): void
{
$this->stubSingularRequest(
10,
[
[
'blockName' => 'us-scheduler/booking',
'attrs' => ['loginPageId' => 7],
'innerBlocks' => [],
],
],
false
);
$this->bookingPage->shouldNotReceive('loginUrl');
$this->registrar->maybeAutoRedirect();
self::assertSame([], $this->registrar->redirects);
}
public function testNoRedirectWhenTheBookingBlockPointsAtItsOwnPage(): void
{
$this->stubSingularRequest(
7,
[
[
'blockName' => 'us-scheduler/booking',
'attrs' => ['autoRedirect' => true, 'loginPageId' => 7],
'innerBlocks' => [],
],
],
false
);
$this->bookingPage->shouldNotReceive('loginUrl');
$this->registrar->maybeAutoRedirect();
self::assertSame([], $this->registrar->redirects);
}
public function testAutoRedirectSendsLoggedInVisitorToTheBookingPage(): void
{
$this->stubSingularRequest(
20,
[
[
'blockName' => 'us-scheduler/student-login',
'attrs' => ['autoRedirect' => true, 'bookingPageId' => 9],
'innerBlocks' => [],
],
],
true
);
$this->loginPage->shouldReceive('bookingUrl')->once()->with(9)->andReturn('https://example.com/book/');
$this->registrar->maybeAutoRedirect();
self::assertSame(['https://example.com/book/'], $this->registrar->redirects);
}
public function testNoRedirectWhenTheLoginBlockHasNoBookingPageChosen(): void
{
$this->stubSingularRequest(
20,
[
[
'blockName' => 'us-scheduler/student-login',
'attrs' => ['autoRedirect' => true],
'innerBlocks' => [],
],
],
true
);
$this->loginPage->shouldReceive('bookingUrl')->once()->with(0)->andReturnNull();
$this->registrar->maybeAutoRedirect();
self::assertSame([], $this->registrar->redirects);
}
public function testNoRedirectForLoggedInVisitorOnTheBookingPage(): void
{
$this->stubSingularRequest(
10,
[
[
'blockName' => 'us-scheduler/booking',
'attrs' => ['autoRedirect' => true, 'loginPageId' => 7],
'innerBlocks' => [],
],
],
true
);
$this->bookingPage->shouldNotReceive('loginUrl');
$this->registrar->maybeAutoRedirect();
self::assertSame([], $this->registrar->redirects);
}
public function testNoRedirectOutsideSingularFrontEndRequests(): void
{
Functions\when('is_admin')->justReturn(false);
Functions\when('is_singular')->justReturn(false);
$this->registrar->maybeAutoRedirect();
self::assertSame([], $this->registrar->redirects);
}
}
+75
View File
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Booking;
use Brain\Monkey\Functions;
use Unsupervised\Schedular\Booking\BookingPage;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class BookingPageTest extends TestCase
{
private BookingPage $page;
protected function setUp(): void
{
parent::setUp();
$this->page = new BookingPage();
}
public function testLoggedOutVisitorIsLinkedToTheWordPressLoginByDefault(): void
{
Functions\when('is_user_logged_in')->justReturn(false);
Functions\when('get_permalink')->justReturn('https://example.com/book/');
Functions\when('wp_login_url')->alias(
static fn(string $redirect): string => 'https://example.com/wp-login.php?redirect_to=' . $redirect
);
$html = $this->page->render([]);
self::assertStringContainsString(
'href="https://example.com/wp-login.php?redirect_to=https://example.com/book/"',
$html
);
self::assertStringContainsString('log in to book a lesson', $html);
}
public function testLoggedOutVisitorIsLinkedToTheChosenLoginPage(): void
{
Functions\when('is_user_logged_in')->justReturn(false);
Functions\when('get_permalink')->alias(
static fn(int $id = 0): string|false => 5 === $id ? 'https://example.com/login/' : false
);
Functions\expect('wp_login_url')->never();
$html = $this->page->render(['loginPageId' => 5]);
self::assertStringContainsString('href="https://example.com/login/"', $html);
}
public function testShortcodeStyleAttributeSelectsTheLoginPageToo(): void
{
Functions\when('is_user_logged_in')->justReturn(false);
Functions\when('get_permalink')->alias(
static fn(int $id = 0): string|false => 5 === $id ? 'https://example.com/login/' : false
);
$html = $this->page->render(['login_page_id' => '5']);
self::assertStringContainsString('href="https://example.com/login/"', $html);
}
public function testLoginUrlFallsBackToWordPressLoginWhenThePageIsGone(): void
{
// The chosen page was deleted: get_permalink() returns false for it
// and for the current (test) context alike.
Functions\when('get_permalink')->justReturn(false);
Functions\when('wp_login_url')->alias(
static fn(string $redirect): string => '' === $redirect
? 'https://example.com/wp-login.php'
: 'https://example.com/wp-login.php?redirect_to=' . $redirect
);
self::assertSame('https://example.com/wp-login.php', $this->page->loginUrl(5));
}
}
+99
View File
@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit;
use Brain\Monkey\Actions;
use Brain\Monkey\Functions;
use Mockery;
use Unsupervised\Schedular\Auth\LoginPage;
use Unsupervised\Schedular\Auth\RegistrationPage;
use Unsupervised\Schedular\Booking\BookingPage;
use Unsupervised\Schedular\GroupClass\GroupClassPage;
use Unsupervised\Schedular\ShortcodeRegistrar;
class ShortcodeRegistrarTest extends TestCase
{
private BookingPage&Mockery\MockInterface $bookingPage;
private LoginPage&Mockery\MockInterface $loginPage;
private RegistrationPage&Mockery\MockInterface $registrationPage;
private GroupClassPage&Mockery\MockInterface $groupClassPage;
private ShortcodeRegistrar $registrar;
/** @var array<string, callable> */
private array $shortcodes = [];
protected function setUp(): void
{
parent::setUp();
$this->bookingPage = Mockery::mock(BookingPage::class);
$this->loginPage = Mockery::mock(LoginPage::class);
$this->registrationPage = Mockery::mock(RegistrationPage::class);
$this->groupClassPage = Mockery::mock(GroupClassPage::class);
$this->registrar = new ShortcodeRegistrar(
$this->bookingPage,
$this->loginPage,
$this->registrationPage,
$this->groupClassPage,
);
$shortcodes = &$this->shortcodes;
Functions\when('add_shortcode')->alias(
static function (string $tag, callable $callback) use (&$shortcodes): void {
$shortcodes[$tag] = $callback;
}
);
}
public function testRegisterAddsAllFourShortcodesAndHooks(): void
{
Actions\expectAdded('template_redirect')
->once()
->with([$this->registrationPage, 'maybeRedirectToRegistrationPage']);
Actions\expectAdded('wp_enqueue_scripts')
->once()
->with([$this->registrar, 'enqueueAssets']);
$this->registrar->register();
self::assertSame(
['us_booking', 'us_student_login', 'us_student_register', 'us_group_classes'],
array_keys($this->shortcodes)
);
}
/**
* WordPress passes an empty string (not an array) to a shortcode callback
* when the shortcode is used without attributes, e.g. `[us_booking]` —
* the wrapper must normalize it before the typed render methods.
*/
public function testBareShortcodeUsageIsNormalizedToAnEmptyAttributeArray(): void
{
$this->registrar->register();
$this->bookingPage->shouldReceive('render')->once()->with([])->andReturn('booking');
$this->loginPage->shouldReceive('render')->once()->with([])->andReturn('login');
$this->registrationPage->shouldReceive('render')->once()->with([])->andReturn('register');
$this->groupClassPage->shouldReceive('render')->once()->with([])->andReturn('group');
self::assertSame('booking', $this->shortcodes['us_booking'](''));
self::assertSame('login', $this->shortcodes['us_student_login'](''));
self::assertSame('register', $this->shortcodes['us_student_register'](''));
self::assertSame('group', $this->shortcodes['us_group_classes'](''));
}
public function testShortcodeAttributesArePassedThroughUnchanged(): void
{
$this->registrar->register();
$this->bookingPage->shouldReceive('render')
->once()->with(['login_page_id' => '5'])->andReturn('booking');
$this->loginPage->shouldReceive('render')
->once()->with(['booking_page_id' => '9'])->andReturn('login');
self::assertSame('booking', $this->shortcodes['us_booking'](['login_page_id' => '5']));
self::assertSame('login', $this->shortcodes['us_student_login'](['booking_page_id' => '9']));
}
}
+9
View File
@@ -65,6 +65,15 @@ if (! class_exists('WP_REST_Response')) {
}
}
// Minimal WP_Post stub exposing the fields the plugin reads.
if (! class_exists('WP_Post')) {
class WP_Post
{
public int $ID = 0;
public string $post_content = '';
}
}
// Minimal WP_Error stub for code under test that returns error objects.
if (! class_exists('WP_Error')) {
class WP_Error