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
+89 -3
View File
@@ -4,10 +4,44 @@
const { registerBlockType } = wp.blocks;
const { createElement: el } = wp.element;
const { useBlockProps } = wp.blockEditor;
const { useBlockProps, InspectorControls } = wp.blockEditor;
const { PanelBody, SelectControl, ToggleControl } = wp.components;
const { useSelect } = wp.data;
const ServerSideRender = wp.serverSideRender;
const { __ } = wp.i18n;
/**
* Dropdown of published pages with a leading "default" choice.
* Values are page IDs; 0 means the default behaviour.
*/
function PageSelect(props) {
const pages = useSelect(
(select) => select('core').getEntityRecords('postType', 'page', {
per_page: -1,
orderby: 'title',
order: 'asc',
status: 'publish',
_fields: 'id,title',
}),
[]
);
const options = [{ label: props.defaultLabel, value: '0' }].concat(
(pages || []).map((page) => ({
label: (page.title && page.title.rendered) || __('(no title)', 'unsupervised-schedular'),
value: String(page.id),
}))
);
return el(SelectControl, {
label: props.label,
help: props.help,
value: String(props.value || 0),
options: options,
onChange: (value) => props.onChange(parseInt(value, 10) || 0),
});
}
const blocks = [
{
name: 'us-scheduler/booking',
@@ -16,6 +50,27 @@
icon: 'calendar-alt',
keywords: ['booking', 'lesson', 'schedule'],
shortcode: 'us_booking',
attributes: {
loginPageId: { type: 'number', default: 0 },
autoRedirect: { type: 'boolean', default: false },
},
inspector: (attributes, setAttributes) => el(
PanelBody,
{ title: __('Logged-out visitors', 'unsupervised-schedular') },
el(PageSelect, {
label: __('Login page', 'unsupervised-schedular'),
help: __('Where the log-in link sends visitors who are not logged in.', 'unsupervised-schedular'),
defaultLabel: __('WordPress login screen', 'unsupervised-schedular'),
value: attributes.loginPageId,
onChange: (loginPageId) => setAttributes({ loginPageId }),
}),
el(ToggleControl, {
label: __('Redirect automatically', 'unsupervised-schedular'),
help: __('Send logged-out visitors straight to the login page instead of showing a link.', 'unsupervised-schedular'),
checked: !!attributes.autoRedirect,
onChange: (autoRedirect) => setAttributes({ autoRedirect }),
})
),
},
{
name: 'us-scheduler/student-login',
@@ -24,6 +79,27 @@
icon: 'admin-users',
keywords: ['login', 'student', 'sign in'],
shortcode: 'us_student_login',
attributes: {
bookingPageId: { type: 'number', default: 0 },
autoRedirect: { type: 'boolean', default: false },
},
inspector: (attributes, setAttributes) => el(
PanelBody,
{ title: __('Logged-in visitors', 'unsupervised-schedular') },
el(PageSelect, {
label: __('Booking page', 'unsupervised-schedular'),
help: __('Where students are sent after logging in, and where the link shown to already-logged-in visitors points.', 'unsupervised-schedular'),
defaultLabel: __('This page', 'unsupervised-schedular'),
value: attributes.bookingPageId,
onChange: (bookingPageId) => setAttributes({ bookingPageId }),
}),
el(ToggleControl, {
label: __('Redirect automatically', 'unsupervised-schedular'),
help: __('Send logged-in visitors straight to the booking page instead of showing a link. Requires a booking page to be chosen.', 'unsupervised-schedular'),
checked: !!attributes.autoRedirect,
onChange: (autoRedirect) => setAttributes({ autoRedirect }),
})
),
},
{
name: 'us-scheduler/student-register',
@@ -52,9 +128,19 @@
category: 'widgets',
keywords: def.keywords,
supports: { html: false, multiple: false },
attributes: def.attributes || {},
example: {},
edit: function Edit() {
return el('div', useBlockProps(), el(ServerSideRender, { block: def.name }));
edit: function Edit(props) {
const inspector = def.inspector
? el(InspectorControls, {}, def.inspector(props.attributes, props.setAttributes))
: null;
return el(
'div',
useBlockProps(),
inspector,
el(ServerSideRender, { block: def.name, attributes: props.attributes })
);
},
save: () => null,
transforms: {
+30 -2
View File
@@ -19,6 +19,30 @@ output is identical either way. Pasting a shortcode into the block editor
auto-converts it to the matching block via a `transforms.from` shortcode
transform.
## Block options
Two blocks have sidebar (inspector) options controlling where their
logged-in/logged-out link sends the visitor:
| Block | Attribute | Default | Effect |
|---|---|---|---|
| `us-scheduler/booking` | `loginPageId` (number) | `0` | Page the "log in to book a lesson" link points to for logged-out visitors. `0` = the WordPress login screen (with a redirect back to the current page). |
| `us-scheduler/booking` | `autoRedirect` (boolean) | `false` | Send logged-out visitors straight to the login page instead of showing the link. |
| `us-scheduler/student-login` | `bookingPageId` (number) | `0` | Page the "View available lessons" link points to for logged-in visitors, and the post-login redirect target. `0` = the current page. |
| `us-scheduler/student-login` | `autoRedirect` (boolean) | `false` | Send logged-in visitors straight to the booking page instead of showing the link. Does nothing until a booking page is chosen. |
The page selects list all published pages; if a chosen page is later deleted,
the blocks fall back to their defaults. The link targets are also available
to the shortcodes as `[us_booking login_page_id="…"]` and
`[us_student_login booking_page_id="…"]`; auto-redirect is block-only.
Auto-redirect cannot happen during block rendering (output has already
started, so a `Location` header cannot be sent). Instead
`BlockRegistrar::maybeAutoRedirect()` runs on `template_redirect`, parses the
queried singular post's content for the block (including inside nested
blocks), and redirects when the block opts in. A block whose target is its
own page is ignored to avoid a redirect loop.
## How it works
- **`BlockRegistrar`** (`src/BlockRegistrar.php`) hooks `init` and registers
@@ -60,7 +84,11 @@ published page shows instead. The note class only appears in editor previews.
## Tests
- `tests/Unit/BlockRegistrarTest.php` — hook registration, block/asset
registration, front-end delegation to the page objects, preview-mode
routing.
registration, attribute schemas, front-end delegation to the page objects,
preview-mode routing, auto-redirect behaviour.
- `tests/Unit/Booking/BookingPageTest.php` — logged-out login-link targets
and fallbacks.
- `tests/Unit/Auth/LoginPageTest.php` — logged-in booking-link targets and
fallbacks.
- `tests/Unit/BlockPreviewTest.php` — preview markup mirrors the live CSS
classes/ids and includes the editor note.
+23 -6
View File
@@ -8,23 +8,25 @@ use Unsupervised\Schedular\Val;
class LoginPage {
/**
* Renders the student login shortcode output.
* Renders the student login shortcode/block output.
*
* @param array<string> $atts Shortcode attributes (unused — reserved for future options).
* @param array<int|string, mixed> $atts Block attributes (`bookingPageId`) or
* shortcode attributes (`booking_page_id`).
*/
public function render( array $atts ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
public function render( array $atts ): string {
$bookingPageId = Val::int( $atts['bookingPageId'] ?? $atts['booking_page_id'] ?? 0 );
if ( is_user_logged_in() ) {
$redirect = esc_url( (string) get_permalink() );
return sprintf(
'<p>%s <a href="%s">%s</a>.</p>',
esc_html__( 'You are already logged in.', 'unsupervised-schedular' ),
$redirect,
esc_url( $this->bookingUrl( $bookingPageId ) ?? (string) get_permalink() ),
esc_html__( 'View available lessons', 'unsupervised-schedular' )
);
}
$error = '';
$redirect = sanitize_url( (string) get_permalink() );
$redirect = sanitize_url( $this->bookingUrl( $bookingPageId ) ?? (string) get_permalink() );
if ( isset( $_POST['us_login'] ) && check_admin_referer( 'us_student_login' ) ) {
$credentials = [
@@ -48,4 +50,19 @@ class LoginPage {
include USC_PLUGIN_DIR . 'templates/frontend/login-page.php';
return (string) ob_get_clean();
}
/**
* Permalink of the configured booking page, or null when no page is
* chosen (or the chosen page no longer exists). Logged-in visitors are
* linked (and redirected after login) there instead of the current page.
*/
public function bookingUrl( int $bookingPageId ): ?string {
if ( $bookingPageId <= 0 ) {
return null;
}
$url = get_permalink( $bookingPageId );
return is_string( $url ) ? $url : null;
}
}
+1 -1
View File
@@ -22,7 +22,7 @@ class RegistrationPage {
/**
* Renders the student registration shortcode output.
*
* @param array<string> $atts Shortcode attributes (unused — reserved for future options).
* @param array<int|string, mixed> $atts Shortcode attributes (unused — reserved for future options).
*/
public function render( array $atts ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
if ( is_user_logged_in() ) {
+153 -22
View File
@@ -32,15 +32,17 @@ class BlockRegistrar {
public function register(): void {
add_action( 'init', [ $this, 'registerBlocks' ] );
add_action( 'template_redirect', [ $this, 'maybeAutoRedirect' ] );
}
public function registerBlocks(): void {
// The editor script registers the client side of each block (title,
// icon, shortcode transform) and previews it via wp.serverSideRender.
// icon, shortcode transform, inspector controls) and previews it via
// wp.serverSideRender.
wp_register_script(
self::SCRIPT_HANDLE,
USC_PLUGIN_URL . 'assets/js/blocks.js',
[ 'wp-blocks', 'wp-element', 'wp-block-editor', 'wp-server-side-render', 'wp-i18n' ],
[ 'wp-blocks', 'wp-element', 'wp-block-editor', 'wp-components', 'wp-data', 'wp-core-data', 'wp-server-side-render', 'wp-i18n' ],
USC_VERSION,
true
);
@@ -52,67 +54,196 @@ class BlockRegistrar {
wp_register_style( self::STYLE_HANDLE, USC_PLUGIN_URL . 'assets/css/frontend.css', [], USC_VERSION );
}
foreach ( $this->blocks() as $name => $renderCallback ) {
foreach ( $this->blocks() as $name => $config ) {
register_block_type(
$name,
[
'api_version' => '3',
'editor_script' => self::SCRIPT_HANDLE,
'style' => self::STYLE_HANDLE,
'render_callback' => $renderCallback,
'attributes' => $config['attributes'],
'render_callback' => $config['render'],
]
);
}
}
/**
* Block names mapped to their render callbacks.
* Block definitions: render callback plus the attribute schema. The
* schema must be declared server-side too, or the block-renderer preview
* endpoint rejects the attributes wp.serverSideRender sends.
*
* @return array<string, callable(array<string, mixed>): string>
* @return array<string, array{render: callable(array<string, mixed>=): string, attributes: array<string, array{type: string, default: mixed}>}>
*/
private function blocks(): array {
$redirectToggle = [
'type' => 'boolean',
'default' => false,
];
return [
'us-scheduler/booking' => [ $this, 'renderBooking' ],
'us-scheduler/student-login' => [ $this, 'renderLogin' ],
'us-scheduler/student-register' => [ $this, 'renderRegistration' ],
'us-scheduler/group-classes' => [ $this, 'renderGroupClasses' ],
'us-scheduler/booking' => [
'render' => [ $this, 'renderBooking' ],
'attributes' => [
'loginPageId' => [
'type' => 'number',
'default' => 0,
],
'autoRedirect' => $redirectToggle,
],
],
'us-scheduler/student-login' => [
'render' => [ $this, 'renderLogin' ],
'attributes' => [
'bookingPageId' => [
'type' => 'number',
'default' => 0,
],
'autoRedirect' => $redirectToggle,
],
],
'us-scheduler/student-register' => [
'render' => [ $this, 'renderRegistration' ],
'attributes' => [],
],
'us-scheduler/group-classes' => [
'render' => [ $this, 'renderGroupClasses' ],
'attributes' => [],
],
];
}
/**
* Renders the booking block.
*
* @param array<string, mixed> $attributes Block attributes (unused — the blocks have none yet).
* @param array<string, mixed> $attributes Block attributes.
*/
public function renderBooking( array $attributes = [] ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
return $this->isEditorPreview() ? BlockPreview::booking() : $this->bookingPage->render( [] );
public function renderBooking( array $attributes = [] ): string {
return $this->isEditorPreview() ? BlockPreview::booking() : $this->bookingPage->render( $attributes );
}
/**
* Renders the student-login block.
*
* @param array<string, mixed> $attributes Block attributes (unused — the blocks have none yet).
* @param array<string, mixed> $attributes Block attributes.
*/
public function renderLogin( array $attributes = [] ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
return $this->isEditorPreview() ? BlockPreview::login() : $this->loginPage->render( [] );
public function renderLogin( array $attributes = [] ): string {
return $this->isEditorPreview() ? BlockPreview::login() : $this->loginPage->render( $attributes );
}
/**
* Renders the student-registration block.
*
* @param array<string, mixed> $attributes Block attributes (unused — the blocks have none yet).
* @param array<string, mixed> $attributes Block attributes.
*/
public function renderRegistration( array $attributes = [] ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
return $this->isEditorPreview() ? BlockPreview::registration() : $this->registrationPage->render( [] );
public function renderRegistration( array $attributes = [] ): string {
return $this->isEditorPreview() ? BlockPreview::registration() : $this->registrationPage->render( $attributes );
}
/**
* Renders the group-classes block.
*
* @param array<string, mixed> $attributes Block attributes (unused — the blocks have none yet).
* @param array<string, mixed> $attributes Block attributes.
*/
public function renderGroupClasses( array $attributes = [] ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
return $this->isEditorPreview() ? BlockPreview::groupClasses() : $this->groupClassPage->render( [] );
public function renderGroupClasses( array $attributes = [] ): string {
return $this->isEditorPreview() ? BlockPreview::groupClasses() : $this->groupClassPage->render( $attributes );
}
/**
* Server-side auto-redirect for blocks that opt in via their autoRedirect
* attribute: logged-out visitors on a page containing the booking block
* are sent to its login page, and logged-in visitors on a page containing
* the student-login block are sent to its booking page. Hooked on
* `template_redirect` because block rendering happens after output has
* started, too late to send a Location header.
*/
public function maybeAutoRedirect(): void {
if ( is_admin() || ! is_singular() ) {
return;
}
$post = get_post();
if ( ! $post instanceof \WP_Post ) {
return;
}
if ( is_user_logged_in() ) {
$attrs = $this->firstBlockAttrs( $post->post_content, 'us-scheduler/student-login' );
if ( null === $attrs || ! Val::bool( $attrs['autoRedirect'] ?? false ) ) {
return;
}
$bookingPageId = Val::int( $attrs['bookingPageId'] ?? 0 );
if ( $bookingPageId === $post->ID ) {
return; // Redirecting the page to itself would loop.
}
$url = $this->loginPage->bookingUrl( $bookingPageId );
if ( null !== $url ) {
$this->redirect( $url );
}
return;
}
$attrs = $this->firstBlockAttrs( $post->post_content, 'us-scheduler/booking' );
if ( null === $attrs || ! Val::bool( $attrs['autoRedirect'] ?? false ) ) {
return;
}
$loginPageId = Val::int( $attrs['loginPageId'] ?? 0 );
if ( $loginPageId === $post->ID ) {
return; // Redirecting the page to itself would loop.
}
$this->redirect( $this->bookingPage->loginUrl( $loginPageId ) );
}
/**
* Attributes of the first occurrence of the named block in the content,
* searching inner blocks so blocks nested inside groups or columns are
* still found. Null when the block is absent. Attributes equal to their
* schema default are omitted from the serialized block, so callers must
* apply defaults themselves.
*
* @return array<mixed>|null
*/
private function firstBlockAttrs( string $content, string $blockName ): ?array {
if ( ! has_block( $blockName, $content ) ) {
return null;
}
$queue = parse_blocks( $content );
while ( [] !== $queue ) {
$block = array_shift( $queue );
if ( ! is_array( $block ) ) {
continue;
}
if ( ( $block['blockName'] ?? null ) === $blockName ) {
$attrs = $block['attrs'] ?? null;
return is_array( $attrs ) ? $attrs : [];
}
$inner = $block['innerBlocks'] ?? null;
if ( is_array( $inner ) && [] !== $inner ) {
$queue = array_merge( $queue, array_values( $inner ) );
}
}
return null;
}
/**
* Issues the redirect and stops the request. Split out so tests can
* observe redirects without the process exiting.
*/
protected function redirect( string $url ): void {
wp_safe_redirect( $url );
exit;
}
/**
+26 -5
View File
@@ -4,22 +4,24 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Booking;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Val;
class BookingPage {
/**
* Renders the booking shortcode output.
* Renders the booking shortcode/block output.
*
* @param array<string> $atts Shortcode attributes (unused — reserved for future options).
* @param array<int|string, mixed> $atts Block attributes (`loginPageId`) or
* shortcode attributes (`login_page_id`).
*/
public function render( array $atts ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
public function render( array $atts ): string {
if ( ! is_user_logged_in() ) {
$permalink = get_permalink();
$loginPageId = Val::int( $atts['loginPageId'] ?? $atts['login_page_id'] ?? 0 );
return sprintf(
'<p>%s <a href="%s">%s</a>.</p>',
esc_html__( 'Please', 'unsupervised-schedular' ),
esc_url( wp_login_url( false === $permalink ? '' : $permalink ) ),
esc_url( $this->loginUrl( $loginPageId ) ),
esc_html__( 'log in to book a lesson', 'unsupervised-schedular' )
);
}
@@ -35,4 +37,23 @@ class BookingPage {
include USC_PLUGIN_DIR . 'templates/frontend/booking-page.php';
return (string) ob_get_clean();
}
/**
* URL the logged-out prompt sends visitors to: the chosen login page when
* one is configured (and still exists), otherwise the WordPress login
* screen with a redirect back to the current page.
*/
public function loginUrl( int $loginPageId ): string {
if ( $loginPageId > 0 ) {
$url = get_permalink( $loginPageId );
if ( is_string( $url ) ) {
return $url;
}
}
$permalink = get_permalink();
return wp_login_url( false === $permalink ? '' : $permalink );
}
}
+1 -1
View File
@@ -10,7 +10,7 @@ class GroupClassPage {
/**
* Renders the group-class enrolment shortcode output.
*
* @param array<string> $atts Shortcode attributes (unused — reserved for future options).
* @param array<int|string, mixed> $atts Shortcode attributes (unused — reserved for future options).
*/
public function render( array $atts ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
if ( ! is_user_logged_in() ) {
+16 -4
View File
@@ -19,14 +19,26 @@ class ShortcodeRegistrar {
) {}
public function register(): void {
add_shortcode( 'us_booking', [ $this->bookingPage, 'render' ] );
add_shortcode( 'us_student_login', [ $this->loginPage, 'render' ] );
add_shortcode( 'us_student_register', [ $this->registrationPage, 'render' ] );
add_shortcode( 'us_group_classes', [ $this->groupClassPage, 'render' ] );
add_shortcode( 'us_booking', self::shortcode( [ $this->bookingPage, 'render' ] ) );
add_shortcode( 'us_student_login', self::shortcode( [ $this->loginPage, 'render' ] ) );
add_shortcode( 'us_student_register', self::shortcode( [ $this->registrationPage, 'render' ] ) );
add_shortcode( 'us_group_classes', self::shortcode( [ $this->groupClassPage, 'render' ] ) );
add_action( 'template_redirect', [ $this->registrationPage, 'maybeRedirectToRegistrationPage' ] );
add_action( 'wp_enqueue_scripts', [ $this, 'enqueueAssets' ] );
}
/**
* Wraps a page renderer so bare shortcode usage is safe: WordPress passes
* an empty string, not an array, to the callback when a shortcode is used
* without attributes (`shortcode_parse_atts( '' )` returns `''`).
*
* @param callable(array<int|string, mixed>): string $render
* @return \Closure(mixed): string
*/
private static function shortcode( callable $render ): \Closure {
return static fn( mixed $atts ): string => $render( is_array( $atts ) ? $atts : [] );
}
public function enqueueAssets(): void {
wp_register_style( 'us-scheduler', USC_PLUGIN_URL . 'assets/css/frontend.css', [], USC_VERSION );
+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