added unit tests

This commit is contained in:
2026-06-04 21:40:59 +02:00
parent d80a8de559
commit 48a619ee4b
64 changed files with 3807 additions and 33 deletions

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

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

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

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

View File

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

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

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

View File

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

View File

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

View File

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