Files
unsupervised-scheduler/tests/Unit/Payment/StudioSettingsTest.php
T
thatguygriffandClaude Opus 5 c077a653fb
CI / No Debug Code (pull_request) Successful in 5s
CI / Tests (PHP 8.2) (pull_request) Successful in 54s
CI / Tests (PHP 8.1) (pull_request) Successful in 56s
CI / Coding Standards (pull_request) Successful in 2m55s
CI / PHPStan (pull_request) Successful in 3m48s
CI / Tests (PHP 8.3) (pull_request) Successful in 5m31s
CI / Build Plugin Zip (pull_request) Skipped
Let a studio pick its default payment method and disconnect Stripe
Stripe configuration was a one-way door. Keys could be entered but never
removed, and entering them moved every student onto card billing at once,
so there was no way to have Stripe live and satisfy yourself that card
payments worked before committing the studio to them.

Two settings-page changes open both directions:

Default payment method (`us_default_payment_method`) is now an explicit
choice between card and e-transfer for students with no per-student
override, rather than something inferred from whether keys exist. Card
remains the default, so a site that adds keys and changes nothing else
behaves as before. BillingMethodResolver still degrades a card default to
e-transfer while Stripe is unconfigured — there is nothing to charge a
card with — and `comp` is deliberately not offerable studio-wide, since
it would silently stop billing everybody; an unrecognised stored value
reads back as card. Holding the default on e-transfer with Stripe live is
the staged-rollout path: move individual students to card on their detail
page, watch real charges land, then flip the studio over.

Clear Stripe configuration deletes the publishable key, secret key and
webhook signing secret and returns the mode to test, so a re-configuration
later cannot inherit live. Currency, HST, e-transfer and registration
settings are untouched, as are recorded payments. The button only appears
when some Stripe value is stored, and reuses the page's existing nonce and
`manage_billing` check.

`composer test` (915), `composer lint` and `composer cs` all pass. Options
only — no schema change, so no version bump.

Closes #173

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WyktWmwNRgMYuwe5eBuPZm
2026-08-20 12:43:29 -03:00

167 lines
7.1 KiB
PHP

