just testing the environment
Some checks failed
Tests / test (push) Has been cancelled

This commit is contained in:
tom.hempel
2026-05-20 07:10:08 +02:00
parent 2a11dfd0d1
commit 06fc134299
56 changed files with 1890 additions and 361 deletions

View File

@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace Tests\Integration;
use Tests\Support\ApiTestCase;
use Tests\Support\DatabaseFixture;
final class AppQuestionnairesTest extends ApiTestCase
{
public function testListActiveQuestionnaires(): void
{
$this->loginAs(DatabaseFixture::COACH1_USERNAME);
$res = $this->api('GET', 'app_questionnaires');
$this->assertOk($res);
$this->assertIsArray($res['body']['data']);
$ids = array_column($res['body']['data'], 'id');
$this->assertContains(DatabaseFixture::QUESTIONNAIRE_ID, $ids);
$first = $res['body']['data'][0];
$this->assertArrayHasKey('name', $first);
$this->assertArrayHasKey('showPoints', $first);
$this->assertArrayHasKey('condition', $first);
}
public function testFetchQuestionnaireDetail(): void
{
$this->loginAs(DatabaseFixture::COACH1_USERNAME);
$res = $this->api('GET', 'app_questionnaires', null, null, [
'id' => DatabaseFixture::QUESTIONNAIRE_ID,
]);
$this->assertOk($res);
$this->assertSame(DatabaseFixture::QUESTIONNAIRE_ID, $res['body']['data']['meta']['id']);
$this->assertNotEmpty($res['body']['data']['questions']);
$q = $res['body']['data']['questions'][0];
$this->assertSame('q1', $q['id']);
$this->assertSame('radio_question', $q['layout']);
}
public function testCoachCannotUploadForOtherCoachesClient(): void
{
$this->loginAs(DatabaseFixture::COACH1_USERNAME);
$res = $this->api('POST', 'app_questionnaires', [
'questionnaireID' => DatabaseFixture::QUESTIONNAIRE_ID,
'clientCode' => DatabaseFixture::CLIENT_COACH2,
'answers' => [
['questionID' => 'q1', 'answerOptionKey' => 'consent_signed'],
],
]);
$this->assertError($res, 'NOT_FOUND');
}
public function testUploadAnswersMapsShortQuestionIds(): void
{
$this->loginAs(DatabaseFixture::COACH1_USERNAME);
$res = $this->api('POST', 'app_questionnaires', [
'questionnaireID' => DatabaseFixture::QUESTIONNAIRE_ID,
'clientCode' => DatabaseFixture::CLIENT_COACH1,
'completedAt' => time(),
'answers' => [
['questionID' => 'q1', 'answerOptionKey' => 'consent_signed', 'answeredAt' => time()],
],
]);
$this->assertOk($res);
$this->assertTrue($res['body']['data']['submitted']);
$this->assertSame(5, $res['body']['data']['sumPoints']);
}
public function testCoachClientsList(): void
{
$this->loginAs(DatabaseFixture::COACH1_USERNAME);
$res = $this->api('GET', 'app_questionnaires', null, null, ['clients' => '1']);
$this->assertOk($res);
$codes = array_column($res['body']['data']['clients'], 'clientCode');
$this->assertContains(DatabaseFixture::CLIENT_COACH1, $codes);
$this->assertNotContains(DatabaseFixture::CLIENT_COACH2, $codes);
}
}

View File

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Tests\Integration;
use Tests\Support\ApiTestCase;
use Tests\Support\DatabaseFixture;
final class AuthTest extends ApiTestCase
{
public function testLoginSuccess(): void
{
$res = $this->loginAs(DatabaseFixture::ADMIN_USERNAME);
$this->assertOk($res);
$this->assertSame('admin', $res['body']['data']['role']);
$this->assertNotEmpty($this->bearerToken);
}
public function testLoginInvalidPassword(): void
{
$res = $this->api('POST', 'auth/login', [
'username' => DatabaseFixture::ADMIN_USERNAME,
'password' => 'wrong-password',
]);
$this->assertError($res, 'INVALID_CREDENTIALS');
$this->assertSame(401, $res['status']);
}
public function testMustChangePasswordTempTokenBlocked(): void
{
$login = $this->loginAs(DatabaseFixture::MUST_CHANGE_USERNAME);
$this->assertOk($login);
$this->assertTrue($login['body']['data']['mustChangePassword'] ?? false);
$blocked = $this->api('GET', 'app_questionnaires');
$this->assertLegacyAuthError($blocked, 403, 'Password change required before access');
}
}

