diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml new file mode 100644 index 0000000..bf175d4 --- /dev/null +++ b/.github/workflows/phpunit.yml @@ -0,0 +1,27 @@ +name: PHPUnit + +on: + push: + branches: [main, master] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + extensions: json, openssl, pdo, pdo_sqlite, zip + coverage: pcov + tools: composer:v2 + + - name: Install dependencies + run: composer install --no-interaction --prefer-dist + + - name: Run tests with coverage floor + run: php tests/check-coverage.php 40 diff --git a/.gitignore b/.gitignore index c3a775f..746aa56 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,8 @@ yarn.lock package-lock.json # PHP composer artifacts (if using composer in future) +tests/runtime/ +.phpunit.cache/ vendor/ composer.lock diff --git a/api/index.php b/api/index.php index 6deeb51..31353c0 100644 --- a/api/index.php +++ b/api/index.php @@ -20,8 +20,10 @@ $route = strtolower($route); $method = $_SERVER['REQUEST_METHOD']; -qdb_api_log_begin($method, $route !== '' ? $route : '(root)'); -register_shutdown_function('qdb_api_log_finish'); +if (!defined('QDB_TESTING')) { + qdb_api_log_begin($method, $route !== '' ? $route : '(root)'); + register_shutdown_function('qdb_api_log_finish'); +} // CORS header('Access-Control-Allow-Origin: *'); @@ -73,6 +75,13 @@ try { require $handlerFile; } catch (Throwable $e) { + if (defined('QDB_TESTING') && ( + $e instanceof \Tests\Support\JsonSuccessResponse + || $e instanceof \Tests\Support\JsonErrorResponse + || $e instanceof \Tests\Support\RawHttpResponse + )) { + throw $e; + } require_once __DIR__ . '/../lib/errors.php'; qdb_emit_api_error($e, "API $method $route"); } diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..ad7f717 --- /dev/null +++ b/composer.json @@ -0,0 +1,25 @@ +{ + "name": "nat-as/questionnaire-server", + "description": "Encrypted SQLite questionnaire API", + "type": "project", + "require": { + "php": ">=8.2", + "ext-json": "*", + "ext-openssl": "*", + "ext-pdo": "*", + "ext-pdo_sqlite": "*" + }, + "require-dev": { + "phpunit/phpunit": "^11.5" + }, + "autoload-dev": { + "psr-4": { + "Tests\\": "tests/" + } + }, + "scripts": { + "test": "phpunit", + "test:coverage": "phpunit --coverage-text", + "test:ci": "php tests/check-coverage.php 40" + } +} diff --git a/composer.phar b/composer.phar new file mode 100644 index 0000000..fe03d9b Binary files /dev/null and b/composer.phar differ diff --git a/db_init.php b/db_init.php index 7444729..eaed6ba 100644 --- a/db_init.php +++ b/db_init.php @@ -3,8 +3,15 @@ // Shared helpers for opening, creating, and saving the encrypted SQLite database. require_once __DIR__ . '/common.php'; -define('QDB_PATH', __DIR__ . '/uploads/questionnaire_database'); -define('QDB_LOCK', __DIR__ . '/uploads/.qdb_lock'); +if (defined('QDB_TEST_UPLOADS')) { + define('QDB_UPLOADS_DIR', QDB_TEST_UPLOADS); + define('QDB_PATH', QDB_UPLOADS_DIR . '/questionnaire_database'); + define('QDB_LOCK', QDB_UPLOADS_DIR . '/.qdb_lock'); +} else { + define('QDB_UPLOADS_DIR', __DIR__ . '/uploads'); + define('QDB_PATH', QDB_UPLOADS_DIR . '/questionnaire_database'); + define('QDB_LOCK', QDB_UPLOADS_DIR . '/.qdb_lock'); +} define('QDB_SCHEMA', __DIR__ . '/schema.sql'); define('QDB_VERSION', 6); diff --git a/handlers/backup.php b/handlers/backup.php index cff1050..a8a8e3d 100644 --- a/handlers/backup.php +++ b/handlers/backup.php @@ -8,7 +8,7 @@ if ($method !== 'POST') { } $dbPath = QDB_PATH; -$backupDir = __DIR__ . '/../uploads/backups'; +$backupDir = QDB_UPLOADS_DIR . '/backups'; if (!file_exists($dbPath)) { json_error('NOT_FOUND', 'No database to back up', 404); diff --git a/handlers/export.php b/handlers/export.php index ae01601..39d6110 100644 --- a/handlers/export.php +++ b/handlers/export.php @@ -17,10 +17,13 @@ if (!empty($_GET['bundle'])) { qdb_discard($tmpDb, $lockFp); $filename = 'questionnaires_bundle_' . date('Y-m-d_His') . '.json'; - header('Content-Type: application/json; charset=UTF-8'); - header('Content-Disposition: attachment; filename="' . $filename . '"'); - echo json_encode($bundle, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); - exit; + qdb_http_download( + json_encode($bundle, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), + [ + 'Content-Type' => 'application/json; charset=UTF-8', + 'Content-Disposition' => 'attachment; filename="' . $filename . '"', + ] + ); } catch (Throwable $e) { qdb_handler_fail($e, 'Export questionnaires bundle', null, $tmpDb ?? null, $lockFp ?? null); } @@ -37,13 +40,14 @@ if (!empty($_GET['exportAll'])) { $label = $allVersions ? 'all_versions' : 'current'; $filename = 'responses_export_' . $label . '_' . date('Y-m-d_His') . '.zip'; + $zipBody = (string)file_get_contents($zipPath); + @unlink($zipPath); - header('Content-Type: application/zip'); - header('Content-Disposition: attachment; filename="' . $filename . '"'); - header('Content-Length: ' . (string)filesize($zipPath)); - readfile($zipPath); - @unlink($zipPath); - exit; + qdb_http_download($zipBody, [ + 'Content-Type' => 'application/zip', + 'Content-Disposition' => 'attachment; filename="' . $filename . '"', + 'Content-Length' => (string)strlen($zipBody), + ]); } catch (Throwable $e) { qdb_handler_fail($e, 'Export responses ZIP', null, $tmpDb ?? null, $lockFp ?? null); } @@ -80,10 +84,13 @@ if ($allVersions) { $safeName = qdb_export_safe_basename($questionnaire['name']); $filename = $safeName . '_all_versions_' . date('Y-m-d') . '.csv'; - header('Content-Type: text/csv; charset=UTF-8'); - header('Content-Disposition: attachment; filename="' . $filename . '"'); - echo qdb_export_rows_to_csv_string($rows, qdb_export_all_versions_csv_fallback_header($resultColumns)); - exit; + qdb_http_download( + qdb_export_rows_to_csv_string($rows, qdb_export_all_versions_csv_fallback_header($resultColumns)), + [ + 'Content-Type' => 'text/csv; charset=UTF-8', + 'Content-Disposition' => 'attachment; filename="' . $filename . '"', + ] + ); } $rows = qdb_export_current_rows($pdo, $qnID, $questions, $resultColumns, $optionTextMap, $tokenRec); @@ -92,9 +99,13 @@ qdb_discard($tmpDb, $lockFp); $safeName = qdb_export_safe_basename($questionnaire['name']); $filename = $safeName . '_v' . $questionnaire['version'] . '.csv'; - header('Content-Type: text/csv; charset=UTF-8'); - header('Content-Disposition: attachment; filename="' . $filename . '"'); - echo qdb_export_rows_to_csv_string($rows, qdb_export_current_csv_fallback_header($resultColumns)); + qdb_http_download( + qdb_export_rows_to_csv_string($rows, qdb_export_current_csv_fallback_header($resultColumns)), + [ + 'Content-Type' => 'text/csv; charset=UTF-8', + 'Content-Disposition' => 'attachment; filename="' . $filename . '"', + ] + ); } catch (Throwable $e) { qdb_handler_fail($e, 'Export CSV', null, $tmpDb ?? null, $lockFp ?? null); } diff --git a/handlers/settings.php b/handlers/settings.php index a489adc..cc1433b 100644 --- a/handlers/settings.php +++ b/handlers/settings.php @@ -5,8 +5,6 @@ require_once __DIR__ . '/../lib/settings.php'; $tokenRec = require_valid_token_web(); require_role(['admin'], $tokenRec); -const QDB_REVOKE_ALL_CONFIRM = 'REVOKE ALL SESSIONS'; - switch ($method) { case 'GET': @@ -49,10 +47,11 @@ case 'POST': json_error('BAD_REQUEST', 'Unknown action', 400); } $confirm = trim((string)($body['confirmPhrase'] ?? '')); - if ($confirm !== QDB_REVOKE_ALL_CONFIRM) { + $revokeAllPhrase = 'REVOKE ALL SESSIONS'; + if ($confirm !== $revokeAllPhrase) { json_error( 'CONFIRMATION_REQUIRED', - 'Type exactly "' . QDB_REVOKE_ALL_CONFIRM . '" to revoke every session', + 'Type exactly "' . $revokeAllPhrase . '" to revoke every session', 400 ); } diff --git a/handlers/translations.php b/handlers/translations.php index 2c6b9b7..44d109d 100644 --- a/handlers/translations.php +++ b/handlers/translations.php @@ -19,10 +19,13 @@ case 'GET': } $filename = 'translations_bundle_' . date('Y-m-d_His') . '.json'; - header('Content-Type: application/json; charset=UTF-8'); - header('Content-Disposition: attachment; filename="' . $filename . '"'); - echo json_encode($bundle, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); - exit; + qdb_http_download( + json_encode($bundle, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), + [ + 'Content-Type' => 'application/json; charset=UTF-8', + 'Content-Disposition' => 'attachment; filename="' . $filename . '"', + ] + ); } if (isset($_GET['languages'])) { diff --git a/lib/errors.php b/lib/errors.php index 360cace..022e5e5 100644 --- a/lib/errors.php +++ b/lib/errors.php @@ -56,6 +56,9 @@ function qdb_handler_fail( ?string $tmpDb = null, $lockFp = null ): never { + if (defined('QDB_TESTING') && qdb_test_is_control_flow_exception($e)) { + throw $e; + } if ($pdo !== null && $pdo->inTransaction()) { try { $pdo->rollBack(); @@ -74,6 +77,9 @@ function qdb_handler_fail( */ function qdb_emit_api_error(Throwable $e, string $contextLabel): never { + if (defined('QDB_TESTING') && qdb_test_is_control_flow_exception($e)) { + throw $e; + } if (function_exists('qdb_api_log_note_exception')) { qdb_api_log_note_exception($e); } diff --git a/lib/response.php b/lib/response.php index c1988ff..c6d0ffe 100644 --- a/lib/response.php +++ b/lib/response.php @@ -2,7 +2,36 @@ require_once __DIR__ . '/encrypted_payload.php'; +/** @internal PHPUnit: inject request body (cleared by qdb_test_reset_http_state). */ +function qdb_test_set_request_body(string $body): void +{ + if (!defined('QDB_TESTING')) { + throw new RuntimeException('qdb_test_set_request_body is only available in tests'); + } + $GLOBALS['qdb_test_request_body'] = $body; +} + +/** @internal PHPUnit: reset cached body between API calls. */ +function qdb_test_reset_http_state(): void +{ + if (!defined('QDB_TESTING')) { + return; + } + unset($GLOBALS['qdb_test_request_body']); +} + +/** @internal PHPUnit: rethrow response control-flow exceptions. */ +function qdb_test_is_control_flow_exception(\Throwable $e): bool +{ + return $e instanceof \Tests\Support\JsonSuccessResponse + || $e instanceof \Tests\Support\JsonErrorResponse + || $e instanceof \Tests\Support\RawHttpResponse; +} + function json_success(mixed $data, int $status = 200): never { + if (defined('QDB_TESTING')) { + throw new \Tests\Support\JsonSuccessResponse($data, $status); + } http_response_code($status); header('Content-Type: application/json; charset=UTF-8'); echo json_encode(["ok" => true, "data" => $data]); @@ -12,7 +41,28 @@ function json_success(mixed $data, int $status = 200): never { /** * @param mixed $details Optional structured payload (e.g. validation error list). */ +/** + * Send a non-JSON download response (CSV, ZIP, etc.). + * + * @param array $headers + */ +function qdb_http_download(string $body, array $headers, int $status = 200): never +{ + if (defined('QDB_TESTING')) { + throw new \Tests\Support\RawHttpResponse($body, $headers, $status); + } + http_response_code($status); + foreach ($headers as $name => $value) { + header($name . ': ' . $value); + } + echo $body; + exit; +} + function json_error(string $code, string $message, int $status = 400, mixed $details = null): never { + if (defined('QDB_TESTING')) { + throw new \Tests\Support\JsonErrorResponse($code, $message, $status, $details); + } if (function_exists('qdb_api_log_note_api_error')) { qdb_api_log_note_api_error($code, $message, $status); } @@ -28,6 +78,9 @@ function json_error(string $code, string $message, int $status = 400, mixed $det /** Raw request body (cached; php://input is single-read). */ function qdb_raw_request_body(): string { + if (defined('QDB_TESTING') && array_key_exists('qdb_test_request_body', $GLOBALS)) { + return (string)$GLOBALS['qdb_test_request_body']; + } static $raw = null; if ($raw === null) { $read = file_get_contents('php://input'); diff --git a/lib/submissions.php b/lib/submissions.php index 6d4b46f..8ad65e7 100644 --- a/lib/submissions.php +++ b/lib/submissions.php @@ -365,12 +365,12 @@ function qdb_export_rows_to_csv_string(array $rows, array $fallbackHeader = []): } fwrite($fp, "\xEF\xBB\xBF"); if (!empty($rows)) { - fputcsv($fp, array_keys($rows[0])); + fputcsv($fp, array_keys($rows[0]), ',', '"', '\\'); foreach ($rows as $row) { - fputcsv($fp, array_values($row)); + fputcsv($fp, array_values($row), ',', '"', '\\'); } } elseif ($fallbackHeader !== []) { - fputcsv($fp, $fallbackHeader); + fputcsv($fp, $fallbackHeader, ',', '"', '\\'); } rewind($fp); $csv = stream_get_contents($fp); diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..3792903 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,30 @@ + + + + + tests/Unit + + + tests/Integration + + + + + + lib + handlers + db_init.php + common.php + + + tests + vendor + + + diff --git a/tests/Integration/ApiRoutingTest.php b/tests/Integration/ApiRoutingTest.php new file mode 100644 index 0000000..17a9184 --- /dev/null +++ b/tests/Integration/ApiRoutingTest.php @@ -0,0 +1,22 @@ +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); + } +} diff --git a/tests/Integration/AppQuestionnairesTest.php b/tests/Integration/AppQuestionnairesTest.php new file mode 100644 index 0000000..316384e --- /dev/null +++ b/tests/Integration/AppQuestionnairesTest.php @@ -0,0 +1,49 @@ +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); + } +} diff --git a/tests/Integration/AppSubmitExtendedTest.php b/tests/Integration/AppSubmitExtendedTest.php new file mode 100644 index 0000000..11eaa75 --- /dev/null +++ b/tests/Integration/AppSubmitExtendedTest.php @@ -0,0 +1,183 @@ +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); + } +} diff --git a/tests/Integration/AssignmentsActivityLogTest.php b/tests/Integration/AssignmentsActivityLogTest.php new file mode 100644 index 0000000..2f57454 --- /dev/null +++ b/tests/Integration/AssignmentsActivityLogTest.php @@ -0,0 +1,38 @@ +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']); + } +} diff --git a/tests/Integration/AuthTest.php b/tests/Integration/AuthTest.php new file mode 100644 index 0000000..3e1c315 --- /dev/null +++ b/tests/Integration/AuthTest.php @@ -0,0 +1,54 @@ +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); + } +} diff --git a/tests/Integration/BackupDevOpsTest.php b/tests/Integration/BackupDevOpsTest.php new file mode 100644 index 0000000..d2f05f0 --- /dev/null +++ b/tests/Integration/BackupDevOpsTest.php @@ -0,0 +1,80 @@ +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); + } + +} diff --git a/tests/Integration/CatalogApiTest.php b/tests/Integration/CatalogApiTest.php new file mode 100644 index 0000000..75ea251 --- /dev/null +++ b/tests/Integration/CatalogApiTest.php @@ -0,0 +1,94 @@ +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']); + } +} diff --git a/tests/Integration/ChangePasswordTest.php b/tests/Integration/ChangePasswordTest.php new file mode 100644 index 0000000..b7e66af --- /dev/null +++ b/tests/Integration/ChangePasswordTest.php @@ -0,0 +1,39 @@ +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); + } +} diff --git a/tests/Integration/ClientsLifecycleTest.php b/tests/Integration/ClientsLifecycleTest.php new file mode 100644 index 0000000..318587e --- /dev/null +++ b/tests/Integration/ClientsLifecycleTest.php @@ -0,0 +1,64 @@ +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); + } +} diff --git a/tests/Integration/ClientsQuestionnairesTest.php b/tests/Integration/ClientsQuestionnairesTest.php new file mode 100644 index 0000000..2c18813 --- /dev/null +++ b/tests/Integration/ClientsQuestionnairesTest.php @@ -0,0 +1,60 @@ +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); + } +} diff --git a/tests/Integration/CoachesActivityTest.php b/tests/Integration/CoachesActivityTest.php new file mode 100644 index 0000000..6a28884 --- /dev/null +++ b/tests/Integration/CoachesActivityTest.php @@ -0,0 +1,27 @@ +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']); + } +} diff --git a/tests/Integration/DbInitTest.php b/tests/Integration/DbInitTest.php new file mode 100644 index 0000000..965225b --- /dev/null +++ b/tests/Integration/DbInitTest.php @@ -0,0 +1,52 @@ +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); + } +} diff --git a/tests/Integration/DownloadsTest.php b/tests/Integration/DownloadsTest.php new file mode 100644 index 0000000..35db2f4 --- /dev/null +++ b/tests/Integration/DownloadsTest.php @@ -0,0 +1,50 @@ +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']); + } +} diff --git a/tests/Integration/EditorCrudTest.php b/tests/Integration/EditorCrudTest.php new file mode 100644 index 0000000..bc007ec --- /dev/null +++ b/tests/Integration/EditorCrudTest.php @@ -0,0 +1,119 @@ +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); + } +} diff --git a/tests/Integration/ExportExtendedTest.php b/tests/Integration/ExportExtendedTest.php new file mode 100644 index 0000000..708a55d --- /dev/null +++ b/tests/Integration/ExportExtendedTest.php @@ -0,0 +1,116 @@ +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']); + } +} diff --git a/tests/Integration/ExportTest.php b/tests/Integration/ExportTest.php new file mode 100644 index 0000000..d03852b --- /dev/null +++ b/tests/Integration/ExportTest.php @@ -0,0 +1,50 @@ +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']); + } +} diff --git a/tests/Integration/QuestionnaireEditingTest.php b/tests/Integration/QuestionnaireEditingTest.php new file mode 100644 index 0000000..238df1e --- /dev/null +++ b/tests/Integration/QuestionnaireEditingTest.php @@ -0,0 +1,57 @@ +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); + } +} diff --git a/tests/Integration/QuestionnaireImportExportTest.php b/tests/Integration/QuestionnaireImportExportTest.php new file mode 100644 index 0000000..833c58b --- /dev/null +++ b/tests/Integration/QuestionnaireImportExportTest.php @@ -0,0 +1,76 @@ +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'] ?? ''); + } +} diff --git a/tests/Integration/RbacIntegrationTest.php b/tests/Integration/RbacIntegrationTest.php new file mode 100644 index 0000000..479d76c --- /dev/null +++ b/tests/Integration/RbacIntegrationTest.php @@ -0,0 +1,85 @@ +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); + } +} diff --git a/tests/Integration/ResultsAnalyticsTest.php b/tests/Integration/ResultsAnalyticsTest.php new file mode 100644 index 0000000..e14484e --- /dev/null +++ b/tests/Integration/ResultsAnalyticsTest.php @@ -0,0 +1,86 @@ +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']); + } +} diff --git a/tests/Integration/SecurityPathsTest.php b/tests/Integration/SecurityPathsTest.php new file mode 100644 index 0000000..f149edf --- /dev/null +++ b/tests/Integration/SecurityPathsTest.php @@ -0,0 +1,90 @@ +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); + } +} diff --git a/tests/Integration/SessionLogoutTest.php b/tests/Integration/SessionLogoutTest.php new file mode 100644 index 0000000..75de33f --- /dev/null +++ b/tests/Integration/SessionLogoutTest.php @@ -0,0 +1,40 @@ +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); + } +} diff --git a/tests/Integration/SettingsApiTest.php b/tests/Integration/SettingsApiTest.php new file mode 100644 index 0000000..5a6bad8 --- /dev/null +++ b/tests/Integration/SettingsApiTest.php @@ -0,0 +1,66 @@ +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); + } +} diff --git a/tests/Integration/TokenTest.php b/tests/Integration/TokenTest.php new file mode 100644 index 0000000..7a787a2 --- /dev/null +++ b/tests/Integration/TokenTest.php @@ -0,0 +1,39 @@ +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)); + } +} diff --git a/tests/Integration/TranslationsImportExportTest.php b/tests/Integration/TranslationsImportExportTest.php new file mode 100644 index 0000000..c9fbd05 --- /dev/null +++ b/tests/Integration/TranslationsImportExportTest.php @@ -0,0 +1,83 @@ +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); + } +} diff --git a/tests/Integration/TranslationsWriteTest.php b/tests/Integration/TranslationsWriteTest.php new file mode 100644 index 0000000..3d8e161 --- /dev/null +++ b/tests/Integration/TranslationsWriteTest.php @@ -0,0 +1,83 @@ +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); + } +} diff --git a/tests/Integration/UsersAdminTest.php b/tests/Integration/UsersAdminTest.php new file mode 100644 index 0000000..41ffa26 --- /dev/null +++ b/tests/Integration/UsersAdminTest.php @@ -0,0 +1,179 @@ +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); + } +} diff --git a/tests/Integration/UsersTest.php b/tests/Integration/UsersTest.php new file mode 100644 index 0000000..24cca07 --- /dev/null +++ b/tests/Integration/UsersTest.php @@ -0,0 +1,55 @@ +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); + } +} diff --git a/tests/Support/ApiClient.php b/tests/Support/ApiClient.php new file mode 100644 index 0000000..f133240 --- /dev/null +++ b/tests/Support/ApiClient.php @@ -0,0 +1,181 @@ + $headers + * @return array{ok: bool, status: int, data?: mixed, error?: array} + */ + 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} + */ + 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} + */ + 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 $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 $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 $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); + } +} diff --git a/tests/Support/DatabaseSeeder.php b/tests/Support/DatabaseSeeder.php new file mode 100644 index 0000000..ee4a45d --- /dev/null +++ b/tests/Support/DatabaseSeeder.php @@ -0,0 +1,233 @@ +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, + ) { + } +} diff --git a/tests/Support/JsonErrorResponse.php b/tests/Support/JsonErrorResponse.php new file mode 100644 index 0000000..4048527 --- /dev/null +++ b/tests/Support/JsonErrorResponse.php @@ -0,0 +1,20 @@ +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} + */ + 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 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> $answers + * @return array + */ + 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; + } +} diff --git a/tests/Support/RawHttpResponse.php b/tests/Support/RawHttpResponse.php new file mode 100644 index 0000000..df965f1 --- /dev/null +++ b/tests/Support/RawHttpResponse.php @@ -0,0 +1,22 @@ + $headers + */ + public function __construct( + public readonly string $body, + public readonly array $headers, + public readonly int $httpStatus = 200, + ) { + parent::__construct('Raw HTTP response', $httpStatus); + } +} diff --git a/tests/Support/TestFilesystem.php b/tests/Support/TestFilesystem.php new file mode 100644 index 0000000..c311037 --- /dev/null +++ b/tests/Support/TestFilesystem.php @@ -0,0 +1,48 @@ +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); + } + } +} diff --git a/tests/Unit/AppAnswersTest.php b/tests/Unit/AppAnswersTest.php new file mode 100644 index 0000000..3ddd7be --- /dev/null +++ b/tests/Unit/AppAnswersTest.php @@ -0,0 +1,65 @@ +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']); + } +} diff --git a/tests/Unit/AppSubmitValidateTest.php b/tests/Unit/AppSubmitValidateTest.php new file mode 100644 index 0000000..7908164 --- /dev/null +++ b/tests/Unit/AppSubmitValidateTest.php @@ -0,0 +1,109 @@ +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'] ?? ''); + } +} diff --git a/tests/Unit/CommonBundleTest.php b/tests/Unit/CommonBundleTest.php new file mode 100644 index 0000000..b47dd84 --- /dev/null +++ b/tests/Unit/CommonBundleTest.php @@ -0,0 +1,62 @@ + []]); + $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); + } +} diff --git a/tests/Unit/CommonHelpersTest.php b/tests/Unit/CommonHelpersTest.php new file mode 100644 index 0000000..7080182 --- /dev/null +++ b/tests/Unit/CommonHelpersTest.php @@ -0,0 +1,56 @@ +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')); + } +} diff --git a/tests/Unit/CryptoTest.php b/tests/Unit/CryptoTest.php new file mode 100644 index 0000000..3f44fc1 --- /dev/null +++ b/tests/Unit/CryptoTest.php @@ -0,0 +1,53 @@ +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); + } +} diff --git a/tests/Unit/EncryptedPayloadTest.php b/tests/Unit/EncryptedPayloadTest.php new file mode 100644 index 0000000..b0a0a1c --- /dev/null +++ b/tests/Unit/EncryptedPayloadTest.php @@ -0,0 +1,37 @@ +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))); + } +} diff --git a/tests/Unit/ErrorsTest.php b/tests/Unit/ErrorsTest.php new file mode 100644 index 0000000..2736a42 --- /dev/null +++ b/tests/Unit/ErrorsTest.php @@ -0,0 +1,56 @@ +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); + } +} diff --git a/tests/Unit/LoginRateLimitTest.php b/tests/Unit/LoginRateLimitTest.php new file mode 100644 index 0000000..7b9e641 --- /dev/null +++ b/tests/Unit/LoginRateLimitTest.php @@ -0,0 +1,58 @@ + 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); + } +} diff --git a/tests/Unit/RbacTest.php b/tests/Unit/RbacTest.php new file mode 100644 index 0000000..a45bd86 --- /dev/null +++ b/tests/Unit/RbacTest.php @@ -0,0 +1,44 @@ + '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()); + } +} diff --git a/tests/Unit/SettingsTest.php b/tests/Unit/SettingsTest.php new file mode 100644 index 0000000..75ee315 --- /dev/null +++ b/tests/Unit/SettingsTest.php @@ -0,0 +1,54 @@ +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)); + } +} diff --git a/tests/Unit/TextSanitizeTest.php b/tests/Unit/TextSanitizeTest.php new file mode 100644 index 0000000..f97fbb9 --- /dev/null +++ b/tests/Unit/TextSanitizeTest.php @@ -0,0 +1,31 @@ +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)); + } +} diff --git a/tests/Unit/ValidateTest.php b/tests/Unit/ValidateTest.php new file mode 100644 index 0000000..d5f56fc --- /dev/null +++ b/tests/Unit/ValidateTest.php @@ -0,0 +1,55 @@ + '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)); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..8c10c6d --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,29 @@ +&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);