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

181
tests/Support/ApiClient.php Normal file
View File

@ -0,0 +1,181 @@
<?php
declare(strict_types=1);
namespace Tests\Support;
/**
* Dispatches requests through api/index.php (same as production routing).
*/
final class ApiClient
{
public function __construct(
private readonly string $apiIndexPath,
) {
}
/**
* @param array<string, string> $headers
* @return array{ok: bool, status: int, data?: mixed, error?: array<string, mixed>}
*/
public function request(
string $method,
string $route,
?array $body = null,
array $headers = [],
array $query = [],
): array {
qdb_test_reset_http_state();
$_SERVER['REQUEST_METHOD'] = strtoupper($method);
$queryString = $query !== [] ? '?' . http_build_query($query) : '';
$_SERVER['REQUEST_URI'] = '/api/' . ltrim($route, '/') . $queryString;
$_SERVER['SCRIPT_NAME'] = '/api/index.php';
$_GET = $query;
if ($queryString !== '') {
parse_str(ltrim($queryString, '?'), $_GET);
}
unset($_SERVER['HTTP_AUTHORIZATION'], $_SERVER['Authorization']);
unset($_SERVER['HTTP_X_QDB_CLIENT']);
foreach ($headers as $name => $value) {
$key = 'HTTP_' . strtoupper(str_replace('-', '_', $name));
$_SERVER[$key] = $value;
}
if ($body !== null) {
qdb_test_set_request_body(json_encode($body, JSON_THROW_ON_ERROR));
}
ob_start();
try {
require $this->apiIndexPath;
ob_end_clean();
return ['ok' => false, 'status' => 500, 'error' => ['code' => 'NO_RESPONSE', 'message' => 'Handler did not return JSON']];
} catch (JsonSuccessResponse $e) {
$output = ob_get_clean();
return [
'ok' => true,
'status' => $e->httpStatus,
'data' => $e->data,
'_raw' => $output,
];
} catch (JsonErrorResponse $e) {
ob_get_clean();
$err = ['code' => $e->errorCode, 'message' => $e->errorMessage];
if ($e->details !== null) {
$err['details'] = $e->details;
}
return [
'ok' => false,
'status' => $e->httpStatus,
'error' => $err,
];
} catch (RawHttpResponse $e) {
ob_get_clean();
return [
'ok' => true,
'status' => $e->httpStatus,
'raw' => true,
'body' => $e->body,
'headers' => $e->headers,
];
} catch (\Throwable $e) {
ob_end_clean();
throw $e;
}
}
public function loginWeb(string $username, string $password): array
{
return $this->request('POST', 'auth/login', [
'username' => $username,
'password' => $password,
], ['X-QDB-Client' => 'web']);
}
public function loginMobile(string $username, string $password): array
{
return $this->request('POST', 'auth/login', [
'username' => $username,
'password' => $password,
]);
}
/**
* @return array{token: string, data: array<string, mixed>}
*/
public function loginMobileAndGetToken(string $username, string $password): array
{
$res = $this->loginMobile($username, $password);
if (!$res['ok']) {
throw new \RuntimeException('Login failed: ' . ($res['error']['message'] ?? 'unknown'));
}
$token = $res['data']['token'] ?? '';
if ($token === '') {
throw new \RuntimeException('Login response missing token');
}
return ['token' => $token, 'data' => $res['data']];
}
/**
* @return array{token: string, data: array<string, mixed>}
*/
public function loginWebAndGetToken(string $username, string $password): array
{
$res = $this->loginWeb($username, $password);
if (!$res['ok']) {
throw new \RuntimeException('Login failed: ' . ($res['error']['message'] ?? 'unknown'));
}
$token = $res['data']['token'] ?? '';
if ($token === '') {
throw new \RuntimeException('Login response missing token');
}
return ['token' => $token, 'data' => $res['data']];
}
/**
* @param array<string, string> $extraHeaders
*/
public function withToken(string $token, string $method, string $route, ?array $body = null, array $query = [], array $extraHeaders = []): array
{
$headers = array_merge([
'Authorization' => 'Bearer ' . $token,
'X-QDB-Client' => 'web',
], $extraHeaders);
return $this->request($method, $route, $body, $headers, $query);
}
/**
* @param array<string, string> $extraHeaders
*/
public function withMobileToken(
string $token,
string $method,
string $route,
?array $body = null,
array $query = [],
array $extraHeaders = [],
): array {
$headers = array_merge(['Authorization' => 'Bearer ' . $token], $extraHeaders);
return $this->request($method, $route, $body, $headers, $query);
}
/**
* POST an encrypted payload (Android app API).
*
* @param array<string, mixed> $payload
*/
public function encryptedPost(string $token, string $route, array $payload): array
{
$envelope = qdb_sensitive_envelope(
json_encode($payload, JSON_THROW_ON_ERROR),
$token
);
return $this->withMobileToken($token, 'POST', $route, $envelope);
}
}

