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

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

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

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

View File

@ -0,0 +1,74 @@
<?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 testBuildGlassSymptomJsonOmitsEmptyAndReturnsNullForNoSymptoms(): void
{
require_once dirname(__DIR__, 2) . '/lib/app_answers.php';
$this->assertNull(qdb_build_glass_symptom_json([]));
$this->assertNull(qdb_build_glass_symptom_json(['pain' => '']));
$json = qdb_build_glass_symptom_json(['pain' => 'mild', 'fatigue' => 'severe']);
$this->assertSame(['pain' => 'mild', 'fatigue' => 'severe'], json_decode((string)$json, true));
}
public function testGroupAppSubmitAnswersMergesMultiCheck(): void
{
$answers = [
['questionID' => 'q1', 'answerOptionKey' => 'opt_a', 'answeredAt' => 100],
['questionID' => 'q1', 'answerOptionKey' => 'opt_b', 'answeredAt' => 100],
['questionID' => 'q2', 'freeTextValue' => 'hello'],
];
$grouped = qdb_group_app_submit_answers(
$answers,
['q1' => 'multi_check_box_question', 'q2' => 'text_question'],
[]
);
$this->assertCount(2, $grouped);
$this->assertSame('q1', $grouped[0]['questionID']);
$decoded = json_decode((string)$grouped[0]['freeTextValue'], true, 512, JSON_THROW_ON_ERROR);
$this->assertEqualsCanonicalizing(['opt_a', 'opt_b'], $decoded);
$this->assertSame('hello', $grouped[1]['freeTextValue']);
}
public function testGroupAppSubmitAnswersPreservesSymptomRows(): void
{
$answers = [
['questionID' => 'fatigue', 'answerOptionKey' => 'moderate'],
];
$grouped = qdb_group_app_submit_answers(
$answers,
['fatigue' => 'glass_scale_question'],
['fatigue' => 'glass_parent']
);
$this->assertCount(1, $grouped);
$this->assertSame('moderate', $grouped[0]['answerOptionKey']);
}
}

View File

@ -0,0 +1,131 @@
<?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 testUnknownQuestionAllowedInLegacyManifest(): void
{
require_once dirname(__DIR__, 2) . '/lib/app_submit_validate.php';
require_once dirname(__DIR__, 2) . '/lib/questionnaire_structure.php';
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$f = $this->fixture();
$manifest = qdb_build_structure_manifest($pdo, $f->questionnaireId, true);
$maps = qdb_submit_maps_from_manifest($pdo, $f->questionnaireId, $manifest);
$errors = qdb_validate_app_submit_payload(
$pdo,
$f->questionnaireId,
[['questionID' => $f->questionShortId, 'answerOptionKey' => 'yes']],
$maps['shortIdMap'],
$maps['shortIdToType'],
$maps['symptomParentMap'],
$maps['optionMap'],
$manifest
);
qdb_discard($tmpDb, $lockFp);
$this->assertSame([], $errors);
}
public function testValidSingleChoiceWithOptionKeyPasses(): void
{
require_once dirname(__DIR__, 2) . '/lib/app_submit_validate.php';
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$f = $this->fixture();
$shortMap = [$f->questionShortId => $f->questionId];
$typeMap = [$f->questionShortId => 'single_choice_question'];
$optMap = [
$f->questionId => [
'yes' => ['answerOptionID' => $f->optionId, 'points' => 0],
],
];
$errors = qdb_validate_app_submit_payload(
$pdo,
$f->questionnaireId,
[['questionID' => $f->questionShortId, 'answerOptionKey' => 'yes']],
$shortMap,
$typeMap,
[],
$optMap,
);
qdb_discard($tmpDb, $lockFp);
$this->assertSame([], $errors);
}
public function testMultiCheckRequiresSelection(): void
{
require_once dirname(__DIR__, 2) . '/lib/app_submit_validate.php';
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$f = $this->fixture();
$shortMap = [$f->multiCheckShortId => $f->multiCheckQuestionId];
$typeMap = [$f->multiCheckShortId => 'multi_check_box_question'];
$errors = qdb_validate_app_submit_payload(
$pdo,
$f->questionnaireId,
[['questionID' => $f->multiCheckShortId, 'freeTextValue' => '']],
$shortMap,
$typeMap,
[],
[],
);
qdb_discard($tmpDb, $lockFp);
$this->assertSame('EMPTY_ANSWER', $errors[0]['code'] ?? '');
}
public function testInvalidOptionKeyRejected(): void
{
require_once dirname(__DIR__, 2) . '/lib/app_submit_validate.php';
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$f = $this->fixture();
$shortMap = [$f->questionShortId => $f->questionId];
$typeMap = [$f->questionShortId => 'single_choice_question'];
$optMap = [
$f->questionId => [
'yes' => ['answerOptionID' => $f->optionId, 'points' => 0],
],
];
$errors = qdb_validate_app_submit_payload(
$pdo,
$f->questionnaireId,
[['questionID' => $f->questionShortId, 'answerOptionKey' => 'No']],
$shortMap,
$typeMap,
[],
$optMap,
);
qdb_discard($tmpDb, $lockFp);
$this->assertSame('INVALID_OPTION', $errors[0]['code'] ?? '');
}
}

