added unit tests
This commit is contained in:
22
tests/Integration/ApiRoutingTest.php
Normal file
22
tests/Integration/ApiRoutingTest.php
Normal file
@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Integration;
|
||||
|
||||
use Tests\Support\QdbTestCase;
|
||||
|
||||
final class ApiRoutingTest extends QdbTestCase
|
||||
{
|
||||
public function testUnknownRoute404(): void
|
||||
{
|
||||
$res = $this->api()->request('GET', 'does-not-exist');
|
||||
$this->assertApiError($res, 'NOT_FOUND', 404);
|
||||
}
|
||||
|
||||
public function testUnauthorizedWithoutToken(): void
|
||||
{
|
||||
$res = $this->api()->request('GET', 'questionnaires');
|
||||
$this->assertApiError($res, 'UNAUTHORIZED', 401);
|
||||
}
|
||||
}
|
||||
49
tests/Integration/AppQuestionnairesTest.php
Normal file
49
tests/Integration/AppQuestionnairesTest.php
Normal file
@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Integration;
|
||||
|
||||
use Tests\Support\DatabaseSeeder;
|
||||
use Tests\Support\QdbTestCase;
|
||||
|
||||
final class AppQuestionnairesTest extends QdbTestCase
|
||||
{
|
||||
public function testCoachSubmitsQuestionnaire(): void
|
||||
{
|
||||
$res = $this->submitFixtureQuestionnaire();
|
||||
$this->assertApiOk($res);
|
||||
$this->assertTrue($res['data']['submitted'] ?? false);
|
||||
}
|
||||
|
||||
public function testCoachLoadsQuestionnaireDefinition(): void
|
||||
{
|
||||
$login = $this->api()->loginMobileAndGetToken(
|
||||
DatabaseSeeder::COACH_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
);
|
||||
$res = $this->api()->withMobileToken($login['token'], 'GET', 'app_questionnaires', null, [
|
||||
'id' => $this->fixture()->questionnaireId,
|
||||
]);
|
||||
$this->assertApiOk($res);
|
||||
$this->assertNotEmpty($res['data']['questions']);
|
||||
}
|
||||
|
||||
public function testCoachListsAssignedClients(): void
|
||||
{
|
||||
$this->submitFixtureQuestionnaire();
|
||||
$login = $this->api()->loginMobileAndGetToken(
|
||||
DatabaseSeeder::COACH_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
);
|
||||
$res = $this->api()->withMobileToken($login['token'], 'GET', 'app_questionnaires', null, [
|
||||
'clients' => '1',
|
||||
]);
|
||||
$this->assertApiOk($res);
|
||||
$this->assertTrue($res['data']['encrypted'] ?? false);
|
||||
$plain = qdb_decrypt_sensitive_envelope($res['data'], $login['token']);
|
||||
$payload = json_decode($plain, true, 512, JSON_THROW_ON_ERROR);
|
||||
$codes = array_column($payload['clients'], 'clientCode');
|
||||
$this->assertContains($this->fixture()->clientCode, $codes);
|
||||
}
|
||||
}
|
||||
183
tests/Integration/AppSubmitExtendedTest.php
Normal file
183
tests/Integration/AppSubmitExtendedTest.php
Normal file
@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Integration;
|
||||
|
||||
use Tests\Support\DatabaseSeeder;
|
||||
use Tests\Support\QdbTestCase;
|
||||
|
||||
final class AppSubmitExtendedTest extends QdbTestCase
|
||||
{
|
||||
public function testResubmitCreatesSecondSubmissionVersion(): void
|
||||
{
|
||||
$login = $this->api()->loginMobileAndGetToken(
|
||||
DatabaseSeeder::COACH_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
);
|
||||
$f = $this->fixture();
|
||||
|
||||
$this->assertApiOk($this->submitFixtureQuestionnaire());
|
||||
$this->assertSame(1, $this->submissionVersionCount($f->clientCode, $f->questionnaireId));
|
||||
|
||||
$res = $this->submitQuestionnaireAs($login['token'], $f->questionnaireId, $f->clientCode, [
|
||||
['questionID' => $f->questionShortId, 'answerOptionKey' => 'yes'],
|
||||
]);
|
||||
$this->assertApiOk($res);
|
||||
$this->assertTrue($res['data']['submitted'] ?? false);
|
||||
$this->assertSame(2, $this->submissionVersionCount($f->clientCode, $f->questionnaireId));
|
||||
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT COUNT(*) FROM client_answer_submission cas
|
||||
INNER JOIN questionnaire_submission qs ON qs.submissionID = cas.submissionID
|
||||
WHERE qs.clientCode = :cc AND qs.questionnaireID = :qn'
|
||||
);
|
||||
$stmt->execute([':cc' => $f->clientCode, ':qn' => $f->questionnaireId]);
|
||||
$this->assertGreaterThanOrEqual(2, (int)$stmt->fetchColumn());
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
}
|
||||
|
||||
public function testSupervisorCanSubmitForClient(): void
|
||||
{
|
||||
$login = $this->api()->loginMobileAndGetToken(
|
||||
DatabaseSeeder::SUPERVISOR_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
);
|
||||
$f = $this->fixture();
|
||||
$res = $this->submitQuestionnaireAs($login['token'], $f->questionnaireId, $f->clientCode, [
|
||||
['questionID' => $f->questionShortId, 'answerOptionKey' => 'yes'],
|
||||
]);
|
||||
$this->assertApiOk($res);
|
||||
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT submittedByRole FROM questionnaire_submission
|
||||
WHERE clientCode = :cc AND questionnaireID = :qn ORDER BY version DESC LIMIT 1'
|
||||
);
|
||||
$stmt->execute([':cc' => $f->clientCode, ':qn' => $f->questionnaireId]);
|
||||
$this->assertSame('supervisor', $stmt->fetchColumn());
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
}
|
||||
|
||||
public function testSubmitWithFreeTextAndGlassSymptom(): void
|
||||
{
|
||||
$login = $this->api()->loginMobileAndGetToken(
|
||||
DatabaseSeeder::COACH_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
);
|
||||
$f = $this->fixture();
|
||||
$res = $this->submitQuestionnaireAs($login['token'], $f->questionnaireId, $f->clientCode, [
|
||||
['questionID' => $f->questionShortId, 'answerOptionKey' => 'yes'],
|
||||
['questionID' => $f->freeTextShortId, 'freeTextValue' => 'Feeling better'],
|
||||
['questionID' => $f->glassSymptomKey, 'freeTextValue' => 'moderate'],
|
||||
]);
|
||||
$this->assertApiOk($res);
|
||||
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
$ft = $pdo->prepare(
|
||||
'SELECT freeTextValue FROM client_answer WHERE clientCode = :cc AND questionID = :qid'
|
||||
);
|
||||
$ft->execute([':cc' => $f->clientCode, ':qid' => $f->freeTextQuestionId]);
|
||||
$this->assertSame('Feeling better', $ft->fetchColumn());
|
||||
|
||||
$glass = $pdo->prepare(
|
||||
'SELECT freeTextValue FROM client_answer WHERE clientCode = :cc AND questionID = :qid'
|
||||
);
|
||||
$glass->execute([':cc' => $f->clientCode, ':qid' => $f->glassQuestionId]);
|
||||
$json = (string)$glass->fetchColumn();
|
||||
$decoded = json_decode($json, true);
|
||||
$this->assertIsArray($decoded);
|
||||
$this->assertSame('moderate', $decoded[$f->glassSymptomKey] ?? null);
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
}
|
||||
|
||||
public function testCoachLoadsEncryptedAnswersBundle(): void
|
||||
{
|
||||
$this->submitFixtureQuestionnaire();
|
||||
$login = $this->api()->loginMobileAndGetToken(
|
||||
DatabaseSeeder::COACH_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
);
|
||||
$f = $this->fixture();
|
||||
$res = $this->api()->withMobileToken($login['token'], 'GET', 'app_questionnaires', null, [
|
||||
'clientCode' => $f->clientCode,
|
||||
'answers' => '1',
|
||||
]);
|
||||
$this->assertApiOk($res);
|
||||
$this->assertTrue($res['data']['encrypted'] ?? false);
|
||||
$plain = qdb_decrypt_sensitive_envelope($res['data'], $login['token']);
|
||||
$payload = json_decode($plain, true, 512, JSON_THROW_ON_ERROR);
|
||||
$this->assertSame($f->clientCode, $payload['clientCode']);
|
||||
$this->assertNotEmpty($payload['questionnaires']);
|
||||
}
|
||||
|
||||
public function testMultiCheckSubmitAccumulatesPoints(): void
|
||||
{
|
||||
$login = $this->api()->loginMobileAndGetToken(
|
||||
DatabaseSeeder::COACH_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
);
|
||||
$f = $this->fixture();
|
||||
$res = $this->submitQuestionnaireAs($login['token'], $f->questionnaireId, $f->clientCode, [
|
||||
['questionID' => $f->questionShortId, 'answerOptionKey' => 'yes'],
|
||||
['questionID' => $f->multiCheckShortId, 'answerOptionKey' => 'opt_a'],
|
||||
['questionID' => $f->multiCheckShortId, 'answerOptionKey' => 'opt_b'],
|
||||
]);
|
||||
$this->assertApiOk($res);
|
||||
$this->assertSame(3, $res['data']['sumPoints'] ?? 0);
|
||||
}
|
||||
|
||||
public function testRequiredQuestionMissingFailsValidation(): void
|
||||
{
|
||||
$login = $this->api()->loginMobileAndGetToken(
|
||||
DatabaseSeeder::COACH_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
);
|
||||
$f = $this->fixture();
|
||||
$res = $this->submitQuestionnaireAs($login['token'], $f->questionnaireId, $f->clientCode, [
|
||||
['questionID' => $f->freeTextShortId, 'freeTextValue' => 'skipped consent'],
|
||||
]);
|
||||
$this->assertApiError($res, 'VALIDATION_FAILED', 400);
|
||||
$codes = array_column($res['error']['details']['errors'] ?? [], 'code');
|
||||
$this->assertContains('MISSING_ANSWER', $codes);
|
||||
}
|
||||
|
||||
public function testActiveListExcludesDraftQuestionnaire(): void
|
||||
{
|
||||
$login = $this->api()->loginMobileAndGetToken(
|
||||
DatabaseSeeder::COACH_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
);
|
||||
$f = $this->fixture();
|
||||
$res = $this->api()->withMobileToken($login['token'], 'GET', 'app_questionnaires');
|
||||
$this->assertApiOk($res);
|
||||
$ids = array_column($res['data'], 'id');
|
||||
$this->assertContains($f->questionnaireId, $ids);
|
||||
$this->assertNotContains($f->draftQuestionnaireId, $ids);
|
||||
}
|
||||
|
||||
public function testTempTokenCannotSubmitQuestionnaire(): void
|
||||
{
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
$f = $this->fixture();
|
||||
$pdo->prepare('UPDATE users SET mustChangePassword = 1 WHERE userID = :uid')
|
||||
->execute([':uid' => $f->supervisorUserId]);
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
|
||||
$login = $this->api()->loginMobile(
|
||||
DatabaseSeeder::SUPERVISOR_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
);
|
||||
$this->assertApiOk($login);
|
||||
$tempToken = $login['data']['token'];
|
||||
$res = $this->api()->encryptedPost($tempToken, 'app_questionnaires', [
|
||||
'questionnaireID' => $f->questionnaireId,
|
||||
'clientCode' => $f->clientCode,
|
||||
'answers' => [
|
||||
['questionID' => $f->questionShortId, 'answerOptionKey' => 'yes'],
|
||||
],
|
||||
]);
|
||||
$this->assertApiError($res, 'PASSWORD_CHANGE_REQUIRED', 403);
|
||||
}
|
||||
}
|
||||
38
tests/Integration/AssignmentsActivityLogTest.php
Normal file
38
tests/Integration/AssignmentsActivityLogTest.php
Normal file
@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Integration;
|
||||
|
||||
use Tests\Support\DatabaseSeeder;
|
||||
use Tests\Support\QdbTestCase;
|
||||
|
||||
final class AssignmentsActivityLogTest extends QdbTestCase
|
||||
{
|
||||
public function testAssignmentsListForAdmin(): void
|
||||
{
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::ADMIN_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$res = $this->api()->withToken($token, 'GET', 'assignments');
|
||||
$this->assertApiOk($res);
|
||||
$this->assertArrayHasKey('coaches', $res['data']);
|
||||
$this->assertArrayHasKey('clients', $res['data']);
|
||||
}
|
||||
|
||||
public function testActivityLogReadableByAdmin(): void
|
||||
{
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::ADMIN_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$today = gmdate('Y-m-d');
|
||||
$res = $this->api()->withToken($token, 'GET', 'activity-log', null, [
|
||||
'date' => $today,
|
||||
'limit' => '50',
|
||||
]);
|
||||
$this->assertApiOk($res);
|
||||
$this->assertArrayHasKey('entries', $res['data']);
|
||||
}
|
||||
}
|
||||
54
tests/Integration/AuthTest.php
Normal file
54
tests/Integration/AuthTest.php
Normal file
@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Integration;
|
||||
|
||||
use Tests\Support\DatabaseSeeder;
|
||||
use Tests\Support\QdbTestCase;
|
||||
|
||||
final class AuthTest extends QdbTestCase
|
||||
{
|
||||
public function testAdminLoginSuccess(): void
|
||||
{
|
||||
$res = $this->api()->loginWeb(DatabaseSeeder::ADMIN_USERNAME, DatabaseSeeder::PASSWORD);
|
||||
$this->assertApiOk($res);
|
||||
$this->assertNotEmpty($res['data']['token']);
|
||||
$this->assertSame('admin', $res['data']['role']);
|
||||
}
|
||||
|
||||
public function testInvalidCredentials(): void
|
||||
{
|
||||
$res = $this->api()->loginWeb(DatabaseSeeder::ADMIN_USERNAME, 'wrong');
|
||||
$this->assertApiError($res, 'INVALID_CREDENTIALS', 401);
|
||||
}
|
||||
|
||||
public function testCoachBlockedOnWebClient(): void
|
||||
{
|
||||
$res = $this->api()->loginWeb(DatabaseSeeder::COACH_USERNAME, DatabaseSeeder::PASSWORD);
|
||||
$this->assertApiError($res, 'FORBIDDEN', 403);
|
||||
}
|
||||
|
||||
public function testRateLimitAfterFailures(): void
|
||||
{
|
||||
require_once dirname(__DIR__, 2) . '/lib/settings.php';
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
qdb_settings_save_on_pdo($pdo, qdb_settings_validate_and_merge([
|
||||
'login_max_attempts' => 2,
|
||||
'login_window_seconds' => 600,
|
||||
'login_lockout_seconds' => 600,
|
||||
], qdb_settings_get_on_pdo($pdo)));
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
|
||||
$this->api()->loginWeb('rate_user', 'bad1');
|
||||
$this->api()->loginWeb('rate_user', 'bad2');
|
||||
$res = $this->api()->loginWeb('rate_user', 'bad3');
|
||||
$this->assertApiError($res, 'RATE_LIMITED', 429);
|
||||
}
|
||||
|
||||
public function testMissingFields(): void
|
||||
{
|
||||
$res = $this->api()->request('POST', 'auth/login', ['username' => '']);
|
||||
$this->assertApiError($res, 'MISSING_FIELDS', 400);
|
||||
}
|
||||
}
|
||||
80
tests/Integration/BackupDevOpsTest.php
Normal file
80
tests/Integration/BackupDevOpsTest.php
Normal file
@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Integration;
|
||||
|
||||
use Tests\Support\DatabaseSeeder;
|
||||
use Tests\Support\QdbTestCase;
|
||||
|
||||
final class BackupDevOpsTest extends QdbTestCase
|
||||
{
|
||||
public function testAdminBackupCreatesCopyOfDatabase(): void
|
||||
{
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::ADMIN_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$res = $this->api()->withToken($token, 'POST', 'backup');
|
||||
$this->assertApiOk($res);
|
||||
$filename = $res['data']['filename'] ?? '';
|
||||
$this->assertNotSame('', $filename);
|
||||
|
||||
$path = QDB_UPLOADS_DIR . '/backups/' . $filename;
|
||||
$this->assertFileExists($path);
|
||||
$this->assertGreaterThan(0, filesize($path));
|
||||
$this->assertSame(filesize(QDB_PATH), filesize($path));
|
||||
}
|
||||
|
||||
public function testDevImportEmptyFixtureSucceeds(): void
|
||||
{
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::ADMIN_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$res = $this->api()->withToken($token, 'POST', 'dev', [
|
||||
'fixture' => [
|
||||
'fixtureVersion' => 2,
|
||||
'prefix' => 'dev_',
|
||||
'defaultPassword' => 'DevPass1!',
|
||||
'admins' => [],
|
||||
'supervisors' => [],
|
||||
'coaches' => [],
|
||||
'clients' => [],
|
||||
],
|
||||
]);
|
||||
$this->assertApiOk($res);
|
||||
$this->assertSame(0, $res['data']['imported']['clients'] ?? -1);
|
||||
}
|
||||
|
||||
public function testDevRejectsInvalidFixtureVersion(): void
|
||||
{
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::ADMIN_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$res = $this->api()->withToken($token, 'POST', 'dev', [
|
||||
'fixture' => [
|
||||
'fixtureVersion' => 99,
|
||||
'prefix' => 'dev_',
|
||||
'defaultPassword' => 'DevPass1!',
|
||||
],
|
||||
]);
|
||||
$this->assertApiError($res, 'INVALID_FIELD', 400);
|
||||
}
|
||||
|
||||
public function testMigrationAddsCategoryKeyColumn(): void
|
||||
{
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
if (!qdb_column_exists($pdo, 'questionnaire', 'categoryKey')) {
|
||||
$this->markTestSkipped('categoryKey already absent in fresh schema');
|
||||
}
|
||||
$pdo->exec('ALTER TABLE questionnaire DROP COLUMN categoryKey');
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
|
||||
[$pdo2, $tmp2, $lock2] = qdb_open(false);
|
||||
$this->assertTrue(qdb_column_exists($pdo2, 'questionnaire', 'categoryKey'));
|
||||
qdb_discard($tmp2, $lock2);
|
||||
}
|
||||
|
||||
}
|
||||
94
tests/Integration/CatalogApiTest.php
Normal file
94
tests/Integration/CatalogApiTest.php
Normal file
@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Integration;
|
||||
|
||||
use Tests\Support\DatabaseSeeder;
|
||||
use Tests\Support\QdbTestCase;
|
||||
|
||||
final class CatalogApiTest extends QdbTestCase
|
||||
{
|
||||
public function testListLanguages(): void
|
||||
{
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::ADMIN_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$res = $this->api()->withToken($token, 'GET', 'translations', null, ['languages' => '1']);
|
||||
$this->assertApiOk($res);
|
||||
$this->assertIsArray($res['data']['languages']);
|
||||
}
|
||||
|
||||
public function testListQuestionsForQuestionnaire(): void
|
||||
{
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::ADMIN_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$f = $this->fixture();
|
||||
$res = $this->api()->withToken($token, 'GET', 'questions', null, [
|
||||
'questionnaireID' => $f->questionnaireId,
|
||||
]);
|
||||
$this->assertApiOk($res);
|
||||
$ids = array_column($res['data']['questions'], 'questionID');
|
||||
$this->assertContains($f->questionId, $ids);
|
||||
}
|
||||
|
||||
public function testListAnswerOptions(): void
|
||||
{
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::ADMIN_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$res = $this->api()->withToken($token, 'GET', 'answer_options', null, [
|
||||
'questionID' => $this->fixture()->questionId,
|
||||
]);
|
||||
$this->assertApiOk($res);
|
||||
$this->assertNotEmpty($res['data']['answerOptions']);
|
||||
}
|
||||
|
||||
public function testListCoachesForSupervisor(): void
|
||||
{
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::SUPERVISOR_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$res = $this->api()->withToken($token, 'GET', 'coaches');
|
||||
$this->assertApiOk($res);
|
||||
$coachIds = array_column($res['data']['coaches'], 'coachID');
|
||||
$this->assertContains($this->fixture()->coachId, $coachIds);
|
||||
}
|
||||
|
||||
public function testTranslationsAllDashboard(): void
|
||||
{
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::ADMIN_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$res = $this->api()->withToken($token, 'GET', 'translations', null, ['all' => '1']);
|
||||
$this->assertApiOk($res);
|
||||
$this->assertArrayHasKey('questionnaires', $res['data']);
|
||||
$this->assertArrayHasKey('entries', $res['data']);
|
||||
}
|
||||
|
||||
public function testTranslationsForQuestionnaire(): void
|
||||
{
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::ADMIN_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$res = $this->api()->withToken($token, 'GET', 'translations', null, [
|
||||
'questionnaireID' => $this->fixture()->questionnaireId,
|
||||
]);
|
||||
$this->assertApiOk($res);
|
||||
$this->assertArrayHasKey('entries', $res['data']);
|
||||
}
|
||||
|
||||
public function testAppTranslationsPublicNoAuth(): void
|
||||
{
|
||||
$res = $this->api()->request('GET', 'app_questionnaires', null, [], ['translations' => '1']);
|
||||
$this->assertApiOk($res);
|
||||
$this->assertArrayHasKey('translations', $res['data']);
|
||||
}
|
||||
}
|
||||
39
tests/Integration/ChangePasswordTest.php
Normal file
39
tests/Integration/ChangePasswordTest.php
Normal file
@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Integration;
|
||||
|
||||
use Tests\Support\QdbTestCase;
|
||||
|
||||
final class ChangePasswordTest extends QdbTestCase
|
||||
{
|
||||
public function testMustChangePasswordFlow(): void
|
||||
{
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
$f = $this->fixture();
|
||||
$pdo->prepare('UPDATE users SET mustChangePassword = 1 WHERE userID = :uid')
|
||||
->execute([':uid' => $f->supervisorUserId]);
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
|
||||
$login = $this->api()->loginWeb(
|
||||
\Tests\Support\DatabaseSeeder::SUPERVISOR_USERNAME,
|
||||
\Tests\Support\DatabaseSeeder::PASSWORD
|
||||
);
|
||||
$this->assertApiOk($login);
|
||||
$this->assertTrue($login['data']['mustChangePassword']);
|
||||
$tempToken = $login['data']['token'];
|
||||
|
||||
$change = $this->api()->request('POST', 'auth/change-password', [
|
||||
'username' => 'test_supervisor',
|
||||
'old_password' => 'TestPass1!',
|
||||
'new_password' => 'NewPass2!',
|
||||
], ['Authorization' => 'Bearer ' . $tempToken, 'X-QDB-Client' => 'web']);
|
||||
$this->assertApiOk($change);
|
||||
$this->assertNotEmpty($change['data']['token']);
|
||||
|
||||
$session = $this->api()->withToken($change['data']['token'], 'GET', 'session');
|
||||
$this->assertApiOk($session);
|
||||
$this->assertFalse($session['data']['mustChangePassword'] ?? false);
|
||||
}
|
||||
}
|
||||
64
tests/Integration/ClientsLifecycleTest.php
Normal file
64
tests/Integration/ClientsLifecycleTest.php
Normal file
@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Integration;
|
||||
|
||||
use Tests\Support\DatabaseSeeder;
|
||||
use Tests\Support\QdbTestCase;
|
||||
|
||||
final class ClientsLifecycleTest extends QdbTestCase
|
||||
{
|
||||
public function testClientDetailAfterSubmit(): void
|
||||
{
|
||||
$this->submitFixtureQuestionnaire();
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::ADMIN_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$code = $this->fixture()->clientCode;
|
||||
$res = $this->api()->withToken($token, 'GET', 'clients', null, [
|
||||
'clientCode' => $code,
|
||||
'detail' => '1',
|
||||
]);
|
||||
$this->assertApiOk($res);
|
||||
$this->assertSame($code, $res['data']['client']['clientCode'] ?? '');
|
||||
$this->assertNotEmpty($res['data']['questionnaires']);
|
||||
}
|
||||
|
||||
public function testSupervisorCreatesClientForOwnCoach(): void
|
||||
{
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::SUPERVISOR_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$code = 'CLIENT-SV-' . bin2hex(random_bytes(2));
|
||||
$res = $this->api()->withToken($token, 'POST', 'clients', [
|
||||
'clientCode' => $code,
|
||||
'coachID' => $this->fixture()->coachId,
|
||||
]);
|
||||
$this->assertApiOk($res);
|
||||
$this->assertSame($code, $res['data']['clientCode']);
|
||||
}
|
||||
|
||||
public function testAdminDeletesClient(): void
|
||||
{
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::ADMIN_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$code = 'CLIENT-DEL-' . bin2hex(random_bytes(2));
|
||||
$create = $this->api()->withToken($token, 'POST', 'clients', [
|
||||
'clientCode' => $code,
|
||||
'coachID' => $this->fixture()->coachId,
|
||||
]);
|
||||
$this->assertApiOk($create);
|
||||
|
||||
$del = $this->api()->withToken($token, 'DELETE', 'clients', ['clientCode' => $code]);
|
||||
$this->assertApiOk($del);
|
||||
|
||||
$list = $this->api()->withToken($token, 'GET', 'clients');
|
||||
$codes = array_column($list['data']['clients'], 'clientCode');
|
||||
$this->assertNotContains($code, $codes);
|
||||
}
|
||||
}
|
||||
60
tests/Integration/ClientsQuestionnairesTest.php
Normal file
60
tests/Integration/ClientsQuestionnairesTest.php
Normal file
@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Integration;
|
||||
|
||||
use Tests\Support\DatabaseSeeder;
|
||||
use Tests\Support\QdbTestCase;
|
||||
|
||||
final class ClientsQuestionnairesTest extends QdbTestCase
|
||||
{
|
||||
public function testSupervisorSeesClients(): void
|
||||
{
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::SUPERVISOR_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$res = $this->api()->withToken($token, 'GET', 'clients');
|
||||
$this->assertApiOk($res);
|
||||
$codes = array_column($res['data']['clients'], 'clientCode');
|
||||
$this->assertContains($this->fixture()->clientCode, $codes);
|
||||
}
|
||||
|
||||
public function testAdminCreatesClient(): void
|
||||
{
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::ADMIN_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$code = 'CLIENT-NEW-' . bin2hex(random_bytes(2));
|
||||
$res = $this->api()->withToken($token, 'POST', 'clients', [
|
||||
'clientCode' => $code,
|
||||
'coachID' => $this->fixture()->coachId,
|
||||
]);
|
||||
$this->assertApiOk($res);
|
||||
$this->assertSame($code, $res['data']['clientCode']);
|
||||
}
|
||||
|
||||
public function testQuestionnairesList(): void
|
||||
{
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::ADMIN_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$res = $this->api()->withToken($token, 'GET', 'questionnaires');
|
||||
$this->assertApiOk($res);
|
||||
$ids = array_column($res['data']['questionnaires'], 'questionnaireID');
|
||||
$this->assertContains($this->fixture()->questionnaireId, $ids);
|
||||
}
|
||||
|
||||
public function testQuestionsRequireQuestionnaireId(): void
|
||||
{
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::ADMIN_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$res = $this->api()->withToken($token, 'GET', 'questions');
|
||||
$this->assertApiError($res, 'BAD_REQUEST', 400);
|
||||
}
|
||||
}
|
||||
27
tests/Integration/CoachesActivityTest.php
Normal file
27
tests/Integration/CoachesActivityTest.php
Normal file
@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Integration;
|
||||
|
||||
use Tests\Support\DatabaseSeeder;
|
||||
use Tests\Support\QdbTestCase;
|
||||
|
||||
final class CoachesActivityTest extends QdbTestCase
|
||||
{
|
||||
public function testCoachRecentSubmissions(): void
|
||||
{
|
||||
$this->submitFixtureQuestionnaire();
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::SUPERVISOR_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$res = $this->api()->withToken($token, 'GET', 'coaches', null, [
|
||||
'coachID' => $this->fixture()->coachId,
|
||||
'recent' => '1',
|
||||
'limit' => '10',
|
||||
]);
|
||||
$this->assertApiOk($res);
|
||||
$this->assertArrayHasKey('submissions', $res['data']);
|
||||
}
|
||||
}
|
||||
52
tests/Integration/DbInitTest.php
Normal file
52
tests/Integration/DbInitTest.php
Normal file
@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Integration;
|
||||
|
||||
use Tests\Support\QdbTestCase;
|
||||
|
||||
final class DbInitTest extends QdbTestCase
|
||||
{
|
||||
public function testEncryptedDbRoundTrip(): void
|
||||
{
|
||||
$this->assertFileExists(QDB_PATH);
|
||||
[$pdo1, $t1, $l1] = qdb_open(false);
|
||||
$count1 = (int)$pdo1->query('SELECT COUNT(*) FROM users')->fetchColumn();
|
||||
qdb_discard($t1, $l1);
|
||||
|
||||
[$pdo2, $t2, $l2] = qdb_open(true);
|
||||
$pdo2->exec("INSERT INTO language (languageCode, name) VALUES ('fr', 'French')
|
||||
ON CONFLICT(languageCode) DO NOTHING");
|
||||
qdb_save($t2, $l2);
|
||||
|
||||
[$pdo3, $t3, $l3] = qdb_open(false);
|
||||
$fr = $pdo3->query("SELECT name FROM language WHERE languageCode = 'fr'")->fetchColumn();
|
||||
qdb_discard($t3, $l3);
|
||||
$this->assertSame('French', $fr);
|
||||
$this->assertGreaterThan(0, $count1);
|
||||
}
|
||||
|
||||
public function testSystemSettingTableExists(): void
|
||||
{
|
||||
[$pdo, $tmp, $lock] = qdb_open(false);
|
||||
$this->assertTrue(qdb_table_exists($pdo, 'system_setting'));
|
||||
$this->assertTrue(qdb_table_exists($pdo, 'session'));
|
||||
qdb_discard($tmp, $lock);
|
||||
}
|
||||
|
||||
public function testMigrationsRecreateDroppedSubmissionTables(): void
|
||||
{
|
||||
[$pdo, $tmp, $lock] = qdb_open(true);
|
||||
$pdo->exec('DROP TABLE IF EXISTS client_answer_submission');
|
||||
$pdo->exec('DROP TABLE IF EXISTS questionnaire_submission');
|
||||
qdb_save($tmp, $lock);
|
||||
|
||||
[$pdo2, $tmp2, $lock2] = qdb_open(false);
|
||||
$this->assertTrue(qdb_table_exists($pdo2, 'questionnaire_submission'));
|
||||
$this->assertTrue(qdb_table_exists($pdo2, 'client_answer_submission'));
|
||||
qdb_discard($tmp2, $lock2);
|
||||
|
||||
$this->assertFileExists(QDB_PATH);
|
||||
}
|
||||
}
|
||||
50
tests/Integration/DownloadsTest.php
Normal file
50
tests/Integration/DownloadsTest.php
Normal file
@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Integration;
|
||||
|
||||
use Tests\Support\DatabaseSeeder;
|
||||
use Tests\Support\QdbTestCase;
|
||||
|
||||
final class DownloadsTest extends QdbTestCase
|
||||
{
|
||||
public function testExportTranslationsBundle(): void
|
||||
{
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::ADMIN_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$res = $this->api()->withToken($token, 'GET', 'translations', null, ['exportBundle' => '1']);
|
||||
$this->assertRawOk($res, 'application/json');
|
||||
$decoded = json_decode($res['body'], true);
|
||||
$this->assertIsArray($decoded);
|
||||
}
|
||||
|
||||
public function testExportQuestionnairesBundle(): void
|
||||
{
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::SUPERVISOR_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$res = $this->api()->withToken($token, 'GET', 'export', null, ['bundle' => '1']);
|
||||
$this->assertRawOk($res, 'application/json');
|
||||
$decoded = json_decode($res['body'], true);
|
||||
$this->assertIsArray($decoded);
|
||||
}
|
||||
|
||||
public function testExportAllZipAsAdmin(): void
|
||||
{
|
||||
if (!class_exists('ZipArchive')) {
|
||||
$this->markTestSkipped('php-zip extension not installed');
|
||||
}
|
||||
$this->submitFixtureQuestionnaire();
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::ADMIN_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$res = $this->api()->withToken($token, 'GET', 'export', null, ['exportAll' => '1']);
|
||||
$this->assertRawOk($res, 'application/zip');
|
||||
$this->assertStringStartsWith('PK', $res['body']);
|
||||
}
|
||||
}
|
||||
119
tests/Integration/EditorCrudTest.php
Normal file
119
tests/Integration/EditorCrudTest.php
Normal file
@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Integration;
|
||||
|
||||
use Tests\Support\DatabaseSeeder;
|
||||
use Tests\Support\QdbTestCase;
|
||||
|
||||
final class EditorCrudTest extends QdbTestCase
|
||||
{
|
||||
public function testAdminUpdatesAndDeletesAnswerOption(): void
|
||||
{
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::ADMIN_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$f = $this->fixture();
|
||||
|
||||
$create = $this->api()->withToken($token, 'POST', 'answer_options', [
|
||||
'questionID' => $f->questionId,
|
||||
'optionKey' => 'temp_opt',
|
||||
'defaultText' => 'Temporary',
|
||||
'points' => 1,
|
||||
]);
|
||||
$this->assertApiOk($create);
|
||||
$optId = $create['data']['answerOption']['answerOptionID'] ?? '';
|
||||
$this->assertNotSame('', $optId);
|
||||
|
||||
$update = $this->api()->withToken($token, 'PUT', 'answer_options', [
|
||||
'answerOptionID' => $optId,
|
||||
'optionKey' => 'temp_opt',
|
||||
'defaultText' => 'Temporary updated',
|
||||
'points' => 5,
|
||||
]);
|
||||
$this->assertApiOk($update);
|
||||
$this->assertSame(5, $update['data']['answerOption']['points'] ?? 0);
|
||||
|
||||
$del = $this->api()->withToken($token, 'DELETE', 'answer_options', [
|
||||
'answerOptionID' => $optId,
|
||||
]);
|
||||
$this->assertApiOk($del);
|
||||
|
||||
$list = $this->api()->withToken($token, 'GET', 'answer_options', null, [
|
||||
'questionID' => $f->questionId,
|
||||
]);
|
||||
$this->assertApiOk($list);
|
||||
$ids = array_column($list['data']['answerOptions'], 'answerOptionID');
|
||||
$this->assertNotContains($optId, $ids);
|
||||
}
|
||||
|
||||
public function testAdminUpdatesAndDeletesQuestion(): void
|
||||
{
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::ADMIN_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$f = $this->fixture();
|
||||
|
||||
$create = $this->api()->withToken($token, 'POST', 'questions', [
|
||||
'questionnaireID' => $f->questionnaireId,
|
||||
'questionKey' => 'disposable_q',
|
||||
'defaultText' => 'Disposable',
|
||||
'type' => 'free_text_question',
|
||||
'isRequired' => 0,
|
||||
'configJson' => '{}',
|
||||
]);
|
||||
$this->assertApiOk($create);
|
||||
$qid = $create['data']['question']['questionID'] ?? '';
|
||||
$this->assertNotSame('', $qid);
|
||||
|
||||
$update = $this->api()->withToken($token, 'PUT', 'questions', [
|
||||
'questionID' => $qid,
|
||||
'defaultText' => 'Disposable updated',
|
||||
'questionKey' => 'disposable_q',
|
||||
]);
|
||||
$this->assertApiOk($update);
|
||||
$this->assertSame('Disposable updated', $update['data']['question']['defaultText'] ?? '');
|
||||
|
||||
$del = $this->api()->withToken($token, 'DELETE', 'questions', [
|
||||
'questionID' => $qid,
|
||||
]);
|
||||
$this->assertApiOk($del);
|
||||
|
||||
$list = $this->api()->withToken($token, 'GET', 'questions', null, [
|
||||
'questionnaireID' => $f->questionnaireId,
|
||||
]);
|
||||
$ids = array_column($list['data']['questions'], 'questionID');
|
||||
$this->assertNotContains($qid, $ids);
|
||||
}
|
||||
|
||||
public function testAdminReordersQuestions(): void
|
||||
{
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::ADMIN_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$f = $this->fixture();
|
||||
|
||||
$before = $this->api()->withToken($token, 'GET', 'questions', null, [
|
||||
'questionnaireID' => $f->questionnaireId,
|
||||
]);
|
||||
$this->assertApiOk($before);
|
||||
$ids = array_column($before['data']['questions'], 'questionID');
|
||||
$reversed = array_reverse($ids);
|
||||
|
||||
$patch = $this->api()->withToken($token, 'PATCH', 'questions', [
|
||||
'questionnaireID' => $f->questionnaireId,
|
||||
'order' => $reversed,
|
||||
]);
|
||||
$this->assertApiOk($patch);
|
||||
|
||||
$after = $this->api()->withToken($token, 'GET', 'questions', null, [
|
||||
'questionnaireID' => $f->questionnaireId,
|
||||
]);
|
||||
$afterIds = array_column($after['data']['questions'], 'questionID');
|
||||
$this->assertSame($reversed, $afterIds);
|
||||
}
|
||||
}
|
||||
116
tests/Integration/ExportExtendedTest.php
Normal file
116
tests/Integration/ExportExtendedTest.php
Normal file
@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Integration;
|
||||
|
||||
use Tests\Support\DatabaseSeeder;
|
||||
use Tests\Support\QdbTestCase;
|
||||
use ZipArchive;
|
||||
|
||||
final class ExportExtendedTest extends QdbTestCase
|
||||
{
|
||||
public function testExportAllVersionsCsvIncludesBothSubmissions(): void
|
||||
{
|
||||
$login = $this->api()->loginMobileAndGetToken(
|
||||
DatabaseSeeder::COACH_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
);
|
||||
$f = $this->fixture();
|
||||
$this->assertApiOk($this->submitFixtureQuestionnaire());
|
||||
$this->submitQuestionnaireAs($login['token'], $f->questionnaireId, $f->clientCode, [
|
||||
['questionID' => $f->questionShortId, 'answerOptionKey' => 'yes'],
|
||||
]);
|
||||
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::ADMIN_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$res = $this->api()->withToken($token, 'GET', 'export', null, [
|
||||
'questionnaireID' => $f->questionnaireId,
|
||||
'allVersions' => '1',
|
||||
]);
|
||||
$this->assertRawOk($res, 'text/csv');
|
||||
$this->assertStringContainsString($f->clientCode, $res['body']);
|
||||
$lines = array_filter(explode("\n", trim($res['body'])));
|
||||
$this->assertGreaterThanOrEqual(3, count($lines), 'header plus at least two data rows');
|
||||
}
|
||||
|
||||
public function testExportAllZipContainsCsvForSubmittedQuestionnaire(): void
|
||||
{
|
||||
if (!class_exists(ZipArchive::class)) {
|
||||
$this->markTestSkipped('php-zip extension not installed');
|
||||
}
|
||||
|
||||
$this->submitFixtureQuestionnaire();
|
||||
$f = $this->fixture();
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::ADMIN_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
|
||||
$res = $this->api()->withToken($token, 'GET', 'export', null, ['exportAll' => '1']);
|
||||
$this->assertRawOk($res, 'application/zip');
|
||||
|
||||
$zipPath = sys_get_temp_dir() . '/qdb_test_export_' . bin2hex(random_bytes(4)) . '.zip';
|
||||
file_put_contents($zipPath, $res['body']);
|
||||
$zip = new ZipArchive();
|
||||
$this->assertTrue($zip->open($zipPath) === true);
|
||||
$this->assertGreaterThanOrEqual(1, $zip->numFiles);
|
||||
$foundCsv = false;
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$name = $zip->getNameIndex($i);
|
||||
if (is_string($name) && str_ends_with(strtolower($name), '.csv')) {
|
||||
$csv = $zip->getFromIndex($i);
|
||||
if (is_string($csv) && str_contains($csv, $f->clientCode)) {
|
||||
$foundCsv = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
$zip->close();
|
||||
@unlink($zipPath);
|
||||
$this->assertTrue($foundCsv, 'ZIP should contain a CSV with submitted client code');
|
||||
}
|
||||
|
||||
public function testCoachForbiddenOnPerQuestionnaireCsvExport(): void
|
||||
{
|
||||
$this->submitFixtureQuestionnaire();
|
||||
$token = $this->api()->loginMobileAndGetToken(
|
||||
DatabaseSeeder::COACH_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$f = $this->fixture();
|
||||
$res = $this->api()->withMobileToken($token, 'GET', 'export', null, [
|
||||
'questionnaireID' => $f->questionnaireId,
|
||||
]);
|
||||
$this->assertApiError($res, 'FORBIDDEN', 403);
|
||||
}
|
||||
|
||||
public function testExportAllWithAllVersionsZip(): void
|
||||
{
|
||||
if (!class_exists(\ZipArchive::class)) {
|
||||
$this->markTestSkipped('php-zip extension not installed');
|
||||
}
|
||||
$login = $this->api()->loginMobileAndGetToken(
|
||||
DatabaseSeeder::COACH_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
);
|
||||
$f = $this->fixture();
|
||||
$this->submitFixtureQuestionnaire();
|
||||
$this->submitQuestionnaireAs($login['token'], $f->questionnaireId, $f->clientCode, [
|
||||
['questionID' => $f->questionShortId, 'answerOptionKey' => 'yes'],
|
||||
]);
|
||||
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::ADMIN_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$res = $this->api()->withToken($token, 'GET', 'export', null, [
|
||||
'exportAll' => '1',
|
||||
'allVersions' => '1',
|
||||
]);
|
||||
$this->assertRawOk($res, 'application/zip');
|
||||
$this->assertStringStartsWith('PK', $res['body']);
|
||||
}
|
||||
}
|
||||
50
tests/Integration/ExportTest.php
Normal file
50
tests/Integration/ExportTest.php
Normal file
@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Integration;
|
||||
|
||||
use Tests\Support\DatabaseSeeder;
|
||||
use Tests\Support\QdbTestCase;
|
||||
|
||||
final class ExportTest extends QdbTestCase
|
||||
{
|
||||
public function testExportRequiresQuestionnaireId(): void
|
||||
{
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::ADMIN_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$res = $this->api()->withToken($token, 'GET', 'export');
|
||||
$this->assertApiError($res, 'MISSING_PARAM', 400);
|
||||
}
|
||||
|
||||
public function testExportAllZipRequiresAdminRole(): void
|
||||
{
|
||||
if (!class_exists('ZipArchive')) {
|
||||
$this->markTestSkipped('php-zip extension not installed');
|
||||
}
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::SUPERVISOR_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$res = $this->api()->withToken($token, 'GET', 'export', null, ['exportAll' => '1']);
|
||||
$this->assertApiError($res, 'FORBIDDEN', 403);
|
||||
}
|
||||
|
||||
public function testExportCsvForQuestionnaire(): void
|
||||
{
|
||||
$this->submitFixtureQuestionnaire();
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::ADMIN_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$f = $this->fixture();
|
||||
|
||||
$res = $this->api()->withToken($token, 'GET', 'export', null, [
|
||||
'questionnaireID' => $f->questionnaireId,
|
||||
]);
|
||||
$this->assertRawOk($res, 'text/csv');
|
||||
$this->assertStringContainsString($f->clientCode, $res['body']);
|
||||
}
|
||||
}
|
||||
57
tests/Integration/QuestionnaireEditingTest.php
Normal file
57
tests/Integration/QuestionnaireEditingTest.php
Normal file
@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Integration;
|
||||
|
||||
use Tests\Support\DatabaseSeeder;
|
||||
use Tests\Support\QdbTestCase;
|
||||
|
||||
final class QuestionnaireEditingTest extends QdbTestCase
|
||||
{
|
||||
public function testAdminCreatesQuestion(): void
|
||||
{
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::ADMIN_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$f = $this->fixture();
|
||||
$res = $this->api()->withToken($token, 'POST', 'questions', [
|
||||
'questionnaireID' => $f->questionnaireId,
|
||||
'questionKey' => 'extra_q',
|
||||
'defaultText' => 'Extra question text',
|
||||
'type' => 'free_text',
|
||||
'isRequired' => 0,
|
||||
'configJson' => '{}',
|
||||
]);
|
||||
$this->assertApiOk($res);
|
||||
$this->assertSame('extra_q', $res['data']['question']['questionKey'] ?? '');
|
||||
}
|
||||
|
||||
public function testAdminCreatesAnswerOption(): void
|
||||
{
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::ADMIN_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$f = $this->fixture();
|
||||
$res = $this->api()->withToken($token, 'POST', 'answer_options', [
|
||||
'questionID' => $f->questionId,
|
||||
'optionKey' => 'no_option',
|
||||
'defaultText' => 'No',
|
||||
'points' => 0,
|
||||
]);
|
||||
$this->assertApiOk($res);
|
||||
$this->assertSame('no_option', $res['data']['answerOption']['optionKey'] ?? '');
|
||||
}
|
||||
|
||||
public function testAnswerOptionsRequireQuestionId(): void
|
||||
{
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::ADMIN_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$res = $this->api()->withToken($token, 'GET', 'answer_options');
|
||||
$this->assertApiError($res, 'MISSING_PARAM', 400);
|
||||
}
|
||||
}
|
||||
76
tests/Integration/QuestionnaireImportExportTest.php
Normal file
76
tests/Integration/QuestionnaireImportExportTest.php
Normal file
@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Integration;
|
||||
|
||||
use Tests\Support\DatabaseSeeder;
|
||||
use Tests\Support\QdbTestCase;
|
||||
|
||||
final class QuestionnaireImportExportTest extends QdbTestCase
|
||||
{
|
||||
public function testExportImportRoundTripPreservesQuestionnaire(): void
|
||||
{
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::ADMIN_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$f = $this->fixture();
|
||||
|
||||
$export = $this->api()->withToken($token, 'GET', 'export', null, ['bundle' => '1']);
|
||||
$this->assertRawOk($export, 'application/json');
|
||||
$bundle = json_decode($export['body'], true, 512, JSON_THROW_ON_ERROR);
|
||||
$this->assertGreaterThanOrEqual(1, $bundle['questionnaireCount'] ?? 0);
|
||||
|
||||
$del = $this->api()->withToken($token, 'DELETE', 'questionnaires', [
|
||||
'questionnaireID' => $f->questionnaireId,
|
||||
]);
|
||||
$this->assertApiOk($del);
|
||||
|
||||
$list = $this->api()->withToken($token, 'GET', 'questionnaires');
|
||||
$ids = array_column($list['data']['questionnaires'], 'questionnaireID');
|
||||
$this->assertNotContains($f->questionnaireId, $ids);
|
||||
|
||||
$import = $this->api()->withToken($token, 'POST', 'questionnaires', [
|
||||
'action' => 'importBundle',
|
||||
'bundle' => $bundle,
|
||||
'replaceIfExists' => true,
|
||||
]);
|
||||
$this->assertApiOk($import);
|
||||
$this->assertGreaterThanOrEqual(1, $import['data']['imported'] ?? 0);
|
||||
|
||||
$questions = $this->api()->withToken($token, 'GET', 'questions', null, [
|
||||
'questionnaireID' => $f->questionnaireId,
|
||||
]);
|
||||
$this->assertApiOk($questions);
|
||||
$qIds = array_column($questions['data']['questions'], 'questionID');
|
||||
$this->assertContains($f->questionId, $qIds);
|
||||
$this->assertContains($f->freeTextQuestionId, $qIds);
|
||||
}
|
||||
|
||||
public function testAdminCreatesAndUpdatesQuestionnaire(): void
|
||||
{
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::ADMIN_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
|
||||
$create = $this->api()->withToken($token, 'POST', 'questionnaires', [
|
||||
'name' => 'Imported via API',
|
||||
'version' => '2',
|
||||
'state' => 'draft',
|
||||
]);
|
||||
$this->assertApiOk($create);
|
||||
$newId = $create['data']['questionnaire']['questionnaireID'] ?? '';
|
||||
$this->assertNotSame('', $newId);
|
||||
|
||||
$update = $this->api()->withToken($token, 'PUT', 'questionnaires', [
|
||||
'questionnaireID' => $newId,
|
||||
'name' => 'Renamed questionnaire',
|
||||
'state' => 'active',
|
||||
]);
|
||||
$this->assertApiOk($update);
|
||||
$this->assertSame('Renamed questionnaire', $update['data']['questionnaire']['name'] ?? '');
|
||||
$this->assertSame('active', $update['data']['questionnaire']['state'] ?? '');
|
||||
}
|
||||
}
|
||||
85
tests/Integration/RbacIntegrationTest.php
Normal file
85
tests/Integration/RbacIntegrationTest.php
Normal file
@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Integration;
|
||||
|
||||
use Tests\Support\DatabaseSeeder;
|
||||
use Tests\Support\QdbTestCase;
|
||||
|
||||
final class RbacIntegrationTest extends QdbTestCase
|
||||
{
|
||||
public function testSupervisorCannotCreateSupervisorUser(): void
|
||||
{
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::SUPERVISOR_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$res = $this->api()->withToken($token, 'POST', 'users', [
|
||||
'username' => 'new_supervisor',
|
||||
'password' => 'Secret1!',
|
||||
'role' => 'supervisor',
|
||||
'location' => 'Elsewhere',
|
||||
]);
|
||||
$this->assertApiError($res, 'FORBIDDEN', 403);
|
||||
}
|
||||
|
||||
public function testSupervisorCannotAssignClientToForeignCoach(): void
|
||||
{
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
$foreignSupervisorId = 'sv-foreign-' . bin2hex(random_bytes(4));
|
||||
$foreignCoachId = 'coach-foreign-' . bin2hex(random_bytes(4));
|
||||
$pdo->prepare('INSERT INTO supervisor (supervisorID, username, location) VALUES (:id, :u, :loc)')
|
||||
->execute([':id' => $foreignSupervisorId, ':u' => 'foreign_sv', ':loc' => 'Region B']);
|
||||
$pdo->prepare('INSERT INTO coach (coachID, supervisorID, username) VALUES (:id, :sid, :u)')
|
||||
->execute([':id' => $foreignCoachId, ':sid' => $foreignSupervisorId, ':u' => 'foreign_coach']);
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::SUPERVISOR_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$res = $this->api()->withToken($token, 'POST', 'clients', [
|
||||
'clientCode' => 'CLIENT-FOREIGN-001',
|
||||
'coachID' => $foreignCoachId,
|
||||
]);
|
||||
$this->assertApiError($res, 'NOT_FOUND', 404);
|
||||
}
|
||||
|
||||
public function testCoachMobileTokenRejectedOnWebSettings(): void
|
||||
{
|
||||
$token = $this->api()->loginMobileAndGetToken(
|
||||
DatabaseSeeder::COACH_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$res = $this->api()->withToken($token, 'GET', 'settings');
|
||||
$this->assertApiError($res, 'FORBIDDEN', 403);
|
||||
}
|
||||
|
||||
public function testCoachCannotCreateAnswerOptionOnWeb(): void
|
||||
{
|
||||
$token = $this->api()->loginMobileAndGetToken(
|
||||
DatabaseSeeder::COACH_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$res = $this->api()->withToken($token, 'POST', 'answer_options', [
|
||||
'questionID' => $this->fixture()->questionId,
|
||||
'optionKey' => 'blocked',
|
||||
'defaultText' => 'Blocked',
|
||||
]);
|
||||
$this->assertApiError($res, 'FORBIDDEN', 403);
|
||||
}
|
||||
|
||||
public function testSupervisorCannotReassignCoach(): void
|
||||
{
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::SUPERVISOR_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$res = $this->api()->withToken($token, 'PUT', 'users', [
|
||||
'userID' => $this->fixture()->coachUserId,
|
||||
'supervisorID' => $this->fixture()->supervisorId,
|
||||
]);
|
||||
$this->assertApiError($res, 'FORBIDDEN', 403);
|
||||
}
|
||||
}
|
||||
86
tests/Integration/ResultsAnalyticsTest.php
Normal file
86
tests/Integration/ResultsAnalyticsTest.php
Normal file
@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Integration;
|
||||
|
||||
use Tests\Support\DatabaseSeeder;
|
||||
use Tests\Support\QdbTestCase;
|
||||
|
||||
final class ResultsAnalyticsTest extends QdbTestCase
|
||||
{
|
||||
public function testResultsIncludeSubmittedClient(): void
|
||||
{
|
||||
$this->submitFixtureQuestionnaire();
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::ADMIN_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$f = $this->fixture();
|
||||
|
||||
$res = $this->api()->withToken($token, 'GET', 'results', null, [
|
||||
'questionnaireID' => $f->questionnaireId,
|
||||
]);
|
||||
$this->assertApiOk($res);
|
||||
$byCode = [];
|
||||
foreach ($res['data']['clients'] as $row) {
|
||||
$byCode[$row['clientCode']] = $row;
|
||||
}
|
||||
$this->assertArrayHasKey($f->clientCode, $byCode);
|
||||
$this->assertSame('completed', $byCode[$f->clientCode]['status']);
|
||||
$this->assertNotNull($byCode[$f->clientCode]['completedAt']);
|
||||
}
|
||||
|
||||
public function testAnalyticsOverviewForSupervisor(): void
|
||||
{
|
||||
$this->submitFixtureQuestionnaire();
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::SUPERVISOR_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
|
||||
$res = $this->api()->withToken($token, 'GET', 'analytics', null, ['overview' => '1']);
|
||||
$this->assertApiOk($res);
|
||||
$this->assertArrayHasKey('clientCount', $res['data']);
|
||||
$this->assertArrayHasKey('questionnaires', $res['data']);
|
||||
$this->assertGreaterThan(0, $res['data']['clientCount']);
|
||||
}
|
||||
|
||||
public function testStaleClientsList(): void
|
||||
{
|
||||
$this->submitFixtureQuestionnaire();
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::SUPERVISOR_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$res = $this->api()->withToken($token, 'GET', 'analytics', null, ['staleClients' => '1']);
|
||||
$this->assertApiOk($res);
|
||||
$this->assertArrayHasKey('clients', $res['data']);
|
||||
}
|
||||
|
||||
public function testFollowUpNoteRoundTrip(): void
|
||||
{
|
||||
$this->submitFixtureQuestionnaire();
|
||||
$token = $this->adminToken();
|
||||
$code = $this->fixture()->clientCode;
|
||||
$note = 'Call back next week';
|
||||
$save = $this->api()->withToken($token, 'PUT', 'analytics', [
|
||||
'clientCode' => $code,
|
||||
'note' => $note,
|
||||
]);
|
||||
$this->assertApiOk($save);
|
||||
$this->assertSame($note, $save['data']['note']);
|
||||
|
||||
$stale = $this->api()->withToken($token, 'GET', 'analytics', null, ['staleClients' => '1']);
|
||||
$this->assertApiOk($stale);
|
||||
$match = null;
|
||||
foreach ($stale['data']['clients'] as $row) {
|
||||
if (($row['clientCode'] ?? '') === $code) {
|
||||
$match = $row;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$this->assertIsArray($match);
|
||||
$this->assertSame($note, $match['note']);
|
||||
}
|
||||
}
|
||||
90
tests/Integration/SecurityPathsTest.php
Normal file
90
tests/Integration/SecurityPathsTest.php
Normal file
@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Integration;
|
||||
|
||||
use Tests\Support\DatabaseSeeder;
|
||||
use Tests\Support\QdbTestCase;
|
||||
|
||||
final class SecurityPathsTest extends QdbTestCase
|
||||
{
|
||||
public function testExpiredTokenRejected(): void
|
||||
{
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$f = $this->fixture();
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
$pdo->prepare(
|
||||
'INSERT INTO session (token, userID, role, entityID, createdAt, expiresAt, temp)
|
||||
VALUES (:t, :uid, :role, :eid, :ca, :ea, 0)'
|
||||
)->execute([
|
||||
':t' => $token,
|
||||
':uid' => $f->adminUserId,
|
||||
':role' => 'admin',
|
||||
':eid' => 'ent',
|
||||
':ca' => time() - 7200,
|
||||
':ea' => time() - 3600,
|
||||
]);
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
|
||||
$res = $this->api()->withToken($token, 'GET', 'session');
|
||||
$this->assertApiError($res, 'UNAUTHORIZED', 401);
|
||||
}
|
||||
|
||||
public function testInvalidBearerRejected(): void
|
||||
{
|
||||
$res = $this->api()->request('GET', 'session', null, [
|
||||
'Authorization' => 'Bearer not-a-valid-session-token',
|
||||
'X-QDB-Client' => 'web',
|
||||
]);
|
||||
$this->assertApiError($res, 'UNAUTHORIZED', 401);
|
||||
}
|
||||
|
||||
public function testAppSubmitRequiresEncryptedBody(): void
|
||||
{
|
||||
$login = $this->api()->loginMobileAndGetToken(
|
||||
DatabaseSeeder::COACH_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
);
|
||||
$f = $this->fixture();
|
||||
$res = $this->api()->withMobileToken($login['token'], 'POST', 'app_questionnaires', [
|
||||
'questionnaireID' => $f->questionnaireId,
|
||||
'clientCode' => $f->clientCode,
|
||||
'answers' => [],
|
||||
]);
|
||||
$this->assertApiError($res, 'ENCRYPTION_REQUIRED', 400);
|
||||
}
|
||||
|
||||
public function testAppSubmitValidationFailure(): void
|
||||
{
|
||||
$login = $this->api()->loginMobileAndGetToken(
|
||||
DatabaseSeeder::COACH_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
);
|
||||
$f = $this->fixture();
|
||||
$res = $this->api()->encryptedPost($login['token'], 'app_questionnaires', [
|
||||
'questionnaireID' => $f->questionnaireId,
|
||||
'clientCode' => $f->clientCode,
|
||||
'answers' => [],
|
||||
]);
|
||||
$this->assertApiError($res, 'VALIDATION_FAILED', 400);
|
||||
$this->assertNotEmpty($res['error']['details']['errors'] ?? []);
|
||||
}
|
||||
|
||||
public function testCoachCannotSubmitForUnknownClient(): void
|
||||
{
|
||||
$login = $this->api()->loginMobileAndGetToken(
|
||||
DatabaseSeeder::COACH_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
);
|
||||
$f = $this->fixture();
|
||||
$res = $this->api()->encryptedPost($login['token'], 'app_questionnaires', [
|
||||
'questionnaireID' => $f->questionnaireId,
|
||||
'clientCode' => 'CLIENT-DOES-NOT-EXIST',
|
||||
'answers' => [
|
||||
['questionID' => $f->questionShortId, 'answerOptionKey' => 'yes'],
|
||||
],
|
||||
]);
|
||||
$this->assertApiError($res, 'NOT_FOUND', 404);
|
||||
}
|
||||
}
|
||||
40
tests/Integration/SessionLogoutTest.php
Normal file
40
tests/Integration/SessionLogoutTest.php
Normal file
@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Integration;
|
||||
|
||||
use Tests\Support\DatabaseSeeder;
|
||||
use Tests\Support\QdbTestCase;
|
||||
|
||||
final class SessionLogoutTest extends QdbTestCase
|
||||
{
|
||||
public function testSessionValidWithToken(): void
|
||||
{
|
||||
$login = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::ADMIN_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
);
|
||||
$res = $this->api()->withToken($login['token'], 'GET', 'session');
|
||||
$this->assertApiOk($res);
|
||||
$this->assertTrue($res['data']['valid']);
|
||||
}
|
||||
|
||||
public function testSessionRejectsMissingToken(): void
|
||||
{
|
||||
$res = $this->api()->request('GET', 'session');
|
||||
$this->assertApiError($res, 'UNAUTHORIZED', 401);
|
||||
}
|
||||
|
||||
public function testLogoutRevokesToken(): void
|
||||
{
|
||||
$login = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::SUPERVISOR_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
);
|
||||
$del = $this->api()->withToken($login['token'], 'DELETE', 'logout');
|
||||
$this->assertApiOk($del);
|
||||
$check = $this->api()->withToken($login['token'], 'GET', 'session');
|
||||
$this->assertApiError($check, 'UNAUTHORIZED', 401);
|
||||
}
|
||||
}
|
||||
66
tests/Integration/SettingsApiTest.php
Normal file
66
tests/Integration/SettingsApiTest.php
Normal file
@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Integration;
|
||||
|
||||
use Tests\Support\DatabaseSeeder;
|
||||
use Tests\Support\QdbTestCase;
|
||||
|
||||
final class SettingsApiTest extends QdbTestCase
|
||||
{
|
||||
public function testAdminCanReadSettings(): void
|
||||
{
|
||||
$res = $this->api()->withToken($this->adminToken(), 'GET', 'settings');
|
||||
$this->assertApiOk($res);
|
||||
$this->assertArrayHasKey('settings', $res['data']);
|
||||
$this->assertArrayHasKey('activeSessions', $res['data']);
|
||||
}
|
||||
|
||||
public function testSupervisorForbidden(): void
|
||||
{
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::SUPERVISOR_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$res = $this->api()->withToken($token, 'GET', 'settings');
|
||||
$this->assertApiError($res, 'FORBIDDEN', 403);
|
||||
}
|
||||
|
||||
public function testUpdateSettings(): void
|
||||
{
|
||||
$token = $this->adminToken();
|
||||
$res = $this->api()->withToken($token, 'PUT', 'settings', [
|
||||
'login_max_attempts' => 8,
|
||||
'login_window_seconds' => 1200,
|
||||
'login_lockout_seconds' => 1200,
|
||||
'session_ttl_seconds' => 86400,
|
||||
'temp_session_ttl_seconds' => 900,
|
||||
]);
|
||||
$this->assertApiOk($res);
|
||||
$this->assertSame(8, $res['data']['settings']['login_max_attempts']);
|
||||
}
|
||||
|
||||
public function testRevokeAllSessionsRequiresPhrase(): void
|
||||
{
|
||||
$res = $this->api()->withToken($this->adminToken(), 'POST', 'settings', [
|
||||
'action' => 'revokeAllSessions',
|
||||
'confirmPhrase' => 'wrong',
|
||||
]);
|
||||
$this->assertApiError($res, 'CONFIRMATION_REQUIRED', 400);
|
||||
}
|
||||
|
||||
public function testRevokeAllSessions(): void
|
||||
{
|
||||
$admin = $this->adminToken();
|
||||
$this->api()->loginWebAndGetToken(DatabaseSeeder::SUPERVISOR_USERNAME, DatabaseSeeder::PASSWORD);
|
||||
$res = $this->api()->withToken($admin, 'POST', 'settings', [
|
||||
'action' => 'revokeAllSessions',
|
||||
'confirmPhrase' => 'REVOKE ALL SESSIONS',
|
||||
]);
|
||||
$this->assertApiOk($res);
|
||||
$this->assertGreaterThanOrEqual(1, $res['data']['revokedSessions']);
|
||||
$check = $this->api()->withToken($admin, 'GET', 'session');
|
||||
$this->assertApiError($check, 'UNAUTHORIZED', 401);
|
||||
}
|
||||
}
|
||||
39
tests/Integration/TokenTest.php
Normal file
39
tests/Integration/TokenTest.php
Normal file
@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Integration;
|
||||
|
||||
use Tests\Support\QdbTestCase;
|
||||
|
||||
final class TokenTest extends QdbTestCase
|
||||
{
|
||||
public function testTokenAddGetRevoke(): void
|
||||
{
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$f = $this->fixture();
|
||||
token_add($token, 3600, [
|
||||
'userID' => $f->adminUserId,
|
||||
'role' => 'admin',
|
||||
'entityID' => 'ent',
|
||||
]);
|
||||
$rec = token_get_record($token);
|
||||
$this->assertNotNull($rec);
|
||||
$this->assertSame('admin', $rec['role']);
|
||||
|
||||
token_revoke($token);
|
||||
$this->assertNull(token_get_record($token));
|
||||
}
|
||||
|
||||
public function testRevokeAllForUser(): void
|
||||
{
|
||||
$f = $this->fixture();
|
||||
$t1 = bin2hex(random_bytes(16));
|
||||
$t2 = bin2hex(random_bytes(16));
|
||||
token_add($t1, 3600, ['userID' => $f->supervisorUserId, 'role' => 'supervisor', 'entityID' => 'x']);
|
||||
token_add($t2, 3600, ['userID' => $f->supervisorUserId, 'role' => 'supervisor', 'entityID' => 'x']);
|
||||
$n = token_revoke_all_for_user($f->supervisorUserId);
|
||||
$this->assertGreaterThanOrEqual(2, $n);
|
||||
$this->assertNull(token_get_record($t1));
|
||||
}
|
||||
}
|
||||
83
tests/Integration/TranslationsImportExportTest.php
Normal file
83
tests/Integration/TranslationsImportExportTest.php
Normal file
@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Integration;
|
||||
|
||||
use Tests\Support\DatabaseSeeder;
|
||||
use Tests\Support\QdbTestCase;
|
||||
|
||||
final class TranslationsImportExportTest extends QdbTestCase
|
||||
{
|
||||
public function testTranslationsBundleExportImportRoundTrip(): void
|
||||
{
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::ADMIN_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$f = $this->fixture();
|
||||
|
||||
$this->api()->withToken($token, 'PUT', 'translations', [
|
||||
'type' => 'language',
|
||||
'languageCode' => 'es',
|
||||
'name' => 'Spanish',
|
||||
]);
|
||||
$this->api()->withToken($token, 'PUT', 'translations', [
|
||||
'type' => 'question',
|
||||
'id' => $f->questionId,
|
||||
'languageCode' => 'es',
|
||||
'text' => 'Consentimiento',
|
||||
]);
|
||||
|
||||
$export = $this->api()->withToken($token, 'GET', 'translations', null, ['exportBundle' => '1']);
|
||||
$this->assertRawOk($export, 'application/json');
|
||||
$bundle = json_decode($export['body'], true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
$this->api()->withToken($token, 'DELETE', 'translations', [
|
||||
'type' => 'question',
|
||||
'id' => $f->questionId,
|
||||
'languageCode' => 'es',
|
||||
]);
|
||||
|
||||
$import = $this->api()->withToken($token, 'POST', 'translations', [
|
||||
'action' => 'importBundle',
|
||||
'bundle' => $bundle,
|
||||
]);
|
||||
$this->assertApiOk($import);
|
||||
|
||||
$get = $this->api()->withToken($token, 'GET', 'translations', null, [
|
||||
'type' => 'question',
|
||||
'id' => $f->questionId,
|
||||
]);
|
||||
$es = null;
|
||||
foreach ($get['data']['translations'] as $row) {
|
||||
if ($row['languageCode'] === 'es') {
|
||||
$es = $row['text'];
|
||||
}
|
||||
}
|
||||
$this->assertSame('Consentimiento', $es);
|
||||
}
|
||||
|
||||
public function testDeleteTranslationLanguage(): void
|
||||
{
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::ADMIN_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
|
||||
$this->api()->withToken($token, 'PUT', 'translations', [
|
||||
'type' => 'language',
|
||||
'languageCode' => 'it',
|
||||
'name' => 'Italian',
|
||||
]);
|
||||
$del = $this->api()->withToken($token, 'DELETE', 'translations', [
|
||||
'type' => 'language',
|
||||
'languageCode' => 'it',
|
||||
]);
|
||||
$this->assertApiOk($del);
|
||||
|
||||
$langs = $this->api()->withToken($token, 'GET', 'translations', null, ['languages' => '1']);
|
||||
$codes = array_column($langs['data']['languages'], 'languageCode');
|
||||
$this->assertNotContains('it', $codes);
|
||||
}
|
||||
}
|
||||
83
tests/Integration/TranslationsWriteTest.php
Normal file
83
tests/Integration/TranslationsWriteTest.php
Normal file
@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Integration;
|
||||
|
||||
use Tests\Support\DatabaseSeeder;
|
||||
use Tests\Support\QdbTestCase;
|
||||
|
||||
final class TranslationsWriteTest extends QdbTestCase
|
||||
{
|
||||
public function testPutQuestionTranslationAndReadBack(): void
|
||||
{
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::ADMIN_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$f = $this->fixture();
|
||||
|
||||
$save = $this->api()->withToken($token, 'PUT', 'translations', [
|
||||
'type' => 'language',
|
||||
'languageCode' => 'en',
|
||||
'name' => 'English',
|
||||
]);
|
||||
$this->assertApiOk($save);
|
||||
|
||||
$put = $this->api()->withToken($token, 'PUT', 'translations', [
|
||||
'type' => 'question',
|
||||
'id' => $f->questionId,
|
||||
'languageCode' => 'en',
|
||||
'text' => 'Consent in English?',
|
||||
]);
|
||||
$this->assertApiOk($put);
|
||||
|
||||
$get = $this->api()->withToken($token, 'GET', 'translations', null, [
|
||||
'type' => 'question',
|
||||
'id' => $f->questionId,
|
||||
]);
|
||||
$this->assertApiOk($get);
|
||||
$texts = [];
|
||||
foreach ($get['data']['translations'] as $row) {
|
||||
$texts[$row['languageCode']] = $row['text'];
|
||||
}
|
||||
$this->assertArrayHasKey('en', $texts);
|
||||
$this->assertSame('Consent in English?', $texts['en']);
|
||||
}
|
||||
|
||||
public function testPutAnswerOptionTranslation(): void
|
||||
{
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::ADMIN_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$f = $this->fixture();
|
||||
|
||||
$this->api()->withToken($token, 'PUT', 'translations', [
|
||||
'type' => 'language',
|
||||
'languageCode' => 'fr',
|
||||
'name' => 'French',
|
||||
]);
|
||||
|
||||
$put = $this->api()->withToken($token, 'PUT', 'translations', [
|
||||
'type' => 'answer_option',
|
||||
'id' => $f->optionId,
|
||||
'languageCode' => 'fr',
|
||||
'text' => 'Oui',
|
||||
]);
|
||||
$this->assertApiOk($put);
|
||||
|
||||
$get = $this->api()->withToken($token, 'GET', 'translations', null, [
|
||||
'type' => 'answer_option',
|
||||
'id' => $f->optionId,
|
||||
]);
|
||||
$this->assertApiOk($get);
|
||||
$fr = null;
|
||||
foreach ($get['data']['translations'] as $row) {
|
||||
if ($row['languageCode'] === 'fr') {
|
||||
$fr = $row['text'];
|
||||
}
|
||||
}
|
||||
$this->assertSame('Oui', $fr);
|
||||
}
|
||||
}
|
||||
179
tests/Integration/UsersAdminTest.php
Normal file
179
tests/Integration/UsersAdminTest.php
Normal file
@ -0,0 +1,179 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Integration;
|
||||
|
||||
use Tests\Support\DatabaseSeeder;
|
||||
use Tests\Support\QdbTestCase;
|
||||
|
||||
final class UsersAdminTest extends QdbTestCase
|
||||
{
|
||||
public function testAdminCreatesCoach(): void
|
||||
{
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::ADMIN_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$f = $this->fixture();
|
||||
$username = 'coach_new_' . bin2hex(random_bytes(2));
|
||||
|
||||
$res = $this->api()->withToken($token, 'POST', 'users', [
|
||||
'username' => $username,
|
||||
'password' => 'CoachPass1!',
|
||||
'role' => 'coach',
|
||||
'supervisorID' => $f->supervisorId,
|
||||
]);
|
||||
$this->assertApiOk($res);
|
||||
$this->assertSame($username, $res['data']['username']);
|
||||
$this->assertSame('coach', $res['data']['role']);
|
||||
$this->assertSame($f->supervisorId, $res['data']['supervisorID']);
|
||||
|
||||
$login = $this->api()->loginMobile($username, 'CoachPass1!');
|
||||
$this->assertApiOk($login);
|
||||
}
|
||||
|
||||
public function testSupervisorCreatesCoachUnderSelf(): void
|
||||
{
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::SUPERVISOR_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$username = 'coach_sv_' . bin2hex(random_bytes(2));
|
||||
|
||||
$res = $this->api()->withToken($token, 'POST', 'users', [
|
||||
'username' => $username,
|
||||
'password' => 'CoachPass2!',
|
||||
'role' => 'coach',
|
||||
]);
|
||||
$this->assertApiOk($res);
|
||||
$this->assertSame($this->fixture()->supervisorId, $res['data']['supervisorID']);
|
||||
}
|
||||
|
||||
public function testAdminResetsCoachPassword(): void
|
||||
{
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::ADMIN_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$f = $this->fixture();
|
||||
|
||||
$res = $this->api()->withToken($token, 'PUT', 'users', [
|
||||
'userID' => $f->coachUserId,
|
||||
'newPassword' => 'ResetPass3!',
|
||||
'mustChangePassword' => 0,
|
||||
]);
|
||||
$this->assertApiOk($res);
|
||||
|
||||
$oldLogin = $this->api()->loginMobile(DatabaseSeeder::COACH_USERNAME, DatabaseSeeder::PASSWORD);
|
||||
$this->assertApiError($oldLogin, 'INVALID_CREDENTIALS', 401);
|
||||
|
||||
$newLogin = $this->api()->loginMobile(DatabaseSeeder::COACH_USERNAME, 'ResetPass3!');
|
||||
$this->assertApiOk($newLogin);
|
||||
}
|
||||
|
||||
public function testAdminDeletesCreatedCoach(): void
|
||||
{
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::ADMIN_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$f = $this->fixture();
|
||||
$username = 'coach_del_' . bin2hex(random_bytes(2));
|
||||
|
||||
$create = $this->api()->withToken($token, 'POST', 'users', [
|
||||
'username' => $username,
|
||||
'password' => 'CoachPass4!',
|
||||
'role' => 'coach',
|
||||
'supervisorID' => $f->supervisorId,
|
||||
]);
|
||||
$this->assertApiOk($create);
|
||||
$userId = $create['data']['userID'];
|
||||
|
||||
$del = $this->api()->withToken($token, 'DELETE', 'users', ['userID' => $userId]);
|
||||
$this->assertApiOk($del);
|
||||
|
||||
$list = $this->api()->withToken($token, 'GET', 'users');
|
||||
$ids = array_column($list['data']['users'], 'userID');
|
||||
$this->assertNotContains($userId, $ids);
|
||||
}
|
||||
|
||||
public function testAdminCreatesSupervisor(): void
|
||||
{
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::ADMIN_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$username = 'supervisor_new_' . bin2hex(random_bytes(2));
|
||||
$res = $this->api()->withToken($token, 'POST', 'users', [
|
||||
'username' => $username,
|
||||
'password' => 'SuperPass1!',
|
||||
'role' => 'supervisor',
|
||||
'location' => 'Region B',
|
||||
]);
|
||||
$this->assertApiOk($res);
|
||||
$this->assertSame('supervisor', $res['data']['role']);
|
||||
}
|
||||
|
||||
public function testAdminReassignsCoachToSupervisor(): void
|
||||
{
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::ADMIN_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$f = $this->fixture();
|
||||
|
||||
$sv = $this->api()->withToken($token, 'POST', 'users', [
|
||||
'username' => 'supervisor_reassign_' . bin2hex(random_bytes(2)),
|
||||
'password' => 'SuperPass2!',
|
||||
'role' => 'supervisor',
|
||||
'location' => 'Region C',
|
||||
]);
|
||||
$this->assertApiOk($sv);
|
||||
$newSvId = $sv['data']['entityID'];
|
||||
|
||||
$res = $this->api()->withToken($token, 'PUT', 'users', [
|
||||
'userID' => $f->coachUserId,
|
||||
'supervisorID' => $newSvId,
|
||||
]);
|
||||
$this->assertApiOk($res);
|
||||
$this->assertSame($newSvId, $res['data']['supervisorID']);
|
||||
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
$stmt = $pdo->prepare('SELECT supervisorID FROM coach WHERE coachID = :cid');
|
||||
$stmt->execute([':cid' => $f->coachId]);
|
||||
$this->assertSame($newSvId, $stmt->fetchColumn());
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
}
|
||||
|
||||
public function testAdminReassignsClientViaAssignments(): void
|
||||
{
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::ADMIN_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$f = $this->fixture();
|
||||
|
||||
$newCoach = $this->api()->withToken($token, 'POST', 'users', [
|
||||
'username' => 'coach_assign_' . bin2hex(random_bytes(2)),
|
||||
'password' => 'CoachPass5!',
|
||||
'role' => 'coach',
|
||||
'supervisorID' => $f->supervisorId,
|
||||
]);
|
||||
$this->assertApiOk($newCoach);
|
||||
$newCoachId = $newCoach['data']['entityID'];
|
||||
|
||||
$assign = $this->api()->withToken($token, 'POST', 'assignments', [
|
||||
'coachID' => $newCoachId,
|
||||
'clientCodes' => [$f->clientCode],
|
||||
]);
|
||||
$this->assertApiOk($assign);
|
||||
$this->assertSame(1, $assign['data']['assigned']);
|
||||
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
$stmt = $pdo->prepare('SELECT coachID FROM client WHERE clientCode = :cc');
|
||||
$stmt->execute([':cc' => $f->clientCode]);
|
||||
$this->assertSame($newCoachId, $stmt->fetchColumn());
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
}
|
||||
}
|
||||
55
tests/Integration/UsersTest.php
Normal file
55
tests/Integration/UsersTest.php
Normal file
@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Integration;
|
||||
|
||||
use Tests\Support\DatabaseSeeder;
|
||||
use Tests\Support\QdbTestCase;
|
||||
|
||||
final class UsersTest extends QdbTestCase
|
||||
{
|
||||
public function testAdminListsUsers(): void
|
||||
{
|
||||
$token = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::ADMIN_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
$res = $this->api()->withToken($token, 'GET', 'users');
|
||||
$this->assertApiOk($res);
|
||||
$this->assertGreaterThanOrEqual(3, count($res['data']['users']));
|
||||
}
|
||||
|
||||
public function testRevokeUserSessions(): void
|
||||
{
|
||||
$admin = $this->adminToken();
|
||||
$coach = $this->api()->loginMobileAndGetToken(
|
||||
DatabaseSeeder::COACH_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
);
|
||||
|
||||
$f = $this->fixture();
|
||||
$res = $this->api()->withToken($admin, 'PUT', 'users', [
|
||||
'action' => 'revokeSessions',
|
||||
'userID' => $f->coachUserId,
|
||||
]);
|
||||
$this->assertApiOk($res);
|
||||
$this->assertGreaterThanOrEqual(1, $res['data']['revokedSessions']);
|
||||
|
||||
$dead = $this->api()->withMobileToken($coach['token'], 'GET', 'session');
|
||||
$this->assertApiError($dead, 'UNAUTHORIZED', 401);
|
||||
}
|
||||
|
||||
public function testCannotRevokeOwnSessions(): void
|
||||
{
|
||||
$login = $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::ADMIN_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
);
|
||||
$res = $this->api()->withToken($login['token'], 'PUT', 'users', [
|
||||
'action' => 'revokeSessions',
|
||||
'userID' => $this->fixture()->adminUserId,
|
||||
]);
|
||||
$this->assertApiError($res, 'FORBIDDEN', 400);
|
||||
}
|
||||
}
|
||||
181
tests/Support/ApiClient.php
Normal file
181
tests/Support/ApiClient.php
Normal file
@ -0,0 +1,181 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Support;
|
||||
|
||||
/**
|
||||
* Dispatches requests through api/index.php (same as production routing).
|
||||
*/
|
||||
final class ApiClient
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $apiIndexPath,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $headers
|
||||
* @return array{ok: bool, status: int, data?: mixed, error?: array<string, mixed>}
|
||||
*/
|
||||
public function request(
|
||||
string $method,
|
||||
string $route,
|
||||
?array $body = null,
|
||||
array $headers = [],
|
||||
array $query = [],
|
||||
): array {
|
||||
qdb_test_reset_http_state();
|
||||
|
||||
$_SERVER['REQUEST_METHOD'] = strtoupper($method);
|
||||
$queryString = $query !== [] ? '?' . http_build_query($query) : '';
|
||||
$_SERVER['REQUEST_URI'] = '/api/' . ltrim($route, '/') . $queryString;
|
||||
$_SERVER['SCRIPT_NAME'] = '/api/index.php';
|
||||
$_GET = $query;
|
||||
if ($queryString !== '') {
|
||||
parse_str(ltrim($queryString, '?'), $_GET);
|
||||
}
|
||||
|
||||
unset($_SERVER['HTTP_AUTHORIZATION'], $_SERVER['Authorization']);
|
||||
unset($_SERVER['HTTP_X_QDB_CLIENT']);
|
||||
|
||||
foreach ($headers as $name => $value) {
|
||||
$key = 'HTTP_' . strtoupper(str_replace('-', '_', $name));
|
||||
$_SERVER[$key] = $value;
|
||||
}
|
||||
|
||||
if ($body !== null) {
|
||||
qdb_test_set_request_body(json_encode($body, JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
ob_start();
|
||||
try {
|
||||
require $this->apiIndexPath;
|
||||
ob_end_clean();
|
||||
return ['ok' => false, 'status' => 500, 'error' => ['code' => 'NO_RESPONSE', 'message' => 'Handler did not return JSON']];
|
||||
} catch (JsonSuccessResponse $e) {
|
||||
$output = ob_get_clean();
|
||||
return [
|
||||
'ok' => true,
|
||||
'status' => $e->httpStatus,
|
||||
'data' => $e->data,
|
||||
'_raw' => $output,
|
||||
];
|
||||
} catch (JsonErrorResponse $e) {
|
||||
ob_get_clean();
|
||||
$err = ['code' => $e->errorCode, 'message' => $e->errorMessage];
|
||||
if ($e->details !== null) {
|
||||
$err['details'] = $e->details;
|
||||
}
|
||||
return [
|
||||
'ok' => false,
|
||||
'status' => $e->httpStatus,
|
||||
'error' => $err,
|
||||
];
|
||||
} catch (RawHttpResponse $e) {
|
||||
ob_get_clean();
|
||||
return [
|
||||
'ok' => true,
|
||||
'status' => $e->httpStatus,
|
||||
'raw' => true,
|
||||
'body' => $e->body,
|
||||
'headers' => $e->headers,
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
ob_end_clean();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function loginWeb(string $username, string $password): array
|
||||
{
|
||||
return $this->request('POST', 'auth/login', [
|
||||
'username' => $username,
|
||||
'password' => $password,
|
||||
], ['X-QDB-Client' => 'web']);
|
||||
}
|
||||
|
||||
public function loginMobile(string $username, string $password): array
|
||||
{
|
||||
return $this->request('POST', 'auth/login', [
|
||||
'username' => $username,
|
||||
'password' => $password,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{token: string, data: array<string, mixed>}
|
||||
*/
|
||||
public function loginMobileAndGetToken(string $username, string $password): array
|
||||
{
|
||||
$res = $this->loginMobile($username, $password);
|
||||
if (!$res['ok']) {
|
||||
throw new \RuntimeException('Login failed: ' . ($res['error']['message'] ?? 'unknown'));
|
||||
}
|
||||
$token = $res['data']['token'] ?? '';
|
||||
if ($token === '') {
|
||||
throw new \RuntimeException('Login response missing token');
|
||||
}
|
||||
return ['token' => $token, 'data' => $res['data']];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{token: string, data: array<string, mixed>}
|
||||
*/
|
||||
public function loginWebAndGetToken(string $username, string $password): array
|
||||
{
|
||||
$res = $this->loginWeb($username, $password);
|
||||
if (!$res['ok']) {
|
||||
throw new \RuntimeException('Login failed: ' . ($res['error']['message'] ?? 'unknown'));
|
||||
}
|
||||
$token = $res['data']['token'] ?? '';
|
||||
if ($token === '') {
|
||||
throw new \RuntimeException('Login response missing token');
|
||||
}
|
||||
return ['token' => $token, 'data' => $res['data']];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $extraHeaders
|
||||
*/
|
||||
public function withToken(string $token, string $method, string $route, ?array $body = null, array $query = [], array $extraHeaders = []): array
|
||||
{
|
||||
$headers = array_merge([
|
||||
'Authorization' => 'Bearer ' . $token,
|
||||
'X-QDB-Client' => 'web',
|
||||
], $extraHeaders);
|
||||
|
||||
return $this->request($method, $route, $body, $headers, $query);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $extraHeaders
|
||||
*/
|
||||
public function withMobileToken(
|
||||
string $token,
|
||||
string $method,
|
||||
string $route,
|
||||
?array $body = null,
|
||||
array $query = [],
|
||||
array $extraHeaders = [],
|
||||
): array {
|
||||
$headers = array_merge(['Authorization' => 'Bearer ' . $token], $extraHeaders);
|
||||
|
||||
return $this->request($method, $route, $body, $headers, $query);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST an encrypted payload (Android app API).
|
||||
*
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
public function encryptedPost(string $token, string $route, array $payload): array
|
||||
{
|
||||
$envelope = qdb_sensitive_envelope(
|
||||
json_encode($payload, JSON_THROW_ON_ERROR),
|
||||
$token
|
||||
);
|
||||
|
||||
return $this->withMobileToken($token, 'POST', $route, $envelope);
|
||||
}
|
||||
}
|
||||
233
tests/Support/DatabaseSeeder.php
Normal file
233
tests/Support/DatabaseSeeder.php
Normal file
@ -0,0 +1,233 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Support;
|
||||
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* Seeds a minimal org graph for integration tests.
|
||||
*/
|
||||
final class DatabaseSeeder
|
||||
{
|
||||
public const PASSWORD = 'TestPass1!';
|
||||
|
||||
public const ADMIN_USERNAME = 'test_admin';
|
||||
public const SUPERVISOR_USERNAME = 'test_supervisor';
|
||||
public const COACH_USERNAME = 'test_coach';
|
||||
|
||||
public static function seed(PDO $pdo): FixtureIds
|
||||
{
|
||||
$now = time();
|
||||
$passwordHash = password_hash(self::PASSWORD, PASSWORD_DEFAULT);
|
||||
|
||||
// Stable IDs so fixture metadata always matches seeded rows.
|
||||
$adminId = 'admin-test';
|
||||
$adminUserId = 'u-admin-test';
|
||||
$supervisorId = 'sv-test';
|
||||
$supervisorUserId = 'u-sv-test';
|
||||
$coachId = 'coach-test';
|
||||
$coachUserId = 'u-coach-test';
|
||||
$qnId = 'qn-test';
|
||||
$questionId = $qnId . '__q1';
|
||||
$optionId = 'opt-test-q1-yes';
|
||||
$freeTextQuestionId = $qnId . '__q2';
|
||||
$glassQuestionId = $qnId . '__q3';
|
||||
$glassSymptomKey = 'pain';
|
||||
$multiQuestionId = $qnId . '__q4';
|
||||
$multiOptionAId = 'opt-test-q4-a';
|
||||
$multiOptionBId = 'opt-test-q4-b';
|
||||
$draftQuestionnaireId = 'qn-draft-test';
|
||||
|
||||
$pdo->prepare('INSERT INTO admin (adminID, username, location) VALUES (:id, :u, :loc)')
|
||||
->execute([':id' => $adminId, ':u' => self::ADMIN_USERNAME, ':loc' => 'HQ']);
|
||||
$pdo->prepare(
|
||||
'INSERT INTO users (userID, username, passwordHash, role, entityID, mustChangePassword, createdAt)
|
||||
VALUES (:uid, :u, :h, :role, :eid, 0, :now)'
|
||||
)->execute([
|
||||
':uid' => $adminUserId,
|
||||
':u' => self::ADMIN_USERNAME,
|
||||
':h' => $passwordHash,
|
||||
':role' => 'admin',
|
||||
':eid' => $adminId,
|
||||
':now' => $now,
|
||||
]);
|
||||
|
||||
$pdo->prepare('INSERT INTO supervisor (supervisorID, username, location) VALUES (:id, :u, :loc)')
|
||||
->execute([':id' => $supervisorId, ':u' => self::SUPERVISOR_USERNAME, ':loc' => 'Region A']);
|
||||
$pdo->prepare(
|
||||
'INSERT INTO users (userID, username, passwordHash, role, entityID, mustChangePassword, createdAt)
|
||||
VALUES (:uid, :u, :h, :role, :eid, 0, :now)'
|
||||
)->execute([
|
||||
':uid' => $supervisorUserId,
|
||||
':u' => self::SUPERVISOR_USERNAME,
|
||||
':h' => $passwordHash,
|
||||
':role' => 'supervisor',
|
||||
':eid' => $supervisorId,
|
||||
':now' => $now,
|
||||
]);
|
||||
|
||||
$pdo->prepare('INSERT INTO coach (coachID, supervisorID, username) VALUES (:id, :sid, :u)')
|
||||
->execute([':id' => $coachId, ':sid' => $supervisorId, ':u' => self::COACH_USERNAME]);
|
||||
$pdo->prepare(
|
||||
'INSERT INTO users (userID, username, passwordHash, role, entityID, mustChangePassword, createdAt)
|
||||
VALUES (:uid, :u, :h, :role, :eid, 0, :now)'
|
||||
)->execute([
|
||||
':uid' => $coachUserId,
|
||||
':u' => self::COACH_USERNAME,
|
||||
':h' => $passwordHash,
|
||||
':role' => 'coach',
|
||||
':eid' => $coachId,
|
||||
':now' => $now,
|
||||
]);
|
||||
|
||||
$pdo->prepare('INSERT INTO client (clientCode, coachID) VALUES (:cc, :cid)')
|
||||
->execute([':cc' => 'CLIENT-TEST-001', ':cid' => $coachId]);
|
||||
|
||||
$pdo->prepare(
|
||||
'INSERT INTO questionnaire (questionnaireID, name, version, state, orderIndex, showPoints, conditionJson, categoryKey)
|
||||
VALUES (:id, :n, :v, :s, 1, 0, :cj, :ck)'
|
||||
)->execute([
|
||||
':id' => $qnId,
|
||||
':n' => 'Test questionnaire',
|
||||
':v' => '1',
|
||||
':s' => 'active',
|
||||
':cj' => '{}',
|
||||
':ck' => '',
|
||||
]);
|
||||
$pdo->prepare('UPDATE questionnaire SET showPoints = 1 WHERE questionnaireID = :id')
|
||||
->execute([':id' => $qnId]);
|
||||
|
||||
$pdo->prepare(
|
||||
'INSERT INTO question (questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson)
|
||||
VALUES (:id, :qn, :txt, :type, 0, 1, :cfg)'
|
||||
)->execute([
|
||||
':id' => $questionId,
|
||||
':qn' => $qnId,
|
||||
':txt' => 'Consent?',
|
||||
':type' => 'single_choice_question',
|
||||
':cfg' => json_encode(['questionKey' => 'consent_q'], JSON_THROW_ON_ERROR),
|
||||
]);
|
||||
|
||||
$pdo->prepare(
|
||||
'INSERT INTO answer_option (answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId)
|
||||
VALUES (:id, :qid, :txt, 0, 0, :next)'
|
||||
)->execute([
|
||||
':next' => '',
|
||||
':id' => $optionId,
|
||||
':qid' => $questionId,
|
||||
':txt' => 'yes',
|
||||
]);
|
||||
|
||||
$pdo->prepare(
|
||||
'INSERT INTO question (questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson)
|
||||
VALUES (:id, :qn, :txt, :type, 1, 0, :cfg)'
|
||||
)->execute([
|
||||
':id' => $freeTextQuestionId,
|
||||
':qn' => $qnId,
|
||||
':txt' => 'Notes',
|
||||
':type' => 'free_text_question',
|
||||
':cfg' => json_encode(['questionKey' => 'notes_q'], JSON_THROW_ON_ERROR),
|
||||
]);
|
||||
|
||||
$glassConfig = json_encode(
|
||||
['questionKey' => 'symptoms_q', 'symptoms' => [$glassSymptomKey, 'fatigue']],
|
||||
JSON_THROW_ON_ERROR
|
||||
);
|
||||
$pdo->prepare(
|
||||
'INSERT INTO question (questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson)
|
||||
VALUES (:id, :qn, :txt, :type, 2, 0, :cfg)'
|
||||
)->execute([
|
||||
':id' => $glassQuestionId,
|
||||
':qn' => $qnId,
|
||||
':txt' => 'Symptoms',
|
||||
':type' => 'glass_scale_question',
|
||||
':cfg' => $glassConfig,
|
||||
]);
|
||||
|
||||
require_once dirname(__DIR__, 2) . '/lib/settings.php';
|
||||
qdb_settings_save_on_pdo($pdo, qdb_settings_defaults());
|
||||
|
||||
$pdo->prepare(
|
||||
'INSERT INTO question (questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson)
|
||||
VALUES (:id, :qn, :txt, :type, 3, 0, :cfg)'
|
||||
)->execute([
|
||||
':id' => $multiQuestionId,
|
||||
':qn' => $qnId,
|
||||
':txt' => 'Pick several',
|
||||
':type' => 'multi_check_box_question',
|
||||
':cfg' => json_encode(['questionKey' => 'multi_q'], JSON_THROW_ON_ERROR),
|
||||
]);
|
||||
$pdo->prepare(
|
||||
'INSERT INTO answer_option (answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId)
|
||||
VALUES (:id, :qid, :txt, 1, 0, :next)'
|
||||
)->execute([':next' => '', ':id' => $multiOptionAId, ':qid' => $multiQuestionId, ':txt' => 'opt_a']);
|
||||
$pdo->prepare(
|
||||
'INSERT INTO answer_option (answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId)
|
||||
VALUES (:id, :qid, :txt, 2, 1, :next)'
|
||||
)->execute([':next' => '', ':id' => $multiOptionBId, ':qid' => $multiQuestionId, ':txt' => 'opt_b']);
|
||||
qdb_upsert_source_translation($pdo, 'answer_option', $multiOptionAId, 'A');
|
||||
qdb_upsert_source_translation($pdo, 'answer_option', $multiOptionBId, 'B');
|
||||
|
||||
$pdo->prepare(
|
||||
'INSERT INTO questionnaire (questionnaireID, name, version, state, orderIndex, showPoints, conditionJson, categoryKey)
|
||||
VALUES (:id, :n, :v, :s, 2, 0, :cj, :ck)'
|
||||
)->execute([
|
||||
':id' => $draftQuestionnaireId,
|
||||
':n' => 'Draft only',
|
||||
':v' => '1',
|
||||
':s' => 'draft',
|
||||
':cj' => '{}',
|
||||
':ck' => '',
|
||||
]);
|
||||
|
||||
qdb_upsert_source_translation($pdo, 'answer_option', $optionId, 'Ja');
|
||||
|
||||
return new FixtureIds(
|
||||
adminUserId: $adminUserId,
|
||||
supervisorUserId: $supervisorUserId,
|
||||
coachUserId: $coachUserId,
|
||||
coachId: $coachId,
|
||||
supervisorId: $supervisorId,
|
||||
questionnaireId: $qnId,
|
||||
questionId: $questionId,
|
||||
questionShortId: 'q1',
|
||||
optionId: $optionId,
|
||||
freeTextQuestionId: $freeTextQuestionId,
|
||||
freeTextShortId: 'q2',
|
||||
glassQuestionId: $glassQuestionId,
|
||||
glassShortId: 'q3',
|
||||
glassSymptomKey: $glassSymptomKey,
|
||||
multiCheckQuestionId: $multiQuestionId,
|
||||
multiCheckShortId: 'q4',
|
||||
draftQuestionnaireId: $draftQuestionnaireId,
|
||||
clientCode: 'CLIENT-TEST-001',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final class FixtureIds
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $adminUserId,
|
||||
public readonly string $supervisorUserId,
|
||||
public readonly string $coachUserId,
|
||||
public readonly string $coachId,
|
||||
public readonly string $supervisorId,
|
||||
public readonly string $questionnaireId,
|
||||
public readonly string $questionId,
|
||||
public readonly string $questionShortId,
|
||||
public readonly string $optionId,
|
||||
public readonly string $freeTextQuestionId,
|
||||
public readonly string $freeTextShortId,
|
||||
public readonly string $glassQuestionId,
|
||||
public readonly string $glassShortId,
|
||||
public readonly string $glassSymptomKey,
|
||||
public readonly string $multiCheckQuestionId,
|
||||
public readonly string $multiCheckShortId,
|
||||
public readonly string $draftQuestionnaireId,
|
||||
public readonly string $clientCode,
|
||||
) {
|
||||
}
|
||||
}
|
||||
20
tests/Support/JsonErrorResponse.php
Normal file
20
tests/Support/JsonErrorResponse.php
Normal file
@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Support;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/** Thrown instead of exit() when QDB_TESTING is defined. */
|
||||
final class JsonErrorResponse extends RuntimeException
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $errorCode,
|
||||
public readonly string $errorMessage,
|
||||
public readonly int $httpStatus = 400,
|
||||
public readonly mixed $details = null,
|
||||
) {
|
||||
parent::__construct($errorMessage, $httpStatus);
|
||||
}
|
||||
}
|
||||
18
tests/Support/JsonSuccessResponse.php
Normal file
18
tests/Support/JsonSuccessResponse.php
Normal file
@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Support;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/** Thrown instead of exit() when QDB_TESTING is defined. */
|
||||
final class JsonSuccessResponse extends RuntimeException
|
||||
{
|
||||
public function __construct(
|
||||
public readonly mixed $data,
|
||||
public readonly int $httpStatus = 200,
|
||||
) {
|
||||
parent::__construct('JSON success', $httpStatus);
|
||||
}
|
||||
}
|
||||
166
tests/Support/QdbTestCase.php
Normal file
166
tests/Support/QdbTestCase.php
Normal file
@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Support;
|
||||
|
||||
use PHPUnit\Framework\TestCase as PHPUnitTestCase;
|
||||
|
||||
abstract class QdbTestCase extends PHPUnitTestCase
|
||||
{
|
||||
protected static ApiClient $api;
|
||||
|
||||
protected ?FixtureIds $fixtureIds = null;
|
||||
|
||||
public static function setUpBeforeClass(): void
|
||||
{
|
||||
parent::setUpBeforeClass();
|
||||
self::$api = new ApiClient(dirname(__DIR__, 2) . '/api/index.php');
|
||||
}
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
TestFilesystem::resetUploads();
|
||||
$_SERVER['REMOTE_ADDR'] = '127.0.0.1';
|
||||
$this->bootstrapDatabase();
|
||||
}
|
||||
|
||||
protected function bootstrapDatabase(): void
|
||||
{
|
||||
if (defined('QDB_PATH') && is_file(QDB_PATH)) {
|
||||
@unlink(QDB_PATH);
|
||||
}
|
||||
if (defined('QDB_LOCK') && is_file(QDB_LOCK)) {
|
||||
@unlink(QDB_LOCK);
|
||||
}
|
||||
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
$this->fixtureIds = DatabaseSeeder::seed($pdo);
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
}
|
||||
|
||||
protected function api(): ApiClient
|
||||
{
|
||||
return self::$api;
|
||||
}
|
||||
|
||||
protected function fixture(): FixtureIds
|
||||
{
|
||||
if ($this->fixtureIds === null) {
|
||||
$this->fail('Fixture not seeded');
|
||||
}
|
||||
return $this->fixtureIds;
|
||||
}
|
||||
|
||||
protected function adminToken(): string
|
||||
{
|
||||
return $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::ADMIN_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
}
|
||||
|
||||
protected function supervisorToken(): string
|
||||
{
|
||||
return $this->api()->loginWebAndGetToken(
|
||||
DatabaseSeeder::SUPERVISOR_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
)['token'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{token: string, data: array<string, mixed>}
|
||||
*/
|
||||
protected function coachMobileLogin(): array
|
||||
{
|
||||
return $this->api()->loginMobileAndGetToken(
|
||||
DatabaseSeeder::COACH_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
);
|
||||
}
|
||||
|
||||
protected function assertApiOk(array $response, string $message = ''): void
|
||||
{
|
||||
$this->assertTrue($response['ok'] ?? false, $message . ' ' . json_encode($response['error'] ?? []));
|
||||
}
|
||||
|
||||
protected function assertApiError(array $response, string $code, ?int $status = null): void
|
||||
{
|
||||
$this->assertFalse($response['ok'] ?? true);
|
||||
$this->assertSame($code, $response['error']['code'] ?? null);
|
||||
if ($status !== null) {
|
||||
$this->assertSame($status, $response['status']);
|
||||
}
|
||||
}
|
||||
|
||||
protected function assertRawOk(array $response, string $contentTypePrefix): void
|
||||
{
|
||||
$this->assertTrue($response['ok'] ?? false);
|
||||
$this->assertTrue($response['raw'] ?? false);
|
||||
$this->assertStringStartsWith($contentTypePrefix, $response['headers']['Content-Type'] ?? '');
|
||||
$this->assertNotSame('', $response['body'] ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed> API response from app submit
|
||||
*/
|
||||
protected function submitFixtureQuestionnaire(): array
|
||||
{
|
||||
$login = $this->api()->loginMobileAndGetToken(
|
||||
DatabaseSeeder::COACH_USERNAME,
|
||||
DatabaseSeeder::PASSWORD
|
||||
);
|
||||
$f = $this->fixture();
|
||||
$now = time();
|
||||
|
||||
return $this->submitQuestionnaireAs(
|
||||
$login['token'],
|
||||
$f->questionnaireId,
|
||||
$f->clientCode,
|
||||
[
|
||||
['questionID' => $f->questionShortId, 'answerOptionKey' => 'yes'],
|
||||
],
|
||||
$now - 60,
|
||||
$now
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $answers
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function submitQuestionnaireAs(
|
||||
string $token,
|
||||
string $questionnaireId,
|
||||
string $clientCode,
|
||||
array $answers,
|
||||
?int $startedAt = null,
|
||||
?int $completedAt = null,
|
||||
): array {
|
||||
$now = time();
|
||||
$startedAt ??= $now - 60;
|
||||
$completedAt ??= $now;
|
||||
|
||||
return $this->api()->encryptedPost($token, 'app_questionnaires', [
|
||||
'questionnaireID' => $questionnaireId,
|
||||
'clientCode' => $clientCode,
|
||||
'answers' => $answers,
|
||||
'startedAt' => $startedAt,
|
||||
'completedAt' => $completedAt,
|
||||
]);
|
||||
}
|
||||
|
||||
protected function submissionVersionCount(string $clientCode, string $questionnaireId): int
|
||||
{
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT COUNT(*) FROM questionnaire_submission WHERE clientCode = :cc AND questionnaireID = :qn'
|
||||
);
|
||||
$stmt->execute([':cc' => $clientCode, ':qn' => $questionnaireId]);
|
||||
$count = (int)$stmt->fetchColumn();
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
|
||||
return $count;
|
||||
}
|
||||
}
|
||||
22
tests/Support/RawHttpResponse.php
Normal file
22
tests/Support/RawHttpResponse.php
Normal file
@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Support;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/** Thrown instead of exit() for file/download responses when QDB_TESTING is defined. */
|
||||
final class RawHttpResponse extends RuntimeException
|
||||
{
|
||||
/**
|
||||
* @param array<string, string> $headers
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $body,
|
||||
public readonly array $headers,
|
||||
public readonly int $httpStatus = 200,
|
||||
) {
|
||||
parent::__construct('Raw HTTP response', $httpStatus);
|
||||
}
|
||||
}
|
||||
48
tests/Support/TestFilesystem.php
Normal file
48
tests/Support/TestFilesystem.php
Normal file
@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Support;
|
||||
|
||||
final class TestFilesystem
|
||||
{
|
||||
public static function resetUploads(): void
|
||||
{
|
||||
if (!defined('QDB_TEST_UPLOADS')) {
|
||||
return;
|
||||
}
|
||||
if (defined('QDB_PATH')) {
|
||||
if (is_file(QDB_PATH)) {
|
||||
@unlink(QDB_PATH);
|
||||
}
|
||||
if (defined('QDB_LOCK') && is_file(QDB_LOCK)) {
|
||||
@unlink(QDB_LOCK);
|
||||
}
|
||||
}
|
||||
self::rmTree(QDB_TEST_UPLOADS);
|
||||
mkdir(QDB_TEST_UPLOADS, 0755, true);
|
||||
}
|
||||
|
||||
private static function rmTree(string $dir): void
|
||||
{
|
||||
if (!is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
$items = scandir($dir);
|
||||
if ($items === false) {
|
||||
return;
|
||||
}
|
||||
foreach ($items as $item) {
|
||||
if ($item === '.' || $item === '..') {
|
||||
continue;
|
||||
}
|
||||
$path = $dir . DIRECTORY_SEPARATOR . $item;
|
||||
if (is_dir($path)) {
|
||||
self::rmTree($path);
|
||||
} else {
|
||||
@unlink($path);
|
||||
}
|
||||
}
|
||||
@rmdir($dir);
|
||||
}
|
||||
}
|
||||
109
tests/Unit/ApiLogTest.php
Normal file
109
tests/Unit/ApiLogTest.php
Normal file
@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ApiLogTest extends TestCase
|
||||
{
|
||||
private string $logDir;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
require_once dirname(__DIR__, 2) . '/lib/api_log.php';
|
||||
$this->logDir = $_ENV['QDB_API_LOG_DIR'] ?? (QDB_TEST_UPLOADS . '/logs/api');
|
||||
if (!is_dir($this->logDir)) {
|
||||
mkdir($this->logDir, 0775, true);
|
||||
}
|
||||
$this->clearLogFiles();
|
||||
unset($GLOBALS['qdb_api_log_ctx']);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->clearLogFiles();
|
||||
unset($GLOBALS['qdb_api_log_ctx']);
|
||||
}
|
||||
|
||||
public function testClassifyActivity(): void
|
||||
{
|
||||
$this->assertNull(qdb_api_log_classify_activity('OPTIONS', 'web', 'users'));
|
||||
$this->assertSame('web_change', qdb_api_log_classify_activity('POST', 'web', 'users'));
|
||||
$this->assertSame('app_change', qdb_api_log_classify_activity('POST', '', 'app_questionnaires'));
|
||||
$this->assertSame('app_sync', qdb_api_log_classify_activity('GET', '', 'app_questionnaires'));
|
||||
$this->assertSame('web_export', qdb_api_log_classify_activity('GET', 'web', 'export'));
|
||||
$this->assertSame('web_change', qdb_api_log_classify_activity('POST', 'web', 'backup'));
|
||||
$this->assertTrue(qdb_api_log_is_website_download('backup'));
|
||||
}
|
||||
|
||||
public function testRedactSensitiveFields(): void
|
||||
{
|
||||
$redacted = qdb_api_log_redact_value([
|
||||
'username' => 'coach1',
|
||||
'password' => 'secret',
|
||||
'encrypted' => str_repeat('x', 40),
|
||||
]);
|
||||
$this->assertSame('coach1', $redacted['username']);
|
||||
$this->assertSame('[REDACTED]', $redacted['password']);
|
||||
$this->assertSame('[ENCRYPTED:40 chars]', $redacted['encrypted']);
|
||||
}
|
||||
|
||||
public function testRedactQueryString(): void
|
||||
{
|
||||
$qs = qdb_api_log_redact_query_string('questionnaireID=qn1&token=abc&password=pw');
|
||||
parse_str((string)$qs, $params);
|
||||
$this->assertSame('qn1', $params['questionnaireID'] ?? null);
|
||||
$this->assertSame('[REDACTED]', $params['token'] ?? null);
|
||||
$this->assertSame('[REDACTED]', $params['password'] ?? null);
|
||||
}
|
||||
|
||||
public function testTokenHintMasksMiddle(): void
|
||||
{
|
||||
$hint = qdb_api_log_token_hint('0123456789abcdef');
|
||||
$this->assertStringContainsString('…', $hint);
|
||||
$this->assertStringNotContainsString('56789', $hint);
|
||||
}
|
||||
|
||||
public function testClientIpUsesForwardedFor(): void
|
||||
{
|
||||
$_SERVER['HTTP_X_FORWARDED_FOR'] = '203.0.113.50, 198.51.100.1';
|
||||
$_SERVER['REMOTE_ADDR'] = '127.0.0.1';
|
||||
$this->assertSame('203.0.113.50', qdb_api_log_client_ip());
|
||||
unset($_SERVER['HTTP_X_FORWARDED_FOR']);
|
||||
}
|
||||
|
||||
public function testBeginFinishWritesWebChangeEntry(): void
|
||||
{
|
||||
$_SERVER['HTTP_X_QDB_CLIENT'] = 'web';
|
||||
$_SERVER['REQUEST_URI'] = '/api/users';
|
||||
$_SERVER['QUERY_STRING'] = '';
|
||||
qdb_test_set_request_body(json_encode(['username' => 'new_coach'], JSON_THROW_ON_ERROR));
|
||||
http_response_code(200);
|
||||
|
||||
qdb_api_log_begin('POST', 'users');
|
||||
qdb_api_log_finish();
|
||||
|
||||
$date = date('Y-m-d');
|
||||
$read = qdb_api_log_read_entries($date, 'web_change', 10);
|
||||
$entries = $read['entries'];
|
||||
$this->assertNotEmpty($entries);
|
||||
$last = $entries[0];
|
||||
$this->assertSame('web_change', $last['activity']);
|
||||
$this->assertSame('POST', $last['method']);
|
||||
$this->assertSame('users', $last['route']);
|
||||
$changes = $last['changes'] ?? [];
|
||||
$fields = array_column($changes, 'field');
|
||||
$this->assertContains('username', $fields);
|
||||
|
||||
qdb_test_reset_http_state();
|
||||
}
|
||||
|
||||
private function clearLogFiles(): void
|
||||
{
|
||||
foreach (glob($this->logDir . '/api-*.log') ?: [] as $path) {
|
||||
@unlink($path);
|
||||
}
|
||||
}
|
||||
}
|
||||
65
tests/Unit/AppAnswersTest.php
Normal file
65
tests/Unit/AppAnswersTest.php
Normal file
@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class AppAnswersTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
require_once dirname(__DIR__, 2) . '/lib/app_answers.php';
|
||||
}
|
||||
|
||||
public function testEncodeMultiCheckDedupesAndSortsKeys(): void
|
||||
{
|
||||
$json = qdb_encode_multi_check_values(['b', 'a', 'b', '']);
|
||||
$decoded = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
|
||||
$this->assertEqualsCanonicalizing(['a', 'b'], $decoded);
|
||||
}
|
||||
|
||||
public function testDecodeMultiCheckFromJsonArray(): void
|
||||
{
|
||||
$this->assertSame(['x', 'y'], qdb_decode_multi_check_values('["x","y"]'));
|
||||
}
|
||||
|
||||
public function testDecodeMultiCheckFromCommaSeparated(): void
|
||||
{
|
||||
$this->assertSame(['one', 'two'], qdb_decode_multi_check_values('one, two'));
|
||||
}
|
||||
|
||||
public function testGroupAppSubmitAnswersMergesMultiCheck(): void
|
||||
{
|
||||
$answers = [
|
||||
['questionID' => 'q1', 'answerOptionKey' => 'opt_a', 'answeredAt' => 100],
|
||||
['questionID' => 'q1', 'answerOptionKey' => 'opt_b', 'answeredAt' => 100],
|
||||
['questionID' => 'q2', 'freeTextValue' => 'hello'],
|
||||
];
|
||||
$grouped = qdb_group_app_submit_answers(
|
||||
$answers,
|
||||
['q1' => 'multi_check_box_question', 'q2' => 'text_question'],
|
||||
[]
|
||||
);
|
||||
$this->assertCount(2, $grouped);
|
||||
$this->assertSame('q1', $grouped[0]['questionID']);
|
||||
$decoded = json_decode((string)$grouped[0]['freeTextValue'], true, 512, JSON_THROW_ON_ERROR);
|
||||
$this->assertEqualsCanonicalizing(['opt_a', 'opt_b'], $decoded);
|
||||
$this->assertSame('hello', $grouped[1]['freeTextValue']);
|
||||
}
|
||||
|
||||
public function testGroupAppSubmitAnswersPreservesSymptomRows(): void
|
||||
{
|
||||
$answers = [
|
||||
['questionID' => 'fatigue', 'answerOptionKey' => 'moderate'],
|
||||
];
|
||||
$grouped = qdb_group_app_submit_answers(
|
||||
$answers,
|
||||
['fatigue' => 'glass_scale_question'],
|
||||
['fatigue' => 'glass_parent']
|
||||
);
|
||||
$this->assertCount(1, $grouped);
|
||||
$this->assertSame('moderate', $grouped[0]['answerOptionKey']);
|
||||
}
|
||||
}
|
||||
109
tests/Unit/AppSubmitValidateTest.php
Normal file
109
tests/Unit/AppSubmitValidateTest.php
Normal file
@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use Tests\Support\QdbTestCase;
|
||||
|
||||
final class AppSubmitValidateTest extends QdbTestCase
|
||||
{
|
||||
public function testEmptyAnswersRejected(): void
|
||||
{
|
||||
require_once dirname(__DIR__, 2) . '/lib/app_submit_validate.php';
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
$f = $this->fixture();
|
||||
$errors = qdb_validate_app_submit_payload($pdo, $f->questionnaireId, [], [], [], [], []);
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
$this->assertNotEmpty($errors);
|
||||
$this->assertSame('ANSWERS_REQUIRED', $errors[0]['code']);
|
||||
}
|
||||
|
||||
public function testUnknownQuestionRejected(): void
|
||||
{
|
||||
require_once dirname(__DIR__, 2) . '/lib/app_submit_validate.php';
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
$f = $this->fixture();
|
||||
$errors = qdb_validate_app_submit_payload(
|
||||
$pdo,
|
||||
$f->questionnaireId,
|
||||
[['questionID' => 'nope', 'answerOptionKey' => 'x']],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[]
|
||||
);
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
$this->assertSame('UNKNOWN_QUESTION', $errors[0]['code'] ?? '');
|
||||
}
|
||||
|
||||
public function testValidSingleChoiceWithOptionKeyPasses(): void
|
||||
{
|
||||
require_once dirname(__DIR__, 2) . '/lib/app_submit_validate.php';
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
$f = $this->fixture();
|
||||
$shortMap = [$f->questionShortId => $f->questionId];
|
||||
$typeMap = [$f->questionShortId => 'single_choice_question'];
|
||||
$optMap = [
|
||||
$f->questionId => [
|
||||
'yes' => ['answerOptionID' => $f->optionId, 'points' => 0],
|
||||
],
|
||||
];
|
||||
$errors = qdb_validate_app_submit_payload(
|
||||
$pdo,
|
||||
$f->questionnaireId,
|
||||
[['questionID' => $f->questionShortId, 'answerOptionKey' => 'yes']],
|
||||
$shortMap,
|
||||
$typeMap,
|
||||
[],
|
||||
$optMap,
|
||||
);
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
$this->assertSame([], $errors);
|
||||
}
|
||||
|
||||
public function testMultiCheckRequiresSelection(): void
|
||||
{
|
||||
require_once dirname(__DIR__, 2) . '/lib/app_submit_validate.php';
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
$f = $this->fixture();
|
||||
$shortMap = [$f->multiCheckShortId => $f->multiCheckQuestionId];
|
||||
$typeMap = [$f->multiCheckShortId => 'multi_check_box_question'];
|
||||
$errors = qdb_validate_app_submit_payload(
|
||||
$pdo,
|
||||
$f->questionnaireId,
|
||||
[['questionID' => $f->multiCheckShortId, 'freeTextValue' => '']],
|
||||
$shortMap,
|
||||
$typeMap,
|
||||
[],
|
||||
[],
|
||||
);
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
$this->assertSame('EMPTY_ANSWER', $errors[0]['code'] ?? '');
|
||||
}
|
||||
|
||||
public function testInvalidOptionKeyRejected(): void
|
||||
{
|
||||
require_once dirname(__DIR__, 2) . '/lib/app_submit_validate.php';
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
$f = $this->fixture();
|
||||
$shortMap = [$f->questionShortId => $f->questionId];
|
||||
$typeMap = [$f->questionShortId => 'single_choice_question'];
|
||||
$optMap = [
|
||||
$f->questionId => [
|
||||
'yes' => ['answerOptionID' => $f->optionId, 'points' => 0],
|
||||
],
|
||||
];
|
||||
$errors = qdb_validate_app_submit_payload(
|
||||
$pdo,
|
||||
$f->questionnaireId,
|
||||
[['questionID' => $f->questionShortId, 'answerOptionKey' => 'No']],
|
||||
$shortMap,
|
||||
$typeMap,
|
||||
[],
|
||||
$optMap,
|
||||
);
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
$this->assertSame('INVALID_OPTION', $errors[0]['code'] ?? '');
|
||||
}
|
||||
}
|
||||
62
tests/Unit/CommonBundleTest.php
Normal file
62
tests/Unit/CommonBundleTest.php
Normal file
@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use Tests\Support\JsonErrorResponse;
|
||||
use Tests\Support\QdbTestCase;
|
||||
|
||||
final class CommonBundleTest extends QdbTestCase
|
||||
{
|
||||
public function testImportQuestionnairesBundleRejectsEmptyList(): void
|
||||
{
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
try {
|
||||
qdb_import_questionnaires_bundle($pdo, ['questionnaires' => []]);
|
||||
$this->fail('Expected JsonErrorResponse');
|
||||
} catch (JsonErrorResponse $e) {
|
||||
$this->assertSame('MISSING_FIELDS', $e->errorCode);
|
||||
} finally {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
}
|
||||
}
|
||||
|
||||
public function testImportTranslationsBundleRoundTripOnPdo(): void
|
||||
{
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
$f = $this->fixture();
|
||||
qdb_put_translation($pdo, 'question', $f->questionId, 'en', 'Hello EN');
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
|
||||
[$pdo2, $tmp2, $lock2] = qdb_open(false);
|
||||
$bundle = qdb_export_translations_bundle($pdo2);
|
||||
qdb_discard($tmp2, $lock2);
|
||||
|
||||
[$pdo3, $tmp3, $lock3] = qdb_open(true);
|
||||
$pdo3->prepare('DELETE FROM question_translation WHERE questionID = :id AND languageCode = :lc')
|
||||
->execute([':id' => $f->questionId, ':lc' => 'en']);
|
||||
$result = qdb_import_translations_bundle($pdo3, $bundle);
|
||||
qdb_save($tmp3, $lock3);
|
||||
$this->assertGreaterThan(0, $result['imported'] ?? 0);
|
||||
|
||||
[$pdo4, $tmp4, $lock4] = qdb_open(false);
|
||||
$stmt = $pdo4->prepare(
|
||||
'SELECT text FROM question_translation WHERE questionID = :id AND languageCode = :lc'
|
||||
);
|
||||
$stmt->execute([':id' => $f->questionId, ':lc' => 'en']);
|
||||
$this->assertSame('Hello EN', $stmt->fetchColumn());
|
||||
qdb_discard($tmp4, $lock4);
|
||||
}
|
||||
|
||||
public function testExportQuestionnaireBundleIncludesQuestionKeys(): void
|
||||
{
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
$f = $this->fixture();
|
||||
$item = qdb_export_questionnaire_bundle($pdo, $f->questionnaireId);
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
$this->assertNotNull($item);
|
||||
$keys = array_column($item['questions'], 'questionKey');
|
||||
$this->assertContains('consent_q', $keys);
|
||||
}
|
||||
}
|
||||
56
tests/Unit/CommonHelpersTest.php
Normal file
56
tests/Unit/CommonHelpersTest.php
Normal file
@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class CommonHelpersTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
require_once dirname(__DIR__, 2) . '/common.php';
|
||||
}
|
||||
|
||||
public function testIsStableKey(): void
|
||||
{
|
||||
$this->assertTrue(qdb_is_stable_key('question_key'));
|
||||
$this->assertTrue(qdb_is_stable_key('yes'));
|
||||
$this->assertFalse(qdb_is_stable_key('Question Key'));
|
||||
$this->assertFalse(qdb_is_stable_key(''));
|
||||
}
|
||||
|
||||
public function testNormalizeStableKeyCoercesSpaces(): void
|
||||
{
|
||||
$this->assertSame('My_Key', qdb_normalize_stable_key('My Key'));
|
||||
$this->assertSame('consent_q', qdb_normalize_stable_key('consent_q'));
|
||||
}
|
||||
|
||||
public function testValidateStableKeyReturnsNormalized(): void
|
||||
{
|
||||
$this->assertSame('My_Key', qdb_validate_stable_key('My Key', 'Question key'));
|
||||
}
|
||||
|
||||
public function testMessageKeyFromConditionJson(): void
|
||||
{
|
||||
$json = json_encode(['messageKey' => 'locked_hint'], JSON_THROW_ON_ERROR);
|
||||
$this->assertSame('locked_hint', qdb_message_key_from_condition_json($json));
|
||||
$this->assertSame('', qdb_message_key_from_condition_json('{}'));
|
||||
}
|
||||
|
||||
public function testMergeGlassSymptomJson(): void
|
||||
{
|
||||
$merged = qdb_merge_glass_symptom_json('{"pain":"mild"}', ['fatigue' => 'severe']);
|
||||
$data = json_decode($merged, true, 512, JSON_THROW_ON_ERROR);
|
||||
$this->assertSame('mild', $data['pain']);
|
||||
$this->assertSame('severe', $data['fatigue']);
|
||||
}
|
||||
|
||||
public function testGlassSymptomAnswerValue(): void
|
||||
{
|
||||
$json = json_encode(['pain' => 'moderate'], JSON_THROW_ON_ERROR);
|
||||
$this->assertSame('moderate', qdb_glass_symptom_answer_value($json, 'pain'));
|
||||
$this->assertSame('', qdb_glass_symptom_answer_value($json, 'missing'));
|
||||
}
|
||||
}
|
||||
53
tests/Unit/CryptoTest.php
Normal file
53
tests/Unit/CryptoTest.php
Normal file
@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class CryptoTest extends TestCase
|
||||
{
|
||||
public function testAesRoundTrip(): void
|
||||
{
|
||||
$key = str_repeat('K', 32);
|
||||
$plain = '{"clients":[{"clientCode":"C1"}]}';
|
||||
$enc = aes256_cbc_encrypt_bytes($plain, $key);
|
||||
$this->assertGreaterThan(16, strlen($enc));
|
||||
$dec = aes256_cbc_decrypt_bytes($enc, $key);
|
||||
$this->assertSame($plain, $dec);
|
||||
}
|
||||
|
||||
public function testHkdfDeterministicForToken(): void
|
||||
{
|
||||
$token = bin2hex(random_bytes(16));
|
||||
$k1 = hkdf_session_key_from_token($token);
|
||||
$k2 = hkdf_session_key_from_token($token);
|
||||
$this->assertSame(32, strlen($k1));
|
||||
$this->assertSame($k1, $k2);
|
||||
}
|
||||
|
||||
public function testHkdfDiffersForDifferentTokens(): void
|
||||
{
|
||||
$a = hkdf_session_key_from_token(bin2hex(random_bytes(16)));
|
||||
$b = hkdf_session_key_from_token(bin2hex(random_bytes(16)));
|
||||
$this->assertNotSame($a, $b);
|
||||
}
|
||||
|
||||
public function testDecryptFailsWithWrongKey(): void
|
||||
{
|
||||
$key = str_repeat('K', 32);
|
||||
$enc = aes256_cbc_encrypt_bytes('payload', $key);
|
||||
$this->expectException(\Exception::class);
|
||||
aes256_cbc_decrypt_bytes($enc, str_repeat('X', 32));
|
||||
}
|
||||
|
||||
public function testDecryptFailsWhenCiphertextTampered(): void
|
||||
{
|
||||
$key = str_repeat('K', 32);
|
||||
$enc = aes256_cbc_encrypt_bytes('payload', $key);
|
||||
$enc[20] = $enc[20] === 'a' ? 'b' : 'a';
|
||||
$this->expectException(\Exception::class);
|
||||
aes256_cbc_decrypt_bytes($enc, $key);
|
||||
}
|
||||
}
|
||||
37
tests/Unit/EncryptedPayloadTest.php
Normal file
37
tests/Unit/EncryptedPayloadTest.php
Normal file
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class EncryptedPayloadTest extends TestCase
|
||||
{
|
||||
public function testEnvelopeRoundTrip(): void
|
||||
{
|
||||
require_once dirname(__DIR__, 2) . '/lib/encrypted_payload.php';
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$json = '{"clientCode":"X","answers":[]}';
|
||||
$env = qdb_sensitive_envelope($json, $token);
|
||||
$this->assertTrue($env['encrypted']);
|
||||
$plain = qdb_decrypt_sensitive_envelope($env, $token);
|
||||
$this->assertSame($json, $plain);
|
||||
}
|
||||
|
||||
public function testWrongTokenFailsDecrypt(): void
|
||||
{
|
||||
require_once dirname(__DIR__, 2) . '/lib/encrypted_payload.php';
|
||||
$env = qdb_sensitive_envelope('{}', bin2hex(random_bytes(32)));
|
||||
$this->expectException(\Exception::class);
|
||||
qdb_decrypt_sensitive_envelope($env, bin2hex(random_bytes(32)));
|
||||
}
|
||||
|
||||
public function testInvalidEnvelopeRejected(): void
|
||||
{
|
||||
require_once dirname(__DIR__, 2) . '/lib/encrypted_payload.php';
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
qdb_decrypt_sensitive_envelope(['encrypted' => false], bin2hex(random_bytes(8)));
|
||||
}
|
||||
}
|
||||
56
tests/Unit/ErrorsTest.php
Normal file
56
tests/Unit/ErrorsTest.php
Normal file
@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use Exception;
|
||||
use PDOException;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ErrorsTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
require_once dirname(__DIR__, 2) . '/lib/errors.php';
|
||||
}
|
||||
|
||||
public function testLockExceptionDetection(): void
|
||||
{
|
||||
$this->assertTrue(qdb_is_lock_exception(new Exception('Could not acquire lock')));
|
||||
$this->assertTrue(qdb_is_lock_exception(new Exception('Could not open lock file')));
|
||||
$this->assertFalse(qdb_is_lock_exception(new Exception('other')));
|
||||
}
|
||||
|
||||
public function testPublicErrorMessageForLock(): void
|
||||
{
|
||||
$msg = qdb_public_error_message(new Exception('Could not acquire lock'), 'Save');
|
||||
$this->assertStringContainsString('busy', strtolower($msg));
|
||||
}
|
||||
|
||||
public function testPublicErrorMessageForDecrypt(): void
|
||||
{
|
||||
$msg = qdb_public_error_message(new Exception('decrypt failed'), 'Open');
|
||||
$this->assertStringContainsString('configuration', strtolower($msg));
|
||||
}
|
||||
|
||||
public function testPublicErrorMessageForPdo(): void
|
||||
{
|
||||
$msg = qdb_public_error_message(new PDOException('SQLITE_CONSTRAINT'), 'Query');
|
||||
$this->assertStringContainsString('database error', strtolower($msg));
|
||||
}
|
||||
|
||||
public function testApiErrorForLockUses503(): void
|
||||
{
|
||||
[$code, , $status] = qdb_api_error_for_exception(new Exception('Could not acquire lock'), 'Write');
|
||||
$this->assertSame('LOCKED', $code);
|
||||
$this->assertSame(503, $status);
|
||||
}
|
||||
|
||||
public function testApiErrorForGenericUses500(): void
|
||||
{
|
||||
[$code, , $status] = qdb_api_error_for_exception(new Exception('boom'), 'Op');
|
||||
$this->assertSame('SERVER_ERROR', $code);
|
||||
$this->assertSame(500, $status);
|
||||
}
|
||||
}
|
||||
58
tests/Unit/LoginRateLimitTest.php
Normal file
58
tests/Unit/LoginRateLimitTest.php
Normal file
@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use Tests\Support\QdbTestCase;
|
||||
|
||||
final class LoginRateLimitTest extends QdbTestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
require_once dirname(__DIR__, 2) . '/lib/login_rate_limit.php';
|
||||
$_SERVER['REMOTE_ADDR'] = '10.0.0.50';
|
||||
}
|
||||
|
||||
public function testAllowsUnderLimit(): void
|
||||
{
|
||||
$settings = [
|
||||
'login_max_attempts' => 3,
|
||||
'login_window_seconds' => 300,
|
||||
'login_lockout_seconds' => 600,
|
||||
];
|
||||
[$ok, $retry] = qdb_login_rate_limit_check('user_a', $settings);
|
||||
$this->assertTrue($ok);
|
||||
$this->assertSame(0, $retry);
|
||||
}
|
||||
|
||||
public function testLocksAfterMaxFailures(): void
|
||||
{
|
||||
$settings = [
|
||||
'login_max_attempts' => 2,
|
||||
'login_window_seconds' => 300,
|
||||
'login_lockout_seconds' => 600,
|
||||
];
|
||||
$user = 'lock_test_' . bin2hex(random_bytes(4));
|
||||
qdb_login_rate_limit_record_failure($user, $settings);
|
||||
qdb_login_rate_limit_record_failure($user, $settings);
|
||||
[$ok, $retry] = qdb_login_rate_limit_check($user, $settings);
|
||||
$this->assertFalse($ok);
|
||||
$this->assertGreaterThan(0, $retry);
|
||||
}
|
||||
|
||||
public function testClearResetsCounter(): void
|
||||
{
|
||||
$settings = [
|
||||
'login_max_attempts' => 1,
|
||||
'login_window_seconds' => 300,
|
||||
'login_lockout_seconds' => 600,
|
||||
];
|
||||
$user = 'clear_test_' . bin2hex(random_bytes(4));
|
||||
qdb_login_rate_limit_record_failure($user, $settings);
|
||||
qdb_login_rate_limit_clear($user);
|
||||
[$ok,] = qdb_login_rate_limit_check($user, $settings);
|
||||
$this->assertTrue($ok);
|
||||
}
|
||||
}
|
||||
44
tests/Unit/RbacTest.php
Normal file
44
tests/Unit/RbacTest.php
Normal file
@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class RbacTest extends TestCase
|
||||
{
|
||||
public function testAdminSeesAllClients(): void
|
||||
{
|
||||
[$clause, $params] = rbac_client_filter(['role' => 'admin', 'entityID' => 'a1'], 'cl');
|
||||
$this->assertSame('1=1', $clause);
|
||||
$this->assertSame([], $params);
|
||||
}
|
||||
|
||||
public function testSupervisorFilterBindsEntity(): void
|
||||
{
|
||||
[$clause, $params] = rbac_client_filter(
|
||||
['role' => 'supervisor', 'entityID' => 'sv-99'],
|
||||
'cl'
|
||||
);
|
||||
$this->assertStringContainsString('supervisorID', $clause);
|
||||
$this->assertSame([':rbac_eid' => 'sv-99'], $params);
|
||||
}
|
||||
|
||||
public function testCoachFilterBindsCoach(): void
|
||||
{
|
||||
[$clause, $params] = rbac_client_filter(
|
||||
['role' => 'coach', 'entityID' => 'coach-1'],
|
||||
'cl'
|
||||
);
|
||||
$this->assertStringContainsString('coachID', $clause);
|
||||
$this->assertSame([':rbac_eid' => 'coach-1'], $params);
|
||||
}
|
||||
|
||||
public function testWebRolesList(): void
|
||||
{
|
||||
$this->assertContains('admin', qdb_web_roles());
|
||||
$this->assertContains('supervisor', qdb_web_roles());
|
||||
$this->assertNotContains('coach', qdb_web_roles());
|
||||
}
|
||||
}
|
||||
54
tests/Unit/SettingsTest.php
Normal file
54
tests/Unit/SettingsTest.php
Normal file
@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use Tests\Support\DatabaseSeeder;
|
||||
use Tests\Support\QdbTestCase;
|
||||
|
||||
final class SettingsTest extends QdbTestCase
|
||||
{
|
||||
public function testDefaults(): void
|
||||
{
|
||||
require_once dirname(__DIR__, 2) . '/lib/settings.php';
|
||||
$d = qdb_settings_defaults();
|
||||
$this->assertSame(10, $d['login_max_attempts']);
|
||||
$this->assertSame(30 * 24 * 60 * 60, $d['session_ttl_seconds']);
|
||||
}
|
||||
|
||||
public function testValidateRejectsOutOfRange(): void
|
||||
{
|
||||
require_once dirname(__DIR__, 2) . '/lib/settings.php';
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
qdb_settings_validate_and_merge(['login_max_attempts' => 0]);
|
||||
}
|
||||
|
||||
public function testSaveAndLoadRoundTrip(): void
|
||||
{
|
||||
require_once dirname(__DIR__, 2) . '/lib/settings.php';
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
$custom = qdb_settings_validate_and_merge([
|
||||
'login_max_attempts' => 5,
|
||||
'login_window_seconds' => 600,
|
||||
'login_lockout_seconds' => 1200,
|
||||
'session_ttl_seconds' => 86400,
|
||||
'temp_session_ttl_seconds' => 900,
|
||||
]);
|
||||
qdb_settings_save_on_pdo($pdo, $custom);
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
[$pdo2, $tmp2, $lock2] = qdb_open(false);
|
||||
$loaded = qdb_settings_get_on_pdo($pdo2);
|
||||
qdb_discard($tmp2, $lock2);
|
||||
$this->assertSame(5, $loaded['login_max_attempts']);
|
||||
$this->assertSame(86400, $loaded['session_ttl_seconds']);
|
||||
}
|
||||
|
||||
public function testSessionTtlHelper(): void
|
||||
{
|
||||
require_once dirname(__DIR__, 2) . '/lib/settings.php';
|
||||
$this->assertSame(600, qdb_session_ttl_seconds(['temp_session_ttl_seconds' => 600], true));
|
||||
$this->assertSame(3600, qdb_session_ttl_seconds(['session_ttl_seconds' => 3600], false));
|
||||
}
|
||||
}
|
||||
31
tests/Unit/TextSanitizeTest.php
Normal file
31
tests/Unit/TextSanitizeTest.php
Normal file
@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class TextSanitizeTest extends TestCase
|
||||
{
|
||||
public function testStripsSqlLikePatterns(): void
|
||||
{
|
||||
require_once dirname(__DIR__, 2) . '/lib/text_sanitize.php';
|
||||
$out = sanitize_free_text("hello'; DROP TABLE users;--");
|
||||
$this->assertStringNotContainsString('DROP TABLE', $out);
|
||||
$this->assertStringNotContainsString('--', $out);
|
||||
}
|
||||
|
||||
public function testPreservesNewlines(): void
|
||||
{
|
||||
require_once dirname(__DIR__, 2) . '/lib/text_sanitize.php';
|
||||
$out = sanitize_free_text("line1\nline2");
|
||||
$this->assertStringContainsString("\n", $out);
|
||||
}
|
||||
|
||||
public function testNonStringReturnsEmpty(): void
|
||||
{
|
||||
require_once dirname(__DIR__, 2) . '/lib/text_sanitize.php';
|
||||
$this->assertSame('', sanitize_free_text(null));
|
||||
}
|
||||
}
|
||||
55
tests/Unit/ValidateTest.php
Normal file
55
tests/Unit/ValidateTest.php
Normal file
@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Tests\Support\JsonErrorResponse;
|
||||
|
||||
final class ValidateTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
if (!defined('QDB_TESTING')) {
|
||||
throw new \RuntimeException('PHPUnit bootstrap must define QDB_TESTING');
|
||||
}
|
||||
require_once dirname(__DIR__, 2) . '/lib/validate.php';
|
||||
}
|
||||
|
||||
public function testRequireFieldsPasses(): void
|
||||
{
|
||||
$body = ['a' => '1', 'b' => 'x'];
|
||||
require_fields($body, ['a', 'b']);
|
||||
$this->assertSame(['a' => '1', 'b' => 'x'], $body);
|
||||
}
|
||||
|
||||
public function testRequireFieldsMissing(): void
|
||||
{
|
||||
$this->expectException(JsonErrorResponse::class);
|
||||
$this->expectExceptionMessage('Required fields');
|
||||
try {
|
||||
require_fields(['a' => ''], ['a', 'b']);
|
||||
} catch (JsonErrorResponse $e) {
|
||||
$this->assertSame('MISSING_FIELDS', $e->errorCode);
|
||||
$this->assertSame(400, $e->httpStatus);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function testValidateStringTrims(): void
|
||||
{
|
||||
$s = validate_string(' hello ', 'field', 1, 20);
|
||||
$this->assertSame('hello', $s);
|
||||
}
|
||||
|
||||
public function testValidateEnum(): void
|
||||
{
|
||||
$this->assertSame('admin', validate_enum('admin', 'role', ['admin', 'coach']));
|
||||
}
|
||||
|
||||
public function testValidateIntBounds(): void
|
||||
{
|
||||
$this->assertSame(5, validate_int('5', 'n', 1, 10));
|
||||
}
|
||||
}
|
||||
29
tests/bootstrap.php
Normal file
29
tests/bootstrap.php
Normal file
@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
define('QDB_TESTING', true);
|
||||
define('QDB_TEST_UPLOADS', __DIR__ . '/runtime/uploads');
|
||||
|
||||
$runtimeRoot = dirname(QDB_TEST_UPLOADS);
|
||||
if (!is_dir($runtimeRoot)) {
|
||||
mkdir($runtimeRoot, 0755, true);
|
||||
}
|
||||
if (!is_dir(QDB_TEST_UPLOADS)) {
|
||||
mkdir(QDB_TEST_UPLOADS, 0755, true);
|
||||
}
|
||||
putenv('QDB_API_LOG_DIR=' . QDB_TEST_UPLOADS . '/logs/api');
|
||||
$_ENV['QDB_API_LOG_DIR'] = QDB_TEST_UPLOADS . '/logs/api';
|
||||
|
||||
$testMasterKey = base64_encode(str_repeat('T', 32));
|
||||
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
|
||||
require_once dirname(__DIR__) . '/lib/response.php';
|
||||
require_once dirname(__DIR__) . '/common.php';
|
||||
|
||||
// Always use the test key, even when .env defines QDB_MASTER_KEY (avoids decrypt/seed drift).
|
||||
qdb_env_set('QDB_MASTER_KEY', $testMasterKey);
|
||||
$GLOBALS['qdb_dotenv']['QDB_MASTER_KEY'] = $testMasterKey;
|
||||
|
||||
require_once dirname(__DIR__) . '/db_init.php';
|
||||
60
tests/check-coverage.php
Normal file
60
tests/check-coverage.php
Normal file
@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Fail CI when line coverage falls below the configured floor.
|
||||
* Usage: php tests/check-coverage.php [minPercent]
|
||||
*/
|
||||
|
||||
$min = isset($argv[1]) ? (float)$argv[1] : 40.0;
|
||||
$root = dirname(__DIR__);
|
||||
|
||||
$envPrefix = '';
|
||||
if (extension_loaded('xdebug') && !extension_loaded('pcov')) {
|
||||
// Xdebug 3 defaults to develop mode; PHPUnit needs coverage mode.
|
||||
$envPrefix = 'XDEBUG_MODE=coverage ';
|
||||
}
|
||||
|
||||
$cmd = sprintf(
|
||||
'%s%s %s/vendor/bin/phpunit --coverage-text --colors=never 2>&1',
|
||||
$envPrefix,
|
||||
escapeshellarg(PHP_BINARY),
|
||||
escapeshellarg($root)
|
||||
);
|
||||
|
||||
$output = [];
|
||||
exec($cmd, $output, $exitCode);
|
||||
echo implode("\n", $output) . "\n";
|
||||
|
||||
if ($exitCode !== 0) {
|
||||
exit($exitCode);
|
||||
}
|
||||
|
||||
$linesPct = null;
|
||||
foreach ($output as $line) {
|
||||
// PHPUnit 11 / Xdebug: "Lines: 25.33% (1484/5859)"
|
||||
if (preg_match('/^\s*Lines:\s+([\d.]+)%\s+\(/', $line, $m)) {
|
||||
$linesPct = (float)$m[1];
|
||||
break;
|
||||
}
|
||||
// PCOV / older format: "Lines: 1484/5859 (25.33%)"
|
||||
if (preg_match('/^\s*Lines:\s+[\d.]+\/\d+\s+\(\s*([\d.]+)%/', $line, $m)) {
|
||||
$linesPct = (float)$m[1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($linesPct === null) {
|
||||
fwrite(STDERR, "Could not parse coverage summary. Is PCOV or Xdebug enabled?\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
if ($linesPct < $min) {
|
||||
fwrite(STDERR, sprintf("Line coverage %.2f%% is below minimum %.2f%%\n", $linesPct, $min));
|
||||
exit(1);
|
||||
}
|
||||
|
||||
fwrite(STDERR, sprintf("Line coverage %.2f%% meets minimum %.2f%%\n", $linesPct, $min));
|
||||
exit(0);
|
||||
Reference in New Issue
Block a user