<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Payment;
use Brain\Monkey\Functions;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Payment\Payment;
use Unsupervised\Schedular\Payment\StudioSettings;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class StudioSettingsTest extends TestCase
{
public function testRegistrationModeDefaultsToInvite(): void
{
Functions\when('get_option')->alias(static fn (string $name, $default) => $default);
$settings = new StudioSettings();
self::assertSame(StudioSettings::MODE_INVITE, $settings->registrationMode());
self::assertFalse($settings->openRegistrationEnabled());
}
public function testCancellationCutoffDefaultsToOneDay(): void
{
Functions\when('get_option')->alias(static fn (string $name, $default) => $default);
self::assertSame(24, (new StudioSettings())->cancellationCutoffHours());
}
public function testCancellationCutoffReadsStoredHours(): void
{
Functions\when('get_option')->alias(static fn (string $name) =>
$name === StudioSettings::OPT_CANCELLATION_CUTOFF_HOURS ? '72' : '');
self::assertSame(72, (new StudioSettings())->cancellationCutoffHours());
}
public function testCancellationCutoffClampsNegativeToZero(): void
{
Functions\when('get_option')->alias(static fn (string $name) =>
$name === StudioSettings::OPT_CANCELLATION_CUTOFF_HOURS ? '-5' : '');
self::assertSame(0, (new StudioSettings())->cancellationCutoffHours());
}
public function testOpenRegistrationEnabledWhenStored(): void
{
Functions\when('get_option')->alias(static fn (string $name) =>
$name === StudioSettings::OPT_REGISTRATION_MODE ? StudioSettings::MODE_SELF_APPROVAL : '');
self::assertTrue((new StudioSettings())->openRegistrationEnabled());
}
public function testEnablingMirrorsCoreOptionsAndSnapshots(): void
{
Functions\when('get_option')->alias(static function (string $name, $default = false) {
return match ($name) {
StudioSettings::OPT_REGISTRATION_MODE => StudioSettings::MODE_INVITE, // currently closed
'users_can_register' => false, // snapshot as '0'
'default_role' => 'contributor',
default => $default,
};
});
Functions\expect('update_option')->once()->with(StudioSettings::OPT_PREV_USERS_CAN_REGISTER, '0');
Functions\expect('update_option')->once()->with(StudioSettings::OPT_PREV_DEFAULT_ROLE, 'contributor');
Functions\expect('update_option')->once()->with('users_can_register', '1');
Functions\expect('update_option')->once()->with('default_role', RoleManager::STUDENT);
Functions\expect('update_option')->once()->with(StudioSettings::OPT_REGISTRATION_MODE, StudioSettings::MODE_SELF_APPROVAL);
$this->applyMode(true);
}
public function testDisablingRestoresSnapshot(): void
{
Functions\when('get_option')->alias(static function (string $name, $default = false) {
return match ($name) {
StudioSettings::OPT_REGISTRATION_MODE => StudioSettings::MODE_SELF_APPROVAL, // currently open
StudioSettings::OPT_PREV_USERS_CAN_REGISTER => '1',
StudioSettings::OPT_PREV_DEFAULT_ROLE => 'contributor',
default => $default,
};
});
Functions\expect('update_option')->once()->with('users_can_register', '1');
Functions\expect('update_option')->once()->with('default_role', 'contributor');
Functions\expect('delete_option')->once()->with(StudioSettings::OPT_PREV_USERS_CAN_REGISTER);
Functions\expect('delete_option')->once()->with(StudioSettings::OPT_PREV_DEFAULT_ROLE);
Functions\expect('update_option')->once()->with(StudioSettings::OPT_REGISTRATION_MODE, StudioSettings::MODE_INVITE);
$this->applyMode(false);
}
public function testNoTransitionLeavesCoreOptionsUntouched(): void
{
// Already open, asked to enable again: nothing should be written.
Functions\when('get_option')->alias(static fn (string $name, $default = false) =>
$name === StudioSettings::OPT_REGISTRATION_MODE ? StudioSettings::MODE_SELF_APPROVAL : $default);
Functions\expect('update_option')->never();
Functions\expect('delete_option')->never();
$this->applyMode(true);
}
public function testDefaultPaymentMethodIsCardWhenUnset(): void
{
Functions\when('get_option')->alias(static fn (string $name, $default) => $default);
self::assertSame(Payment::METHOD_CARD, (new StudioSettings())->defaultPaymentMethod());
}
public function testDefaultPaymentMethodReadsStoredEtransfer(): void
{
Functions\when('get_option')->alias(static fn (string $name) =>
$name === StudioSettings::OPT_DEFAULT_PAYMENT_METHOD ? Payment::METHOD_ETRANSFER : '');
self::assertSame(Payment::METHOD_ETRANSFER, (new StudioSettings())->defaultPaymentMethod());
}
public function testUnrecognisedStoredDefaultPaymentMethodFallsBackToCard(): void
{
// `comp` is a per-student choice only: it must never become the studio
// default and quietly stop billing anyone.
Functions\when('get_option')->alias(static fn (string $name) =>
$name === StudioSettings::OPT_DEFAULT_PAYMENT_METHOD ? Payment::METHOD_COMP : '');
self::assertSame(Payment::METHOD_CARD, (new StudioSettings())->defaultPaymentMethod());
}
public function testClearStripeConfigDeletesEveryStripeOption(): void
{
Functions\expect('delete_option')->once()->with(StudioSettings::OPT_PUBLISHABLE);
Functions\expect('delete_option')->once()->with(StudioSettings::OPT_SECRET);
Functions\expect('delete_option')->once()->with(StudioSettings::OPT_WEBHOOK_SECRET);
Functions\expect('delete_option')->once()->with(StudioSettings::OPT_MODE);
(new StudioSettings())->clearStripeConfig();
}
public function testClearStripeConfigLeavesNonStripeSettingsAlone(): void
{
$deleted = [];
Functions\when('delete_option')->alias(static function (string $name) use (&$deleted): bool {
$deleted[] = $name;
return true;
});
Functions\expect('update_option')->never();
(new StudioSettings())->clearStripeConfig();
self::assertNotContains(StudioSettings::OPT_CURRENCY, $deleted);
self::assertNotContains(StudioSettings::OPT_ETRANSFER_EMAIL, $deleted);
self::assertNotContains(StudioSettings::OPT_HST_RATE, $deleted);
self::assertNotContains(StudioSettings::OPT_REGISTRATION_MODE, $deleted);
self::assertNotContains(StudioSettings::OPT_CANCELLATION_CUTOFF_HOURS, $deleted);
}
private function applyMode(bool $enable): void
{
$method = new \ReflectionMethod(StudioSettings::class, 'applyRegistrationMode');
$method->invoke(new StudioSettings(), $enable);
}
}