View File

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

View File

@ -0,0 +1,100 @@
<?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'));
}
public function testApplyGlassSymptomsConfig(): void
{
$config = ['scaleType' => 'glass'];
$rows = [
['key' => 'pain', 'labelDe' => 'Schmerz'],
['key' => 'fatigue', 'labelDe' => 'Müdigkeit'],
];
$out = qdb_apply_glass_symptoms_config($config, $rows);
$this->assertSame(['pain', 'fatigue'], $out['symptoms']);
$this->assertSame('glass', $out['scaleType']);
}
public function testApplyGlassSymptomsConfigClearsWhenEmpty(): void
{
$config = ['symptoms' => ['old_key'], 'scaleType' => 'thermometer'];
$out = qdb_apply_glass_symptoms_config($config, []);
$this->assertArrayNotHasKey('symptoms', $out);
}
public function testParseGlassSymptomsBody(): void
{
$body = [
'glassSymptoms' => [
['key' => 'pain', 'labelDe' => 'Schmerz'],
['key' => 'invalid key', 'labelDe' => 'x'],
],
];
$rows = qdb_parse_glass_symptoms_body($body, []);
$this->assertSame([
['key' => 'pain', 'labelDe' => 'Schmerz'],
['key' => 'invalid_key', 'labelDe' => 'x'],
], $rows);
}
public function testParseGlassSymptomsBodyFromConfigWhenMissing(): void
{
$config = ['symptoms' => ['fatigue', 'pain']];
$rows = qdb_parse_glass_symptoms_body([], $config);
$this->assertSame([
['key' => 'fatigue', 'labelDe' => ''],
['key' => 'pain', 'labelDe' => ''],
], $rows);
}
}

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

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

View File

@ -0,0 +1,51 @@
<?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']);
$this->assertSame('A256GCM', $env['alg']);
$plain = qdb_decrypt_sensitive_envelope($env, $token);
$this->assertSame($json, $plain);
}
public function testLegacyCbcEnvelopeStillDecrypts(): void
{
require_once dirname(__DIR__, 2) . '/lib/encrypted_payload.php';
$token = bin2hex(random_bytes(32));
$json = '{"legacy":true}';
$key = hkdf_session_key_from_token($token);
$env = [
'encrypted' => true,
'payload' => base64_encode(aes256_cbc_encrypt_bytes($json, $key)),
];
$this->assertSame($json, qdb_decrypt_sensitive_envelope($env, $token));
}
public function testWrongTokenFailsDecrypt(): void
{
require_once dirname(__DIR__, 2) . '/lib/encrypted_payload.php';
$env = qdb_sensitive_envelope('{}', bin2hex(random_bytes(32)));
$this->expectException(\Exception::class);
qdb_decrypt_sensitive_envelope($env, bin2hex(random_bytes(32)));
}
public function testInvalidEnvelopeRejected(): void
{
require_once dirname(__DIR__, 2) . '/lib/encrypted_payload.php';
$this->expectException(InvalidArgumentException::class);
qdb_decrypt_sensitive_envelope(['encrypted' => false], bin2hex(random_bytes(8)));
}
}

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

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

View File

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