View File

@ -0,0 +1,233 @@
<?php
declare(strict_types=1);
namespace Tests\Support;
use PDO;
/**
* Seeds a minimal org graph for integration tests.
*/
final class DatabaseSeeder
{
public const PASSWORD = 'TestPass1!';
public const ADMIN_USERNAME = 'test_admin';
public const SUPERVISOR_USERNAME = 'test_supervisor';
public const COACH_USERNAME = 'test_coach';
public static function seed(PDO $pdo): FixtureIds
{
$now = time();
$passwordHash = password_hash(self::PASSWORD, PASSWORD_DEFAULT);
// Stable IDs so fixture metadata always matches seeded rows.
$adminId = 'admin-test';
$adminUserId = 'u-admin-test';
$supervisorId = 'sv-test';
$supervisorUserId = 'u-sv-test';
$coachId = 'coach-test';
$coachUserId = 'u-coach-test';
$qnId = 'qn-test';
$questionId = $qnId . '__q1';
$optionId = 'opt-test-q1-yes';
$freeTextQuestionId = $qnId . '__q2';
$glassQuestionId = $qnId . '__q3';
$glassSymptomKey = 'pain';
$multiQuestionId = $qnId . '__q4';
$multiOptionAId = 'opt-test-q4-a';
$multiOptionBId = 'opt-test-q4-b';
$draftQuestionnaireId = 'qn-draft-test';
$pdo->prepare('INSERT INTO admin (adminID, username, location) VALUES (:id, :u, :loc)')
->execute([':id' => $adminId, ':u' => self::ADMIN_USERNAME, ':loc' => 'HQ']);
$pdo->prepare(
'INSERT INTO users (userID, username, passwordHash, role, entityID, mustChangePassword, createdAt)
VALUES (:uid, :u, :h, :role, :eid, 0, :now)'
)->execute([
':uid' => $adminUserId,
':u' => self::ADMIN_USERNAME,
':h' => $passwordHash,
':role' => 'admin',
':eid' => $adminId,
':now' => $now,
]);
$pdo->prepare('INSERT INTO supervisor (supervisorID, username, location) VALUES (:id, :u, :loc)')
->execute([':id' => $supervisorId, ':u' => self::SUPERVISOR_USERNAME, ':loc' => 'Region A']);
$pdo->prepare(
'INSERT INTO users (userID, username, passwordHash, role, entityID, mustChangePassword, createdAt)
VALUES (:uid, :u, :h, :role, :eid, 0, :now)'
)->execute([
':uid' => $supervisorUserId,
':u' => self::SUPERVISOR_USERNAME,
':h' => $passwordHash,
':role' => 'supervisor',
':eid' => $supervisorId,
':now' => $now,
]);
$pdo->prepare('INSERT INTO coach (coachID, supervisorID, username) VALUES (:id, :sid, :u)')
->execute([':id' => $coachId, ':sid' => $supervisorId, ':u' => self::COACH_USERNAME]);
$pdo->prepare(
'INSERT INTO users (userID, username, passwordHash, role, entityID, mustChangePassword, createdAt)
VALUES (:uid, :u, :h, :role, :eid, 0, :now)'
)->execute([
':uid' => $coachUserId,
':u' => self::COACH_USERNAME,
':h' => $passwordHash,
':role' => 'coach',
':eid' => $coachId,
':now' => $now,
]);
$pdo->prepare('INSERT INTO client (clientCode, coachID) VALUES (:cc, :cid)')
->execute([':cc' => 'CLIENT-TEST-001', ':cid' => $coachId]);
$pdo->prepare(
'INSERT INTO questionnaire (questionnaireID, name, version, state, orderIndex, showPoints, conditionJson, categoryKey)
VALUES (:id, :n, :v, :s, 1, 0, :cj, :ck)'
)->execute([
':id' => $qnId,
':n' => 'Test questionnaire',
':v' => '1',
':s' => 'active',
':cj' => '{}',
':ck' => '',
]);
$pdo->prepare('UPDATE questionnaire SET showPoints = 1 WHERE questionnaireID = :id')
->execute([':id' => $qnId]);
$pdo->prepare(
'INSERT INTO question (questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson)
VALUES (:id, :qn, :txt, :type, 0, 1, :cfg)'
)->execute([
':id' => $questionId,
':qn' => $qnId,
':txt' => 'Consent?',
':type' => 'single_choice_question',
':cfg' => json_encode(['questionKey' => 'consent_q'], JSON_THROW_ON_ERROR),
]);
$pdo->prepare(
'INSERT INTO answer_option (answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId)
VALUES (:id, :qid, :txt, 0, 0, :next)'
)->execute([
':next' => '',
':id' => $optionId,
':qid' => $questionId,
':txt' => 'yes',
]);
$pdo->prepare(
'INSERT INTO question (questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson)
VALUES (:id, :qn, :txt, :type, 1, 0, :cfg)'
)->execute([
':id' => $freeTextQuestionId,
':qn' => $qnId,
':txt' => 'Notes',
':type' => 'free_text_question',
':cfg' => json_encode(['questionKey' => 'notes_q'], JSON_THROW_ON_ERROR),
]);
$glassConfig = json_encode(
['questionKey' => 'symptoms_q', 'symptoms' => [$glassSymptomKey, 'fatigue']],
JSON_THROW_ON_ERROR
);
$pdo->prepare(
'INSERT INTO question (questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson)
VALUES (:id, :qn, :txt, :type, 2, 0, :cfg)'
)->execute([
':id' => $glassQuestionId,
':qn' => $qnId,
':txt' => 'Symptoms',
':type' => 'glass_scale_question',
':cfg' => $glassConfig,
]);
require_once dirname(__DIR__, 2) . '/lib/settings.php';
qdb_settings_save_on_pdo($pdo, qdb_settings_defaults());
$pdo->prepare(
'INSERT INTO question (questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson)
VALUES (:id, :qn, :txt, :type, 3, 0, :cfg)'
)->execute([
':id' => $multiQuestionId,
':qn' => $qnId,
':txt' => 'Pick several',
':type' => 'multi_check_box_question',
':cfg' => json_encode(['questionKey' => 'multi_q'], JSON_THROW_ON_ERROR),
]);
$pdo->prepare(
'INSERT INTO answer_option (answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId)
VALUES (:id, :qid, :txt, 1, 0, :next)'
)->execute([':next' => '', ':id' => $multiOptionAId, ':qid' => $multiQuestionId, ':txt' => 'opt_a']);
$pdo->prepare(
'INSERT INTO answer_option (answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId)
VALUES (:id, :qid, :txt, 2, 1, :next)'
)->execute([':next' => '', ':id' => $multiOptionBId, ':qid' => $multiQuestionId, ':txt' => 'opt_b']);
qdb_upsert_source_translation($pdo, 'answer_option', $multiOptionAId, 'A');
qdb_upsert_source_translation($pdo, 'answer_option', $multiOptionBId, 'B');
$pdo->prepare(
'INSERT INTO questionnaire (questionnaireID, name, version, state, orderIndex, showPoints, conditionJson, categoryKey)
VALUES (:id, :n, :v, :s, 2, 0, :cj, :ck)'
)->execute([
':id' => $draftQuestionnaireId,
':n' => 'Draft only',
':v' => '1',
':s' => 'draft',
':cj' => '{}',
':ck' => '',
]);
qdb_upsert_source_translation($pdo, 'answer_option', $optionId, 'Ja');
return new FixtureIds(
adminUserId: $adminUserId,
supervisorUserId: $supervisorUserId,
coachUserId: $coachUserId,
coachId: $coachId,
supervisorId: $supervisorId,
questionnaireId: $qnId,
questionId: $questionId,
questionShortId: 'q1',
optionId: $optionId,
freeTextQuestionId: $freeTextQuestionId,
freeTextShortId: 'q2',
glassQuestionId: $glassQuestionId,
glassShortId: 'q3',
glassSymptomKey: $glassSymptomKey,
multiCheckQuestionId: $multiQuestionId,
multiCheckShortId: 'q4',
draftQuestionnaireId: $draftQuestionnaireId,
clientCode: 'CLIENT-TEST-001',
);
}
}
final class FixtureIds
{
public function __construct(
public readonly string $adminUserId,
public readonly string $supervisorUserId,
public readonly string $coachUserId,
public readonly string $coachId,
public readonly string $supervisorId,
public readonly string $questionnaireId,
public readonly string $questionId,
public readonly string $questionShortId,
public readonly string $optionId,
public readonly string $freeTextQuestionId,
public readonly string $freeTextShortId,
public readonly string $glassQuestionId,
public readonly string $glassShortId,
public readonly string $glassSymptomKey,
public readonly string $multiCheckQuestionId,
public readonly string $multiCheckShortId,
public readonly string $draftQuestionnaireId,
public readonly string $clientCode,
) {
}
}

