added unit tests

This commit is contained in:
2026-06-04 21:40:59 +02:00
parent d80a8de559
commit 48a619ee4b
64 changed files with 3807 additions and 33 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,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);
}
}

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

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

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

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,
':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,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));
}
}

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

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

181
tests/Support/ApiClient.php Normal file
View File

@ -0,0 +1,181 @@
<?php
declare(strict_types=1);
namespace Tests\Support;
/**
* Dispatches requests through api/index.php (same as production routing).
*/
final class ApiClient
{
public function __construct(
private readonly string $apiIndexPath,
) {
}
/**
* @param array<string, string> $headers
* @return array{ok: bool, status: int, data?: mixed, error?: array<string, mixed>}
*/
public function request(
string $method,
string $route,
?array $body = null,
array $headers = [],
array $query = [],
): array {
qdb_test_reset_http_state();
$_SERVER['REQUEST_METHOD'] = strtoupper($method);
$queryString = $query !== [] ? '?' . http_build_query($query) : '';
$_SERVER['REQUEST_URI'] = '/api/' . ltrim($route, '/') . $queryString;
$_SERVER['SCRIPT_NAME'] = '/api/index.php';
$_GET = $query;
if ($queryString !== '') {
parse_str(ltrim($queryString, '?'), $_GET);
}
unset($_SERVER['HTTP_AUTHORIZATION'], $_SERVER['Authorization']);
unset($_SERVER['HTTP_X_QDB_CLIENT']);
foreach ($headers as $name => $value) {
$key = 'HTTP_' . strtoupper(str_replace('-', '_', $name));
$_SERVER[$key] = $value;
}
if ($body !== null) {
qdb_test_set_request_body(json_encode($body, JSON_THROW_ON_ERROR));
}
ob_start();
try {
require $this->apiIndexPath;
ob_end_clean();
return ['ok' => false, 'status' => 500, 'error' => ['code' => 'NO_RESPONSE', 'message' => 'Handler did not return JSON']];
} catch (JsonSuccessResponse $e) {
$output = ob_get_clean();
return [
'ok' => true,
'status' => $e->httpStatus,
'data' => $e->data,
'_raw' => $output,
];
} catch (JsonErrorResponse $e) {
ob_get_clean();
$err = ['code' => $e->errorCode, 'message' => $e->errorMessage];
if ($e->details !== null) {
$err['details'] = $e->details;
}
return [
'ok' => false,
'status' => $e->httpStatus,
'error' => $err,
];
} catch (RawHttpResponse $e) {
ob_get_clean();
return [
'ok' => true,
'status' => $e->httpStatus,
'raw' => true,
'body' => $e->body,
'headers' => $e->headers,
];
} catch (\Throwable $e) {
ob_end_clean();
throw $e;
}
}
public function loginWeb(string $username, string $password): array
{
return $this->request('POST', 'auth/login', [
'username' => $username,
'password' => $password,
], ['X-QDB-Client' => 'web']);
}
public function loginMobile(string $username, string $password): array
{
return $this->request('POST', 'auth/login', [
'username' => $username,
'password' => $password,
]);
}
/**
* @return array{token: string, data: array<string, mixed>}
*/
public function loginMobileAndGetToken(string $username, string $password): array
{
$res = $this->loginMobile($username, $password);
if (!$res['ok']) {
throw new \RuntimeException('Login failed: ' . ($res['error']['message'] ?? 'unknown'));
}
$token = $res['data']['token'] ?? '';
if ($token === '') {
throw new \RuntimeException('Login response missing token');
}
return ['token' => $token, 'data' => $res['data']];
}
/**
* @return array{token: string, data: array<string, mixed>}
*/
public function loginWebAndGetToken(string $username, string $password): array
{
$res = $this->loginWeb($username, $password);
if (!$res['ok']) {
throw new \RuntimeException('Login failed: ' . ($res['error']['message'] ?? 'unknown'));
}
$token = $res['data']['token'] ?? '';
if ($token === '') {
throw new \RuntimeException('Login response missing token');
}
return ['token' => $token, 'data' => $res['data']];
}
/**
* @param array<string, string> $extraHeaders
*/
public function withToken(string $token, string $method, string $route, ?array $body = null, array $query = [], array $extraHeaders = []): array
{
$headers = array_merge([
'Authorization' => 'Bearer ' . $token,
'X-QDB-Client' => 'web',
], $extraHeaders);
return $this->request($method, $route, $body, $headers, $query);
}
/**
* @param array<string, string> $extraHeaders
*/
public function withMobileToken(
string $token,
string $method,
string $route,
?array $body = null,
array $query = [],
array $extraHeaders = [],
): array {
$headers = array_merge(['Authorization' => 'Bearer ' . $token], $extraHeaders);
return $this->request($method, $route, $body, $headers, $query);
}
/**
* POST an encrypted payload (Android app API).
*
* @param array<string, mixed> $payload
*/
public function encryptedPost(string $token, string $route, array $payload): array
{
$envelope = qdb_sensitive_envelope(
json_encode($payload, JSON_THROW_ON_ERROR),
$token
);
return $this->withMobileToken($token, 'POST', $route, $envelope);
}
}

View File