View File

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Tests\Unit;
use Tests\Support\QdbTestCase;
final class QuestionnaireStructureTest extends QdbTestCase
{
public function testBumpIncrementsRevisionAndStoresSnapshot(): void
{
require_once dirname(__DIR__, 2) . '/lib/questionnaire_structure.php';
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
$f = $this->fixture();
$before = qdb_questionnaire_structure_revision($pdo, $f->questionnaireId);
$newRev = qdb_bump_structure_revision($pdo, $f->questionnaireId, 'test_bump');
$after = qdb_questionnaire_structure_revision($pdo, $f->questionnaireId);
$this->assertSame($before + 1, $newRev);
$this->assertSame($newRev, $after);
$manifest = qdb_load_structure_manifest($pdo, $f->questionnaireId, $before);
$this->assertNotNull($manifest);
$this->assertNotEmpty($manifest['questions'] ?? []);
$this->assertSame($before, (int)($manifest['structureRevision'] ?? 0));
qdb_save($tmpDb, $lockFp);
}
public function testRetirePreservesClientAnswers(): void
{
require_once dirname(__DIR__, 2) . '/lib/questionnaire_structure.php';
$this->assertApiOk($this->submitFixtureQuestionnaire());
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
$f = $this->fixture();
$this->assertTrue(qdb_question_has_client_data($pdo, $f->questionId));
$rev = qdb_bump_structure_revision($pdo, $f->questionnaireId, 'retire_test');
qdb_retire_question($pdo, $f->questionId, $rev);
$row = $pdo->prepare('SELECT retiredAt FROM question WHERE questionID = :id');
$row->execute([':id' => $f->questionId]);
$this->assertGreaterThan(0, (int)$row->fetchColumn());
$ans = $pdo->prepare(
'SELECT COUNT(*) FROM client_answer WHERE clientCode = :cc AND questionID = :qid'
);
$ans->execute([':cc' => $f->clientCode, ':qid' => $f->questionId]);
$this->assertSame(1, (int)$ans->fetchColumn());
qdb_save($tmpDb, $lockFp);
}
}

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

@ -0,0 +1,48 @@
<?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->assertStringContainsString('1=1', $clause);
$this->assertStringContainsString('archived', $clause);
$this->assertSame([], $params);
[$allClause] = rbac_client_filter(['role' => 'admin', 'entityID' => 'a1'], 'cl', 'all');
$this->assertStringNotContainsString('archived', $allClause);
}
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());
}
}

211
tests/Unit/ScoringTest.php Normal file
View File

