initial prototype based on nat-as-server
Some checks failed
PHPUnit / test (push) Has been cancelled

This commit is contained in:
tom.hempel
2026-06-29 12:39:55 +02:00
commit f1caa9e681
148 changed files with 34905 additions and 0 deletions

View 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);
}
}

View File

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace Tests\Integration;
use Tests\Support\DatabaseSeeder;
use Tests\Support\QdbTestCase;
final class AppQuestionnairesBulkTest extends QdbTestCase
{
public function testCoachLoadsBulkAnswers(): void
{
$this->submitFixtureQuestionnaire();
$login = $this->api()->loginMobileAndGetToken(
DatabaseSeeder::COACH_USERNAME,
DatabaseSeeder::PASSWORD
);
$res = $this->api()->withMobileToken($login['token'], 'GET', 'app_questionnaires', null, [
'answersBulk' => '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->assertIsArray($payload['clients'] ?? null);
$found = null;
foreach ($payload['clients'] as $row) {
if (($row['clientCode'] ?? '') === $this->fixture()->clientCode) {
$found = $row;
break;
}
}
$this->assertNotNull($found, 'fixture client missing from bulk payload');
$this->assertNotEmpty($found['questionnaires'] ?? []);
$qn = $found['questionnaires'][0];
$this->assertSame($this->fixture()->questionnaireId, $qn['questionnaireID'] ?? null);
$this->assertNotEmpty($qn['answers'] ?? []);
}
public function testReadDbOpenUsesCacheOnSecondCall(): void
{
[$pdo1, $tmp1] = qdb_open(false);
[$pdo2, $tmp2] = qdb_open(false);
$this->assertSame(QDB_READONLY_CACHE_HANDLE, $tmp1);
$this->assertSame(QDB_READONLY_CACHE_HANDLE, $tmp2);
$this->assertSame($pdo1, $pdo2);
qdb_discard($tmp1, null);
qdb_discard($tmp2, null);
}
}

View File

@ -0,0 +1,61 @@
<?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']);
$glass = null;
foreach ($res['data']['questions'] as $q) {
if (($q['layout'] ?? '') === 'glass_scale_question') {
$glass = $q;
break;
}
}
$this->assertNotNull($glass);
$this->assertIsArray($glass['glassSymptoms'] ?? null);
$symptomKeys = array_column($glass['glassSymptoms'], 'key');
$this->assertContains($this->fixture()->glassSymptomKey, $symptomKeys);
}
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);
}
}

View File

@ -0,0 +1,271 @@
<?php
declare(strict_types=1);
namespace Tests\Integration;
use Tests\Support\DatabaseSeeder;
use Tests\Support\QdbTestCase;
final class AppSubmitExtendedTest extends QdbTestCase
{
public function testResubmitClearsOmittedOptionalAnswers(): void
{
$login = $this->api()->loginMobileAndGetToken(
DatabaseSeeder::COACH_USERNAME,
DatabaseSeeder::PASSWORD
);
$f = $this->fixture();
$this->assertApiOk($this->submitQuestionnaireAs($login['token'], $f->questionnaireId, $f->clientCode, [
['questionID' => $f->questionShortId, 'answerOptionKey' => 'yes'],
['questionID' => $f->freeTextShortId, 'freeTextValue' => 'Old notes'],
['questionID' => $f->glassSymptomKey, 'freeTextValue' => 'moderate'],
['questionID' => 'fatigue', 'freeTextValue' => 'severe'],
]));
$res = $this->submitQuestionnaireAs($login['token'], $f->questionnaireId, $f->clientCode, [
['questionID' => $f->questionShortId, 'answerOptionKey' => 'yes'],
]);
$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->assertFalse($ft->fetchColumn());
$glass = $pdo->prepare(
'SELECT freeTextValue FROM client_answer WHERE clientCode = :cc AND questionID = :qid'
);
$glass->execute([':cc' => $f->clientCode, ':qid' => $f->glassQuestionId]);
$this->assertFalse($glass->fetchColumn());
$sub = $pdo->prepare(
'SELECT submissionID FROM questionnaire_submission
WHERE clientCode = :cc AND questionnaireID = :qn ORDER BY version DESC LIMIT 1'
);
$sub->execute([':cc' => $f->clientCode, ':qn' => $f->questionnaireId]);
$submissionId = (string)$sub->fetchColumn();
$archived = $pdo->prepare(
'SELECT freeTextValue FROM client_answer_submission
WHERE submissionID = :sid AND questionID = :qid'
);
$archived->execute([':sid' => $submissionId, ':qid' => $f->freeTextQuestionId]);
$this->assertFalse($archived->fetchColumn());
$archivedGlass = $pdo->prepare(
'SELECT freeTextValue FROM client_answer_submission
WHERE submissionID = :sid AND questionID = :qid'
);
$archivedGlass->execute([':sid' => $submissionId, ':qid' => $f->glassQuestionId]);
$this->assertFalse($archivedGlass->fetchColumn());
qdb_discard($tmpDb, $lockFp);
}
public function testResubmitReplacesGlassSymptomsInsteadOfMerging(): void
{
$login = $this->api()->loginMobileAndGetToken(
DatabaseSeeder::COACH_USERNAME,
DatabaseSeeder::PASSWORD
);
$f = $this->fixture();
$this->assertApiOk($this->submitQuestionnaireAs($login['token'], $f->questionnaireId, $f->clientCode, [
['questionID' => $f->questionShortId, 'answerOptionKey' => 'yes'],
['questionID' => $f->glassSymptomKey, 'freeTextValue' => 'moderate'],
['questionID' => 'fatigue', 'freeTextValue' => 'severe'],
]));
$res = $this->submitQuestionnaireAs($login['token'], $f->questionnaireId, $f->clientCode, [
['questionID' => $f->questionShortId, 'answerOptionKey' => 'yes'],
['questionID' => $f->glassSymptomKey, 'freeTextValue' => 'mild'],
]);
$this->assertApiOk($res);
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$glass = $pdo->prepare(
'SELECT freeTextValue FROM client_answer WHERE clientCode = :cc AND questionID = :qid'
);
$glass->execute([':cc' => $f->clientCode, ':qid' => $f->glassQuestionId]);
$decoded = json_decode((string)$glass->fetchColumn(), true);
$this->assertSame(['pain' => 'mild'], $decoded);
qdb_discard($tmpDb, $lockFp);
}
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);
}
}

