Compare commits

...
23 Commits
Author SHA1 Message Date
thatguygriff 61a40f0e8d Merge pull request 'Update stripe-php from 17.6.0 to 21.2.1' (#195) from feature/194-stripe-php-21 into main
CI / Coding Standards (push) Successful in 17s
CI / No Debug Code (push) Successful in 1s
CI / Tests (PHP 8.1) (push) Successful in 32s
CI / Tests (PHP 8.5) (push) Successful in 29s
CI / Tests (PHP 8.3) (push) Successful in 42s
CI / Static Analysis (push) Successful in 52s
CI / Tests (PHP 8.2) (push) Successful in 52s
CI / Build Plugin Zip (push) Successful in 23s
Release / Build and Publish Release (push) Successful in 20s
Release / Open next-version bump PR (push) Successful in 4s
Reviewed-on: #195
2026-08-25 02:18:36 +00:00
thatguygriffandClaude Opus 5 88a8d0ae5c Date the Stripe changelog entry by version rather than by "current"
CI / Coding Standards (pull_request) Successful in 26s
CI / Tests (PHP 8.1) (pull_request) Successful in 28s
CI / No Debug Code (pull_request) Successful in 7s
CI / Tests (PHP 8.2) (pull_request) Successful in 36s
CI / Tests (PHP 8.5) (pull_request) Successful in 34s
CI / Static Analysis (pull_request) Successful in 37s
CI / Tests (PHP 8.3) (pull_request) Successful in 36s
CI / Build Plugin Zip (pull_request) Skipped
"the current Stripe library" is only true until Stripe ships the next major.
A changelog is read long after the release it describes, so name the
versions — 17 to 21, API version 2026-07-29.dahlia — and let those date the
entry.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01D9acV1mHktGAb1uyvNmrR2
2026-08-24 23:17:33 -03:00
thatguygriffandClaude Opus 5 e2da45a1b0 Update stripe-php from 17.6.0 to 21.2.1
CI / Coding Standards (pull_request) Successful in 27s
CI / No Debug Code (pull_request) Successful in 6s
CI / Tests (PHP 8.1) (pull_request) Successful in 35s
CI / Tests (PHP 8.2) (pull_request) Successful in 34s
CI / Tests (PHP 8.5) (pull_request) Successful in 24s
CI / Tests (PHP 8.3) (pull_request) Successful in 37s
CI / Static Analysis (pull_request) Successful in 45s
CI / Build Plugin Zip (pull_request) Skipped
Four majors behind on the plugin's only production dependency. No site is
using Stripe yet and the integration has not been verified end to end, so
the pinned API version can move now at close to zero cost — later it would
mean coordinating with live payment traffic.

The library surface this plugin uses is four symbols, all in StripeGateway:
StripeClient, paymentIntents->create, Webhook::constructEvent and Event.
Checked each against the installed v21 by reflection; all present with
compatible signatures. Most of the breakage flagged across 18 to 21 is in
the V2 Events API, which this does not use — webhooks go through the V1
Webhook::constructEvent path.

The pinned Stripe API version moves to 2026-07-29.dahlia.

Verified beyond the suite, which mocks the client: a real HMAC-signed
payload round-trips through Webhook::constructEvent and returns a
Stripe\Event with the expected type and data, and a forged signature still
raises SignatureVerificationException. The 21.0.0 ErrorObject retyping to
null|string produces no new PHPStan findings.

Part of #194

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01D9acV1mHktGAb1uyvNmrR2
2026-08-24 23:15:01 -03:00
thatguygriff dfa29745df Merge pull request 'Update the dev dependencies' (#193) from chore/update-dev-dependencies into main
CI / Coding Standards (push) Successful in 13s
CI / No Debug Code (push) Successful in 3s
CI / Tests (PHP 8.1) (push) Successful in 30s
CI / Tests (PHP 8.5) (push) Successful in 30s
CI / Tests (PHP 8.2) (push) Successful in 46s
CI / Tests (PHP 8.3) (push) Successful in 45s
CI / Static Analysis (push) Successful in 54s
CI / Build Plugin Zip (push) Successful in 9s
Reviewed-on: #193
2026-08-25 02:10:14 +00:00
thatguygriffandClaude Opus 5 54a8b906f7 Update the dev dependencies
CI / Coding Standards (pull_request) Successful in 20s
CI / No Debug Code (pull_request) Successful in 5s
CI / Static Analysis (pull_request) Successful in 31s
CI / Tests (PHP 8.2) (pull_request) Successful in 31s
CI / Tests (PHP 8.1) (pull_request) Successful in 32s
CI / Tests (PHP 8.3) (pull_request) Successful in 32s
CI / Tests (PHP 8.5) (pull_request) Successful in 35s
CI / Build Plugin Zip (pull_request) Skipped
Routine catch-up on the test and analysis tooling. Every package here is
already allowed by the existing constraints, so composer.json does not move
and no production dependency is touched.

  mockery/mockery              1.6.12  -> 1.6.15
  phpstan/phpstan               2.2.2  -> 2.2.9
  phpunit/phpunit             10.5.63  -> 10.5.64
  hamcrest/hamcrest-php        v2.1.1  -> v3.0.0   (transitive, via mockery)
  myclabs/deep-copy            1.13.4  -> 1.14.0
  nikic/php-parser             v5.7.0  -> v5.8.0
  sebastian/recursion-context   5.0.1  -> 5.0.2

Seven patch releases of PHPStan is the one worth watching, since a static
analyser routinely gains inferences between them. It reports no new errors
here, and PHPCS and the suite are unchanged.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01D9acV1mHktGAb1uyvNmrR2
2026-08-24 23:08:54 -03:00
thatguygriff b3ed3a67d5 Merge pull request 'Pin the Composer platform to PHP 8.1 and clear the PHPCS security advisories' (#192) from fix/187-composer-platform-pin into main
CI / Coding Standards (push) Successful in 17s
CI / No Debug Code (push) Successful in 2s
CI / Tests (PHP 8.2) (push) Successful in 32s
CI / Tests (PHP 8.1) (push) Successful in 48s
CI / Static Analysis (push) Successful in 38s
CI / Tests (PHP 8.3) (push) Successful in 47s
CI / Tests (PHP 8.5) (push) Successful in 47s
CI / Build Plugin Zip (push) Successful in 21s
Reviewed-on: #192
2026-08-25 02:08:07 +00:00
thatguygriffandClaude Opus 5 908b7fcd1f Update the coding-standards tooling off three security advisories
CI / Coding Standards (pull_request) Successful in 16s
CI / No Debug Code (pull_request) Successful in 4s
CI / Tests (PHP 8.3) (pull_request) Successful in 21s
CI / Tests (PHP 8.5) (pull_request) Successful in 22s
CI / Tests (PHP 8.2) (pull_request) Successful in 48s
CI / Tests (PHP 8.1) (pull_request) Successful in 49s
CI / Static Analysis (pull_request) Successful in 57s
CI / Build Plugin Zip (pull_request) Skipped
composer audit reported three advisories against the PHPCS stack, two rated
high:

  squizlabs/php_codesniffer  3.13.5 -> 3.13.6  CVE-2026-67434, OS command injection
  wp-coding-standards/wpcs   3.3.0  -> 3.4.1   CVE-2026-45293, arbitrary code execution
  phpcsstandards/phpcsutils  1.2.2  -> 1.2.3   CVE-2026-65954, arbitrary code execution

All three are dev-only and none ship in the plugin — bin/build-zip.sh installs
--no-dev — but they execute against repository content on every CI run.

The existing ^3.7 and ^3.0 constraints already allowed the fixed releases, so
composer.json is unchanged. phpcsextra and the codesniffer installer come
along as transitive dependencies. Five packages move, none are added or
removed, and the updated standards report no new violations.

composer audit is now clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01D9acV1mHktGAb1uyvNmrR2
2026-08-24 22:50:34 -03:00
thatguygriffandClaude Opus 5 b17adf02ff Pin the Composer platform to PHP 8.1
CI / Tests (PHP 8.2) (pull_request) Successful in 26s
CI / No Debug Code (pull_request) Successful in 3s
CI / Tests (PHP 8.3) (pull_request) Successful in 29s
CI / Tests (PHP 8.5) (pull_request) Successful in 30s
CI / Coding Standards (pull_request) Successful in 36s
CI / Tests (PHP 8.1) (pull_request) Successful in 46s
CI / Static Analysis (pull_request) Successful in 49s
CI / Build Plugin Zip (pull_request) Skipped
composer.json declares "php": ">=8.1", but resolution ran against whatever
PHP the machine doing the update happened to have. Running composer update
on 8.5 could therefore produce a lock containing a package that requires 8.2
or newer, which installs fine locally and fails on the 8.1 leg of the test
matrix — a lock that is only discovered to be wrong in CI.

config.platform.php makes resolution always target the declared minimum, so
a lock that resolves at all is a lock that installs everywhere the plugin
claims to run.

No package versions change: nothing currently locked requires a PHP newer
than 8.1, so this only records platform-overrides and the new content hash.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01D9acV1mHktGAb1uyvNmrR2
2026-08-24 22:46:11 -03:00
thatguygriff a23490ec80 Merge pull request 'CI: run jobs in shared ci-php images, commit composer.lock' (#191) from feature/187-run-jobs-in-ci-images into main
CI / Coding Standards (push) Successful in 16s
CI / No Debug Code (push) Successful in 5s
CI / Tests (PHP 8.3) (push) Successful in 25s
CI / Tests (PHP 8.5) (push) Successful in 31s
CI / Static Analysis (push) Successful in 37s
CI / Tests (PHP 8.2) (push) Successful in 35s
CI / Tests (PHP 8.1) (push) Successful in 39s
CI / Build Plugin Zip (push) Successful in 9s
Reviewed-on: #191
2026-08-25 01:41:19 +00:00
thatguygriffandClaude Opus 5 1552bf4b5f Move the CI image definitions to the shared ci-php repository
CI / Coding Standards (pull_request) Successful in 14s
CI / Tests (PHP 8.1) (pull_request) Successful in 30s
CI / Tests (PHP 8.2) (pull_request) Successful in 30s
CI / Tests (PHP 8.3) (pull_request) Successful in 29s
CI / No Debug Code (pull_request) Successful in 3s
CI / Tests (PHP 8.5) (pull_request) Successful in 21s
CI / Static Analysis (pull_request) Successful in 37s
CI / Build Plugin Zip (pull_request) Skipped
The image definition and its publishing workflow were only here because this
was the first project to need them. They are project-agnostic, so they now
live in Unsupervised/ci-php and any PHP project can consume the same tags.
The registry path is unchanged — Gitea container images are named
owner/image, not after the repository that pushes them — so nothing here
needs repointing.

Split the combined quality job into separate phpcs and phpstan jobs. They
were folded together to halve the number of toolchain installs per run; with
the toolchain in the image that saves nothing, and separating them means a
coding-standards failure no longer hides the static analysis result.

Rewrite the workflow comments to describe what the jobs do rather than the
setup step they replaced, and cut docs/ci.md down to what is specific to this
project — the image contents and publishing are documented in ci-php.

Closes #187

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01D9acV1mHktGAb1uyvNmrR2
2026-08-24 22:39:08 -03:00
thatguygriffandClaude Opus 5 f8762e1095 Correct the registry auth docs: the Actions token does not work
CI Images / Build CI image (PHP 8.2) (pull_request) Successful in 3s
CI Images / Build CI image (PHP 8.5) (pull_request) Successful in 8s
CI / Tests (PHP 8.3) (pull_request) Successful in 30s
CI Images / Build CI image (PHP 8.3) (pull_request) Successful in 4s
CI / Tests (PHP 8.2) (pull_request) Successful in 23s
CI / Tests (PHP 8.1) (pull_request) Successful in 28s
CI / No Debug Code (pull_request) Successful in 4s
CI / Coding Standards & Static Analysis (pull_request) Successful in 43s
CI / Tests (PHP 8.5) (pull_request) Successful in 23s
CI / Build Plugin Zip (pull_request) Skipped
CI Images / Build CI image (PHP 8.1) (pull_request) Successful in 1m3s
docs/ci.md and the workflow comment both described secrets.GITHUB_TOKEN as
the working credential with REGISTRY_TOKEN as a fallback. That is backwards:
the task token is rejected by Gitea's container registry (go-gitea/gitea#23642)
and the first publish attempt failed on exactly that. REGISTRY_TOKEN is
required.

Part of #187

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01D9acV1mHktGAb1uyvNmrR2
2026-08-24 22:35:10 -03:00
thatguygriffandClaude Opus 5 c9d18fec74 Commit composer.lock so CI and releases install a fixed dependency set
CI / Tests (PHP 8.2) (pull_request) Successful in 25s
CI / Tests (PHP 8.1) (pull_request) Successful in 26s
CI / No Debug Code (pull_request) Successful in 3s
CI / Tests (PHP 8.3) (pull_request) Successful in 32s
CI / Coding Standards & Static Analysis (pull_request) Successful in 42s
CI / Tests (PHP 8.5) (pull_request) Successful in 32s
CI / Build Plugin Zip (pull_request) Skipped
composer.lock was gitignored, so `composer install` resolved the graph afresh
on every job. CI could quietly start testing different dependency versions
than it did the day before, and two builds of the same tag could ship
different vendor trees — the run that produces the zip is the same run that
picks the versions.

Track the lock, and make the two places that consume it use it:

- the Composer cache keys hash composer.lock rather than composer.json, since
  the lock is now what actually fingerprints the dependency set;
- bin/build-zip.sh stages the lock alongside composer.json, so the --no-dev
  production install resolves to the locked versions. It already deleted both
  from the staging directory before zipping, so the shipped plugin is
  unchanged.

The current lock installs cleanly on 8.1: nothing in it requires a PHP newer
than the >=8.1 composer.json declares.

Closes #187

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01D9acV1mHktGAb1uyvNmrR2
2026-08-24 22:33:04 -03:00
thatguygriffandClaude Opus 5 ab609898d6 Run CI and release jobs inside the prebuilt CI images
CI / Coding Standards & Static Analysis (pull_request) Successful in 37s
CI / Tests (PHP 8.1) (pull_request) Successful in 21s
CI / Tests (PHP 8.2) (pull_request) Successful in 20s
CI / Tests (PHP 8.3) (pull_request) Successful in 19s
CI / Tests (PHP 8.5) (pull_request) Successful in 19s
CI / No Debug Code (pull_request) Successful in 2s
CI / Build Plugin Zip (pull_request) Skipped
Replaces setup-php with `container:` in every job that needs PHP. The images
published by ci-images.yml already carry PHP, Composer, the intl and zip
extensions and the CLI tools the workflow scripts shell out to, so nothing
installs PHP at job time any more.

That removes the apt path #178 was about: a ~145s floor on 8.3 against ~35s
on 8.1/8.2, with a tail that twice ran past the step timeout and failed the
run. All three copies of the `Keep downloaded .debs` / `Cache apt packages`
workaround go with it.

no-debug and bump-version stay on the runner image — neither needs PHP, and
no-debug wants the runner's GNU `grep --include`.

The Composer cache moves to /composer/cache, which is COMPOSER_HOME in the
image, and release.yml gains the cache step it never had.

Also corrects the image size in docs/ci.md to the 67MB measured from the
published manifest; the ~120MB in the original was an estimate.

Closes #187

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01D9acV1mHktGAb1uyvNmrR2
2026-08-24 22:25:02 -03:00
thatguygriff 4bc8e80837 Merge pull request 'CI: install GNU tar in the CI image so actions/cache works' (#190) from fix/187-ci-image-gnu-tar into main
CI Images / Build CI image (PHP 8.3) (push) Successful in 1m14s
CI Images / Build CI image (PHP 8.2) (push) Successful in 1m30s
CI Images / Build CI image (PHP 8.1) (push) Successful in 1m31s
CI Images / Build CI image (PHP 8.5) (push) Successful in 1m54s
CI / Tests (PHP 8.2) (push) Successful in 52s
CI / No Debug Code (push) Successful in 2s
CI / Tests (PHP 8.5) (push) Successful in 3m0s
CI / Tests (PHP 8.1) (push) Successful in 6m0s
CI / Tests (PHP 8.3) (push) Successful in 6m1s
CI / Coding Standards & Static Analysis (push) Failing after 17m19s
CI / Build Plugin Zip (push) Skipped
Reviewed-on: #190
2026-08-25 01:23:12 +00:00
thatguygriffandClaude Opus 5 28f586d207 Install GNU tar in the CI image so actions/cache works
CI Images / Build CI image (PHP 8.1) (pull_request) Successful in 48s
CI / Tests (PHP 8.1) (pull_request) Successful in 55s
CI Images / Build CI image (PHP 8.2) (pull_request) Successful in 1m46s
CI Images / Build CI image (PHP 8.3) (pull_request) Successful in 1m48s
CI Images / Build CI image (PHP 8.5) (pull_request) Successful in 2m26s
CI / No Debug Code (pull_request) Successful in 3s
CI / Tests (PHP 8.2) (pull_request) Successful in 53s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m50s
CI / Tests (PHP 8.5) (pull_request) Successful in 2m58s
CI / Coding Standards & Static Analysis (pull_request) Failing after 17m18s
CI / Build Plugin Zip (pull_request) Skipped
actions/cache shells out to `tar --posix -P`. Alpine's busybox tar rejects
both flags, so the cache step would fail in every job that runs inside these
images — which is all of them once ci.yml switches over.

coreutils does not cover this: tar is its own Alpine package. Add it, add
zstd (which actions/cache prefers over gzip when present), and assert GNU
tar in the image's smoke test so a future base-image change cannot quietly
drop it again.

Part of #187

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01D9acV1mHktGAb1uyvNmrR2
2026-08-24 22:15:40 -03:00
thatguygriff e097b7a0bf Merge pull request 'CI: publish prebuilt CI images to the Gitea container registry' (#188) from feature/187-ci-container-images into main
CI Images / Build CI image (PHP 8.1) (push) Failing after 5s
CI Images / Build CI image (PHP 8.2) (push) Failing after 7s
CI Images / Build CI image (PHP 8.3) (push) Failing after 6s
CI Images / Build CI image (PHP 8.5) (push) Failing after 17s
CI / No Debug Code (push) Successful in 9s
CI / Tests (PHP 8.2) (push) Successful in 52s
CI / Tests (PHP 8.3) (push) Successful in 2m47s
CI / Coding Standards & Static Analysis (push) Successful in 3m41s
CI / Tests (PHP 8.1) (push) Successful in 5m59s
CI / Tests (PHP 8.5) (push) Successful in 7m5s
CI / Build Plugin Zip (push) Successful in 6m0s
Reviewed-on: #188
2026-08-25 01:05:33 +00:00
thatguygriffandClaude Opus 5 572aaf5b49 Publish prebuilt CI images to the Gitea container registry
CI Images / Build CI image (PHP 8.2) (pull_request) Successful in 1m14s
CI Images / Build CI image (PHP 8.1) (pull_request) Successful in 1m26s
CI Images / Build CI image (PHP 8.3) (pull_request) Successful in 2m3s
CI Images / Build CI image (PHP 8.5) (pull_request) Successful in 2m9s
CI / Tests (PHP 8.2) (pull_request) Successful in 1m7s
CI / No Debug Code (pull_request) Successful in 2s
CI / Tests (PHP 8.3) (pull_request) Successful in 3m8s
CI / Tests (PHP 8.1) (pull_request) Successful in 5m55s
CI / Tests (PHP 8.5) (pull_request) Successful in 6m21s
CI / Coding Standards & Static Analysis (pull_request) Successful in 18m26s
CI / Build Plugin Zip (pull_request) Skipped
setup-php installs PHP 8.3+ from apt on these arm64 runners: a ~145s floor
against ~35s for 8.1/8.2, with a tail that twice ran past the step timeout
and failed the run (#178). Caching the .debs softened it without removing
the apt step, and 8.5 has the same problem.

Add a per-version CI image built on php:<version>-cli-alpine and a workflow
that publishes it to git.unsupervised.ca/unsupervised/ci-php:<version>. The
org is public, so the packages pull anonymously.

The image carries bash and nodejs because act_runner runs JavaScript actions
inside the job container, GNU coreutils/grep/sed because the workflow scripts
use `tac` and `grep --include`, and curl/jq/git/zip for release.yml and
bin/build-zip.sh. Composer 2 and the intl and zip extensions round it out.

Nothing consumes the images yet — ci.yml and release.yml switch over in a
follow-up, because a job cannot run in an image that has not been published.

Part of #187

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01D9acV1mHktGAb1uyvNmrR2
2026-08-24 21:55:28 -03:00
thatguygriff 116394f2ff Merge pull request 'Bump version to 1.5.5' (#189) from release/bump-1.5.5 into main
CI / No Debug Code (push) Successful in 3s
CI / Tests (PHP 8.1) (push) Successful in 53s
CI / Tests (PHP 8.5) (push) Successful in 3m28s
CI / Tests (PHP 8.3) (push) Successful in 5m46s
CI / Tests (PHP 8.2) (push) Successful in 6m14s
CI / Coding Standards & Static Analysis (push) Failing after 12m16s
CI / Build Plugin Zip (push) Skipped
Reviewed-on: #189
2026-08-25 00:50:11 +00:00
Release Bot dda8386c1f Bump version to 1.5.5 and open changelog section 2026-08-24 23:50:02 +00:00
thatguygriff 7278309bf5 Merge pull request 'Let the studio register the students who cannot register themselves' (#186) from fix/185-book-for-child-and-vet-group-enrolment into main
CI / Coding Standards & Static Analysis (push) Successful in 13m23s
CI / Tests (PHP 8.1) (push) Successful in 55s
CI / Tests (PHP 8.2) (push) Successful in 1m7s
CI / Tests (PHP 8.3) (push) Successful in 6m59s
CI / Tests (PHP 8.5) (push) Successful in 12m55s
CI / No Debug Code (push) Successful in 3s
CI / Build Plugin Zip (push) Successful in 5m53s
Release / Build and Publish Release (push) Successful in 8m5s
Release / Open next-version bump PR (push) Successful in 5s
Reviewed-on: #186
2026-08-24 22:03:11 +00:00
thatguygriffandClaude Opus 5 5ce42f0003 Let the studio register the students who cannot register themselves
CI / No Debug Code (pull_request) Successful in 4s
CI / Tests (PHP 8.2) (pull_request) Successful in 50s
CI / Tests (PHP 8.1) (pull_request) Successful in 1m3s
CI / Tests (PHP 8.5) (pull_request) Successful in 2m48s
CI / Tests (PHP 8.3) (pull_request) Successful in 3m24s
CI / Coding Standards & Static Analysis (pull_request) Successful in 8m21s
CI / Build Plugin Zip (pull_request) Skipped
The Book a lesson for a student panel built its picker from the us_student
role but vetted the submission with the book_lesson capability. ChildLoginGate
and RegistrationLoginGate withhold that capability from accounts that keep the
role, so the panel offered every guardian-managed child and every unapproved
signup and then refused them — with a message claiming no student had been
chosen, and a form cleared of all five fields.

Withholding book_lesson stops those accounts registering in their own name. It
was never meant to stop the studio acting for them, which is what the panel is
for, and for a child is the only route to a lesson besides their guardian.

Guard the student role instead, via a new RoleManager::isStudent() shared with
every picker and guard on the staff side so the two cannot drift apart again.
Group enrolment gets the same predicate: addDirect() and grantAccess() vetted
their posted ids not at all, and would enrol an instructor, an administrator,
or an account deleted since the page was drawn — raising a real payment against
them for a priced class.

Keep a refused booking's fields as submitted, reading the form through one
LessonController::submittedBooking() so what gets booked and what is shown
again cannot disagree about a field name. A booking that succeeds still leaves
an empty form, so the next one does not inherit it.

Closes #185

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XunBYk2sFEc1oL14sUiuBU
2026-08-24 18:42:59 -03:00
thatguygriff 76530878b5 Merge pull request 'Bump version to 1.5.4' (#184) from release/bump-1.5.4 into main
CI / Tests (PHP 8.1) (push) Successful in 58s
CI / No Debug Code (push) Successful in 2s
CI / Tests (PHP 8.5) (push) Successful in 4m20s
CI / Coding Standards & Static Analysis (push) Successful in 5m21s
CI / Tests (PHP 8.2) (push) Successful in 6m4s
CI / Tests (PHP 8.3) (push) Successful in 12m56s
CI / Build Plugin Zip (push) Successful in 2m39s
Reviewed-on: #184
2026-08-24 17:59:40 +00:00
Release Bot 6031a75012 Bump version to 1.5.4 and open changelog section 2026-08-24 17:56:38 +00:00
20 changed files with 3079 additions and 154 deletions
+45 -113
View File
@@ -7,56 +7,31 @@ on:
- develop - develop
pull_request: pull_request:
# Jobs that need PHP run inside the shared CI images maintained in the
# Unsupervised/ci-php repository. PHP, Composer, the intl and zip extensions
# and the GNU CLI tools are already in the image, so there is no toolchain
# setup step in any job here.
#
# The registry path is written out at each use because
# jobs.<id>.container.image cannot read the `env` context.
jobs: jobs:
# PHPCS and PHPStan share a job so the two of them draw once on Setup PHP phpcs:
# rather than twice. That step is slow and intermittently fails on 8.3 name: Coding Standards
# (see #178), so every job that can be folded into another is one less
# chance for a run to fall over. They run as separate steps, and PHPCS
# failing stops the job before PHPStan reports.
quality:
name: Coding Standards & Static Analysis
runs-on: ubuntu-latest runs-on: ubuntu-latest
container:
image: git.unsupervised.ca/unsupervised/ci-php:8.3
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
# setup-php installs PHP 8.3+ through php-builder, which apt-installs ~70 # COMPOSER_HOME is /composer in the image, so that is where the
# -dev packages before unpacking the build (#178). Ubuntu's image deletes # download cache lives. composer.lock is what fingerprints the
# the .debs after install, so every job re-downloads them. Keeping them # dependency set.
# and restoring them from the cache server, which lives in the cluster,
# turns a WAN download into a local one.
#
# Keyed per PHP version because the install path differs by version and
# the package sets are not interchangeable: 8.3+ pulls the ~70 -dev
# packages through php-builder, while 8.1 and 8.2 come from the ondrej
# PPA as a handful of runtime packages. Sharing one key across both lets
# whichever job finishes first decide what the others restore, and 8.1 is
# always first.
#
# Only the .debs are cached, never /var/lib/apt/lists — a stale index is
# how you get 404s mid-install.
- name: Keep downloaded .debs
run: |
sudo rm -f /etc/apt/apt.conf.d/docker-clean
echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' \
| sudo tee /etc/apt/apt.conf.d/99keep-downloaded-packages >/dev/null
- name: Cache apt packages
uses: actions/cache@v4
with:
path: /var/cache/apt/archives/*.deb
key: apt-php8.3-${{ runner.arch }}-v1
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
tools: composer:v2
- name: Cache Composer packages - name: Cache Composer packages
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: ~/.composer/cache path: /composer/cache
key: composer-${{ hashFiles('composer.json') }} key: composer-${{ hashFiles('composer.lock') }}
- name: Install dependencies - name: Install dependencies
run: composer install --prefer-dist --no-progress --no-interaction run: composer install --prefer-dist --no-progress --no-interaction
@@ -64,15 +39,36 @@ jobs:
- name: Run PHPCS - name: Run PHPCS
run: composer cs run: composer cs
phpstan:
name: Static Analysis
runs-on: ubuntu-latest
container:
image: git.unsupervised.ca/unsupervised/ci-php:8.3
steps:
- uses: actions/checkout@v4
- name: Cache Composer packages
uses: actions/cache@v4
with:
path: /composer/cache
key: composer-${{ hashFiles('composer.lock') }}
- name: Install dependencies
run: composer install --prefer-dist --no-progress --no-interaction
- name: Run PHPStan - name: Run PHPStan
run: composer lint run: composer lint
test: test:
name: Tests (PHP ${{ matrix.php }}) name: Tests (PHP ${{ matrix.php }})
runs-on: ubuntu-latest runs-on: ubuntu-latest
container:
image: git.unsupervised.ca/unsupervised/ci-php:${{ matrix.php }}
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
# A version can only be added here once ci-php publishes the matching
# tag.
php: php:
- '8.1' - '8.1'
- '8.2' - '8.2'
@@ -81,46 +77,11 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
# setup-php installs PHP 8.3+ through php-builder, which apt-installs ~70
# -dev packages before unpacking the build (#178). Ubuntu's image deletes
# the .debs after install, so every job re-downloads them. Keeping them
# and restoring them from the cache server, which lives in the cluster,
# turns a WAN download into a local one.
#
# Keyed per PHP version because the install path differs by version and
# the package sets are not interchangeable: 8.3+ pulls the ~70 -dev
# packages through php-builder, while 8.1 and 8.2 come from the ondrej
# PPA as a handful of runtime packages. Sharing one key across both lets
# whichever job finishes first decide what the others restore, and 8.1 is
# always first.
#
# Only the .debs are cached, never /var/lib/apt/lists — a stale index is
# how you get 404s mid-install.
- name: Keep downloaded .debs
run: |
sudo rm -f /etc/apt/apt.conf.d/docker-clean
echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' \
| sudo tee /etc/apt/apt.conf.d/99keep-downloaded-packages >/dev/null
- name: Cache apt packages
uses: actions/cache@v4
with:
path: /var/cache/apt/archives/*.deb
key: apt-php${{ matrix.php }}-${{ runner.arch }}-v1
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: mbstring, intl
coverage: none
tools: composer:v2
- name: Cache Composer packages - name: Cache Composer packages
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: ~/.composer/cache path: /composer/cache
key: ${{ matrix.php }}-composer-${{ hashFiles('composer.json') }} key: ${{ matrix.php }}-composer-${{ hashFiles('composer.lock') }}
- name: Install dependencies - name: Install dependencies
run: composer install --prefer-dist --no-progress --no-interaction run: composer install --prefer-dist --no-progress --no-interaction
@@ -128,6 +89,8 @@ jobs:
- name: Run PHPUnit - name: Run PHPUnit
run: composer test run: composer test
# Runs on the runner image rather than a container: it needs no PHP, and it
# uses GNU grep's --include.
no-debug: no-debug:
name: No Debug Code name: No Debug Code
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -144,46 +107,15 @@ jobs:
build: build:
name: Build Plugin Zip name: Build Plugin Zip
runs-on: ubuntu-latest runs-on: ubuntu-latest
container:
image: git.unsupervised.ca/unsupervised/ci-php:8.3
# Only build a shippable artifact once changes land on main, and only # Only build a shippable artifact once changes land on main, and only
# after the quality gates pass. # after the quality gates pass.
needs: [quality, test, no-debug] needs: [phpcs, phpstan, test, no-debug]
if: github.event_name == 'push' && github.ref == 'refs/heads/main' if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
# setup-php installs PHP 8.3+ through php-builder, which apt-installs ~70
# -dev packages before unpacking the build (#178). Ubuntu's image deletes
# the .debs after install, so every job re-downloads them. Keeping them
# and restoring them from the cache server, which lives in the cluster,
# turns a WAN download into a local one.
#
# Keyed per PHP version because the install path differs by version and
# the package sets are not interchangeable: 8.3+ pulls the ~70 -dev
# packages through php-builder, while 8.1 and 8.2 come from the ondrej
# PPA as a handful of runtime packages. Sharing one key across both lets
# whichever job finishes first decide what the others restore, and 8.1 is
# always first.
#
# Only the .debs are cached, never /var/lib/apt/lists — a stale index is
# how you get 404s mid-install.
- name: Keep downloaded .debs
run: |
sudo rm -f /etc/apt/apt.conf.d/docker-clean
echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' \
| sudo tee /etc/apt/apt.conf.d/99keep-downloaded-packages >/dev/null
- name: Cache apt packages
uses: actions/cache@v4
with:
path: /var/cache/apt/archives/*.deb
key: apt-php8.3-${{ runner.arch }}-v1
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
tools: composer:v2
- name: Build plugin zip - name: Build plugin zip
run: composer build run: composer build
+12 -6
View File
@@ -15,15 +15,13 @@ jobs:
release: release:
name: Build and Publish Release name: Build and Publish Release
runs-on: ubuntu-latest runs-on: ubuntu-latest
# The shared CI image carries composer, curl, jq and the GNU coreutils
# the steps below shell out to. See docs/ci.md.
container:
image: git.unsupervised.ca/unsupervised/ci-php:8.3
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
tools: composer:v2
# A tag that disagrees with the plugin header would make sites see a # A tag that disagrees with the plugin header would make sites see a
# phantom update forever (or never see a real one), so fail fast. # phantom update forever (or never see a real one), so fail fast.
- name: Verify tag matches plugin version - name: Verify tag matches plugin version
@@ -37,6 +35,14 @@ jobs:
fi fi
echo "version=${header_version}" >> "$GITHUB_OUTPUT" echo "version=${header_version}" >> "$GITHUB_OUTPUT"
# COMPOSER_HOME is /composer in the image, so that is where the
# download cache lives.
- name: Cache Composer packages
uses: actions/cache@v4
with:
path: /composer/cache
key: composer-${{ hashFiles('composer.lock') }}
- name: Install dependencies - name: Install dependencies
run: composer install --prefer-dist --no-progress --no-interaction run: composer install --prefer-dist --no-progress --no-interaction
-1
View File
@@ -1,5 +1,4 @@
vendor/ vendor/
composer.lock
coverage/ coverage/
.phpunit.result.cache .phpunit.result.cache
*.log *.log
+12
View File
@@ -11,6 +11,18 @@ When a `v*` tag is pushed, `.gitea/workflows/release.yml` publishes the matching
the plugin to the next patch version and adds a fresh section here for it. Record the plugin to the next patch version and adds a fresh section here for it. Record
each change under the current top section as you work. each change under the current top section as you work.
## [1.5.5]
### Changed
- **Payments moved up to version 21 of Stripe's PHP library**, from version 17. Being four major versions behind also meant asking Stripe to behave like an older version of its API; the plugin now uses API version `2026-07-29.dahlia`. Taking a card payment and handling a webhook are unchanged — the same charge is raised, the same events are honoured, and a forged webhook is still rejected. Nothing to do on your side.
## [1.5.4]
### Fixed
- **Adding students to a group class now checks that each one is actually a student.** **Add students directly** and **Make available** acted on whatever ids the form posted without confirming they named students at all, so a stale page — or a tampered submission — could put an instructor, an administrator, or an account that had since been deleted onto a class roster, raising a real payment against them. Both controls now skip anything that is not a student, and the "%d student(s) added" count tells you how many actually went through. Children and students awaiting approval are unaffected: they are students, and adding them is what these controls are for.
- **A refused booking no longer empties the Book a lesson for a student form.** Whatever the reason it came back — the time taken while you were typing, a weekly reservation asked for on a time that does not repeat — the panel reopens with the student, time, lesson type, both ticks and your note exactly as you left them, so a correction is one field, not five. A booking that goes through still leaves an empty form behind for the next one.
- **Booking a lesson for a child, or for a student you have not approved yet, no longer fails with "Choose a student to book for."** The **Book a lesson for a student** panel offered every student it could see, but then refused a good half of them: a parent's child and a self-signup still awaiting approval both appeared in the list, and both were rejected on submit — with an error that read as though no student had been chosen, and which cleared the form. Neither of those accounts is allowed to book *in their own name* (a child's is never signed in to at all, and an unapproved signup waits for you), and the panel was mistakenly applying that same restriction to the studio booking on their behalf, which is precisely the case it was built for. Anyone the panel offers can now be booked for.
## [1.5.3] ## [1.5.3]
### Added ### Added
+4
View File
@@ -23,6 +23,10 @@ INCLUDE=(
"$SLUG.php" "$SLUG.php"
"uninstall.php" "uninstall.php"
"composer.json" "composer.json"
# Staged so the production install below resolves to the locked versions
# rather than whatever is newest that day. Both are deleted again before
# the zip is written.
"composer.lock"
"src" "src"
"templates" "templates"
"assets" "assets"
+4 -1
View File
@@ -5,7 +5,7 @@
"license": "GPL-2.0-or-later", "license": "GPL-2.0-or-later",
"require": { "require": {
"php": ">=8.1", "php": ">=8.1",
"stripe/stripe-php": "^17.0" "stripe/stripe-php": "^21.0"
}, },
"require-dev": { "require-dev": {
"phpunit/phpunit": "^10.5", "phpunit/phpunit": "^10.5",
@@ -36,6 +36,9 @@
"build": "bash bin/build-zip.sh" "build": "bash bin/build-zip.sh"
}, },
"config": { "config": {
"platform": {
"php": "8.1"
},
"allow-plugins": { "allow-plugins": {
"dealerdirect/phpcodesniffer-composer-installer": true "dealerdirect/phpcodesniffer-composer-installer": true
} }
Generated
+2589
View File
File diff suppressed because it is too large Load Diff
+45
View File
@@ -0,0 +1,45 @@
# CI
CI and release jobs do not install PHP. They run inside the shared images
maintained in [Unsupervised/ci-php](https://git.unsupervised.ca/Unsupervised/ci-php):
```
git.unsupervised.ca/unsupervised/ci-php:8.1
git.unsupervised.ca/unsupervised/ci-php:8.2
git.unsupervised.ca/unsupervised/ci-php:8.3
git.unsupervised.ca/unsupervised/ci-php:8.5
```
The `Unsupervised` org is public, so they pull anonymously — no registry
credentials in any job here. What the images contain, how they are published,
and how to add a PHP version are documented in that repository's README.
## Which job runs where
| Job | Runs in |
|---|---|
| Coding Standards (PHPCS) | `ci-php:8.3` |
| Static Analysis (PHPStan) | `ci-php:8.3` |
| Tests | `ci-php:${{ matrix.php }}` |
| Build Plugin Zip | `ci-php:8.3` |
| No Debug Code | runner image — no PHP, and it uses GNU `grep --include` |
| Open next-version bump PR (release.yml) | runner image — no PHP |
PHPCS and PHPStan are separate jobs so a coding-standards failure still lets
the static analysis result through. They run in parallel.
## Composer
`composer.lock` is committed, so every job installs the same dependency set
and two builds of the same tag ship the same vendor tree. `bin/build-zip.sh`
stages the lock into its build directory for the same reason, then removes it
before writing the zip.
The Composer download cache lives at `/composer/cache``COMPOSER_HOME` is
`/composer` in the image — and is keyed on `composer.lock`.
## Adding a PHP version to the test matrix
The image has to exist first. Add the version to the `php` matrix in
`ci-php`'s `.gitea/workflows/publish.yml` and merge, then add it to the `test`
matrix in `.gitea/workflows/ci.yml` here.
+11
View File
@@ -192,6 +192,17 @@ controls beneath it:
a **pending** invite, the grant is attached to that invite and **no second link is a **pending** invite, the grant is attached to that invite and **no second link is
sent**. An address that already has an account is treated as **Make available** instead. sent**. An address that already has an account is treated as **Make available** instead.
Both student-picking controls vet every posted id with `Auth\RoleManager::isStudent()`
before acting on it — the same predicate the picker is built from, and the same one
`Booking\AdminBooking` guards a staff booking with. A posted id naming an instructor,
an administrator, or an account deleted since the page was drawn is skipped rather
than enrolled, so nothing can put a non-student on a roster or raise a payment
against one. Being a student is a matter of the **role**, not the `book_lesson`
capability, so a guardian's child and a signup still awaiting approval are both
fully enrollable — neither may enrol *themselves*, which is exactly what the studio
adding them is for. The reported count is what was actually added, so a skipped id
shows up as a smaller number.
When an email-invited person completes registration, `RegistrationPage` links their new When an email-invited person completes registration, `RegistrationPage` links their new
account to the grant (`GroupAccessRepository::linkStudentByEmail`), so the invite-only account to the grant (`GroupAccessRepository::linkStudentByEmail`), so the invite-only
class becomes enrollable for them — they choose whether to enrol. class becomes enrollable for them — they choose whether to enrol.
+15 -2
View File
@@ -96,11 +96,13 @@ lesson. The times offered are the open slots of the next eight weeks — every
instructor's on the studio **Scheduler**, only the instructor's own on **My instructor's on the studio **Scheduler**, only the instructor's own on **My
Lessons**, which `AdminBooking::book()` re-checks rather than trusting the Lessons**, which `AdminBooking::book()` re-checks rather than trusting the
posted slot id. The result is reported as a notice above the panel saying what posted slot id. The result is reported as a notice above the panel saying what
was booked and what it left owing; a refusal reopens the panel with the reason. was booked and what it left owing; a refusal reopens the panel with the reason
and every field as it was submitted, so only the mistake needs correcting. A
booking that succeeds clears the form, so the next one does not inherit it.
It is the same booking a student makes — `LessonBooker` claims the slot(s), It is the same booking a student makes — `LessonBooker` claims the slot(s),
writes the lesson row(s), and raises the payment exactly as `POST /bookings` writes the lesson row(s), and raises the payment exactly as `POST /bookings`
does — and differs in three deliberate ways: does — and differs in four deliberate ways:
1. **No intake answers or policy acceptances are recorded at booking time.** 1. **No intake answers or policy acceptances are recorded at booking time.**
Those are the student's to give; staff ticking the boxes for them would be an Those are the student's to give; staff ticking the boxes for them would be an
@@ -113,6 +115,17 @@ does — and differs in three deliberate ways:
whole series) is `confirmed` at once. Without the tick a pending payment is whole series) is `confirmed` at once. Without the tick a pending payment is
raised at the lesson type's price, per-occurrence for a weekly reservation, raised at the lesson type's price, per-occurrence for a weekly reservation,
and the lesson confirms when it settles like any other. and the lesson confirms when it settles like any other.
4. **It can book for a student who cannot book at all.** The guard is
`Auth\RoleManager::isStudent()` — the student *role*, not the `book_lesson`
capability — so it covers a guardian's child and a self-signup still awaiting
approval alike, and is shared with the group-class **Add students directly**
and **Make available** controls so the two paths cannot drift. Both hold the role;
both have `book_lesson` withheld (`Guardian\ChildLoginGate`,
`Auth\RegistrationLoginGate`) so that neither can book in their own name.
That restriction is on them, not on the studio acting for them — and for a
child, whose account is never signed in to, it is the only route to a lesson
besides their guardian's. The picker and the guard therefore accept exactly
the same set, so nothing offered in the panel can be refused as ineligible.
A weekly reservation needs a time that actually repeats: asked for one on a A weekly reservation needs a time that actually repeats: asked for one on a
one-off slot, the form refuses (`not_weekly`) rather than quietly booking a one-off slot, the form refuses (`not_weekly`) rather than quietly booking a
+21
View File
@@ -60,6 +60,27 @@ class RoleManager {
self::CAP_EXPORT_PAYMENTS, self::CAP_EXPORT_PAYMENTS,
]; ];
/**
* Whether a user account is a student the studio may act for.
*
* Deliberately the role and not the `book_lesson` capability: that capability
* is withheld from a guardian's child ({@see \Unsupervised\Schedular\Guardian\ChildLoginGate})
* and from a self-signup still awaiting approval
* ({@see \Unsupervised\Schedular\Auth\RegistrationLoginGate}), so that neither
* can book or enrol *in their own name*. Staff booking or enrolling on their
* behalf is the case those restrictions exist to leave open and for a child,
* whose account is never signed in to, it is the only route there is.
*
* Use this for every "may the studio register this person?" check, so the
* pickers staff choose from and the guards that vet their choice cannot drift
* into offering someone who is then refused.
*/
public static function isStudent( int $userId ): bool {
$user = $userId > 0 ? get_userdata( $userId ) : false;
return $user instanceof \WP_User && in_array( self::STUDENT, (array) $user->roles, true );
}
public function __construct( private AccessSettings $access = new AccessSettings() ) {} public function __construct( private AccessSettings $access = new AccessSettings() ) {}
public function register(): void { public function register(): void {
+13 -4
View File
@@ -19,7 +19,7 @@ use Unsupervised\Schedular\Val;
* flow. * flow.
* *
* It reuses `LessonBooker` the same offering rules, the same atomic slot claim, * It reuses `LessonBooker` the same offering rules, the same atomic slot claim,
* the same billing and differs from a student's own booking in exactly three * the same billing and differs from a student's own booking in exactly four
* ways, each deliberate: * ways, each deliberate:
* *
* 1. **No intake questions or policy acceptances are recorded.** They are the * 1. **No intake questions or policy acceptances are recorded.** They are the
@@ -30,6 +30,10 @@ use Unsupervised\Schedular\Val;
* slot of the instructor's, including one only reachable past a deadline. * slot of the instructor's, including one only reachable past a deadline.
* 3. **It can be booked at no charge**, for a make-up or goodwill lesson, which * 3. **It can be booked at no charge**, for a make-up or goodwill lesson, which
* skips the payment entirely and confirms the lesson at once. * skips the payment entirely and confirms the lesson at once.
* 4. **It can book for a student who cannot book at all** a guardian's child,
* or someone still awaiting approval. Both hold the student role but have
* `book_lesson` withheld so that neither can book in their own name; that is
* a limit on them, never on the studio acting for them.
*/ */
class AdminBooking { class AdminBooking {
@@ -54,7 +58,10 @@ class AdminBooking {
* @return string|\WP_Error Success notice, or why nothing was booked. * @return string|\WP_Error Success notice, or why nothing was booked.
*/ */
public function book( int $studentId, int $slotId, int $offeringId, string $recurrence, bool $noCharge, string $notes, int $onlyInstructorId = 0 ): string|\WP_Error { public function book( int $studentId, int $slotId, int $offeringId, string $recurrence, bool $noCharge, string $notes, int $onlyInstructorId = 0 ): string|\WP_Error {
if ( $studentId <= 0 || ! user_can( $studentId, RoleManager::CAP_BOOK_LESSON ) ) { // The student role, not the `book_lesson` capability — see
// {@see RoleManager::isStudent()} for why a child and an unapproved signup
// must both be bookable for.
if ( ! RoleManager::isStudent( $studentId ) ) {
return new \WP_Error( 'invalid_student', __( 'Choose a student to book for.', 'unsupervised-schedular' ) ); return new \WP_Error( 'invalid_student', __( 'Choose a student to book for.', 'unsupervised-schedular' ) );
} }
@@ -250,8 +257,10 @@ class AdminBooking {
} }
/** /**
* Everyone who can be booked for, by name students and the children a * Everyone who can be booked for, by name every holder of the student role,
* guardian books for alike, since both hold `book_lesson`. * which is exactly the set {@see book()} accepts. That deliberately includes
* the children a guardian books for and students still awaiting approval:
* neither may book in their own name, both may be booked for.
* *
* @return list<array{id: int, name: string}> * @return list<array{id: int, name: string}>
*/ */
+50 -9
View File
@@ -177,7 +177,7 @@ class LessonController {
* *
* @param list<array<string, mixed>> $rows * @param list<array<string, mixed>> $rows
*/ */
// phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter -- $notice and $error are read by the included template. // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter -- $notice is read by the included template.
private function renderLessonsPage( array $rows, string $pageSlug, int $onlyInstructorId, string $notice, string $error ): void { private function renderLessonsPage( array $rows, string $pageSlug, int $onlyInstructorId, string $notice, string $error ): void {
// View-state query params only (which view, which week) — nothing is // View-state query params only (which view, which week) — nothing is
// mutated from them, so no nonce applies. // mutated from them, so no nonce applies.
@@ -193,6 +193,11 @@ class LessonController {
$baseUrl = admin_url( 'admin.php?page=' . $pageSlug ); $baseUrl = admin_url( 'admin.php?page=' . $pageSlug );
$bookForm = $this->adminBooking->formData( $onlyInstructorId ); $bookForm = $this->adminBooking->formData( $onlyInstructorId );
// A refused booking is shown again as it was typed — losing five fields to a
// single mistake is what made the panel infuriating to correct. A successful
// one starts empty, so the next booking does not inherit the last one's.
$bookValues = '' !== $error ? $this->submittedBooking() : $this->emptyBooking();
include USC_PLUGIN_DIR . 'templates/admin/lessons.php'; include USC_PLUGIN_DIR . 'templates/admin/lessons.php';
} }
@@ -229,23 +234,59 @@ class LessonController {
* @return array{string, string} * @return array{string, string}
*/ */
private function bookForStudent( int $onlyInstructorId ): array { private function bookForStudent( int $onlyInstructorId ): array {
// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce checked by the caller. $submitted = $this->submittedBooking();
$result = $this->adminBooking->book( $result = $this->adminBooking->book(
absint( Val::int( $_POST['student_id'] ?? 0 ) ), $submitted['student_id'],
absint( Val::int( $_POST['slot_id'] ?? 0 ) ), $submitted['slot_id'],
absint( Val::int( $_POST['offering_id'] ?? 0 ) ), $submitted['offering_id'],
isset( $_POST['recurrence_weekly'] ) ? Lesson::RECURRENCE_WEEKLY : Lesson::RECURRENCE_SINGLE, $submitted['weekly'] ? Lesson::RECURRENCE_WEEKLY : Lesson::RECURRENCE_SINGLE,
isset( $_POST['no_charge'] ), $submitted['no_charge'],
sanitize_text_field( Val::string( wp_unslash( $_POST['notes'] ?? '' ) ) ), $submitted['notes'],
$onlyInstructorId $onlyInstructorId
); );
// phpcs:enable WordPress.Security.NonceVerification.Missing
return $result instanceof \WP_Error return $result instanceof \WP_Error
? [ '', $result->get_error_message() ] ? [ '', $result->get_error_message() ]
: [ $result, '' ]; : [ $result, '' ];
} }
/**
* The book-for-a-student form exactly as submitted. Read in one place so what
* gets booked and what the form shows again after a refusal cannot drift apart
* on a field name.
*
* @return array{student_id: int, slot_id: int, offering_id: int, weekly: bool, no_charge: bool, notes: string}
*/
private function submittedBooking(): array {
// phpcs:disable WordPress.Security.NonceVerification.Missing -- read only after handleFormAction() has verified the nonce: to book, or to re-render (escaped) a form it refused.
return [
'student_id' => absint( Val::int( $_POST['student_id'] ?? 0 ) ),
'slot_id' => absint( Val::int( $_POST['slot_id'] ?? 0 ) ),
'offering_id' => absint( Val::int( $_POST['offering_id'] ?? 0 ) ),
'weekly' => isset( $_POST['recurrence_weekly'] ),
'no_charge' => isset( $_POST['no_charge'] ),
'notes' => sanitize_text_field( Val::string( wp_unslash( $_POST['notes'] ?? '' ) ) ),
];
// phpcs:enable WordPress.Security.NonceVerification.Missing
}
/**
* An untouched book-for-a-student form.
*
* @return array{student_id: int, slot_id: int, offering_id: int, weekly: bool, no_charge: bool, notes: string}
*/
private function emptyBooking(): array {
return [
'student_id' => 0,
'slot_id' => 0,
'offering_id' => 0,
'weekly' => false,
'no_charge' => false,
'notes' => '',
];
}
/** /**
* Apply a per-lesson payment override. When $onlyOwn, the payment must belong * Apply a per-lesson payment override. When $onlyOwn, the payment must belong
* to the current instructor. * to the current instructor.
+10 -2
View File
@@ -640,7 +640,15 @@ class GroupClassController {
} }
/** /**
* The de-duplicated positive student ids posted from a multi-select. * The de-duplicated student ids posted from a multi-select, keeping only ids
* that are actually students.
*
* The select is built from {@see studentOptions()}, but nothing stops a posted
* id naming an instructor, an administrator, or an account deleted since the
* page was drawn and enrolling one would write a roster row, and bill it,
* against someone who is not in the class. Vetting here covers both actions at
* once, and against the same {@see RoleManager::isStudent()} the picker uses,
* so a child or an unapproved signup is still perfectly enrollable.
* *
* @return list<int> * @return list<int>
*/ */
@@ -650,7 +658,7 @@ class GroupClassController {
$raw = (array) ( $_POST['student_ids'] ?? [] ); $raw = (array) ( $_POST['student_ids'] ?? [] );
$ids = array_filter( array_map( static fn( mixed $v ): int => absint( Val::int( $v ) ), $raw ) ); $ids = array_filter( array_map( static fn( mixed $v ): int => absint( Val::int( $v ) ), $raw ) );
return array_values( array_unique( $ids ) ); return array_values( array_filter( array_unique( $ids ), RoleManager::isStudent( ... ) ) );
} }
/** /**
+7 -6
View File
@@ -16,6 +16,7 @@ if (! defined('ABSPATH')) {
* @var string $notice * @var string $notice
* @var string $error * @var string $error
* @var array{students: list<array{id: int, name: string}>, offerings: list<array{id: int, label: string}>, slots: list<array{id: int, label: string, weekly: bool}>} $bookForm * @var array{students: list<array{id: int, name: string}>, offerings: list<array{id: int, label: string}>, slots: list<array{id: int, label: string, weekly: bool}>} $bookForm
* @var array{student_id: int, slot_id: int, offering_id: int, weekly: bool, no_charge: bool, notes: string} $bookValues
*/ */
?> ?>
<div class="wrap"> <div class="wrap">
@@ -54,7 +55,7 @@ if (! defined('ABSPATH')) {
<select name="student_id" id="usc-book-student" required> <select name="student_id" id="usc-book-student" required>
<option value=""><?php esc_html_e('Choose a student', 'unsupervised-schedular'); ?></option> <option value=""><?php esc_html_e('Choose a student', 'unsupervised-schedular'); ?></option>
<?php foreach ($bookForm['students'] as $student) : ?> <?php foreach ($bookForm['students'] as $student) : ?>
<option value="<?php echo esc_attr((string) $student['id']); ?>"><?php echo esc_html($student['name']); ?></option> <option value="<?php echo esc_attr((string) $student['id']); ?>" <?php selected($bookValues['student_id'], $student['id']); ?>><?php echo esc_html($student['name']); ?></option>
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
</td> </td>
@@ -65,7 +66,7 @@ if (! defined('ABSPATH')) {
<select name="slot_id" id="usc-book-slot" required style="max-width:100%;"> <select name="slot_id" id="usc-book-slot" required style="max-width:100%;">
<option value=""><?php esc_html_e('Choose an open time', 'unsupervised-schedular'); ?></option> <option value=""><?php esc_html_e('Choose an open time', 'unsupervised-schedular'); ?></option>
<?php foreach ($bookForm['slots'] as $slot) : ?> <?php foreach ($bookForm['slots'] as $slot) : ?>
<option value="<?php echo esc_attr((string) $slot['id']); ?>"><?php echo esc_html($slot['label']); ?></option> <option value="<?php echo esc_attr((string) $slot['id']); ?>" <?php selected($bookValues['slot_id'], $slot['id']); ?>><?php echo esc_html($slot['label']); ?></option>
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
</td> </td>
@@ -76,7 +77,7 @@ if (! defined('ABSPATH')) {
<select name="offering_id" id="usc-book-offering" style="max-width:100%;"> <select name="offering_id" id="usc-book-offering" style="max-width:100%;">
<option value="0"><?php esc_html_e('Use the time\'s own lesson type', 'unsupervised-schedular'); ?></option> <option value="0"><?php esc_html_e('Use the time\'s own lesson type', 'unsupervised-schedular'); ?></option>
<?php foreach ($bookForm['offerings'] as $offering) : ?> <?php foreach ($bookForm['offerings'] as $offering) : ?>
<option value="<?php echo esc_attr((string) $offering['id']); ?>"><?php echo esc_html($offering['label']); ?></option> <option value="<?php echo esc_attr((string) $offering['id']); ?>" <?php selected($bookValues['offering_id'], $offering['id']); ?>><?php echo esc_html($offering['label']); ?></option>
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
<p class="description"><?php esc_html_e('A time already tied to a lesson type is booked as that type; a general time needs one chosen here.', 'unsupervised-schedular'); ?></p> <p class="description"><?php esc_html_e('A time already tied to a lesson type is booked as that type; a general time needs one chosen here.', 'unsupervised-schedular'); ?></p>
@@ -86,12 +87,12 @@ if (! defined('ABSPATH')) {
<th scope="row"><?php esc_html_e('Options', 'unsupervised-schedular'); ?></th> <th scope="row"><?php esc_html_e('Options', 'unsupervised-schedular'); ?></th>
<td> <td>
<label> <label>
<input type="checkbox" name="recurrence_weekly" value="1"> <input type="checkbox" name="recurrence_weekly" value="1" <?php checked($bookValues['weekly']); ?>>
<?php esc_html_e('Reserve this time weekly for the rest of the term', 'unsupervised-schedular'); ?> <?php esc_html_e('Reserve this time weekly for the rest of the term', 'unsupervised-schedular'); ?>
</label> </label>
<p class="description"><?php esc_html_e('Only for a time that repeats weekly. Billed upfront as one payment.', 'unsupervised-schedular'); ?></p> <p class="description"><?php esc_html_e('Only for a time that repeats weekly. Billed upfront as one payment.', 'unsupervised-schedular'); ?></p>
<label> <label>
<input type="checkbox" name="no_charge" value="1"> <input type="checkbox" name="no_charge" value="1" <?php checked($bookValues['no_charge']); ?>>
<?php esc_html_e('No charge — book it free and confirm it now', 'unsupervised-schedular'); ?> <?php esc_html_e('No charge — book it free and confirm it now', 'unsupervised-schedular'); ?>
</label> </label>
<p class="description"><?php esc_html_e('For a make-up or goodwill lesson. Otherwise a pending payment is raised at the lesson type\'s price.', 'unsupervised-schedular'); ?></p> <p class="description"><?php esc_html_e('For a make-up or goodwill lesson. Otherwise a pending payment is raised at the lesson type\'s price.', 'unsupervised-schedular'); ?></p>
@@ -99,7 +100,7 @@ if (! defined('ABSPATH')) {
</tr> </tr>
<tr> <tr>
<th scope="row"><label for="usc-book-notes"><?php esc_html_e('Notes', 'unsupervised-schedular'); ?></label></th> <th scope="row"><label for="usc-book-notes"><?php esc_html_e('Notes', 'unsupervised-schedular'); ?></label></th>
<td><input type="text" name="notes" id="usc-book-notes" class="regular-text" maxlength="500"></td> <td><input type="text" name="notes" id="usc-book-notes" class="regular-text" maxlength="500" value="<?php echo esc_attr($bookValues['notes']); ?>"></td>
</tr> </tr>
</table> </table>
<p> <p>
+44
View File
@@ -155,4 +155,48 @@ class RoleManagerTest extends TestCase
(new RoleManager())->createRoles(); (new RoleManager())->createRoles();
} }
/**
* The predicate every staff-side "register this person" path shares. It is
* deliberately the role and not `book_lesson`, so the two accounts that have
* that capability withheld a guardian's child and an unapproved signup are
* still people the studio can act for.
*/
public function testIsStudentAcceptsAnyHolderOfTheStudentRole(): void
{
Functions\when('get_userdata')->justReturn($this->userWithRoles([RoleManager::STUDENT]));
self::assertTrue(RoleManager::isStudent(5));
}
public function testIsStudentRejectsSomeoneWhoIsNotAStudent(): void
{
Functions\when('get_userdata')->justReturn($this->userWithRoles([RoleManager::INSTRUCTOR]));
self::assertFalse(RoleManager::isStudent(5));
}
public function testIsStudentRejectsAnAccountThatNoLongerExists(): void
{
Functions\when('get_userdata')->justReturn(false);
self::assertFalse(RoleManager::isStudent(5));
}
public function testIsStudentRejectsNoOneChosenWithoutLookingAnyoneUp(): void
{
Functions\expect('get_userdata')->never();
self::assertFalse(RoleManager::isStudent(0));
}
/** @param list<string> $roles */
private function userWithRoles(array $roles): \WP_User
{
$user = \Mockery::mock(\WP_User::class);
$user->ID = 5;
$user->roles = $roles;
return $user;
}
} }
+72 -6
View File
@@ -5,6 +5,7 @@ namespace Unsupervised\Schedular\Tests\Unit\Booking;
use Brain\Monkey\Functions; use Brain\Monkey\Functions;
use Mockery; use Mockery;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Availability\AvailabilityRepository; use Unsupervised\Schedular\Availability\AvailabilityRepository;
use Unsupervised\Schedular\Availability\AvailabilitySlot; use Unsupervised\Schedular\Availability\AvailabilitySlot;
use Unsupervised\Schedular\Booking\AdminBooking; use Unsupervised\Schedular\Booking\AdminBooking;
@@ -36,10 +37,10 @@ class AdminBookingTest extends TestCase
Functions\when('mysql2date')->alias( Functions\when('mysql2date')->alias(
static fn (string $format, string $date): string => date($format, (int) strtotime($date)) static fn (string $format, string $date): string => date($format, (int) strtotime($date))
); );
Functions\when('get_userdata')->justReturn(false); // The picker offers holders of the student role, and that is what the guard
// accepts; it is exercised on its own below.
Functions\when('get_userdata')->alias(fn (int $id): \WP_User => $this->user($id, 'Jane Doe'));
Functions\when('get_users')->justReturn([]); Functions\when('get_users')->justReturn([]);
// Everyone offered in the picker can book; the guard is exercised on its own.
Functions\when('user_can')->justReturn(true);
// The staff member doing the booking; stamped on the lesson as booked_by. // The staff member doing the booking; stamped on the lesson as booked_by.
Functions\when('get_current_user_id')->justReturn(3); Functions\when('get_current_user_id')->justReturn(3);
@@ -175,9 +176,9 @@ class AdminBookingTest extends TestCase
self::assertSame('slot_taken', $result->get_error_code()); self::assertSame('slot_taken', $result->get_error_code());
} }
public function testSomeoneWhoCannotBookLessonsIsRefused(): void public function testSomeoneWhoIsNotAStudentIsRefused(): void
{ {
Functions\when('user_can')->justReturn(false); Functions\when('get_userdata')->alias(fn (int $id): \WP_User => $this->user($id, 'Jane Doe', [RoleManager::INSTRUCTOR]));
$this->availability->shouldReceive('findById')->never(); $this->availability->shouldReceive('findById')->never();
$result = $this->admin->book(42, 7, 3, Lesson::RECURRENCE_SINGLE, false, ''); $result = $this->admin->book(42, 7, 3, Lesson::RECURRENCE_SINGLE, false, '');
@@ -186,6 +187,69 @@ class AdminBookingTest extends TestCase
self::assertSame('invalid_student', $result->get_error_code()); self::assertSame('invalid_student', $result->get_error_code());
} }
public function testAnAccountThatNoLongerExistsIsRefused(): void
{
Functions\when('get_userdata')->justReturn(false);
$this->availability->shouldReceive('findById')->never();
$result = $this->admin->book(42, 7, 3, Lesson::RECURRENCE_SINGLE, false, '');
self::assertInstanceOf(\WP_Error::class, $result);
self::assertSame('invalid_student', $result->get_error_code());
}
public function testNoStudentChosenIsRefusedWithoutLookingAnyoneUp(): void
{
Functions\expect('get_userdata')->never();
$this->availability->shouldReceive('findById')->never();
$result = $this->admin->book(0, 7, 3, Lesson::RECURRENCE_SINGLE, false, '');
self::assertInstanceOf(\WP_Error::class, $result);
self::assertSame('invalid_student', $result->get_error_code());
}
/**
* A child holds the student role but never `book_lesson` withheld so the
* account cannot book in its own name. The studio booking for them is the only
* route a child has to a lesson, so it must not be blocked by that.
*/
public function testBooksForAGuardiansChildWhoCannotBookThemselves(): void
{
Functions\when('user_can')->justReturn(false);
$this->availability->shouldReceive('findById')->with(7)->andReturn($this->slot());
$this->offerings->shouldReceive('findById')->with(3)->andReturn($this->offering());
$this->availability->shouldReceive('claim')->once()->with(7)->andReturn(true);
$this->bookings->shouldReceive('insert')->once()->andReturn(100);
$this->payments->shouldReceive('createForRegistration')->once()->andReturn($this->pendingPayment());
$notice = $this->admin->book(42, 7, 3, Lesson::RECURRENCE_SINGLE, false, '');
self::assertIsString($notice);
self::assertStringContainsString('30 min piano', $notice);
}
/**
* Same for a self-signup the studio has not approved yet: the front desk can
* still get them onto the calendar while the paperwork catches up.
*/
public function testBooksForAStudentStillAwaitingApproval(): void
{
Functions\when('user_can')->justReturn(false);
$this->availability->shouldReceive('findById')->with(7)->andReturn($this->slot());
$this->offerings->shouldReceive('findById')->with(3)->andReturn($this->offering());
$this->availability->shouldReceive('claim')->once()->with(7)->andReturn(true);
$this->bookings->shouldReceive('insert')->once()->with(Mockery::on(
static fn (Lesson $l): bool => 42 === $l->studentId
))->andReturn(100);
$this->payments->shouldReceive('createForRegistration')->once()->andReturn($this->pendingPayment());
$notice = $this->admin->book(42, 7, 3, Lesson::RECURRENCE_SINGLE, false, '');
self::assertIsString($notice);
self::assertStringContainsString('pending payment', $notice);
}
public function testATiedTimeCannotBeBookedAsADifferentLessonType(): void public function testATiedTimeCannotBeBookedAsADifferentLessonType(): void
{ {
$this->availability->shouldReceive('findById')->with(7)->andReturn($this->slot(offeringId: 3)); $this->availability->shouldReceive('findById')->with(7)->andReturn($this->slot(offeringId: 3));
@@ -299,10 +363,12 @@ class AdminBookingTest extends TestCase
); );
} }
private function user(int $id, string $name): \WP_User /** @param list<string> $roles */
private function user(int $id, string $name, array $roles = [RoleManager::STUDENT]): \WP_User
{ {
$user = Mockery::mock(\WP_User::class); $user = Mockery::mock(\WP_User::class);
$user->ID = $id; $user->ID = $id;
$user->roles = $roles;
$user->first_name = ''; $user->first_name = '';
$user->last_name = ''; $user->last_name = '';
$user->nickname = $name; $user->nickname = $name;
@@ -316,6 +316,54 @@ class LessonControllerTest extends TestCase
self::assertStringContainsString('name="usc_action" value="book_for_student"', $html); self::assertStringContainsString('name="usc_action" value="book_for_student"', $html);
} }
/**
* A refusal used to clear all five fields, so one mistake meant retyping the
* whole form and the panel is only ever reopened *because* something was
* refused.
*/
public function testARefusedBookingComesBackWithEveryFieldStillFilledIn(): void
{
$this->postBooking();
$this->offerPanel();
$this->adminBooking->shouldReceive('book')->once()->andReturn(
new \WP_Error('slot_taken', 'That time has already been booked.')
);
$this->bookings->shouldReceive('findAllUpcoming')->once()->andReturn([]);
$html = $this->render();
self::assertStringContainsString('That time has already been booked.', $html);
// Attribute spacing in the template is not what is under test here.
$tags = (string) preg_replace('/\s+/', ' ', $html);
// The panel is reopened, showing the student, time and lesson type as posted.
self::assertStringContainsString(' open>', $html);
self::assertStringContainsString('value="42" selected=\'selected\'', $tags);
self::assertStringContainsString('value="7" selected=\'selected\'', $tags);
self::assertStringContainsString('value="3" selected=\'selected\'', $tags);
// Both ticks and the note survive too.
self::assertSame(2, substr_count($html, "checked='checked'"));
self::assertStringContainsString('value="Make-up lesson"', $html);
}
public function testASuccessfulBookingLeavesAnEmptyFormForTheNextOne(): void
{
$this->postBooking();
$this->offerPanel();
$this->adminBooking->shouldReceive('book')->once()->andReturn('Booked.');
$this->bookings->shouldReceive('findAllUpcoming')->once()->andReturn([]);
$html = $this->render();
self::assertStringContainsString('Booked.', $html);
// Nothing carried over, or the next booking silently inherits this one's.
self::assertStringNotContainsString("selected='selected'", $html);
self::assertStringNotContainsString("checked='checked'", $html);
self::assertStringContainsString('value=""', $html);
}
public function testTheStudioSchedulerBooksAgainstAnyInstructorsTime(): void public function testTheStudioSchedulerBooksAgainstAnyInstructorsTime(): void
{ {
$this->postBooking(); $this->postBooking();
@@ -390,6 +438,16 @@ class LessonControllerTest extends TestCase
Functions\when('absint')->alias(static fn ($v): int => abs((int) $v)); Functions\when('absint')->alias(static fn ($v): int => abs((int) $v));
} }
/** The panel with one of each choice, so a re-selected value has somewhere to land. */
private function offerPanel(): void
{
$this->adminBooking->shouldReceive('formData')->once()->andReturn([
'students' => [['id' => 42, 'name' => 'Ada Lovelace']],
'offerings' => [['id' => 3, 'label' => '30 min piano (30 min)']],
'slots' => [['id' => 7, 'label' => 'Wed Jul 1, 2026 10:00 AM (30 min)', 'weekly' => false]],
]);
}
public function testAStaffBookedLessonOffersTheRecordIntakeForm(): void public function testAStaffBookedLessonOffersTheRecordIntakeForm(): void
{ {
$_GET['lesson_id'] = '1'; $_GET['lesson_id'] = '1';
@@ -7,6 +7,7 @@ use Brain\Monkey\Functions;
use Mockery; use Mockery;
use Unsupervised\Schedular\Auth\InviteRepository; use Unsupervised\Schedular\Auth\InviteRepository;
use Unsupervised\Schedular\Auth\RegistrationMailer; use Unsupervised\Schedular\Auth\RegistrationMailer;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\GroupClass\Enrollment; use Unsupervised\Schedular\GroupClass\Enrollment;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository; use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository; use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
@@ -87,7 +88,8 @@ class GroupClassControllerTest extends TestCase
{ {
[$first, $last] = array_pad(explode(' ', $full, 2), 2, ''); [$first, $last] = array_pad(explode(' ', $full, 2), 2, '');
$user = Mockery::mock(\WP_User::class); $user = Mockery::mock(\WP_User::class);
$user->roles = [RoleManager::STUDENT];
$user->first_name = $first; $user->first_name = $first;
$user->last_name = $last; $user->last_name = $last;
$user->nickname = $full; $user->nickname = $full;
@@ -406,6 +408,8 @@ class GroupClassControllerTest extends TestCase
Functions\when('wp_unslash')->returnArg(); Functions\when('wp_unslash')->returnArg();
Functions\when('sanitize_email')->returnArg(); Functions\when('sanitize_email')->returnArg();
Functions\when('absint')->alias(static fn ($v) => abs((int) $v)); Functions\when('absint')->alias(static fn ($v) => abs((int) $v));
// Every posted id is vetted as a student before it is enrolled or granted.
Functions\when('get_userdata')->justReturn($this->userNamed('Ada Lovelace'));
// Render tail: no classes/enrolments to draw so the assertion targets the notice. // Render tail: no classes/enrolments to draw so the assertion targets the notice.
$this->offerings->shouldReceive('findAll')->with(3, Offering::KIND_GROUP_CLASS)->andReturn([]); $this->offerings->shouldReceive('findAll')->with(3, Offering::KIND_GROUP_CLASS)->andReturn([]);
@@ -544,6 +548,64 @@ class GroupClassControllerTest extends TestCase
$this->audit->shouldReceive('acceptances')->with($enrollment)->andReturn([]); $this->audit->shouldReceive('acceptances')->with($enrollment)->andReturn([]);
} }
/**
* The multi-select is built from the studio's students, but a posted id is just
* a number: it could name an instructor, an administrator, or an account
* deleted since the page was drawn. Enrolling one would put a non-student on
* the roster and raise a payment against them.
*/
public function testAddDirectIgnoresAnIdThatIsNotAStudent(): void
{
$_POST = ['usc_action' => 'add_direct', 'offering_id' => 8, 'student_ids' => [5]];
$this->stubActionContext();
$instructor = Mockery::mock(\WP_User::class);
$instructor->roles = [RoleManager::INSTRUCTOR];
Functions\when('get_userdata')->justReturn($instructor);
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->inviteOnlyOffering(100.0));
$this->enrollments->shouldReceive('insert')->never();
$this->paymentService->shouldReceive('createForRegistration')->never();
$this->access->shouldReceive('markEnrolled')->never();
$html = $this->renderInstructor();
self::assertStringContainsString('0 student(s) added to the class.', $html);
}
public function testAddDirectIgnoresAnAccountThatNoLongerExists(): void
{
$_POST = ['usc_action' => 'add_direct', 'offering_id' => 8, 'student_ids' => [5]];
$this->stubActionContext();
Functions\when('get_userdata')->justReturn(false);
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->inviteOnlyOffering(100.0));
$this->enrollments->shouldReceive('insert')->never();
$this->paymentService->shouldReceive('createForRegistration')->never();
$html = $this->renderInstructor();
self::assertStringContainsString('0 student(s) added to the class.', $html);
}
public function testGrantAccessIgnoresAnIdThatIsNotAStudent(): void
{
$_POST = ['usc_action' => 'grant_access', 'offering_id' => 8, 'student_ids' => [5]];
$this->stubActionContext();
$instructor = Mockery::mock(\WP_User::class);
$instructor->roles = [RoleManager::INSTRUCTOR];
Functions\when('get_userdata')->justReturn($instructor);
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->inviteOnlyOffering());
$this->access->shouldReceive('insert')->never();
$this->mailer->shouldReceive('sendClassAccessGranted')->never();
$html = $this->renderInstructor();
self::assertStringContainsString('0 student(s) granted access.', $html);
}
public function testGrantAccessCreatesGrantAndEmailsStudent(): void public function testGrantAccessCreatesGrantAndEmailsStudent(): void
{ {
$_POST = ['usc_action' => 'grant_access', 'offering_id' => 8, 'student_ids' => [5]]; $_POST = ['usc_action' => 'grant_access', 'offering_id' => 8, 'student_ids' => [5]];
@@ -554,7 +616,8 @@ class GroupClassControllerTest extends TestCase
$this->access->shouldReceive('hasGrant')->with(8, 5)->andReturn(false); $this->access->shouldReceive('hasGrant')->with(8, 5)->andReturn(false);
$this->access->shouldReceive('insert')->once()->andReturn(1); $this->access->shouldReceive('insert')->once()->andReturn(1);
$user = Mockery::mock(\WP_User::class); $user = Mockery::mock(\WP_User::class);
$user->roles = [RoleManager::STUDENT];
$user->user_email = '[email protected]'; $user->user_email = '[email protected]';
Functions\when('get_userdata')->justReturn($user); Functions\when('get_userdata')->justReturn($user);
$this->mailer->shouldReceive('sendClassAccessGranted')->once()->with($user, 'Private Choir')->andReturn(true); $this->mailer->shouldReceive('sendClassAccessGranted')->once()->with($user, 'Private Choir')->andReturn(true);
+2 -2
View File
@@ -3,7 +3,7 @@
* Plugin Name: Unsupervised Scheduler * Plugin Name: Unsupervised Scheduler
* Plugin URI: https://git.unsupervised.ca/Unsupervised/unsupervised-scheduler * Plugin URI: https://git.unsupervised.ca/Unsupervised/unsupervised-scheduler
* Description: Instructor/student lesson scheduling for WordPress. * Description: Instructor/student lesson scheduling for WordPress.
* Version: 1.5.3 * Version: 1.5.5
* Requires at least: 6.2 * Requires at least: 6.2
* Requires PHP: 8.1 * Requires PHP: 8.1
* Author: Unsupervised * Author: Unsupervised
@@ -21,7 +21,7 @@ if (! defined('ABSPATH')) {
exit; exit;
} }
define('USC_VERSION', '1.5.3'); define('USC_VERSION', '1.5.5');
define('USC_PLUGIN_FILE', __FILE__); define('USC_PLUGIN_FILE', __FILE__);
define('USC_PLUGIN_DIR', plugin_dir_path(__FILE__)); define('USC_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('USC_PLUGIN_URL', plugin_dir_url(__FILE__)); define('USC_PLUGIN_URL', plugin_dir_url(__FILE__));