initial prototype based on nat-as-server
Some checks failed
PHPUnit / test (push) Has been cancelled
Some checks failed
PHPUnit / test (push) Has been cancelled
This commit is contained in:
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
74
tests/Unit/AppAnswersTest.php
Normal file
74
tests/Unit/AppAnswersTest.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
131
tests/Unit/AppSubmitValidateTest.php
Normal file
131
tests/Unit/AppSubmitValidateTest.php
Normal 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'] ?? '');
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
100
tests/Unit/CommonHelpersTest.php
Normal file
100
tests/Unit/CommonHelpersTest.php
Normal 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
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);
|
||||
}
|
||||
}
|
||||
51
tests/Unit/EncryptedPayloadTest.php
Normal file
51
tests/Unit/EncryptedPayloadTest.php
Normal 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
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);
|
||||
}
|
||||
}
|
||||
56
tests/Unit/QuestionnaireStructureTest.php
Normal file
56
tests/Unit/QuestionnaireStructureTest.php
Normal 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
48
tests/Unit/RbacTest.php
Normal 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
211
tests/Unit/ScoringTest.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user