View File

@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace Tests\Integration;
use Tests\Support\DatabaseSeeder;
use Tests\Support\QdbTestCase;
final class AppSubmitLegacyTest extends QdbTestCase
{
public function testLegacySubmitAcceptsRetiredQuestionAfterBump(): void
{
$login = $this->api()->loginMobileAndGetToken(
DatabaseSeeder::COACH_USERNAME,
DatabaseSeeder::PASSWORD
);
$f = $this->fixture();
$this->assertApiOk($this->submitQuestionnaireAs(
$login['token'],
$f->questionnaireId,
$f->clientCode,
[['questionID' => $f->questionShortId, 'answerOptionKey' => 'yes']],
null,
null,
1
));
require_once dirname(__DIR__, 2) . '/lib/questionnaire_structure.php';
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
$rev = qdb_bump_structure_revision($pdo, $f->questionnaireId, 'legacy_test_retire');
qdb_retire_question($pdo, $f->questionId, $rev);
$currentRev = qdb_questionnaire_structure_revision($pdo, $f->questionnaireId);
qdb_save($tmpDb, $lockFp);
$this->assertGreaterThan(1, $currentRev);
$res = $this->submitQuestionnaireAs(
$login['token'],
$f->questionnaireId,
$f->clientCode,
[['questionID' => $f->questionShortId, 'answerOptionKey' => 'yes']],
null,
null,
1
);
$this->assertApiOk($res);
$this->assertTrue($res['data']['submitted'] ?? false);
$this->assertTrue($res['data']['legacySubmit'] ?? false);
$this->assertSame(1, $res['data']['structureRevision'] ?? null);
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$sub = $pdo->prepare(
'SELECT structureRevision, structureSnapshotJson FROM questionnaire_submission
WHERE clientCode = :cc AND questionnaireID = :qn ORDER BY version DESC LIMIT 1'
);
$sub->execute([':cc' => $f->clientCode, ':qn' => $f->questionnaireId]);
$row = $sub->fetch(\PDO::FETCH_ASSOC);
$this->assertSame(1, (int)($row['structureRevision'] ?? 0));
$snap = json_decode($row['structureSnapshotJson'] ?? '{}', true) ?: [];
$this->assertNotEmpty($snap['questions'] ?? []);
$archived = $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 AND cas.questionID = :qid'
);
$archived->execute([
':cc' => $f->clientCode,
':qn' => $f->questionnaireId,
':qid' => $f->questionId,
]);
$this->assertGreaterThan(0, (int)$archived->fetchColumn());
qdb_discard($tmpDb, $lockFp);
}
}

View 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']);
}
}

View File