View File

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Tests\Support;
use RuntimeException;
/** Thrown instead of exit() when QDB_TESTING is defined. */
final class JsonErrorResponse extends RuntimeException
{
public function __construct(
public readonly string $errorCode,
public readonly string $errorMessage,
public readonly int $httpStatus = 400,
public readonly mixed $details = null,
) {
parent::__construct($errorMessage, $httpStatus);
}
}

View File

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Tests\Support;
use RuntimeException;
/** Thrown instead of exit() when QDB_TESTING is defined. */
final class JsonSuccessResponse extends RuntimeException
{
public function __construct(
public readonly mixed $data,
public readonly int $httpStatus = 200,
) {
parent::__construct('JSON success', $httpStatus);
}
}

View File

@ -0,0 +1,172 @@
<?php
declare(strict_types=1);
namespace Tests\Support;
use PHPUnit\Framework\TestCase as PHPUnitTestCase;
abstract class QdbTestCase extends PHPUnitTestCase
{
protected static ApiClient $api;
protected ?FixtureIds $fixtureIds = null;
public static function setUpBeforeClass(): void
{
parent::setUpBeforeClass();
self::$api = new ApiClient(dirname(__DIR__, 2) . '/api/index.php');
}
protected function setUp(): void
{
parent::setUp();
TestFilesystem::resetUploads();
$_SERVER['REMOTE_ADDR'] = '127.0.0.1';
$this->bootstrapDatabase();
}
protected function bootstrapDatabase(): void
{
if (defined('QDB_PATH') && is_file(QDB_PATH)) {
@unlink(QDB_PATH);
}
if (defined('QDB_LOCK') && is_file(QDB_LOCK)) {
@unlink(QDB_LOCK);
}
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
$this->fixtureIds = DatabaseSeeder::seed($pdo);
qdb_save($tmpDb, $lockFp);
}
protected function api(): ApiClient
{
return self::$api;
}
protected function fixture(): FixtureIds
{
if ($this->fixtureIds === null) {
$this->fail('Fixture not seeded');
}
return $this->fixtureIds;
}
protected function adminToken(): string
{
return $this->api()->loginWebAndGetToken(
DatabaseSeeder::ADMIN_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
}
protected function supervisorToken(): string
{
return $this->api()->loginWebAndGetToken(
DatabaseSeeder::SUPERVISOR_USERNAME,
DatabaseSeeder::PASSWORD
)['token'];
}
/**
* @return array{token: string, data: array<string, mixed>}
*/
protected function coachMobileLogin(): array
{
return $this->api()->loginMobileAndGetToken(
DatabaseSeeder::COACH_USERNAME,
DatabaseSeeder::PASSWORD
);
}
protected function assertApiOk(array $response, string $message = ''): void
{
$this->assertTrue($response['ok'] ?? false, $message . ' ' . json_encode($response['error'] ?? []));
}
protected function assertApiError(array $response, string $code, ?int $status = null): void
{
$this->assertFalse($response['ok'] ?? true);
$this->assertSame($code, $response['error']['code'] ?? null);
if ($status !== null) {
$this->assertSame($status, $response['status']);
}
}
protected function assertRawOk(array $response, string $contentTypePrefix): void
{
$this->assertTrue($response['ok'] ?? false);
$this->assertTrue($response['raw'] ?? false);
$this->assertStringStartsWith($contentTypePrefix, $response['headers']['Content-Type'] ?? '');
$this->assertNotSame('', $response['body'] ?? '');
}
/**
* @return array<string, mixed> API response from app submit
*/
protected function submitFixtureQuestionnaire(): array
{
$login = $this->api()->loginMobileAndGetToken(
DatabaseSeeder::COACH_USERNAME,
DatabaseSeeder::PASSWORD
);
$f = $this->fixture();
$now = time();
return $this->submitQuestionnaireAs(
$login['token'],
$f->questionnaireId,
$f->clientCode,
[
['questionID' => $f->questionShortId, 'answerOptionKey' => 'yes'],
],
$now - 60,
$now
);
}
/**
* @param list<array<string, mixed>> $answers
* @return array<string, mixed>
*/
protected function submitQuestionnaireAs(
string $token,
string $questionnaireId,
string $clientCode,
array $answers,
?int $startedAt = null,
?int $completedAt = null,
?int $structureRevision = null,
): array {
$now = time();
$startedAt ??= $now - 60;
$completedAt ??= $now;
$payload = [
'questionnaireID' => $questionnaireId,
'clientCode' => $clientCode,
'answers' => $answers,
'startedAt' => $startedAt,
'completedAt' => $completedAt,
];
if ($structureRevision !== null) {
$payload['structureRevision'] = $structureRevision;
}
return $this->api()->encryptedPost($token, 'app_questionnaires', $payload);
}
protected function submissionVersionCount(string $clientCode, string $questionnaireId): int
{
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$stmt = $pdo->prepare(
'SELECT COUNT(*) FROM questionnaire_submission WHERE clientCode = :cc AND questionnaireID = :qn'
);
$stmt->execute([':cc' => $clientCode, ':qn' => $questionnaireId]);
$count = (int)$stmt->fetchColumn();
qdb_discard($tmpDb, $lockFp);
return $count;
}
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Tests\Support;
use RuntimeException;
/** Thrown instead of exit() for file/download responses when QDB_TESTING is defined. */
final class RawHttpResponse extends RuntimeException
{
/**
* @param array<string, string> $headers
*/
public function __construct(
public readonly string $body,
public readonly array $headers,
public readonly int $httpStatus = 200,
) {
parent::__construct('Raw HTTP response', $httpStatus);
}
}

View File

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Tests\Support;
final class TestFilesystem
{
public static function resetUploads(): void
{
if (!defined('QDB_TEST_UPLOADS')) {
return;
}
if (defined('QDB_PATH')) {
if (is_file(QDB_PATH)) {
@unlink(QDB_PATH);
}
if (defined('QDB_LOCK') && is_file(QDB_LOCK)) {
@unlink(QDB_LOCK);
}
}
self::rmTree(QDB_TEST_UPLOADS);
mkdir(QDB_TEST_UPLOADS, 0755, true);
}
private static function rmTree(string $dir): void
{
if (!is_dir($dir)) {
return;
}
$items = scandir($dir);
if ($items === false) {
return;
}
foreach ($items as $item) {
if ($item === '.' || $item === '..') {
continue;
}
$path = $dir . DIRECTORY_SEPARATOR . $item;
if (is_dir($path)) {
self::rmTree($path);
} else {
@unlink($path);
}
}
@rmdir($dir);
}
}