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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user