@ -0,0 +1,211 @@
<?php
declare(strict_types=1);
namespace Tests\Unit;
use PDO;
use PHPUnit\Framework\TestCase;
final class ScoringTest extends TestCase
{
private PDO $pdo;
protected function setUp(): void
{
require_once dirname(__DIR__, 2) . '/common.php';
require_once dirname(__DIR__, 2) . '/lib/scoring.php';
$this->pdo = new PDO('sqlite::memory:');
$this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$this->pdo->exec('PRAGMA foreign_keys = ON');
$schema = file_get_contents(dirname(__DIR__, 2) . '/schema.sql');
$this->pdo->exec($schema);
}
public function testBandForTotal(): void
{
$legacy = ['greenMax' => 12, 'yellowMax' => 36];
$this->assertSame('green', qdb_band_for_total(0, $legacy));
$this->assertSame('green', qdb_band_for_total(12, $legacy));
$this->assertSame('yellow', qdb_band_for_total(13, $legacy));
$this->assertSame('yellow', qdb_band_for_total(36, $legacy));
$this->assertSame('red', qdb_band_for_total(37, $legacy));
$custom = [
'greenMin' => 5,
'greenMax' => 15,
'yellowMin' => 16,
'yellowMax' => 30,
'redMin' => 31,
];
$this->assertSame('green', qdb_band_for_total(5, $custom));
$this->assertSame('green', qdb_band_for_total(15, $custom));
$this->assertSame('yellow', qdb_band_for_total(16, $custom));
$this->assertSame('red', qdb_band_for_total(31, $custom));
}
public function testValidateScoringBands(): void
{
$this->assertNull(qdb_validate_scoring_bands(qdb_normalize_scoring_bands(['greenMax' => 12, 'yellowMax' => 36])));
$this->assertNotNull(qdb_validate_scoring_bands([
'greenMin' => 0, 'greenMax' => 20,
'yellowMin' => 15, 'yellowMax' => 36,
'redMin' => 37,
]));
}
public function testGlassScoreRulesFromSymptoms(): void
{
$rules = qdb_score_rules_from_glass_symptoms([
['key' => 'pain', 'scoreLevels' => ['never_glass' => 0, 'extreme_glass' => 5]],
]);
$this->assertCount(5, $rules);
$painExtreme = array_values(array_filter(
$rules,
fn ($r) => $r['scopeKey'] === 'pain' && $r['levelKey'] === 'extreme_glass'
));
$this->assertSame(5, $painExtreme[0]['points'] ?? null);
}
private function seedCoachChain(): void
{
$this->pdo->exec("INSERT INTO supervisor (supervisorID, username) VALUES ('sup1', 'sup')");
$this->pdo->exec("INSERT INTO coach (coachID, supervisorID, username) VALUES ('coach1', 'sup1', 'coach')");
}
public function testRadioAndGlassScoring(): void
{
$this->seedCoachChain();
$qnID = 'qn_test';
$this->pdo->exec("INSERT INTO questionnaire (questionnaireID, name, version, state, orderIndex, showPoints, conditionJson, categoryKey)
VALUES ('$qnID', 'Test', '1', 'active', 0, 0, '{}', '')");
$radioQ = "{$qnID}__radio";
$this->pdo->exec("INSERT INTO question (questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson)
VALUES ('$radioQ', '$qnID', 'Pick one', 'radio_question', 0, 1, '{}')");
$this->pdo->exec("INSERT INTO answer_option (answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId)
VALUES ('ao1', '$radioQ', 'yes', 3, 0, '')");
$glassQ = "{$qnID}__glass";
$this->pdo->exec("INSERT INTO question (questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson)
VALUES ('$glassQ', '$qnID', 'Symptoms', 'glass_scale_question', 1, 1, '{\"symptoms\":[\"pain\"]}')");
qdb_sync_question_score_rules($this->pdo, $glassQ, [
['scopeKey' => 'pain', 'levelKey' => 'moderate_glass', 'points' => 2],
]);
$client = 'CLIENT-001';
$this->pdo->exec("INSERT INTO client (clientCode, coachID) VALUES ('$client', 'coach1')");
$this->pdo->exec("INSERT INTO client_answer (clientCode, questionID, answerOptionID, freeTextValue, numericValue)
VALUES ('$client', '$radioQ', 'ao1', NULL, NULL)");
$this->pdo->exec("INSERT INTO client_answer (clientCode, questionID, answerOptionID, freeTextValue, numericValue)
VALUES ('$client', '$glassQ', NULL, '{\"pain\":\"moderate_glass\"}', NULL)");
$score = qdb_compute_questionnaire_score($this->pdo, $client, $qnID);
$this->assertSame(5, $score);
}
public function testIncompleteProfileProducesNoResult(): void
{
$this->seedCoachChain();
$qnA = 'qn_a';
$qnB = 'qn_b';
foreach ([$qnA, $qnB] as $id) {
$this->pdo->exec("INSERT INTO questionnaire (questionnaireID, name, version, state, orderIndex, showPoints, conditionJson, categoryKey)
VALUES ('$id', '$id', '1', 'active', 0, 0, '{}', '')");
}
$profileID = 'profile1';
$this->pdo->exec("INSERT INTO scoring_profile (profileID, name, description, isActive,
greenMin, greenMax, yellowMin, yellowMax, redMin, createdAt, updatedAt)
VALUES ('$profileID', 'Combo', '', 1, 0, 10, 11, 20, 21, 0, 0)");
$this->pdo->exec("INSERT INTO scoring_profile_questionnaire (profileID, questionnaireID, weight, orderIndex)
VALUES ('$profileID', '$qnA', 1.0, 0), ('$profileID', '$qnB', 1.0, 1)");
$client = 'CLIENT-002';
$this->pdo->exec("INSERT INTO client (clientCode, coachID) VALUES ('$client', 'coach1')");
$this->pdo->exec("INSERT INTO completed_questionnaire (clientCode, questionnaireID, status, sumPoints, completedAt)
VALUES ('$client', '$qnA', 'completed', 5, 1)");
qdb_recompute_profile_scores_for_client($this->pdo, $client);
$stmt = $this->pdo->prepare('SELECT COUNT(*) FROM client_scoring_profile_result WHERE clientCode = :cc');
$stmt->execute([':cc' => $client]);
$this->assertSame(0, (int)$stmt->fetchColumn());
}
public function testCoachScoringReviewPreservesComputedBand(): void
{
$this->seedCoachChain();
$qnID = 'qn_review';
$this->pdo->exec("INSERT INTO questionnaire (questionnaireID, name, version, state, orderIndex, showPoints, conditionJson, categoryKey)
VALUES ('$qnID', 'Review', '1', 'active', 0, 0, '{}', '')");
$profileID = 'profile_review';
$this->pdo->exec("INSERT INTO scoring_profile (profileID, name, description, isActive,
greenMin, greenMax, yellowMin, yellowMax, redMin, createdAt, updatedAt)
VALUES ('$profileID', 'Review profile', '', 1, 0, 10, 11, 20, 21, 0, 0)");
$this->pdo->exec("INSERT INTO scoring_profile_questionnaire (profileID, questionnaireID, weight, orderIndex)
VALUES ('$profileID', '$qnID', 1.0, 0)");
$client = 'CLIENT-REVIEW';
$this->pdo->exec("INSERT INTO client (clientCode, coachID) VALUES ('$client', 'coach1')");
$this->pdo->exec("INSERT INTO completed_questionnaire (clientCode, questionnaireID, status, sumPoints, completedAt)
VALUES ('$client', '$qnID', 'completed', 15, 1)");
qdb_recompute_profile_scores_for_client($this->pdo, $client);
$row = $this->pdo->query(
"SELECT band, coachBand FROM client_scoring_profile_result WHERE clientCode = '$client'"
)->fetch(PDO::FETCH_ASSOC);
$this->assertSame('yellow', $row['band']);
$this->assertSame('', $row['coachBand']);
qdb_set_coach_scoring_band($this->pdo, $client, $profileID, 'green', 'user1');
$row = $this->pdo->query(
"SELECT band, coachBand FROM client_scoring_profile_result WHERE clientCode = '$client'"
)->fetch(PDO::FETCH_ASSOC);
$this->assertSame('yellow', $row['band']);
$this->assertSame('green', $row['coachBand']);
qdb_recompute_profile_scores_for_client($this->pdo, $client);
$row = $this->pdo->query(
"SELECT band, coachBand FROM client_scoring_profile_result WHERE clientCode = '$client'"
)->fetch(PDO::FETCH_ASSOC);
$this->assertSame('yellow', $row['band']);
$this->assertSame('green', $row['coachBand']);
}
public function testSaveCoachReviewFromAppBeforeServerRecompute(): void
{
$this->seedCoachChain();
$qnID = 'qn_pre';
$this->pdo->exec("INSERT INTO questionnaire (questionnaireID, name, version, state, orderIndex, showPoints, conditionJson, categoryKey)
VALUES ('$qnID', 'Pre', '1', 'active', 0, 0, '{}', '')");
$profileID = 'profile_pre';
$this->pdo->exec("INSERT INTO scoring_profile (profileID, name, description, isActive,
greenMin, greenMax, yellowMin, yellowMax, redMin, createdAt, updatedAt)
VALUES ('$profileID', 'Pre profile', '', 1, 0, 10, 11, 20, 21, 0, 0)");
$this->pdo->exec("INSERT INTO scoring_profile_questionnaire (profileID, questionnaireID, weight, orderIndex)
VALUES ('$profileID', '$qnID', 1.0, 0)");
$client = 'CLIENT-PRE';
$this->pdo->exec("INSERT INTO client (clientCode, coachID) VALUES ('$client', 'coach1')");
qdb_save_coach_scoring_review(
$this->pdo,
$client,
$profileID,
'yellow',
'user1',
15.0,
'yellow'
);
$row = $this->pdo->query(
"SELECT band, coachBand, weightedTotal FROM client_scoring_profile_result WHERE clientCode = '$client'"
)->fetch(PDO::FETCH_ASSOC);
$this->assertSame('yellow', $row['band']);
$this->assertSame('yellow', $row['coachBand']);
$this->assertSame(15.0, (float)$row['weightedTotal']);
}
}

View File

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

View File

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

View File

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