@ -0,0 +1,233 @@
<?php
declare(strict_types=1);
namespace Tests\Support;
use PDO;
/**
* Seeds a minimal org graph for integration tests.
*/
final class DatabaseSeeder
{
public const PASSWORD = 'TestPass1!';
public const ADMIN_USERNAME = 'test_admin';
public const SUPERVISOR_USERNAME = 'test_supervisor';
public const COACH_USERNAME = 'test_coach';
public static function seed(PDO $pdo): FixtureIds
{
$now = time();
$passwordHash = password_hash(self::PASSWORD, PASSWORD_DEFAULT);
// Stable IDs so fixture metadata always matches seeded rows.
$adminId = 'admin-test';
$adminUserId = 'u-admin-test';
$supervisorId = 'sv-test';
$supervisorUserId = 'u-sv-test';
$coachId = 'coach-test';
$coachUserId = 'u-coach-test';
$qnId = 'qn-test';
$questionId = $qnId . '__q1';
$optionId = 'opt-test-q1-yes';
$freeTextQuestionId = $qnId . '__q2';
$glassQuestionId = $qnId . '__q3';
$glassSymptomKey = 'pain';
$multiQuestionId = $qnId . '__q4';
$multiOptionAId = 'opt-test-q4-a';
$multiOptionBId = 'opt-test-q4-b';
$draftQuestionnaireId = 'qn-draft-test';
$pdo->prepare('INSERT INTO admin (adminID, username, location) VALUES (:id, :u, :loc)')
->execute([':id' => $adminId, ':u' => self::ADMIN_USERNAME, ':loc' => 'HQ']);
$pdo->prepare(
'INSERT INTO users (userID, username, passwordHash, role, entityID, mustChangePassword, createdAt)
VALUES (:uid, :u, :h, :role, :eid, 0, :now)'
)->execute([
':uid' => $adminUserId,
':u' => self::ADMIN_USERNAME,
':h' => $passwordHash,
':role' => 'admin',
':eid' => $adminId,
':now' => $now,
]);
$pdo->prepare('INSERT INTO supervisor (supervisorID, username, location) VALUES (:id, :u, :loc)')
->execute([':id' => $supervisorId, ':u' => self::SUPERVISOR_USERNAME, ':loc' => 'Region A']);
$pdo->prepare(
'INSERT INTO users (userID, username, passwordHash, role, entityID, mustChangePassword, createdAt)
VALUES (:uid, :u, :h, :role, :eid, 0, :now)'
)->execute([
':uid' => $supervisorUserId,
':u' => self::SUPERVISOR_USERNAME,
':h' => $passwordHash,
':role' => 'supervisor',
':eid' => $supervisorId,
':now' => $now,
]);
$pdo->prepare('INSERT INTO coach (coachID, supervisorID, username) VALUES (:id, :sid, :u)')
->execute([':id' => $coachId, ':sid' => $supervisorId, ':u' => self::COACH_USERNAME]);
$pdo->prepare(
'INSERT INTO users (userID, username, passwordHash, role, entityID, mustChangePassword, createdAt)
VALUES (:uid, :u, :h, :role, :eid, 0, :now)'
)->execute([
':uid' => $coachUserId,
':u' => self::COACH_USERNAME,
':h' => $passwordHash,
':role' => 'coach',
':eid' => $coachId,
':now' => $now,
]);
$pdo->prepare('INSERT INTO client (clientCode, coachID) VALUES (:cc, :cid)')
->execute([':cc' => 'CLIENT-TEST-001', ':cid' => $coachId]);
$pdo->prepare(
'INSERT INTO questionnaire (questionnaireID, name, version, state, orderIndex, showPoints, conditionJson, categoryKey)
VALUES (:id, :n, :v, :s, 1, 0, :cj, :ck)'
)->execute([
':id' => $qnId,
':n' => 'Test questionnaire',
':v' => '1',
':s' => 'active',
':cj' => '{}',
':ck' => '',
]);
$pdo->prepare('UPDATE questionnaire SET showPoints = 1 WHERE questionnaireID = :id')
->execute([':id' => $qnId]);
$pdo->prepare(
'INSERT INTO question (questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson)
VALUES (:id, :qn, :txt, :type, 0, 1, :cfg)'
)->execute([
':id' => $questionId,
':qn' => $qnId,
':txt' => 'Consent?',
':type' => 'single_choice_question',
':cfg' => json_encode(['questionKey' => 'consent_q'], JSON_THROW_ON_ERROR),
]);
$pdo->prepare(
'INSERT INTO answer_option (answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId)
VALUES (:id, :qid, :txt, 0, 0, :next)'
)->execute([
':next' => '',
':id' => $optionId,
':qid' => $questionId,
':txt' => 'yes',
]);
$pdo->prepare(
'INSERT INTO question (questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson)
VALUES (:id, :qn, :txt, :type, 1, 0, :cfg)'
)->execute([
':id' => $freeTextQuestionId,
':qn' => $qnId,
':txt' => 'Notes',
':type' => 'free_text_question',
':cfg' => json_encode(['questionKey' => 'notes_q'], JSON_THROW_ON_ERROR),
]);
$glassConfig = json_encode(
['questionKey' => 'symptoms_q', 'symptoms' => [$glassSymptomKey, 'fatigue']],
JSON_THROW_ON_ERROR
);
$pdo->prepare(
'INSERT INTO question (questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson)
VALUES (:id, :qn, :txt, :type, 2, 0, :cfg)'
)->execute([
':id' => $glassQuestionId,
':qn' => $qnId,
':txt' => 'Symptoms',
':type' => 'glass_scale_question',
':cfg' => $glassConfig,
]);
require_once dirname(__DIR__, 2) . '/lib/settings.php';
qdb_settings_save_on_pdo($pdo, qdb_settings_defaults());
$pdo->prepare(
'INSERT INTO question (questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson)
VALUES (:id, :qn, :txt, :type, 3, 0, :cfg)'
)->execute([
':id' => $multiQuestionId,
':qn' => $qnId,
':txt' => 'Pick several',
':type' => 'multi_check_box_question',
':cfg' => json_encode(['questionKey' => 'multi_q'], JSON_THROW_ON_ERROR),
]);
$pdo->prepare(
'INSERT INTO answer_option (answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId)
VALUES (:id, :qid, :txt, 1, 0, :next)'
)->execute([':next' => '', ':id' => $multiOptionAId, ':qid' => $multiQuestionId, ':txt' => 'opt_a']);
$pdo->prepare(
'INSERT INTO answer_option (answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId)
VALUES (:id, :qid, :txt, 2, 1, :next)'
)->execute([':next' => '', ':id' => $multiOptionBId, ':qid' => $multiQuestionId, ':txt' => 'opt_b']);
qdb_upsert_source_translation($pdo, 'answer_option', $multiOptionAId, 'A');
qdb_upsert_source_translation($pdo, 'answer_option', $multiOptionBId, 'B');
$pdo->prepare(
'INSERT INTO questionnaire (questionnaireID, name, version, state, orderIndex, showPoints, conditionJson, categoryKey)
VALUES (:id, :n, :v, :s, 2, 0, :cj, :ck)'
)->execute([
':id' => $draftQuestionnaireId,
':n' => 'Draft only',
':v' => '1',
':s' => 'draft',
':cj' => '{}',
':ck' => '',
]);
qdb_upsert_source_translation($pdo, 'answer_option', $optionId, 'Ja');
return new FixtureIds(
adminUserId: $adminUserId,
supervisorUserId: $supervisorUserId,
coachUserId: $coachUserId,
coachId: $coachId,
supervisorId: $supervisorId,
questionnaireId: $qnId,
questionId: $questionId,
questionShortId: 'q1',
optionId: $optionId,
freeTextQuestionId: $freeTextQuestionId,
freeTextShortId: 'q2',
glassQuestionId: $glassQuestionId,
glassShortId: 'q3',
glassSymptomKey: $glassSymptomKey,
multiCheckQuestionId: $multiQuestionId,
multiCheckShortId: 'q4',
draftQuestionnaireId: $draftQuestionnaireId,
clientCode: 'CLIENT-TEST-001',
);
}
}
final class FixtureIds
{
public function __construct(
public readonly string $adminUserId,
public readonly string $supervisorUserId,
public readonly string $coachUserId,
public readonly string $coachId,
public readonly string $supervisorId,
public readonly string $questionnaireId,
public readonly string $questionId,
public readonly string $questionShortId,
public readonly string $optionId,
public readonly string $freeTextQuestionId,
public readonly string $freeTextShortId,
public readonly string $glassQuestionId,
public readonly string $glassShortId,
public readonly string $glassSymptomKey,
public readonly string $multiCheckQuestionId,
public readonly string $multiCheckShortId,
public readonly string $draftQuestionnaireId,
public readonly string $clientCode,
) {
}
}

