View a policy version's content, and make policy text readable
CI / Tests (PHP 8.1) (pull_request) Successful in 50s
CI / Tests (PHP 8.2) (pull_request) Successful in 1m12s
CI / No Debug Code (pull_request) Successful in 3s
CI / Coding Standards (pull_request) Successful in 2m50s
CI / PHPStan (pull_request) Successful in 3m4s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m42s
CI / Build Plugin Zip (pull_request) Skipped

The Policies admin page listed versions but never showed what any of them
said, so revising a policy meant retyping it blind into an empty draft box.
Each version row now has a View action that renders that version's text on
the page, editable in place. A draft is saved back to itself; editing a
published or archived version branches a new draft and leaves the original
alone, because acceptances are recorded against policy_version_id and text a
student agreed to must stay exactly as they saw it.

That viewer also exposed why a studio reported the acceptance box as
unreadable — one squashed line, overlapping words, a horizontal scrollbar.
Bodies are typed into a bare textarea, so most carry no markup, and the raw
text was emitted with its blank lines intact but nothing to turn them into
paragraphs. PolicyVersion::bodyHtml() now renders every body the way
WordPress renders post content (kses, then wpautop) and feeds all three
consumers: the booking/enrolment JSON, the signup form, and the new viewer.
Bodies written with markup are unaffected.

The other half was that .us-policy-body had no CSS whatsoever and inherited
whatever the theme did with an unstyled block in a form. It is now a bounded
reading box that scrolls vertically and breaks long tokens, so a pasted URL
cannot force the page sideways and a long policy cannot push the accept
checkbox out of view. RegistrationPage was also never enqueueing the plugin
stylesheet, which is why the signup gate looked worst of all.

Closes #126
Closes #127

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-07-28 17:05:00 -03:00
co-authored by Claude Opus 5
parent 907f665876
commit 3a954bac57
14 changed files with 533 additions and 12 deletions
+256
View File
@@ -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();
}
}
+30 -1
View File
@@ -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) [