Merge pull request 'View a policy version's content, and make policy text readable' (#128) from feature/policy-version-viewer-and-readable-text into main
CI / Tests (PHP 8.2) (push) Successful in 46s
CI / Tests (PHP 8.1) (push) Successful in 57s
CI / No Debug Code (push) Successful in 2s
CI / PHPStan (push) Successful in 2m55s
CI / Coding Standards (push) Successful in 2m58s
CI / Tests (PHP 8.3) (push) Successful in 2m42s
CI / Build Plugin Zip (push) Successful in 2m49s
Release / Build and Publish Release (push) Successful in 2m50s
Release / Open next-version bump PR (push) Successful in 4s
CI / Tests (PHP 8.2) (push) Successful in 46s
CI / Tests (PHP 8.1) (push) Successful in 57s
CI / No Debug Code (push) Successful in 2s
CI / PHPStan (push) Successful in 2m55s
CI / Coding Standards (push) Successful in 2m58s
CI / Tests (PHP 8.3) (push) Successful in 2m42s
CI / Build Plugin Zip (push) Successful in 2m49s
Release / Build and Publish Release (push) Successful in 2m50s
Release / Open next-version bump PR (push) Successful in 4s
Reviewed-on: #128
This commit was merged in pull request #128.
This commit is contained in:
@@ -18,8 +18,12 @@ each change under the current top section as you work.
|
||||
|
||||
### Added
|
||||
- Every price a student sees now says **when** it is due. Lesson types in the booking form read `50.00 CAD at booking`, and group-class cards read `120.00 CAD up front`, `40.00 CAD weekly` or `40.00 CAD monthly` — the offering's billing mode, in the student's words. A monthly **private lesson** is quoted per lesson (`50.00 CAD per lesson monthly`), since its monthly charge covers every lesson booked that month; a monthly group class is quoted as the monthly figure it is. A free offering still just reads **Free**.
|
||||
- The **Policies** admin page can now **show you what is actually in a version**. Every row in the versions table has a **View** button that opens that version's text below the table, rendered exactly as students see it at booking and signup, whether the version is the published one, an old archived one, or a draft nobody has seen yet. The text is editable straight from the viewer, and what happens when you save depends on the version: a draft is simply updated in place, while editing a **published or archived version saves your text as a new draft version** and leaves the original exactly as students accepted it. The new draft then opens in the viewer ready to publish. Nothing a student has agreed to is ever rewritten.
|
||||
- Booking a lesson and enrolling in a class now take a **second confirmation that the student agrees to pay**. Above the Confirm button the form restates the price with its cadence, spells out how it is collected ("Charged on the 1st of each month, for that month's lessons"), adds the studio's HST so the figure matches the total actually billed, and requires a tick on "I agree to pay 56.50 CAD at booking." before it will submit — separate from, and in addition to, the studio policies the student accepts above it. Reserving a time weekly quotes the per-lesson fee and the most it can add up to ("up to 12 lessons, 678.00 CAD in total"), since a week another student takes first is simply not booked. Free offerings have nothing to agree to and show no price block.
|
||||
|
||||
### Fixed
|
||||
- Policies are **readable where students have to accept them**. A policy typed as plain paragraphs — the normal way to write one, with no HTML — was being dropped into the booking, enrolment, and signup forms unformatted, collapsing the whole document into a single squashed line with a horizontal scrollbar and words piling on top of each other. Policy text is now formatted the same way WordPress formats post content, so blank lines become real paragraphs, and the acceptance box is styled as a proper bounded reading panel: long policies scroll vertically instead of running off the side of the page, long pasted links wrap rather than forcing the page sideways, and the "I have read and agree" tick stays in view. Policies written with HTML are unaffected. The studio registration page was also missing the plugin's stylesheet entirely, which is why the problem was at its worst there.
|
||||
|
||||
## [1.2.2]
|
||||
|
||||
### Added
|
||||
|
||||
@@ -282,6 +282,61 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Policy acceptance — booking, enrolment, and signup all render this markup. */
|
||||
.us-policy {
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.us-policy h4 {
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
/*
|
||||
* The body is admin-authored HTML sitting inside whatever layout the theme
|
||||
* provides, so it gets an explicit reading box rather than inheriting one.
|
||||
* `overflow-wrap` breaks pasted URLs instead of letting one long token force
|
||||
* the horizontal scrollbar, and the bounded height keeps a long policy from
|
||||
* pushing the accept checkbox off the screen.
|
||||
*/
|
||||
.us-policy-body {
|
||||
box-sizing: border-box;
|
||||
max-width: 100%;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
background: #fafafa;
|
||||
white-space: normal;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
line-height: 1.5;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.us-policy-body p,
|
||||
.us-policy-body ul,
|
||||
.us-policy-body ol {
|
||||
margin: 0 0 0.75em;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.us-policy-body ul,
|
||||
.us-policy-body ol {
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
|
||||
.us-policy-body > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.us-policy-accept,
|
||||
.us-policies input[type="checkbox"] {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.us-week-grid {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@@ -40,14 +40,22 @@ The studio admin drafts, versions, and publishes policies (e.g. cancellation, pa
|
||||
|
||||
## Versioning & Acceptance Rules
|
||||
- Editing a published policy creates a new `draft` version; the old version stays `published` until the draft is published.
|
||||
- Editing a `draft` version rewrites it in place — nobody has accepted it yet, so there is nothing to preserve and no new version is created. `PATCH /policies/{id}/versions/{vid}` allows only this case; the admin page also accepts an edit to a `published` or `archived` version and branches a new draft from it.
|
||||
- Publishing a draft sets it `published`, stamps `published_at`, archives the prior version, and points `us_policies.current_version_id` at it.
|
||||
- The registration gate requires acceptance of the `current_version_id` of every policy. Because acceptance is tied to `policy_version_id`, a newly published version is unaccepted and must be re-accepted at the student's next booking.
|
||||
|
||||
## Admin Interface
|
||||
**Policies** in wp-admin (`manage_policies`, studio admin only):
|
||||
- Create a policy; draft and edit version bodies
|
||||
- Create a policy; draft version bodies
|
||||
- View the content of any version (`?page=us-policies&policy_id={id}&version_id={vid}`), whatever its status
|
||||
- Edit from the viewer: a draft is saved in place; editing a published or archived version instead saves the text as a **new draft version** (the viewer follows to it), so text students have already accepted is never rewritten
|
||||
- Publish a draft version; view acceptance history per version
|
||||
|
||||
## Rendering a Policy Body
|
||||
Bodies are typed into a plain textarea, so most are written as blank-line-separated prose with no markup. `PolicyVersion::bodyHtml()` is the single render path — `wp_kses_post()` then `wpautop()`, the same treatment WordPress gives post content — so unmarked-up text arrives as real paragraphs and bodies that do carry markup are left alone. It feeds the booking/enrolment JSON (`GET /policies`), the signup form, and the admin version viewer, which therefore previews exactly what students see.
|
||||
|
||||
The acceptance markup (`.us-policy` / `.us-policy-body`) is styled in `assets/css/frontend.css` as a bounded, vertically scrolling reading box with `overflow-wrap: break-word`, so a long policy or a pasted URL cannot force a horizontal scrollbar or push the accept checkbox out of view. `RegistrationPage` enqueues that stylesheet for the signup gate; `BookingPage` and `GroupClassPage` already did.
|
||||
|
||||
## REST API
|
||||
| Method | Endpoint | Permission |
|
||||
|----------|-----------------------------------------------------------------|-------------------|
|
||||
@@ -74,3 +82,5 @@ cover every policy's current version or the registration is rejected.
|
||||
- `tests/Unit/Policy/PolicyVersionRepositoryTest.php`
|
||||
- `tests/Unit/Policy/AcceptanceRepositoryTest.php`
|
||||
- `tests/Unit/Policy/PolicyServiceTest.php`
|
||||
- `tests/Unit/Policy/PolicyControllerTest.php`
|
||||
- `tests/Unit/Policy/PolicyEndpointTest.php`
|
||||
|
||||
@@ -116,6 +116,10 @@ class RegistrationPage {
|
||||
$canRegister = $open || $inviteValid;
|
||||
$inviteOnlyMessage = $this->inviteOnlyMessage( $atts );
|
||||
|
||||
// The signup form carries the same policy-acceptance markup as the booking
|
||||
// gate, so it needs the plugin stylesheet that formats it.
|
||||
wp_enqueue_style( 'us-scheduler' );
|
||||
|
||||
// The two-step script only matters when there is a second step to reveal.
|
||||
if ( $canRegister && '' === $successType && [] !== $accountQuestions ) {
|
||||
wp_enqueue_script( 'us-scheduler-register' );
|
||||
|
||||
@@ -19,20 +19,35 @@ class PolicyController {
|
||||
wp_die( esc_html__( 'You do not have permission to manage policies.', 'unsupervised-schedular' ) );
|
||||
}
|
||||
|
||||
$notice = '';
|
||||
$viewVersionId = 0;
|
||||
|
||||
if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_policy_action' ) ) {
|
||||
$this->handleFormAction();
|
||||
[ $notice, $viewVersionId ] = $this->handleFormAction();
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only policy selector.
|
||||
$policyId = absint( Val::int( $_GET['policy_id'] ?? 0 ) );
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only policy/version selectors.
|
||||
$policyId = absint( Val::int( $_GET['policy_id'] ?? 0 ) );
|
||||
if ( 0 === $viewVersionId ) {
|
||||
$viewVersionId = absint( Val::int( $_GET['version_id'] ?? 0 ) );
|
||||
}
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Recommended
|
||||
|
||||
$policyList = $this->policies->findAll();
|
||||
$selectedPolicy = $policyId > 0 ? $this->policies->findById( $policyId ) : null;
|
||||
$policyVersions = null !== $selectedPolicy ? $this->versions->findByPolicy( (int) $selectedPolicy->id ) : null;
|
||||
$viewedVersion = null !== $selectedPolicy ? $this->loadVersionForPolicy( (int) $selectedPolicy->id, $viewVersionId ) : null;
|
||||
|
||||
include USC_PLUGIN_DIR . 'templates/admin/policies.php';
|
||||
}
|
||||
|
||||
private function handleFormAction(): void {
|
||||
/**
|
||||
* Process the posted action.
|
||||
*
|
||||
* @return array{string, int} Status notice, and the version to open in the
|
||||
* viewer (0 to leave the current selection alone).
|
||||
*/
|
||||
private function handleFormAction(): array {
|
||||
// Nonce is verified by the caller (renderPage) before this method runs.
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Missing
|
||||
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) );
|
||||
@@ -53,12 +68,12 @@ class PolicyController {
|
||||
$this->service->createPolicy( $title, $slug, $scope );
|
||||
}
|
||||
|
||||
return;
|
||||
return [ '', 0 ];
|
||||
}
|
||||
|
||||
$policyId = absint( Val::int( $_POST['policy_id'] ?? 0 ) );
|
||||
if ( $policyId <= 0 || null === $this->policies->findById( $policyId ) ) {
|
||||
return;
|
||||
return [ '', 0 ];
|
||||
}
|
||||
|
||||
if ( 'add_version' === $action ) {
|
||||
@@ -66,6 +81,40 @@ class PolicyController {
|
||||
$this->service->addDraftVersion( $policyId, $body );
|
||||
}
|
||||
|
||||
if ( 'edit_version' === $action ) {
|
||||
$source = $this->loadVersionForPolicy( $policyId, absint( Val::int( $_POST['version_id'] ?? 0 ) ) );
|
||||
if ( null === $source ) {
|
||||
return [ '', 0 ];
|
||||
}
|
||||
|
||||
$body = wp_kses_post( Val::string( wp_unslash( $_POST['body'] ?? '' ) ) );
|
||||
|
||||
// A draft has never been shown to a student, so it is edited in place.
|
||||
// A published (or archived) version is what students accepted, so an
|
||||
// edit branches a new draft and leaves the original untouched.
|
||||
if ( PolicyVersion::STATUS_DRAFT === $source->status ) {
|
||||
$this->versions->updateBody( (int) $source->id, $body );
|
||||
|
||||
return [
|
||||
sprintf(
|
||||
/* translators: %d: the edited version number. */
|
||||
__( 'Draft version %d was updated.', 'unsupervised-schedular' ),
|
||||
$source->versionNumber
|
||||
),
|
||||
(int) $source->id,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
sprintf(
|
||||
/* translators: %d: the version number the edit was based on. */
|
||||
__( 'Your changes to version %d were saved as a new draft version.', 'unsupervised-schedular' ),
|
||||
$source->versionNumber
|
||||
),
|
||||
$this->service->addDraftVersion( $policyId, $body ),
|
||||
];
|
||||
}
|
||||
|
||||
if ( 'publish_version' === $action ) {
|
||||
$versionId = absint( Val::int( $_POST['version_id'] ?? 0 ) );
|
||||
if ( $versionId > 0 ) {
|
||||
@@ -73,5 +122,20 @@ class PolicyController {
|
||||
}
|
||||
}
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
||||
|
||||
return [ '', 0 ];
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a version by id, confirming it belongs to the given policy.
|
||||
*/
|
||||
private function loadVersionForPolicy( int $policyId, int $versionId ): ?PolicyVersion {
|
||||
if ( $versionId <= 0 ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$version = $this->versions->findById( $versionId );
|
||||
|
||||
return null !== $version && $version->policyId === $policyId ? $version : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,9 +104,9 @@ class PolicyEndpoint {
|
||||
'policy_version_id' => $version->id,
|
||||
'version_number' => $version->versionNumber,
|
||||
// Bodies are kses'd on every write path, but the booking JS renders
|
||||
// this HTML raw — sanitise at output too so a missed write path can
|
||||
// never become stored XSS.
|
||||
'body' => wp_kses_post( (string) $version->body ),
|
||||
// this HTML raw — bodyHtml() sanitises at output too, so a missed
|
||||
// write path can never become stored XSS.
|
||||
'body' => $version->bodyHtml(),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,19 @@ class PolicyVersion {
|
||||
return self::STATUS_PUBLISHED === $this->status;
|
||||
}
|
||||
|
||||
/**
|
||||
* The body as display-ready HTML.
|
||||
*
|
||||
* Policy bodies are typed into a plain textarea, so most are written as
|
||||
* blank-line-separated prose with no markup at all — dropped into a page
|
||||
* as-is that collapses into one unreadable run of text. Running the same
|
||||
* `wpautop()` WordPress applies to post content turns those breaks into
|
||||
* paragraphs, and leaves bodies that do carry markup alone.
|
||||
*/
|
||||
public function bodyHtml(): string {
|
||||
return wpautop( wp_kses_post( (string) $this->body ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a plain array representation of the version.
|
||||
*
|
||||
|
||||
@@ -12,11 +12,17 @@ if (! defined('ABSPATH')) {
|
||||
* @var list<\Unsupervised\Schedular\Policy\Policy> $policyList
|
||||
* @var \Unsupervised\Schedular\Policy\Policy|null $selectedPolicy
|
||||
* @var list<\Unsupervised\Schedular\Policy\PolicyVersion>|null $policyVersions
|
||||
* @var \Unsupervised\Schedular\Policy\PolicyVersion|null $viewedVersion Version opened in the viewer, if any.
|
||||
* @var string $notice Status message from the last save.
|
||||
*/
|
||||
?>
|
||||
<div class="wrap">
|
||||
<h1><?php esc_html_e('Policies', 'unsupervised-schedular'); ?></h1>
|
||||
|
||||
<?php if ('' !== $notice) : ?>
|
||||
<div class="notice notice-success is-dismissible"><p><?php echo esc_html($notice); ?></p></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<h2><?php esc_html_e('Add Policy', 'unsupervised-schedular'); ?></h2>
|
||||
<form method="post">
|
||||
<?php wp_nonce_field('usc_policy_action'); ?>
|
||||
@@ -98,6 +104,18 @@ if (! defined('ABSPATH')) {
|
||||
<td><?php echo esc_html($version->status); ?></td>
|
||||
<td><?php echo $version->publishedAt ? esc_html($version->publishedAt) : '—'; ?></td>
|
||||
<td>
|
||||
<a href="<?php echo esc_url(
|
||||
add_query_arg(
|
||||
[
|
||||
'page' => 'us-policies',
|
||||
'policy_id' => (string) $selectedPolicy->id,
|
||||
'version_id' => (string) $version->id,
|
||||
],
|
||||
admin_url('admin.php')
|
||||
)
|
||||
); ?>" class="button button-small">
|
||||
<?php esc_html_e('View', 'unsupervised-schedular'); ?>
|
||||
</a>
|
||||
<?php if (PolicyVersion::STATUS_PUBLISHED !== $version->status) : ?>
|
||||
<form method="post" style="display:inline;">
|
||||
<?php wp_nonce_field('usc_policy_action'); ?>
|
||||
@@ -117,5 +135,48 @@ if (! defined('ABSPATH')) {
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (null !== $viewedVersion) : ?>
|
||||
<hr>
|
||||
<h3>
|
||||
<?php echo esc_html(sprintf(
|
||||
/* translators: 1: version number, 2: version status. */
|
||||
__('Version %1$d (%2$s)', 'unsupervised-schedular'),
|
||||
$viewedVersion->versionNumber,
|
||||
$viewedVersion->status
|
||||
)); ?>
|
||||
</h3>
|
||||
|
||||
<?php // Rendered exactly as the booking and signup gates render it, so this doubles as a preview. ?>
|
||||
<div class="us-policy-version-body" style="background:#fff;border:1px solid #c3c4c7;padding:0 1em;max-width:50em;overflow-wrap:break-word;">
|
||||
<?php echo wp_kses_post($viewedVersion->bodyHtml()); ?>
|
||||
</div>
|
||||
|
||||
<?php $editingDraft = PolicyVersion::STATUS_DRAFT === $viewedVersion->status; ?>
|
||||
<h4><?php esc_html_e('Edit', 'unsupervised-schedular'); ?></h4>
|
||||
<p class="description">
|
||||
<?php
|
||||
echo esc_html(
|
||||
$editingDraft
|
||||
? __('This version is still a draft, so your changes are saved to it directly.', 'unsupervised-schedular')
|
||||
: __('This version has been published, so saving an edit creates a new draft version from this text and leaves the published one untouched.', 'unsupervised-schedular')
|
||||
);
|
||||
?>
|
||||
</p>
|
||||
<form method="post">
|
||||
<?php wp_nonce_field('usc_policy_action'); ?>
|
||||
<input type="hidden" name="usc_action" value="edit_version">
|
||||
<input type="hidden" name="policy_id" value="<?php echo esc_attr((string) $selectedPolicy->id); ?>">
|
||||
<input type="hidden" name="version_id" value="<?php echo esc_attr((string) $viewedVersion->id); ?>">
|
||||
<textarea name="body" rows="12" class="large-text"><?php echo esc_textarea((string) $viewedVersion->body); ?></textarea>
|
||||
<?php
|
||||
submit_button(
|
||||
$editingDraft
|
||||
? esc_html__('Save Draft', 'unsupervised-schedular')
|
||||
: esc_html__('Save as New Draft', 'unsupervised-schedular')
|
||||
);
|
||||
?>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
@@ -108,7 +108,7 @@ $renderQuestionField = static function (Question $question): void {
|
||||
<?php foreach ($policyForms as $form) : ?>
|
||||
<div class="us-policy">
|
||||
<h4><?php echo esc_html($form['policy']->title); ?></h4>
|
||||
<div class="us-policy-body"><?php echo wp_kses_post((string) $form['version']->body); ?></div>
|
||||
<div class="us-policy-body"><?php echo wp_kses_post($form['version']->bodyHtml()); ?></div>
|
||||
<label>
|
||||
<input type="checkbox" name="accept[]" value="<?php echo esc_attr((string) $form['version']->id); ?>" required>
|
||||
<?php
|
||||
|
||||
@@ -37,6 +37,8 @@ class RegistrationPageTest extends TestCase
|
||||
Functions\when('sanitize_email')->alias(static fn ($v) => $v);
|
||||
Functions\when('absint')->alias(static fn ($v) => (int) $v);
|
||||
Functions\when('current_time')->justReturn('2024-01-01 00:00:00');
|
||||
Functions\when('wp_enqueue_style')->justReturn(null);
|
||||
Functions\when('wp_enqueue_script')->justReturn(null);
|
||||
|
||||
$invites = Mockery::mock(InviteRepository::class);
|
||||
$policies = Mockery::mock(PolicyRepository::class);
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Tests\Unit\Policy;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use Mockery;
|
||||
use Unsupervised\Schedular\Policy\Policy;
|
||||
use Unsupervised\Schedular\Policy\PolicyController;
|
||||
use Unsupervised\Schedular\Policy\PolicyRepository;
|
||||
use Unsupervised\Schedular\Policy\PolicyService;
|
||||
use Unsupervised\Schedular\Policy\PolicyVersion;
|
||||
use Unsupervised\Schedular\Policy\PolicyVersionRepository;
|
||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||
|
||||
class PolicyControllerTest extends TestCase
|
||||
{
|
||||
private PolicyRepository&Mockery\MockInterface $policies;
|
||||
private PolicyVersionRepository&Mockery\MockInterface $versions;
|
||||
private PolicyService&Mockery\MockInterface $service;
|
||||
private PolicyController $controller;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->policies = Mockery::mock(PolicyRepository::class);
|
||||
$this->versions = Mockery::mock(PolicyVersionRepository::class);
|
||||
$this->service = Mockery::mock(PolicyService::class);
|
||||
$this->controller = new PolicyController($this->policies, $this->versions, $this->service);
|
||||
|
||||
$_POST = [];
|
||||
$_GET = [];
|
||||
|
||||
Functions\when('current_user_can')->justReturn(true);
|
||||
Functions\when('check_admin_referer')->justReturn(true);
|
||||
Functions\when('wp_unslash')->returnArg();
|
||||
Functions\when('sanitize_text_field')->returnArg();
|
||||
Functions\when('sanitize_title')->returnArg();
|
||||
Functions\when('wp_kses_post')->returnArg();
|
||||
$this->stubAutop();
|
||||
Functions\when('sanitize_key')->alias(
|
||||
static fn ($key) => strtolower((string) preg_replace('/[^a-zA-Z0-9_\-]/', '', (string) $key))
|
||||
);
|
||||
Functions\when('absint')->alias(static fn ($value) => abs((int) $value));
|
||||
Functions\when('selected')->justReturn('');
|
||||
Functions\when('wp_nonce_field')->justReturn('');
|
||||
Functions\when('admin_url')->justReturn('admin.php');
|
||||
Functions\when('add_query_arg')->alias(
|
||||
static fn (array $args, string $url) => $url . '?' . http_build_query($args)
|
||||
);
|
||||
Functions\when('submit_button')->alias(static function (string $text = ''): void {
|
||||
echo $text; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- test stub
|
||||
});
|
||||
}
|
||||
|
||||
public function testViewingAVersionRendersItsBodyAndAnEditForm(): void
|
||||
{
|
||||
$policy = new Policy('Cancellation', 'cancellation', currentVersionId: 7, id: 4);
|
||||
$version = new PolicyVersion(
|
||||
policyId: 4,
|
||||
versionNumber: 2,
|
||||
body: '<p>Cancel 24 hours ahead.</p>',
|
||||
status: PolicyVersion::STATUS_PUBLISHED,
|
||||
publishedAt: '2026-07-01 09:00:00',
|
||||
id: 7,
|
||||
);
|
||||
|
||||
$_GET = [
|
||||
'policy_id' => '4',
|
||||
'version_id' => '7',
|
||||
];
|
||||
|
||||
$this->policies->shouldReceive('findAll')->andReturn([$policy]);
|
||||
$this->policies->shouldReceive('findById')->with(4)->andReturn($policy);
|
||||
$this->versions->shouldReceive('findByPolicy')->with(4)->andReturn([$version]);
|
||||
$this->versions->shouldReceive('findById')->with(7)->andReturn($version);
|
||||
|
||||
$html = $this->render();
|
||||
|
||||
self::assertStringContainsString('Cancel 24 hours ahead.', $html);
|
||||
self::assertStringContainsString('value="edit_version"', $html);
|
||||
self::assertStringContainsString('Save as New Draft', $html);
|
||||
}
|
||||
|
||||
public function testVersionListLinksToTheViewer(): void
|
||||
{
|
||||
$policy = new Policy('Cancellation', 'cancellation', currentVersionId: 7, id: 4);
|
||||
$version = new PolicyVersion(policyId: 4, versionNumber: 2, body: 'text', id: 7);
|
||||
|
||||
$_GET = ['policy_id' => '4'];
|
||||
|
||||
$this->policies->shouldReceive('findAll')->andReturn([$policy]);
|
||||
$this->policies->shouldReceive('findById')->with(4)->andReturn($policy);
|
||||
$this->versions->shouldReceive('findByPolicy')->with(4)->andReturn([$version]);
|
||||
|
||||
$html = $this->render();
|
||||
|
||||
self::assertStringContainsString('page=us-policies&policy_id=4&version_id=7', $html);
|
||||
}
|
||||
|
||||
public function testAVersionBelongingToAnotherPolicyIsNotShown(): void
|
||||
{
|
||||
$policy = new Policy('Cancellation', 'cancellation', id: 4);
|
||||
$other = new PolicyVersion(policyId: 9, versionNumber: 1, body: 'Someone else\'s policy', id: 7);
|
||||
|
||||
$_GET = [
|
||||
'policy_id' => '4',
|
||||
'version_id' => '7',
|
||||
];
|
||||
|
||||
$this->policies->shouldReceive('findAll')->andReturn([$policy]);
|
||||
$this->policies->shouldReceive('findById')->with(4)->andReturn($policy);
|
||||
$this->versions->shouldReceive('findByPolicy')->with(4)->andReturn([]);
|
||||
$this->versions->shouldReceive('findById')->with(7)->andReturn($other);
|
||||
|
||||
$html = $this->render();
|
||||
|
||||
self::assertStringNotContainsString('Someone else\'s policy', $html);
|
||||
self::assertStringNotContainsString('value="edit_version"', $html);
|
||||
}
|
||||
|
||||
public function testEditingAPublishedVersionCreatesANewDraftAndLeavesTheSourceUntouched(): void
|
||||
{
|
||||
$policy = new Policy('Cancellation', 'cancellation', currentVersionId: 7, id: 4);
|
||||
$published = new PolicyVersion(
|
||||
policyId: 4,
|
||||
versionNumber: 2,
|
||||
body: '<p>Old text.</p>',
|
||||
status: PolicyVersion::STATUS_PUBLISHED,
|
||||
id: 7,
|
||||
);
|
||||
$draft = new PolicyVersion(policyId: 4, versionNumber: 3, body: '<p>New text.</p>', id: 11);
|
||||
|
||||
$_GET = ['policy_id' => '4'];
|
||||
$_POST = [
|
||||
'usc_action' => 'edit_version',
|
||||
'policy_id' => '4',
|
||||
'version_id' => '7',
|
||||
'body' => '<p>New text.</p>',
|
||||
];
|
||||
|
||||
$this->policies->shouldReceive('findAll')->andReturn([$policy]);
|
||||
$this->policies->shouldReceive('findById')->with(4)->andReturn($policy);
|
||||
$this->versions->shouldReceive('findByPolicy')->with(4)->andReturn([$draft, $published]);
|
||||
$this->versions->shouldReceive('findById')->with(7)->andReturn($published);
|
||||
$this->versions->shouldReceive('findById')->with(11)->andReturn($draft);
|
||||
|
||||
// A new draft is written; the edited version itself is never updated.
|
||||
$this->service->shouldReceive('addDraftVersion')->once()->with(4, '<p>New text.</p>')->andReturn(11);
|
||||
$this->versions->shouldNotReceive('updateBody');
|
||||
|
||||
$html = $this->render();
|
||||
|
||||
// The viewer follows through to the draft that was just created.
|
||||
self::assertStringContainsString('New text.', $html);
|
||||
self::assertStringContainsString('saved as a new draft version', $html);
|
||||
}
|
||||
|
||||
public function testEditingADraftVersionUpdatesItInPlace(): void
|
||||
{
|
||||
$policy = new Policy('Cancellation', 'cancellation', id: 4);
|
||||
$draft = new PolicyVersion(
|
||||
policyId: 4,
|
||||
versionNumber: 3,
|
||||
body: '<p>Old draft text.</p>',
|
||||
status: PolicyVersion::STATUS_DRAFT,
|
||||
id: 11,
|
||||
);
|
||||
|
||||
$_GET = ['policy_id' => '4'];
|
||||
$_POST = [
|
||||
'usc_action' => 'edit_version',
|
||||
'policy_id' => '4',
|
||||
'version_id' => '11',
|
||||
'body' => '<p>Revised draft text.</p>',
|
||||
];
|
||||
|
||||
$this->policies->shouldReceive('findAll')->andReturn([$policy]);
|
||||
$this->policies->shouldReceive('findById')->with(4)->andReturn($policy);
|
||||
$this->versions->shouldReceive('findByPolicy')->with(4)->andReturn([$draft]);
|
||||
$this->versions->shouldReceive('findById')->with(11)->andReturn($draft);
|
||||
|
||||
// An unpublished draft is rewritten rather than branched.
|
||||
$this->versions->shouldReceive('updateBody')->once()->with(11, '<p>Revised draft text.</p>')->andReturn(true);
|
||||
$this->service->shouldNotReceive('addDraftVersion');
|
||||
|
||||
$html = $this->render();
|
||||
|
||||
self::assertStringContainsString('Draft version 3 was updated', $html);
|
||||
}
|
||||
|
||||
public function testEditingAnArchivedVersionCreatesANewDraft(): void
|
||||
{
|
||||
$policy = new Policy('Cancellation', 'cancellation', currentVersionId: 9, id: 4);
|
||||
$archived = new PolicyVersion(
|
||||
policyId: 4,
|
||||
versionNumber: 1,
|
||||
body: '<p>Superseded text.</p>',
|
||||
status: PolicyVersion::STATUS_ARCHIVED,
|
||||
id: 7,
|
||||
);
|
||||
$draft = new PolicyVersion(policyId: 4, versionNumber: 4, body: '<p>Reinstated text.</p>', id: 12);
|
||||
|
||||
$_GET = ['policy_id' => '4'];
|
||||
$_POST = [
|
||||
'usc_action' => 'edit_version',
|
||||
'policy_id' => '4',
|
||||
'version_id' => '7',
|
||||
'body' => '<p>Reinstated text.</p>',
|
||||
];
|
||||
|
||||
$this->policies->shouldReceive('findAll')->andReturn([$policy]);
|
||||
$this->policies->shouldReceive('findById')->with(4)->andReturn($policy);
|
||||
$this->versions->shouldReceive('findByPolicy')->with(4)->andReturn([$draft, $archived]);
|
||||
$this->versions->shouldReceive('findById')->with(7)->andReturn($archived);
|
||||
$this->versions->shouldReceive('findById')->with(12)->andReturn($draft);
|
||||
|
||||
$this->service->shouldReceive('addDraftVersion')->once()->with(4, '<p>Reinstated text.</p>')->andReturn(12);
|
||||
$this->versions->shouldNotReceive('updateBody');
|
||||
|
||||
$this->render();
|
||||
}
|
||||
|
||||
public function testEditingAVersionOfAnotherPolicyIsRejected(): void
|
||||
{
|
||||
$policy = new Policy('Cancellation', 'cancellation', id: 4);
|
||||
$other = new PolicyVersion(policyId: 9, versionNumber: 1, body: 'Other', id: 7);
|
||||
|
||||
$_GET = ['policy_id' => '4'];
|
||||
$_POST = [
|
||||
'usc_action' => 'edit_version',
|
||||
'policy_id' => '4',
|
||||
'version_id' => '7',
|
||||
'body' => 'Injected text',
|
||||
];
|
||||
|
||||
$this->policies->shouldReceive('findAll')->andReturn([$policy]);
|
||||
$this->policies->shouldReceive('findById')->with(4)->andReturn($policy);
|
||||
$this->versions->shouldReceive('findByPolicy')->with(4)->andReturn([]);
|
||||
$this->versions->shouldReceive('findById')->with(7)->andReturn($other);
|
||||
|
||||
$this->service->shouldNotReceive('addDraftVersion');
|
||||
$this->versions->shouldNotReceive('updateBody');
|
||||
|
||||
$this->render();
|
||||
}
|
||||
|
||||
private function render(): string
|
||||
{
|
||||
ob_start();
|
||||
$this->controller->renderPage();
|
||||
|
||||
return (string) ob_get_clean();
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,14 @@ use Unsupervised\Schedular\Policy\Policy;
|
||||
use Unsupervised\Schedular\Policy\PolicyEndpoint;
|
||||
use Unsupervised\Schedular\Policy\PolicyRepository;
|
||||
use Unsupervised\Schedular\Policy\PolicyService;
|
||||
use Unsupervised\Schedular\Policy\PolicyVersion;
|
||||
use Unsupervised\Schedular\Policy\PolicyVersionRepository;
|
||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||
|
||||
class PolicyEndpointTest extends TestCase
|
||||
{
|
||||
private PolicyRepository&Mockery\MockInterface $policies;
|
||||
private PolicyVersionRepository&Mockery\MockInterface $versions;
|
||||
private PolicyService&Mockery\MockInterface $service;
|
||||
private PolicyEndpoint $endpoint;
|
||||
|
||||
@@ -24,16 +26,43 @@ class PolicyEndpointTest extends TestCase
|
||||
|
||||
Functions\when('sanitize_text_field')->returnArg();
|
||||
Functions\when('sanitize_title')->returnArg();
|
||||
Functions\when('wp_kses_post')->returnArg();
|
||||
$this->stubAutop();
|
||||
|
||||
$this->policies = Mockery::mock(PolicyRepository::class);
|
||||
$this->versions = Mockery::mock(PolicyVersionRepository::class);
|
||||
$this->service = Mockery::mock(PolicyService::class);
|
||||
$this->endpoint = new PolicyEndpoint(
|
||||
$this->policies,
|
||||
Mockery::mock(PolicyVersionRepository::class),
|
||||
$this->versions,
|
||||
$this->service,
|
||||
);
|
||||
}
|
||||
|
||||
public function testIndexReturnsPlainTextBodiesAsParagraphs(): void
|
||||
{
|
||||
$policy = new Policy('Cancellation', 'cancellation', currentVersionId: 7, id: 4);
|
||||
$version = new PolicyVersion(
|
||||
policyId: 4,
|
||||
versionNumber: 2,
|
||||
body: "Give 24 hours notice.\n\nLate cancellations are billed in full.",
|
||||
status: PolicyVersion::STATUS_PUBLISHED,
|
||||
id: 7,
|
||||
);
|
||||
|
||||
$this->policies->shouldReceive('findAll')->andReturn([$policy]);
|
||||
$this->versions->shouldReceive('findById')->with(7)->andReturn($version);
|
||||
|
||||
$body = $this->endpoint->index(new \WP_REST_Request([]))->get_data();
|
||||
|
||||
// The admin typed no markup, so the gate still receives real paragraphs
|
||||
// rather than one unbroken run of text.
|
||||
self::assertSame(
|
||||
'<p>Give 24 hours notice.</p><p>Late cancellations are billed in full.</p>',
|
||||
$body[0]['body']
|
||||
);
|
||||
}
|
||||
|
||||
public function testCreateRejectsTitleLongerThanColumnLimit(): void
|
||||
{
|
||||
$this->service->shouldNotReceive('createPolicy');
|
||||
|
||||
@@ -27,6 +27,16 @@ class PolicyValueObjectsTest extends TestCase
|
||||
self::assertArrayHasKey('acceptance_scope', $policy->toArray());
|
||||
}
|
||||
|
||||
public function testAnEmptyBodyRendersAsNothingRatherThanAnEmptyParagraph(): void
|
||||
{
|
||||
\Brain\Monkey\Functions\when('wp_kses_post')->returnArg();
|
||||
$this->stubAutop();
|
||||
|
||||
$version = new PolicyVersion(policyId: 4, versionNumber: 1, body: null);
|
||||
|
||||
self::assertSame('', $version->bodyHtml());
|
||||
}
|
||||
|
||||
public function testPolicyHandlesNullCurrentVersion(): void
|
||||
{
|
||||
$policy = Policy::fromRow((object) [
|
||||
|
||||
@@ -24,4 +24,17 @@ abstract class TestCase extends BaseTestCase
|
||||
Monkey\tearDown();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub `wpautop()` with a minimal blank-line-to-paragraph transform — enough
|
||||
* to assert that unmarked-up text reaches the page as real paragraphs.
|
||||
*/
|
||||
protected function stubAutop(): void
|
||||
{
|
||||
Monkey\Functions\when('wpautop')->alias(static function (string $text): string {
|
||||
$text = trim($text);
|
||||
|
||||
return '' === $text ? '' : '<p>' . implode('</p><p>', (array) preg_split('/\n\s*\n/', $text)) . '</p>';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user