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