diff --git a/assets/js/blocks.js b/assets/js/blocks.js index b391388..4a0f096 100644 --- a/assets/js/blocks.js +++ b/assets/js/blocks.js @@ -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: { diff --git a/docs/features/editor-blocks.md b/docs/features/editor-blocks.md index 2a5080e..a2af7ea 100644 --- a/docs/features/editor-blocks.md +++ b/docs/features/editor-blocks.md @@ -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. diff --git a/src/Auth/LoginPage.php b/src/Auth/LoginPage.php index 90cef34..1a15605 100644 --- a/src/Auth/LoginPage.php +++ b/src/Auth/LoginPage.php @@ -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 $atts Shortcode attributes (unused — reserved for future options). + * @param array $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( '

%s %s.

', 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; + } } diff --git a/src/Auth/RegistrationPage.php b/src/Auth/RegistrationPage.php index ec6e90a..1b48178 100644 --- a/src/Auth/RegistrationPage.php +++ b/src/Auth/RegistrationPage.php @@ -22,7 +22,7 @@ class RegistrationPage { /** * Renders the student registration shortcode output. * - * @param array $atts Shortcode attributes (unused — reserved for future options). + * @param array $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() ) { diff --git a/src/BlockRegistrar.php b/src/BlockRegistrar.php index a4da54e..217f7aa 100644 --- a/src/BlockRegistrar.php +++ b/src/BlockRegistrar.php @@ -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> + * @return array=): string, attributes: array}> */ 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 $attributes Block attributes (unused — the blocks have none yet). + * @param array $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 $attributes Block attributes (unused — the blocks have none yet). + * @param array $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 $attributes Block attributes (unused — the blocks have none yet). + * @param array $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 $attributes Block attributes (unused — the blocks have none yet). + * @param array $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|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; } /** diff --git a/src/Booking/BookingPage.php b/src/Booking/BookingPage.php index 7d9165b..7839918 100644 --- a/src/Booking/BookingPage.php +++ b/src/Booking/BookingPage.php @@ -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 $atts Shortcode attributes (unused — reserved for future options). + * @param array $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( '

%s %s.

', 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 ); + } } diff --git a/src/GroupClass/GroupClassPage.php b/src/GroupClass/GroupClassPage.php index 1188920..2e8c3d7 100644 --- a/src/GroupClass/GroupClassPage.php +++ b/src/GroupClass/GroupClassPage.php @@ -10,7 +10,7 @@ class GroupClassPage { /** * Renders the group-class enrolment shortcode output. * - * @param array $atts Shortcode attributes (unused — reserved for future options). + * @param array $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() ) { diff --git a/src/ShortcodeRegistrar.php b/src/ShortcodeRegistrar.php index 761b034..6c7fc21 100644 --- a/src/ShortcodeRegistrar.php +++ b/src/ShortcodeRegistrar.php @@ -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): 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 ); diff --git a/tests/Unit/Auth/LoginPageTest.php b/tests/Unit/Auth/LoginPageTest.php new file mode 100644 index 0000000..9a656a6 --- /dev/null +++ b/tests/Unit/Auth/LoginPageTest.php @@ -0,0 +1,94 @@ +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)); + } +} diff --git a/tests/Unit/BlockRegistrarTest.php b/tests/Unit/BlockRegistrarTest.php index 9d4080a..8e12610 100644 --- a/tests/Unit/BlockRegistrarTest.php +++ b/tests/Unit/BlockRegistrarTest.php @@ -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 */ + 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> $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); + } } diff --git a/tests/Unit/Booking/BookingPageTest.php b/tests/Unit/Booking/BookingPageTest.php new file mode 100644 index 0000000..0e8a68a --- /dev/null +++ b/tests/Unit/Booking/BookingPageTest.php @@ -0,0 +1,75 @@ +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)); + } +} diff --git a/tests/Unit/ShortcodeRegistrarTest.php b/tests/Unit/ShortcodeRegistrarTest.php new file mode 100644 index 0000000..2d72dcd --- /dev/null +++ b/tests/Unit/ShortcodeRegistrarTest.php @@ -0,0 +1,99 @@ + */ + 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'])); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 88793dd..8705a68 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -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