View File

@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Tests\Integration;
use Tests\Support\ApiTestCase;
use Tests\Support\DatabaseFixture;
final class ClientsTest extends ApiTestCase
{
public function testRequiresAuth(): void
{
$res = $this->api('GET', 'clients');
$this->assertLegacyAuthError($res, 401, 'Missing Bearer token');
}
public function testAdminSeesAllClients(): void
{
$this->loginAs(DatabaseFixture::ADMIN_USERNAME);
$res = $this->api('GET', 'clients');
$this->assertOk($res);
$codes = array_column($res['body']['data']['clients'], 'clientCode');
$this->assertContains(DatabaseFixture::CLIENT_COACH1, $codes);
$this->assertContains(DatabaseFixture::CLIENT_COACH2, $codes);

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Tests\Integration;
use Tests\Support\ApiTestCase;
use Tests\Support\DatabaseFixture;
final class LogoutTest extends ApiTestCase
{
public function testLogoutRevokesToken(): void
{
$this->loginAs(DatabaseFixture::ADMIN_USERNAME);
$token = $this->bearerToken;
$this->assertNotNull($token);
$logout = $this->api('POST', 'logout');
$this->assertOk($logout);
$this->bearerToken = $token;
$after = $this->api('GET', 'questionnaires');
$this->assertLegacyAuthError($after, 403, 'Invalid or expired token');
}
}

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Tests\Integration;
use Tests\Support\ApiTestCase;
use Tests\Support\DatabaseFixture;
final class QuestionnairesTest extends ApiTestCase
{
public function testRequiresAuth(): void
{
$res = $this->api('GET', 'questionnaires');
$this->assertLegacyAuthError($res, 401, 'Missing Bearer token');
}
public function testAdminCanListQuestionnaires(): void
{
$this->loginAs(DatabaseFixture::ADMIN_USERNAME);
$res = $this->api('GET', 'questionnaires');
$this->assertOk($res);
$this->assertNotEmpty($res['body']['data']['questionnaires'] ?? []);
}
}

View File

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Tests\Integration;
use Tests\Support\ApiTestCase;
use Tests\Support\DatabaseFixture;
final class UsersTest extends ApiTestCase
{
public function testRequiresAuth(): void
{
$res = $this->api('GET', 'users');
$this->assertLegacyAuthError($res, 401, 'Missing Bearer token');
}
public function testCoachForbidden(): void
{
$this->loginAs(DatabaseFixture::COACH1_USERNAME);
$res = $this->api('GET', 'users');
$this->assertLegacyAuthError($res, 403, 'Insufficient permissions');
}

View File

@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
namespace Tests\Support;
use PHPUnit\Framework\TestCase;
use QdbHttpResponse;
/**
* In-process API tests via qdb_dispatch_api_request().
*
* Error envelopes:
* - Handlers: { ok: false, error: { code, message } } — use assertError()
* - Auth middleware (require_valid_token, require_role): { error: "message" } — use assertLegacyAuthError()
*/
abstract class ApiTestCase extends TestCase
{
protected ?string $bearerToken = null;
protected function setUp(): void
{
parent::setUp();
DatabaseFixture::forTests()->reset();
$this->bearerToken = null;
unset($GLOBALS['__TEST_JSON_BODY']);
$_GET = [];
$_POST = [];
}
/**
* @return array{status: int, body: array<string, mixed>, raw: string}
*/
protected function api(
string $method,
string $route,
?array $jsonBody = null,
?string $token = null,
array $query = [],
): array {
$_GET = $query;
$_POST = [];
$_SERVER['REQUEST_METHOD'] = strtoupper($method);
$_SERVER['REQUEST_URI'] = '/api/' . ltrim($route, '/');
$_SERVER['SCRIPT_NAME'] = '/api/index.php';
$_SERVER['HTTP_AUTHORIZATION'] = ($token ?? $this->bearerToken)
? 'Bearer ' . ($token ?? $this->bearerToken)
: '';
if ($jsonBody !== null) {
$GLOBALS['__TEST_JSON_BODY'] = json_encode($jsonBody, JSON_THROW_ON_ERROR);
} else {
unset($GLOBALS['__TEST_JSON_BODY']);
}
ob_start();
$status = 200;
$body = [];
try {
require_once dirname(__DIR__, 2) . '/api/dispatch.php';
qdb_dispatch_api_request();
} catch (QdbHttpResponse $e) {

View File

@ -0,0 +1,186 @@
<?php
declare(strict_types=1);
namespace Tests\Support;
use PDO;
final class DatabaseFixture
{
public const PASSWORD = 'testpass123';
public const ADMIN_USERNAME = 'testadmin';
public const SUPERVISOR1_USERNAME = 'testsuper1';
public const SUPERVISOR2_USERNAME = 'testsuper2';
public const COACH1_USERNAME = 'testcoach1';
public const COACH2_USERNAME = 'testcoach2';
public const MUST_CHANGE_USERNAME = 'testmustchange';
public const ADMIN_ID = 'admin001';
public const SUPERVISOR1_ID = 'sup001';
public const SUPERVISOR2_ID = 'sup002';
public const COACH1_ID = 'coach001';
public const COACH2_ID = 'coach002';
public const CLIENT_COACH1 = 'CLIENT-001';
public const CLIENT_COACH1B = 'CLIENT-002';
public const CLIENT_COACH2 = 'CLIENT-OTHER';
public const QUESTIONNAIRE_ID = 'questionnaire_test_demo';
public function __construct(
private readonly string $uploadsDir,
private readonly string $schemaPath,
) {
}
public static function forTests(): self
{
return new self(
__DIR__ . '/../var/uploads',
__DIR__ . '/../fixtures/schema.sql',
);
}
public static function forE2E(string $projectRoot): self
{
return new self(
$projectRoot . '/uploads',
__DIR__ . '/../fixtures/schema.sql',
);
}
public function reset(): void
{
if (!is_dir($this->uploadsDir)) {
mkdir($this->uploadsDir, 0755, true);
}
$dbFile = $this->uploadsDir . '/questionnaire_database';
$lockFile = $this->uploadsDir . '/.qdb_lock';
if (is_file($dbFile)) {
unlink($dbFile);
}
if (is_file($lockFile)) {
unlink($lockFile);
}
if (!defined('QDB_PATH')) {
define('QDB_PATH', $dbFile);
}
if (!defined('QDB_LOCK')) {
define('QDB_LOCK', $lockFile);
}
if (!defined('QDB_SCHEMA')) {
define('QDB_SCHEMA', $this->schemaPath);
}
require_once dirname(__DIR__, 2) . '/common.php';
require_once dirname(__DIR__, 2) . '/db_init.php';
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
$this->seed($pdo);
qdb_save($tmpDb, $lockFp);
}
private function seed(PDO $pdo): void
{
$now = time();
$hash = password_hash(self::PASSWORD, PASSWORD_DEFAULT);
$mustChangeHash = password_hash(self::PASSWORD, PASSWORD_DEFAULT);
$pdo->exec('DELETE FROM session');
$pdo->exec('DELETE FROM client_answer');
$pdo->exec('DELETE FROM completed_questionnaire');
$pdo->exec('DELETE FROM answer_option_translation');
$pdo->exec('DELETE FROM question_translation');
$pdo->exec('DELETE FROM string_translation');
$pdo->exec('DELETE FROM answer_option');
$pdo->exec('DELETE FROM question');
$pdo->exec('DELETE FROM questionnaire');
$pdo->exec('DELETE FROM client');
$pdo->exec('DELETE FROM coach');
$pdo->exec('DELETE FROM supervisor');
$pdo->exec('DELETE FROM admin');
$pdo->exec('DELETE FROM users');
$pdo->exec('DELETE FROM language');
$insUser = $pdo->prepare(
'INSERT INTO users (userID, username, passwordHash, role, entityID, mustChangePassword, createdAt)
VALUES (:uid, :u, :h, :role, :eid, :mcp, :ca)'
);
$insUser->execute([
':uid' => 'user_admin', ':u' => self::ADMIN_USERNAME, ':h' => $hash,
':role' => 'admin', ':eid' => self::ADMIN_ID, ':mcp' => 0, ':ca' => $now,
]);
$insUser->execute([
':uid' => 'user_sup1', ':u' => self::SUPERVISOR1_USERNAME, ':h' => $hash,
':role' => 'supervisor', ':eid' => self::SUPERVISOR1_ID, ':mcp' => 0, ':ca' => $now,
]);
$insUser->execute([
':uid' => 'user_sup2', ':u' => self::SUPERVISOR2_USERNAME, ':h' => $hash,
':role' => 'supervisor', ':eid' => self::SUPERVISOR2_ID, ':mcp' => 0, ':ca' => $now,
]);
$insUser->execute([
':uid' => 'user_coach1', ':u' => self::COACH1_USERNAME, ':h' => $hash,
':role' => 'coach', ':eid' => self::COACH1_ID, ':mcp' => 0, ':ca' => $now,
]);
$insUser->execute([
':uid' => 'user_coach2', ':u' => self::COACH2_USERNAME, ':h' => $hash,
':role' => 'coach', ':eid' => self::COACH2_ID, ':mcp' => 0, ':ca' => $now,
]);
$insUser->execute([
':uid' => 'user_mustchange', ':u' => self::MUST_CHANGE_USERNAME, ':h' => $mustChangeHash,
':role' => 'coach', ':eid' => self::COACH1_ID, ':mcp' => 1, ':ca' => $now,
]);
$pdo->prepare('INSERT INTO admin (adminID, username, location) VALUES (?, ?, ?)')
->execute([self::ADMIN_ID, self::ADMIN_USERNAME, 'Test']);
$pdo->prepare('INSERT INTO supervisor (supervisorID, username, location) VALUES (?, ?, ?)')
->execute([self::SUPERVISOR1_ID, self::SUPERVISOR1_USERNAME, 'Loc1']);
$pdo->prepare('INSERT INTO supervisor (supervisorID, username, location) VALUES (?, ?, ?)')
->execute([self::SUPERVISOR2_ID, self::SUPERVISOR2_USERNAME, 'Loc2']);
$pdo->prepare('INSERT INTO coach (coachID, supervisorID, username) VALUES (?, ?, ?)')
->execute([self::COACH1_ID, self::SUPERVISOR1_ID, self::COACH1_USERNAME]);
$pdo->prepare('INSERT INTO coach (coachID, supervisorID, username) VALUES (?, ?, ?)')
->execute([self::COACH2_ID, self::SUPERVISOR2_ID, self::COACH2_USERNAME]);
$pdo->prepare('INSERT INTO client (clientCode, coachID) VALUES (?, ?)')
->execute([self::CLIENT_COACH1, self::COACH1_ID]);
$pdo->prepare('INSERT INTO client (clientCode, coachID) VALUES (?, ?)')
->execute([self::CLIENT_COACH1B, self::COACH1_ID]);
$pdo->prepare('INSERT INTO client (clientCode, coachID) VALUES (?, ?)')
->execute([self::CLIENT_COACH2, self::COACH2_ID]);
$pdo->prepare(
'INSERT INTO questionnaire (questionnaireID, name, version, state, orderIndex, showPoints, conditionJson)
VALUES (?, ?, ?, ?, ?, ?, ?)'
)->execute([
self::QUESTIONNAIRE_ID,
'Test Demo',
'1.0',
'active',
0,
1,
'{}',
]);
$qid = self::QUESTIONNAIRE_ID . '__q1';
$pdo->prepare(
'INSERT INTO question (questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson)
VALUES (?, ?, ?, ?, ?, ?, ?)'
)->execute([$qid, self::QUESTIONNAIRE_ID, 'consent_instruction', 'radio_question', 0, 1, '{}']);
$pdo->prepare(
'INSERT INTO answer_option (answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId)
VALUES (?, ?, ?, ?, ?, ?)'
)->execute(['ao1', $qid, 'consent_signed', 5, 0, '']);
$pdo->prepare(
'INSERT INTO answer_option (answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId)
VALUES (?, ?, ?, ?, ?, ?)'
)->execute(['ao2', $qid, 'consent_not_signed', 0, 1, '']);
}
}

51
tests/Unit/CommonTest.php Normal file
View File

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
final class CommonTest extends TestCase
{
protected function setUp(): void
{
require_once dirname(__DIR__, 2) . '/common.php';
}
public function testAesRoundTrip(): void
{
$key = str_repeat('k', 32);
$plain = 'encrypted questionnaire payload';
$enc = aes256_cbc_encrypt_bytes($plain, $key);
$dec = aes256_cbc_decrypt_bytes($enc, $key);
$this->assertSame($plain, $dec);
$this->assertGreaterThan(16, strlen($enc));
}
public function testHkdfDeterministic(): void
{
$token = 'a1b2c3d4e5f60718293a4b5c6d7e8f90123456789abcdef0123456789abcdef';
$k1 = hkdf_session_key_from_token($token);
$k2 = hkdf_session_key_from_token($token);
$this->assertSame($k1, $k2);
$this->assertSame(32, strlen($k1));
}
public function testRbacClientFilter(): void
{
[$adminClause, $adminParams] = rbac_client_filter(['role' => 'admin', 'entityID' => 'x'], 'cl');
$this->assertSame('1=1', $adminClause);
$this->assertSame([], $adminParams);
[$supClause, $supParams] = rbac_client_filter(['role' => 'supervisor', 'entityID' => 'sup001'], 'cl');
$this->assertStringContainsString('supervisorID', $supClause);
$this->assertSame([':rbac_eid' => 'sup001'], $supParams);
[$coachClause, $coachParams] = rbac_client_filter(['role' => 'coach', 'entityID' => 'coach001'], 'cl');
$this->assertStringContainsString('coachID', $coachClause);
$this->assertSame([':rbac_eid' => 'coach001'], $coachParams);
[$denyClause, $denyParams] = rbac_client_filter(['role' => 'guest', 'entityID' => ''], 'cl');
$this->assertSame('0=1', $denyClause);
}
}

View File

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
use QdbHttpResponse;
final class ResponseTest extends TestCase
{
protected function setUp(): void
{
require_once dirname(__DIR__, 2) . '/lib/response.php';
}
public function testJsonSuccessEnvelope(): void
{
try {
json_success(['token' => 'abc'], 201);
$this->fail('Expected QdbHttpResponse');
} catch (QdbHttpResponse $e) {
$this->assertSame(201, $e->status);
$this->assertTrue($e->body['ok']);
$this->assertSame(['token' => 'abc'], $e->body['data']);
}
}
public function testJsonErrorEnvelope(): void
{
try {
json_error('INVALID_CREDENTIALS', 'Bad login', 401);
$this->fail('Expected QdbHttpResponse');
} catch (QdbHttpResponse $e) {
$this->assertSame(401, $e->status);
$this->assertFalse($e->body['ok']);
$this->assertSame('INVALID_CREDENTIALS', $e->body['error']['code']);
$this->assertSame('Bad login', $e->body['error']['message']);
}
}
}

View File

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
/**
* Seed the project uploads/ database for local dev and Playwright E2E runs.
*
* Usage: php tests/bin/seed-test-db.php
*/
$root = dirname(__DIR__, 2);
require_once $root . '/vendor/autoload.php';
putenv('QDB_MASTER_KEY=dGVzdC1tYXN0ZXIta2V5LXRoaXQtMzJieXRzIQ==');
if (!defined('QDB_TESTING')) {
define('QDB_TESTING', false);
}
\Tests\Support\DatabaseFixture::forE2E($root)->reset();
echo "Test database seeded in uploads/\n";

38
tests/bootstrap.php Normal file
View File

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
define('QDB_TESTING', true);
$root = dirname(__DIR__);
$varUploads = __DIR__ . '/var/uploads';
if (!is_dir($varUploads)) {
mkdir($varUploads, 0755, true);
}
define('QDB_PATH', $varUploads . '/questionnaire_database');
define('QDB_LOCK', $varUploads . '/.qdb_lock');
define('QDB_SCHEMA', __DIR__ . '/fixtures/schema.sql');
putenv('QDB_MASTER_KEY=dGVzdC1tYXN0ZXIta2V5LXRoaXQtMzJieXRzIQ==');
if (!extension_loaded('pdo_sqlite')) {
$ini = php_ini_loaded_file() ?: '(unknown)';
fwrite(STDERR, <<<MSG
PHPUnit requires the pdo_sqlite PHP extension (encrypted SQLite database).
Enable it in your php.ini (loaded from: {$ini}):
extension=pdo_sqlite
extension=sqlite3
Then verify: php -r "print_r(PDO::getAvailableDrivers());"
See TESTING.md for details.
MSG);
exit(1);
}

18
tests/phpunit.xml Normal file
View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/11.0/phpunit.xsd"
bootstrap="bootstrap.php"
colors="true"
cacheDirectory=".phpunit.cache">
<testsuites>
<testsuite name="Unit">
<directory>Unit</directory>
</testsuite>
<testsuite name="Integration">
<directory>Integration</directory>
</testsuite>
</testsuites>
<php>
<env name="QDB_MASTER_KEY" value="dGVzdC1tYXN0ZXIta2V5LXRoaXQtMzJieXRzIQ==" force="true"/>
</php>
</phpunit>