CI / Tests (PHP 8.2) (pull_request) Successful in 58s
CI / Tests (PHP 8.1) (pull_request) Successful in 58s
CI / No Debug Code (pull_request) Successful in 3s
CI / Coding Standards (pull_request) Successful in 2m53s
CI / PHPStan (pull_request) Successful in 3m0s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m42s
CI / Build Plugin Zip (pull_request) Skipped
Every account-signup question was asked of everybody who registered, on the same terms: "school and grade" had to be put to an adult signing themselves up, and a question a studio needed answered for each student could only be made required by demanding it of everyone. A question now carries an audience — everyone, or only the students someone registers on behalf of — and its own required flag for each side, so optional for you and required for every student you enrol is expressible. Both settings are account-scope only: an offering asks its questions once, about the student being booked, so there is no second audience to differ from, and an offering question mirrors its single "required" into both columns. Every caller reads askedOfSelf()/isRequiredForSelf()/isRequiredForChild() rather than the raw flags, so a students-only question can neither block the account holder nor have an answer filed against them by a crafted post. The family screen, which only ever adds a student, is held to the students' rule. is_required_child arrives from dbDelta defaulting to 0, which would quietly stop every existing required question being required of the students a guardian registers — the case it most likely existed for. A one-time backfill copies is_required across, guarded by its own option so a question later made optional for students stays that way. Closes #163 Co-Authored-By: Claude Opus 5 <[email protected]>
293 lines
9.9 KiB
PHP
293 lines
9.9 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace Unsupervised\Schedular\Tests\Unit\Registration;
|
|
|
|
use Brain\Monkey\Functions;
|
|
use Mockery;
|
|
use Unsupervised\Schedular\Registration\Question;
|
|
use Unsupervised\Schedular\Registration\QuestionRepository;
|
|
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
|
|
|
class QuestionRepositoryTest extends TestCase
|
|
{
|
|
private \wpdb $db;
|
|
private QuestionRepository $repo;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
$this->db = Mockery::mock(\wpdb::class);
|
|
$this->db->prefix = 'wp_';
|
|
$this->repo = new QuestionRepository($this->db);
|
|
}
|
|
|
|
public function testInsertWithNullOptionsStoresNull(): void
|
|
{
|
|
Functions\expect('current_time')->with('mysql')->andReturn('2026-04-01 12:00:00');
|
|
|
|
$this->db->shouldReceive('insert')
|
|
->once()
|
|
->with(
|
|
'wp_us_questions',
|
|
Mockery::on(static function (array $data): bool {
|
|
return $data['offering_id'] === 7
|
|
&& $data['label'] === 'Your level?'
|
|
&& $data['field_type'] === Question::FIELD_TEXT
|
|
&& $data['options'] === null
|
|
&& $data['is_required'] === 0
|
|
&& $data['created_at'] === '2026-04-01 12:00:00';
|
|
}),
|
|
Mockery::type('array')
|
|
);
|
|
|
|
$this->db->insert_id = 21;
|
|
|
|
$question = new Question(7, 'Your level?');
|
|
|
|
self::assertSame(21, $this->repo->insert($question));
|
|
}
|
|
|
|
public function testInsertEncodesOptionsAsJson(): void
|
|
{
|
|
Functions\expect('current_time')->andReturn('2026-04-01 12:00:00');
|
|
Functions\expect('wp_json_encode')
|
|
->once()
|
|
->with(['Beginner', 'Advanced'])
|
|
->andReturn('["Beginner","Advanced"]');
|
|
|
|
$this->db->shouldReceive('insert')
|
|
->once()
|
|
->with(
|
|
'wp_us_questions',
|
|
Mockery::on(static fn (array $data): bool => $data['options'] === '["Beginner","Advanced"]'),
|
|
Mockery::type('array')
|
|
);
|
|
|
|
$this->db->insert_id = 22;
|
|
|
|
$question = new Question(
|
|
offeringId: 7,
|
|
label: 'Pick a level',
|
|
fieldType: Question::FIELD_SELECT,
|
|
options: ['Beginner', 'Advanced'],
|
|
);
|
|
|
|
self::assertSame(22, $this->repo->insert($question));
|
|
}
|
|
|
|
public function testUpdateReturnsTrueOnSuccess(): void
|
|
{
|
|
$this->db->shouldReceive('update')
|
|
->once()
|
|
->with(
|
|
'wp_us_questions',
|
|
Mockery::on(static fn (array $data): bool => $data['label'] === 'Renamed' && $data['is_required'] === 1),
|
|
['id' => 5],
|
|
Mockery::type('array'),
|
|
['%d']
|
|
)
|
|
->andReturn(1);
|
|
|
|
$question = new Question(7, 'Renamed', isRequired: true, id: 5);
|
|
|
|
self::assertTrue($this->repo->update(5, $question));
|
|
}
|
|
|
|
public function testFindByOfferingActiveOnlyPreparesQuery(): void
|
|
{
|
|
$this->db->shouldReceive('prepare')
|
|
->once()
|
|
->with(
|
|
Mockery::pattern('/offering_id = %d AND is_active = %d/'),
|
|
Mockery::on(static fn (array $p): bool => $p === ['wp_us_questions', 7, 1])
|
|
)
|
|
->andReturn('SELECT ...');
|
|
|
|
$this->db->shouldReceive('get_results')->andReturn([]);
|
|
|
|
self::assertSame([], $this->repo->findByOffering(7, activeOnly: true));
|
|
}
|
|
|
|
public function testFindByOfferingReturnsQuestions(): void
|
|
{
|
|
$row = (object) [
|
|
'id' => '3',
|
|
'offering_id' => '7',
|
|
'scope' => Question::SCOPE_OFFERING,
|
|
'label' => 'Q',
|
|
'field_type' => Question::FIELD_TEXT,
|
|
'options' => null,
|
|
'is_required' => '0',
|
|
'sort_order' => '0',
|
|
'is_active' => '1',
|
|
];
|
|
|
|
$this->db->shouldReceive('prepare')->andReturn('SELECT ...');
|
|
$this->db->shouldReceive('get_results')->andReturn([$row]);
|
|
|
|
$questions = $this->repo->findByOffering(7);
|
|
|
|
self::assertCount(1, $questions);
|
|
self::assertInstanceOf(Question::class, $questions[0]);
|
|
}
|
|
|
|
public function testInsertAccountQuestionStoresScopeAndNullOffering(): void
|
|
{
|
|
Functions\expect('current_time')->andReturn('2026-04-01 12:00:00');
|
|
|
|
$this->db->shouldReceive('insert')
|
|
->once()
|
|
->with(
|
|
'wp_us_questions',
|
|
Mockery::on(static function (array $data): bool {
|
|
return $data['offering_id'] === null
|
|
&& $data['scope'] === Question::SCOPE_ACCOUNT
|
|
&& $data['label'] === 'Emergency contact';
|
|
}),
|
|
Mockery::type('array')
|
|
);
|
|
|
|
$this->db->insert_id = 30;
|
|
|
|
$question = new Question(null, 'Emergency contact', scope: Question::SCOPE_ACCOUNT);
|
|
|
|
self::assertSame(30, $this->repo->insert($question));
|
|
}
|
|
|
|
public function testInsertStoresAudienceAndTheStudentsRequiredFlag(): void
|
|
{
|
|
Functions\expect('current_time')->andReturn('2026-04-01 12:00:00');
|
|
|
|
$this->db->shouldReceive('insert')
|
|
->once()
|
|
->with(
|
|
'wp_us_questions',
|
|
Mockery::on(static function (array $data): bool {
|
|
return $data['audience'] === Question::AUDIENCE_CHILD
|
|
&& $data['is_required'] === 0
|
|
&& $data['is_required_child'] === 1;
|
|
}),
|
|
// One placeholder per column, in the same order.
|
|
Mockery::on(static fn (array $format): bool => count($format) === 11)
|
|
);
|
|
|
|
$this->db->insert_id = 31;
|
|
|
|
$question = new Question(
|
|
null,
|
|
'School and grade',
|
|
scope: Question::SCOPE_ACCOUNT,
|
|
audience: Question::AUDIENCE_CHILD,
|
|
isRequiredChild: true
|
|
);
|
|
|
|
self::assertSame(31, $this->repo->insert($question));
|
|
}
|
|
|
|
public function testBackfillChildRequiredCopiesTheOldRequiredFlagAcross(): void
|
|
{
|
|
$this->db->shouldReceive('prepare')
|
|
->once()
|
|
->with(Mockery::pattern('/UPDATE %i SET is_required_child = 1 WHERE is_required = 1/'), 'wp_us_questions')
|
|
->andReturn('UPDATE `wp_us_questions` SET is_required_child = 1 WHERE is_required = 1');
|
|
|
|
$this->db->shouldReceive('query')
|
|
->once()
|
|
->with('UPDATE `wp_us_questions` SET is_required_child = 1 WHERE is_required = 1')
|
|
->andReturn(2);
|
|
|
|
self::assertTrue($this->repo->backfillChildRequired());
|
|
}
|
|
|
|
public function testBackfillChildRequiredReportsFailureWhenQueryFails(): void
|
|
{
|
|
$this->db->shouldReceive('prepare')->once()->andReturn('UPDATE ...');
|
|
$this->db->shouldReceive('query')->once()->andReturn(false);
|
|
|
|
self::assertFalse($this->repo->backfillChildRequired());
|
|
}
|
|
|
|
public function testFindByScopeActiveOnlyPreparesQuery(): void
|
|
{
|
|
$this->db->shouldReceive('prepare')
|
|
->once()
|
|
->with(
|
|
Mockery::pattern('/scope = %s AND is_active = %d/'),
|
|
Mockery::on(static fn (array $p): bool => $p === ['wp_us_questions', Question::SCOPE_ACCOUNT, 1])
|
|
)
|
|
->andReturn('SELECT ...');
|
|
|
|
$this->db->shouldReceive('get_results')->andReturn([]);
|
|
|
|
self::assertSame([], $this->repo->findByScope(Question::SCOPE_ACCOUNT, activeOnly: true));
|
|
}
|
|
|
|
public function testFindByScopeReturnsQuestions(): void
|
|
{
|
|
$row = (object) [
|
|
'id' => '5',
|
|
'offering_id' => null,
|
|
'scope' => Question::SCOPE_ACCOUNT,
|
|
'label' => 'How did you hear about us?',
|
|
'field_type' => Question::FIELD_TEXT,
|
|
'options' => null,
|
|
'is_required' => '1',
|
|
'sort_order' => '0',
|
|
'is_active' => '1',
|
|
];
|
|
|
|
$this->db->shouldReceive('prepare')->andReturn('SELECT ...');
|
|
$this->db->shouldReceive('get_results')->andReturn([$row]);
|
|
|
|
$questions = $this->repo->findByScope(Question::SCOPE_ACCOUNT);
|
|
|
|
self::assertCount(1, $questions);
|
|
self::assertNull($questions[0]->offeringId);
|
|
self::assertSame(Question::SCOPE_ACCOUNT, $questions[0]->scope);
|
|
}
|
|
|
|
public function testFindByIdReturnsNullWhenNotFound(): void
|
|
{
|
|
$this->db->shouldReceive('prepare')->andReturn('SELECT ...');
|
|
$this->db->shouldReceive('get_row')->andReturn(null);
|
|
|
|
self::assertNull($this->repo->findById(99));
|
|
}
|
|
|
|
public function testDeleteCallsWpdbDelete(): void
|
|
{
|
|
$this->db->shouldReceive('delete')
|
|
->once()
|
|
->with('wp_us_questions', ['id' => 4], ['%d'])
|
|
->andReturn(1);
|
|
|
|
self::assertTrue($this->repo->delete(4));
|
|
}
|
|
|
|
public function testEnsureOfferingNullableRunsAlterAndReportsSuccess(): void
|
|
{
|
|
$this->db->shouldReceive('prepare')
|
|
->once()
|
|
->with(Mockery::pattern('/ALTER TABLE %i MODIFY offering_id .*NULL/'), 'wp_us_questions')
|
|
->andReturn('ALTER TABLE `wp_us_questions` MODIFY offering_id BIGINT UNSIGNED NULL DEFAULT NULL');
|
|
|
|
$this->db->shouldReceive('query')
|
|
->once()
|
|
->with('ALTER TABLE `wp_us_questions` MODIFY offering_id BIGINT UNSIGNED NULL DEFAULT NULL')
|
|
->andReturn(0);
|
|
|
|
// A successful DDL query returns 0 rows affected (not false).
|
|
self::assertTrue($this->repo->ensureOfferingNullable());
|
|
}
|
|
|
|
public function testEnsureOfferingNullableReportsFailureWhenQueryFails(): void
|
|
{
|
|
$this->db->shouldReceive('prepare')->once()->andReturn('ALTER ...');
|
|
$this->db->shouldReceive('query')->once()->andReturn(false);
|
|
|
|
self::assertFalse($this->repo->ensureOfferingNullable());
|
|
}
|
|
}
|