@ -0,0 +1,68 @@
<?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);
}
public function testKeycloakConfigDisabledByDefault(): void
{
$res = $this->api()->request('GET', 'auth/keycloak-config');
$this->assertApiOk($res);
$this->assertFalse($res['data']['enabled']);
$this->assertSame('', $res['data']['loginUrl']);
}
public function testKeycloakLoginRequiresConfiguration(): void
{
$res = $this->api()->request('GET', 'auth/keycloak-login');
$this->assertApiError($res, 'KEYCLOAK_NOT_CONFIGURED', 503);
}
}

View 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);
}
}

View 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']);
}
}

View 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);
}
}

View File

@ -0,0 +1,114 @@
<?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 testArchiveHidesClientFromListsAndFollowUp(): void
{
$this->submitFixtureQuestionnaire();
$token = $this->api()->loginWebAndGetToken(
DatabaseSeeder::ADMIN_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
$code = $this->fixture()->clientCode;
$archive = $this->api()->withToken($token, 'PATCH', 'clients', [
'clientCode' => $code,
'archived' => true,
]);
$this->assertApiOk($archive);
$this->assertSame(1, (int)($archive['data']['archived'] ?? 0));
$active = $this->api()->withToken($token, 'GET', 'clients');
$this->assertApiOk($active);
$activeCodes = array_column($active['data']['clients'], 'clientCode');
$this->assertNotContains($code, $activeCodes);
$archivedOnly = $this->api()->withToken($token, 'GET', 'clients', null, [
'archiveFilter' => 'archived',
]);
$this->assertApiOk($archivedOnly);
$archivedCodes = array_column($archivedOnly['data']['clients'], 'clientCode');
$this->assertContains($code, $archivedCodes);
$assign = $this->api()->withToken($token, 'GET', 'assignments');
$this->assertApiOk($assign);
$assignCodes = array_column($assign['data']['clients'], 'clientCode');
$this->assertNotContains($code, $assignCodes);
$stale = $this->api()->withToken($token, 'GET', 'analytics', null, ['staleClients' => '1']);
$this->assertApiOk($stale);
$staleCodes = array_column($stale['data']['clients'], 'clientCode');
$this->assertNotContains($code, $staleCodes);
$restore = $this->api()->withToken($token, 'PATCH', 'clients', [
'clientCode' => $code,
'archived' => false,
]);
$this->assertApiOk($restore);
$this->assertSame(0, (int)($restore['data']['archived'] ?? 1));
$activeAgain = $this->api()->withToken($token, 'GET', 'clients');
$this->assertApiOk($activeAgain);
$this->assertContains($code, array_column($activeAgain['data']['clients'], '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);
}
}

View 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);
}
}

View 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']);
}
}

View 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);
}
}

View 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']);
}
}

View 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);
}
}

View 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']);
}
}

View 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']);
}
}

View 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);
}
}

View 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'] ?? '');
}
}

View 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);
}
}

View 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']);
}
}

View 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_storage_key($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);
}
}

View 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);
}
}

View 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);
}
}

View File

@ -0,0 +1,65 @@
<?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']);
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$stored = $pdo->query('SELECT token FROM session LIMIT 1')->fetchColumn();
qdb_discard($tmpDb, $lockFp);
$this->assertSame(token_storage_key($token), $stored);
$this->assertNotSame($token, $stored);
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));
}
public function testTokenLookupRefreshesExpiry(): void
{
$token = bin2hex(random_bytes(32));
$f = $this->fixture();
token_add($token, 3600, [
'userID' => $f->adminUserId,
'role' => 'admin',
'entityID' => 'ent',
]);
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
$oldExpiry = time() + 60;
$pdo->prepare('UPDATE session SET expiresAt = :exp WHERE token = :t')
->execute([':exp' => $oldExpiry, ':t' => token_storage_key($token)]);
qdb_save($tmpDb, $lockFp);
$rec = token_get_record($token);
$this->assertNotNull($rec);
$this->assertGreaterThan($oldExpiry + 3000, $rec['exp']);
}
}

View 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);
}
}

View File

@ -0,0 +1,104 @@
<?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);
}
public function testAllTranslationsIncludeGlassSymptomKeys(): void
{
$token = $this->api()->loginWebAndGetToken(
DatabaseSeeder::ADMIN_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
$f = $this->fixture();
$res = $this->api()->withToken($token, 'GET', 'translations', null, ['all' => '1']);
$this->assertApiOk($res);
$stringKeys = [];
foreach ($res['data']['entries'] as $entry) {
if (($entry['type'] ?? '') === 'string') {
$stringKeys[] = $entry['key'] ?? '';
}
}
$this->assertContains($f->glassSymptomKey, $stringKeys);
$this->assertContains('fatigue', $stringKeys);
}
}

View 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);
}
}

View 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);
}
}