View File

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Tests\Support;
use RuntimeException;
/** Thrown instead of exit() when QDB_TESTING is defined. */
final class JsonErrorResponse extends RuntimeException
{
public function __construct(
public readonly string $errorCode,
public readonly string $errorMessage,
public readonly int $httpStatus = 400,
public readonly mixed $details = null,
) {
parent::__construct($errorMessage, $httpStatus);
}
}

View File

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Tests\Support;
use RuntimeException;
/** Thrown instead of exit() when QDB_TESTING is defined. */
final class JsonSuccessResponse extends RuntimeException
{
public function __construct(
public readonly mixed $data,
public readonly int $httpStatus = 200,
) {
parent::__construct('JSON success', $httpStatus);
}
}

View File

@ -0,0 +1,166 @@
<?php
declare(strict_types=1);
namespace Tests\Support;
use PHPUnit\Framework\TestCase as PHPUnitTestCase;
abstract class QdbTestCase extends PHPUnitTestCase
{
protected static ApiClient $api;
protected ?FixtureIds $fixtureIds = null;
public static function setUpBeforeClass(): void
{
parent::setUpBeforeClass();
self::$api = new ApiClient(dirname(__DIR__, 2) . '/api/index.php');
}
protected function setUp(): void
{
parent::setUp();
TestFilesystem::resetUploads();
$_SERVER['REMOTE_ADDR'] = '127.0.0.1';
$this->bootstrapDatabase();
}
protected function bootstrapDatabase(): void
{
if (defined('QDB_PATH') && is_file(QDB_PATH)) {
@unlink(QDB_PATH);
}
if (defined('QDB_LOCK') && is_file(QDB_LOCK)) {
@unlink(QDB_LOCK);
}
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
$this->fixtureIds = DatabaseSeeder::seed($pdo);
qdb_save($tmpDb, $lockFp);
}
protected function api(): ApiClient
{
return self::$api;
}
protected function fixture(): FixtureIds
{
if ($this->fixtureIds === null) {
$this->fail('Fixture not seeded');
}
return $this->fixtureIds;
}
protected function adminToken(): string
{
return $this->api()->loginWebAndGetToken(
DatabaseSeeder::ADMIN_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
}
protected function supervisorToken(): string
{
return $this->api()->loginWebAndGetToken(
DatabaseSeeder::SUPERVISOR_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
}
/**
* @return array{token: string, data: array<string, mixed>}
*/
protected function coachMobileLogin(): array
{
return $this->api()->loginMobileAndGetToken(
DatabaseSeeder::COACH_USERNAME,
DatabaseSeeder::PASSWORD
);
}
protected function assertApiOk(array $response, string $message = ''): void
{
$this->assertTrue($response['ok'] ?? false, $message . ' ' . json_encode($response['error'] ?? []));
}
protected function assertApiError(array $response, string $code, ?int $status = null): void
{
$this->assertFalse($response['ok'] ?? true);
$this->assertSame($code, $response['error']['code'] ?? null);
if ($status !== null) {
$this->assertSame($status, $response['status']);
}
}
protected function assertRawOk(array $response, string $contentTypePrefix): void
{
$this->assertTrue($response['ok'] ?? false);
$this->assertTrue($response['raw'] ?? false);
$this->assertStringStartsWith($contentTypePrefix, $response['headers']['Content-Type'] ?? '');
$this->assertNotSame('', $response['body'] ?? '');
}
/**
* @return array<string, mixed> API response from app submit
*/
protected function submitFixtureQuestionnaire(): array
{
$login = $this->api()->loginMobileAndGetToken(
DatabaseSeeder::COACH_USERNAME,
DatabaseSeeder::PASSWORD
);
$f = $this->fixture();
$now = time();
return $this->submitQuestionnaireAs(
$login['token'],
$f->questionnaireId,
$f->clientCode,
[
['questionID' => $f->questionShortId, 'answerOptionKey' => 'yes'],
],
$now - 60,
$now
);
}
/**
* @param list<array<string, mixed>> $answers
* @return array<string, mixed>
*/
protected function submitQuestionnaireAs(
string $token,
string $questionnaireId,
string $clientCode,
array $answers,
?int $startedAt = null,
?int $completedAt = null,
): array {
$now = time();
$startedAt ??= $now - 60;
$completedAt ??= $now;
return $this->api()->encryptedPost($token, 'app_questionnaires', [
'questionnaireID' => $questionnaireId,
'clientCode' => $clientCode,
'answers' => $answers,
'startedAt' => $startedAt,
'completedAt' => $completedAt,
]);
}
protected function submissionVersionCount(string $clientCode, string $questionnaireId): int
{
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$stmt = $pdo->prepare(
'SELECT COUNT(*) FROM questionnaire_submission WHERE clientCode = :cc AND questionnaireID = :qn'
);
$stmt->execute([':cc' => $clientCode, ':qn' => $questionnaireId]);
$count = (int)$stmt->fetchColumn();
qdb_discard($tmpDb, $lockFp);
return $count;
}
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Tests\Support;
use RuntimeException;
/** Thrown instead of exit() for file/download responses when QDB_TESTING is defined. */
final class RawHttpResponse extends RuntimeException
{
/**
* @param array<string, string> $headers
*/
public function __construct(
public readonly string $body,
public readonly array $headers,
public readonly int $httpStatus = 200,
) {
parent::__construct('Raw HTTP response', $httpStatus);
}
}

View File

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Tests\Support;
final class TestFilesystem
{
public static function resetUploads(): void
{
if (!defined('QDB_TEST_UPLOADS')) {
return;
}
if (defined('QDB_PATH')) {
if (is_file(QDB_PATH)) {
@unlink(QDB_PATH);
}
if (defined('QDB_LOCK') && is_file(QDB_LOCK)) {
@unlink(QDB_LOCK);
}
}
self::rmTree(QDB_TEST_UPLOADS);
mkdir(QDB_TEST_UPLOADS, 0755, true);
}
private static function rmTree(string $dir): void
{
if (!is_dir($dir)) {
return;
}
$items = scandir($dir);
if ($items === false) {
return;
}
foreach ($items as $item) {
if ($item === '.' || $item === '..') {
continue;
}
$path = $dir . DIRECTORY_SEPARATOR . $item;
if (is_dir($path)) {
self::rmTree($path);
} else {
@unlink($path);
}
}
@rmdir($dir);
}
}

109
tests/Unit/ApiLogTest.php Normal file
View File

@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
final class ApiLogTest extends TestCase
{
private string $logDir;
protected function setUp(): void
{
require_once dirname(__DIR__, 2) . '/lib/api_log.php';
$this->logDir = $_ENV['QDB_API_LOG_DIR'] ?? (QDB_TEST_UPLOADS . '/logs/api');
if (!is_dir($this->logDir)) {
mkdir($this->logDir, 0775, true);
}
$this->clearLogFiles();
unset($GLOBALS['qdb_api_log_ctx']);
}
protected function tearDown(): void
{
$this->clearLogFiles();
unset($GLOBALS['qdb_api_log_ctx']);
}
public function testClassifyActivity(): void
{
$this->assertNull(qdb_api_log_classify_activity('OPTIONS', 'web', 'users'));
$this->assertSame('web_change', qdb_api_log_classify_activity('POST', 'web', 'users'));
$this->assertSame('app_change', qdb_api_log_classify_activity('POST', '', 'app_questionnaires'));
$this->assertSame('app_sync', qdb_api_log_classify_activity('GET', '', 'app_questionnaires'));
$this->assertSame('web_export', qdb_api_log_classify_activity('GET', 'web', 'export'));
$this->assertSame('web_change', qdb_api_log_classify_activity('POST', 'web', 'backup'));
$this->assertTrue(qdb_api_log_is_website_download('backup'));
}
public function testRedactSensitiveFields(): void
{
$redacted = qdb_api_log_redact_value([
'username' => 'coach1',
'password' => 'secret',
'encrypted' => str_repeat('x', 40),
]);
$this->assertSame('coach1', $redacted['username']);
$this->assertSame('[REDACTED]', $redacted['password']);
$this->assertSame('[ENCRYPTED:40 chars]', $redacted['encrypted']);
}
public function testRedactQueryString(): void
{
$qs = qdb_api_log_redact_query_string('questionnaireID=qn1&token=abc&password=pw');
parse_str((string)$qs, $params);
$this->assertSame('qn1', $params['questionnaireID'] ?? null);
$this->assertSame('[REDACTED]', $params['token'] ?? null);
$this->assertSame('[REDACTED]', $params['password'] ?? null);
}
public function testTokenHintMasksMiddle(): void
{
$hint = qdb_api_log_token_hint('0123456789abcdef');
$this->assertStringContainsString('…', $hint);
$this->assertStringNotContainsString('56789', $hint);
}
public function testClientIpUsesForwardedFor(): void
{
$_SERVER['HTTP_X_FORWARDED_FOR'] = '203.0.113.50, 198.51.100.1';
$_SERVER['REMOTE_ADDR'] = '127.0.0.1';
$this->assertSame('203.0.113.50', qdb_api_log_client_ip());
unset($_SERVER['HTTP_X_FORWARDED_FOR']);
}
public function testBeginFinishWritesWebChangeEntry(): void
{
$_SERVER['HTTP_X_QDB_CLIENT'] = 'web';
$_SERVER['REQUEST_URI'] = '/api/users';
$_SERVER['QUERY_STRING'] = '';
qdb_test_set_request_body(json_encode(['username' => 'new_coach'], JSON_THROW_ON_ERROR));
http_response_code(200);
qdb_api_log_begin('POST', 'users');
qdb_api_log_finish();
$date = date('Y-m-d');
$read = qdb_api_log_read_entries($date, 'web_change', 10);
$entries = $read['entries'];
$this->assertNotEmpty($entries);
$last = $entries[0];
$this->assertSame('web_change', $last['activity']);
$this->assertSame('POST', $last['method']);
$this->assertSame('users', $last['route']);
$changes = $last['changes'] ?? [];
$fields = array_column($changes, 'field');
$this->assertContains('username', $fields);
qdb_test_reset_http_state();
}
private function clearLogFiles(): void
{
foreach (glob($this->logDir . '/api-*.log') ?: [] as $path) {
@unlink($path);
}
}
}

View File

@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
final class AppAnswersTest extends TestCase
{
protected function setUp(): void
{
require_once dirname(__DIR__, 2) . '/lib/app_answers.php';
}
public function testEncodeMultiCheckDedupesAndSortsKeys(): void
{
$json = qdb_encode_multi_check_values(['b', 'a', 'b', '']);
$decoded = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
$this->assertEqualsCanonicalizing(['a', 'b'], $decoded);
}
public function testDecodeMultiCheckFromJsonArray(): void
{
$this->assertSame(['x', 'y'], qdb_decode_multi_check_values('["x","y"]'));
}
public function testDecodeMultiCheckFromCommaSeparated(): void
{
$this->assertSame(['one', 'two'], qdb_decode_multi_check_values('one, two'));
}
public function testGroupAppSubmitAnswersMergesMultiCheck(): void
{
$answers = [
['questionID' => 'q1', 'answerOptionKey' => 'opt_a', 'answeredAt' => 100],
['questionID' => 'q1', 'answerOptionKey' => 'opt_b', 'answeredAt' => 100],
['questionID' => 'q2', 'freeTextValue' => 'hello'],
];
$grouped = qdb_group_app_submit_answers(
$answers,
['q1' => 'multi_check_box_question', 'q2' => 'text_question'],
[]
);
$this->assertCount(2, $grouped);
$this->assertSame('q1', $grouped[0]['questionID']);
$decoded = json_decode((string)$grouped[0]['freeTextValue'], true, 512, JSON_THROW_ON_ERROR);
$this->assertEqualsCanonicalizing(['opt_a', 'opt_b'], $decoded);
$this->assertSame('hello', $grouped[1]['freeTextValue']);
}
public function testGroupAppSubmitAnswersPreservesSymptomRows(): void
{
$answers = [
['questionID' => 'fatigue', 'answerOptionKey' => 'moderate'],
];
$grouped = qdb_group_app_submit_answers(
$answers,
['fatigue' => 'glass_scale_question'],
['fatigue' => 'glass_parent']
);
$this->assertCount(1, $grouped);
$this->assertSame('moderate', $grouped[0]['answerOptionKey']);
}
}

View File

@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace Tests\Unit;
use Tests\Support\QdbTestCase;
final class AppSubmitValidateTest extends QdbTestCase
{
public function testEmptyAnswersRejected(): void
{
require_once dirname(__DIR__, 2) . '/lib/app_submit_validate.php';
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$f = $this->fixture();
$errors = qdb_validate_app_submit_payload($pdo, $f->questionnaireId, [], [], [], [], []);
qdb_discard($tmpDb, $lockFp);
$this->assertNotEmpty($errors);
$this->assertSame('ANSWERS_REQUIRED', $errors[0]['code']);
}
public function testUnknownQuestionRejected(): void
{
require_once dirname(__DIR__, 2) . '/lib/app_submit_validate.php';
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$f = $this->fixture();
$errors = qdb_validate_app_submit_payload(
$pdo,
$f->questionnaireId,
[['questionID' => 'nope', 'answerOptionKey' => 'x']],
[],
[],
[],
[]
);
qdb_discard($tmpDb, $lockFp);
$this->assertSame('UNKNOWN_QUESTION', $errors[0]['code'] ?? '');
}
public function testValidSingleChoiceWithOptionKeyPasses(): void
{
require_once dirname(__DIR__, 2) . '/lib/app_submit_validate.php';
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$f = $this->fixture();
$shortMap = [$f->questionShortId => $f->questionId];
$typeMap = [$f->questionShortId => 'single_choice_question'];
$optMap = [
$f->questionId => [
'yes' => ['answerOptionID' => $f->optionId, 'points' => 0],
],
];
$errors = qdb_validate_app_submit_payload(
$pdo,
$f->questionnaireId,
[['questionID' => $f->questionShortId, 'answerOptionKey' => 'yes']],
$shortMap,
$typeMap,
[],
$optMap,
);
qdb_discard($tmpDb, $lockFp);
$this->assertSame([], $errors);
}
public function testMultiCheckRequiresSelection(): void
{
require_once dirname(__DIR__, 2) . '/lib/app_submit_validate.php';
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$f = $this->fixture();
$shortMap = [$f->multiCheckShortId => $f->multiCheckQuestionId];
$typeMap = [$f->multiCheckShortId => 'multi_check_box_question'];
$errors = qdb_validate_app_submit_payload(
$pdo,
$f->questionnaireId,
[['questionID' => $f->multiCheckShortId, 'freeTextValue' => '']],
$shortMap,
$typeMap,
[],
[],
);
qdb_discard($tmpDb, $lockFp);
$this->assertSame('EMPTY_ANSWER', $errors[0]['code'] ?? '');
}
public function testInvalidOptionKeyRejected(): void
{
require_once dirname(__DIR__, 2) . '/lib/app_submit_validate.php';
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$f = $this->fixture();
$shortMap = [$f->questionShortId => $f->questionId];
$typeMap = [$f->questionShortId => 'single_choice_question'];
$optMap = [
$f->questionId => [
'yes' => ['answerOptionID' => $f->optionId, 'points' => 0],
],
];
$errors = qdb_validate_app_submit_payload(
$pdo,
$f->questionnaireId,
[['questionID' => $f->questionShortId, 'answerOptionKey' => 'No']],
$shortMap,
$typeMap,
[],
$optMap,
);
qdb_discard($tmpDb, $lockFp);
$this->assertSame('INVALID_OPTION', $errors[0]['code'] ?? '');
}
}

View File

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace Tests\Unit;
use Tests\Support\JsonErrorResponse;
use Tests\Support\QdbTestCase;
final class CommonBundleTest extends QdbTestCase
{
public function testImportQuestionnairesBundleRejectsEmptyList(): void
{
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
try {
qdb_import_questionnaires_bundle($pdo, ['questionnaires' => []]);
$this->fail('Expected JsonErrorResponse');
} catch (JsonErrorResponse $e) {
$this->assertSame('MISSING_FIELDS', $e->errorCode);
} finally {
qdb_discard($tmpDb, $lockFp);
}
}
public function testImportTranslationsBundleRoundTripOnPdo(): void
{
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
$f = $this->fixture();
qdb_put_translation($pdo, 'question', $f->questionId, 'en', 'Hello EN');
qdb_save($tmpDb, $lockFp);
[$pdo2, $tmp2, $lock2] = qdb_open(false);
$bundle = qdb_export_translations_bundle($pdo2);
qdb_discard($tmp2, $lock2);
[$pdo3, $tmp3, $lock3] = qdb_open(true);
$pdo3->prepare('DELETE FROM question_translation WHERE questionID = :id AND languageCode = :lc')
->execute([':id' => $f->questionId, ':lc' => 'en']);
$result = qdb_import_translations_bundle($pdo3, $bundle);
qdb_save($tmp3, $lock3);
$this->assertGreaterThan(0, $result['imported'] ?? 0);
[$pdo4, $tmp4, $lock4] = qdb_open(false);
$stmt = $pdo4->prepare(
'SELECT text FROM question_translation WHERE questionID = :id AND languageCode = :lc'
);
$stmt->execute([':id' => $f->questionId, ':lc' => 'en']);
$this->assertSame('Hello EN', $stmt->fetchColumn());
qdb_discard($tmp4, $lock4);
}
public function testExportQuestionnaireBundleIncludesQuestionKeys(): void
{
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$f = $this->fixture();
$item = qdb_export_questionnaire_bundle($pdo, $f->questionnaireId);
qdb_discard($tmpDb, $lockFp);
$this->assertNotNull($item);
$keys = array_column($item['questions'], 'questionKey');
$this->assertContains('consent_q', $keys);
}
}

View File

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
final class CommonHelpersTest extends TestCase
{
protected function setUp(): void
{
require_once dirname(__DIR__, 2) . '/common.php';
}
public function testIsStableKey(): void
{
$this->assertTrue(qdb_is_stable_key('question_key'));
$this->assertTrue(qdb_is_stable_key('yes'));
$this->assertFalse(qdb_is_stable_key('Question Key'));
$this->assertFalse(qdb_is_stable_key(''));
}
public function testNormalizeStableKeyCoercesSpaces(): void
{
$this->assertSame('My_Key', qdb_normalize_stable_key('My Key'));
$this->assertSame('consent_q', qdb_normalize_stable_key('consent_q'));
}
public function testValidateStableKeyReturnsNormalized(): void
{
$this->assertSame('My_Key', qdb_validate_stable_key('My Key', 'Question key'));
}
public function testMessageKeyFromConditionJson(): void
{
$json = json_encode(['messageKey' => 'locked_hint'], JSON_THROW_ON_ERROR);
$this->assertSame('locked_hint', qdb_message_key_from_condition_json($json));
$this->assertSame('', qdb_message_key_from_condition_json('{}'));
}
public function testMergeGlassSymptomJson(): void
{
$merged = qdb_merge_glass_symptom_json('{"pain":"mild"}', ['fatigue' => 'severe']);
$data = json_decode($merged, true, 512, JSON_THROW_ON_ERROR);
$this->assertSame('mild', $data['pain']);
$this->assertSame('severe', $data['fatigue']);
}
public function testGlassSymptomAnswerValue(): void
{
$json = json_encode(['pain' => 'moderate'], JSON_THROW_ON_ERROR);
$this->assertSame('moderate', qdb_glass_symptom_answer_value($json, 'pain'));
$this->assertSame('', qdb_glass_symptom_answer_value($json, 'missing'));
}
}

53
tests/Unit/CryptoTest.php Normal file
View File

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
final class CryptoTest extends TestCase
{
public function testAesRoundTrip(): void
{
$key = str_repeat('K', 32);
$plain = '{"clients":[{"clientCode":"C1"}]}';
$enc = aes256_cbc_encrypt_bytes($plain, $key);
$this->assertGreaterThan(16, strlen($enc));
$dec = aes256_cbc_decrypt_bytes($enc, $key);
$this->assertSame($plain, $dec);
}
public function testHkdfDeterministicForToken(): void
{
$token = bin2hex(random_bytes(16));
$k1 = hkdf_session_key_from_token($token);
$k2 = hkdf_session_key_from_token($token);
$this->assertSame(32, strlen($k1));
$this->assertSame($k1, $k2);
}
public function testHkdfDiffersForDifferentTokens(): void
{
$a = hkdf_session_key_from_token(bin2hex(random_bytes(16)));
$b = hkdf_session_key_from_token(bin2hex(random_bytes(16)));
$this->assertNotSame($a, $b);
}
public function testDecryptFailsWithWrongKey(): void
{
$key = str_repeat('K', 32);
$enc = aes256_cbc_encrypt_bytes('payload', $key);
$this->expectException(\Exception::class);
aes256_cbc_decrypt_bytes($enc, str_repeat('X', 32));
}
public function testDecryptFailsWhenCiphertextTampered(): void
{
$key = str_repeat('K', 32);
$enc = aes256_cbc_encrypt_bytes('payload', $key);
$enc[20] = $enc[20] === 'a' ? 'b' : 'a';
$this->expectException(\Exception::class);
aes256_cbc_decrypt_bytes($enc, $key);
}
}

View File

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace Tests\Unit;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
final class EncryptedPayloadTest extends TestCase
{
public function testEnvelopeRoundTrip(): void
{
require_once dirname(__DIR__, 2) . '/lib/encrypted_payload.php';
$token = bin2hex(random_bytes(32));
$json = '{"clientCode":"X","answers":[]}';
$env = qdb_sensitive_envelope($json, $token);
$this->assertTrue($env['encrypted']);
$plain = qdb_decrypt_sensitive_envelope($env, $token);
$this->assertSame($json, $plain);
}
public function testWrongTokenFailsDecrypt(): void
{
require_once dirname(__DIR__, 2) . '/lib/encrypted_payload.php';
$env = qdb_sensitive_envelope('{}', bin2hex(random_bytes(32)));
$this->expectException(\Exception::class);
qdb_decrypt_sensitive_envelope($env, bin2hex(random_bytes(32)));
}
public function testInvalidEnvelopeRejected(): void
{
require_once dirname(__DIR__, 2) . '/lib/encrypted_payload.php';
$this->expectException(InvalidArgumentException::class);
qdb_decrypt_sensitive_envelope(['encrypted' => false], bin2hex(random_bytes(8)));
}
}

56
tests/Unit/ErrorsTest.php Normal file
View File

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Tests\Unit;
use Exception;
use PDOException;
use PHPUnit\Framework\TestCase;
final class ErrorsTest extends TestCase
{
protected function setUp(): void
{
require_once dirname(__DIR__, 2) . '/lib/errors.php';
}
public function testLockExceptionDetection(): void
{
$this->assertTrue(qdb_is_lock_exception(new Exception('Could not acquire lock')));
$this->assertTrue(qdb_is_lock_exception(new Exception('Could not open lock file')));
$this->assertFalse(qdb_is_lock_exception(new Exception('other')));
}
public function testPublicErrorMessageForLock(): void
{
$msg = qdb_public_error_message(new Exception('Could not acquire lock'), 'Save');
$this->assertStringContainsString('busy', strtolower($msg));
}
public function testPublicErrorMessageForDecrypt(): void
{
$msg = qdb_public_error_message(new Exception('decrypt failed'), 'Open');
$this->assertStringContainsString('configuration', strtolower($msg));
}
public function testPublicErrorMessageForPdo(): void
{
$msg = qdb_public_error_message(new PDOException('SQLITE_CONSTRAINT'), 'Query');
$this->assertStringContainsString('database error', strtolower($msg));
}
public function testApiErrorForLockUses503(): void
{
[$code, , $status] = qdb_api_error_for_exception(new Exception('Could not acquire lock'), 'Write');
$this->assertSame('LOCKED', $code);
$this->assertSame(503, $status);
}
public function testApiErrorForGenericUses500(): void
{
[$code, , $status] = qdb_api_error_for_exception(new Exception('boom'), 'Op');
$this->assertSame('SERVER_ERROR', $code);
$this->assertSame(500, $status);
}
}

View File

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace Tests\Unit;
use Tests\Support\QdbTestCase;
final class LoginRateLimitTest extends QdbTestCase
{
protected function setUp(): void
{
parent::setUp();
require_once dirname(__DIR__, 2) . '/lib/login_rate_limit.php';
$_SERVER['REMOTE_ADDR'] = '10.0.0.50';
}
public function testAllowsUnderLimit(): void
{
$settings = [
'login_max_attempts' => 3,
'login_window_seconds' => 300,
'login_lockout_seconds' => 600,
];
[$ok, $retry] = qdb_login_rate_limit_check('user_a', $settings);
$this->assertTrue($ok);
$this->assertSame(0, $retry);
}
public function testLocksAfterMaxFailures(): void
{
$settings = [
'login_max_attempts' => 2,
'login_window_seconds' => 300,
'login_lockout_seconds' => 600,
];
$user = 'lock_test_' . bin2hex(random_bytes(4));
qdb_login_rate_limit_record_failure($user, $settings);
qdb_login_rate_limit_record_failure($user, $settings);
[$ok, $retry] = qdb_login_rate_limit_check($user, $settings);
$this->assertFalse($ok);
$this->assertGreaterThan(0, $retry);
}
public function testClearResetsCounter(): void
{
$settings = [
'login_max_attempts' => 1,
'login_window_seconds' => 300,
'login_lockout_seconds' => 600,
];
$user = 'clear_test_' . bin2hex(random_bytes(4));
qdb_login_rate_limit_record_failure($user, $settings);
qdb_login_rate_limit_clear($user);
[$ok,] = qdb_login_rate_limit_check($user, $settings);
$this->assertTrue($ok);
}
}

44
tests/Unit/RbacTest.php Normal file
View File

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
final class RbacTest extends TestCase
{
public function testAdminSeesAllClients(): void
{
[$clause, $params] = rbac_client_filter(['role' => 'admin', 'entityID' => 'a1'], 'cl');
$this->assertSame('1=1', $clause);
$this->assertSame([], $params);
}
public function testSupervisorFilterBindsEntity(): void
{
[$clause, $params] = rbac_client_filter(
['role' => 'supervisor', 'entityID' => 'sv-99'],
'cl'
);
$this->assertStringContainsString('supervisorID', $clause);
$this->assertSame([':rbac_eid' => 'sv-99'], $params);
}
public function testCoachFilterBindsCoach(): void
{
[$clause, $params] = rbac_client_filter(
['role' => 'coach', 'entityID' => 'coach-1'],
'cl'
);
$this->assertStringContainsString('coachID', $clause);
$this->assertSame([':rbac_eid' => 'coach-1'], $params);
}
public function testWebRolesList(): void
{
$this->assertContains('admin', qdb_web_roles());
$this->assertContains('supervisor', qdb_web_roles());
$this->assertNotContains('coach', qdb_web_roles());
}
}

View File

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Tests\Unit;
use InvalidArgumentException;
use Tests\Support\DatabaseSeeder;
use Tests\Support\QdbTestCase;
final class SettingsTest extends QdbTestCase
{
public function testDefaults(): void
{
require_once dirname(__DIR__, 2) . '/lib/settings.php';
$d = qdb_settings_defaults();
$this->assertSame(10, $d['login_max_attempts']);
$this->assertSame(30 * 24 * 60 * 60, $d['session_ttl_seconds']);
}
public function testValidateRejectsOutOfRange(): void
{
require_once dirname(__DIR__, 2) . '/lib/settings.php';
$this->expectException(InvalidArgumentException::class);
qdb_settings_validate_and_merge(['login_max_attempts' => 0]);
}
public function testSaveAndLoadRoundTrip(): void
{
require_once dirname(__DIR__, 2) . '/lib/settings.php';
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
$custom = qdb_settings_validate_and_merge([
'login_max_attempts' => 5,
'login_window_seconds' => 600,
'login_lockout_seconds' => 1200,
'session_ttl_seconds' => 86400,
'temp_session_ttl_seconds' => 900,
]);
qdb_settings_save_on_pdo($pdo, $custom);
qdb_save($tmpDb, $lockFp);
[$pdo2, $tmp2, $lock2] = qdb_open(false);
$loaded = qdb_settings_get_on_pdo($pdo2);
qdb_discard($tmp2, $lock2);
$this->assertSame(5, $loaded['login_max_attempts']);
$this->assertSame(86400, $loaded['session_ttl_seconds']);
}
public function testSessionTtlHelper(): void
{
require_once dirname(__DIR__, 2) . '/lib/settings.php';
$this->assertSame(600, qdb_session_ttl_seconds(['temp_session_ttl_seconds' => 600], true));
$this->assertSame(3600, qdb_session_ttl_seconds(['session_ttl_seconds' => 3600], false));
}
}

View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
final class TextSanitizeTest extends TestCase
{
public function testStripsSqlLikePatterns(): void
{
require_once dirname(__DIR__, 2) . '/lib/text_sanitize.php';
$out = sanitize_free_text("hello'; DROP TABLE users;--");
$this->assertStringNotContainsString('DROP TABLE', $out);
$this->assertStringNotContainsString('--', $out);
}
public function testPreservesNewlines(): void
{
require_once dirname(__DIR__, 2) . '/lib/text_sanitize.php';
$out = sanitize_free_text("line1\nline2");
$this->assertStringContainsString("\n", $out);
}
public function testNonStringReturnsEmpty(): void
{
require_once dirname(__DIR__, 2) . '/lib/text_sanitize.php';
$this->assertSame('', sanitize_free_text(null));
}
}

View File

@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
use Tests\Support\JsonErrorResponse;
final class ValidateTest extends TestCase
{
protected function setUp(): void
{
if (!defined('QDB_TESTING')) {
throw new \RuntimeException('PHPUnit bootstrap must define QDB_TESTING');
}
require_once dirname(__DIR__, 2) . '/lib/validate.php';
}
public function testRequireFieldsPasses(): void
{
$body = ['a' => '1', 'b' => 'x'];
require_fields($body, ['a', 'b']);
$this->assertSame(['a' => '1', 'b' => 'x'], $body);
}
public function testRequireFieldsMissing(): void
{
$this->expectException(JsonErrorResponse::class);
$this->expectExceptionMessage('Required fields');
try {
require_fields(['a' => ''], ['a', 'b']);
} catch (JsonErrorResponse $e) {
$this->assertSame('MISSING_FIELDS', $e->errorCode);
$this->assertSame(400, $e->httpStatus);
throw $e;
}
}
public function testValidateStringTrims(): void
{
$s = validate_string(' hello ', 'field', 1, 20);
$this->assertSame('hello', $s);
}
public function testValidateEnum(): void
{
$this->assertSame('admin', validate_enum('admin', 'role', ['admin', 'coach']));
}
public function testValidateIntBounds(): void
{
$this->assertSame(5, validate_int('5', 'n', 1, 10));
}
}

29
tests/bootstrap.php Normal file
View File

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
define('QDB_TESTING', true);
define('QDB_TEST_UPLOADS', __DIR__ . '/runtime/uploads');
$runtimeRoot = dirname(QDB_TEST_UPLOADS);
if (!is_dir($runtimeRoot)) {
mkdir($runtimeRoot, 0755, true);
}
if (!is_dir(QDB_TEST_UPLOADS)) {
mkdir(QDB_TEST_UPLOADS, 0755, true);
}
putenv('QDB_API_LOG_DIR=' . QDB_TEST_UPLOADS . '/logs/api');
$_ENV['QDB_API_LOG_DIR'] = QDB_TEST_UPLOADS . '/logs/api';
$testMasterKey = base64_encode(str_repeat('T', 32));
require dirname(__DIR__) . '/vendor/autoload.php';
require_once dirname(__DIR__) . '/lib/response.php';
require_once dirname(__DIR__) . '/common.php';
// Always use the test key, even when .env defines QDB_MASTER_KEY (avoids decrypt/seed drift).
qdb_env_set('QDB_MASTER_KEY', $testMasterKey);
$GLOBALS['qdb_dotenv']['QDB_MASTER_KEY'] = $testMasterKey;
require_once dirname(__DIR__) . '/db_init.php';

60
tests/check-coverage.php Normal file
View File

@ -0,0 +1,60 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
/**
* Fail CI when line coverage falls below the configured floor.
* Usage: php tests/check-coverage.php [minPercent]
*/
$min = isset($argv[1]) ? (float)$argv[1] : 40.0;
$root = dirname(__DIR__);
$envPrefix = '';
if (extension_loaded('xdebug') && !extension_loaded('pcov')) {
// Xdebug 3 defaults to develop mode; PHPUnit needs coverage mode.
$envPrefix = 'XDEBUG_MODE=coverage ';
}
$cmd = sprintf(
'%s%s %s/vendor/bin/phpunit --coverage-text --colors=never 2>&1',
$envPrefix,
escapeshellarg(PHP_BINARY),
escapeshellarg($root)
);
$output = [];
exec($cmd, $output, $exitCode);
echo implode("\n", $output) . "\n";
if ($exitCode !== 0) {
exit($exitCode);
}
$linesPct = null;
foreach ($output as $line) {
// PHPUnit 11 / Xdebug: "Lines: 25.33% (1484/5859)"
if (preg_match('/^\s*Lines:\s+([\d.]+)%\s+\(/', $line, $m)) {
$linesPct = (float)$m[1];
break;
}
// PCOV / older format: "Lines: 1484/5859 (25.33%)"
if (preg_match('/^\s*Lines:\s+[\d.]+\/\d+\s+\(\s*([\d.]+)%/', $line, $m)) {
$linesPct = (float)$m[1];
break;
}
}
if ($linesPct === null) {
fwrite(STDERR, "Could not parse coverage summary. Is PCOV or Xdebug enabled?\n");
exit(1);
}
if ($linesPct < $min) {
fwrite(STDERR, sprintf("Line coverage %.2f%% is below minimum %.2f%%\n", $linesPct, $min));
exit(1);
}
fwrite(STDERR, sprintf("Line coverage %.2f%% meets minimum %.2f%%\n", $linesPct, $min));
exit(0);