Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 06fc134299 |
44
.gitea/workflows/tests.yml
Normal file
44
.gitea/workflows/tests.yml
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
# Gitea Actions (act_runner). Requires a runner with label "ubuntu-latest".
|
||||||
|
# Instance: enable [actions] in app.ini; repo: Settings → Actions → enabled.
|
||||||
|
# If checkout/setup actions fail offline, set DEFAULT_ACTIONS_URL = github (or mirror actions).
|
||||||
|
name: Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: shivammathur/setup-php@v2
|
||||||
|
with:
|
||||||
|
php-version: '8.4'
|
||||||
|
extensions: pdo_sqlite, openssl, json
|
||||||
|
coverage: none
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
composer install --no-interaction
|
||||||
|
npm install
|
||||||
|
npm install --prefix website
|
||||||
|
|
||||||
|
- name: Install Playwright browser
|
||||||
|
run: npx playwright install --with-deps chromium
|
||||||
|
|
||||||
|
- name: PHPUnit
|
||||||
|
run: composer test
|
||||||
|
|
||||||
|
- name: Website unit tests (Vitest)
|
||||||
|
run: npm run test:unit --prefix website
|
||||||
|
|
||||||
|
- name: End-to-end tests (Playwright)
|
||||||
|
run: CI=true npm run test:e2e
|
||||||
|
env:
|
||||||
|
QDB_MASTER_KEY: dGVzdC1tYXN0ZXIta2V5LXRoaXQtMzJieXRzIQ==
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@ -8,6 +8,11 @@ valid_tokens.txt
|
|||||||
uploads/*
|
uploads/*
|
||||||
!uploads/.gitkeep
|
!uploads/.gitkeep
|
||||||
|
|
||||||
|
# Test runtime artifacts
|
||||||
|
tests/var/*
|
||||||
|
!tests/var/uploads/.gitkeep
|
||||||
|
tests/.phpunit.cache/
|
||||||
|
|
||||||
# Database lock files (just in case)
|
# Database lock files (just in case)
|
||||||
uploads/.qdb_lock
|
uploads/.qdb_lock
|
||||||
uploads/*.lock
|
uploads/*.lock
|
||||||
|
|||||||
146
TESTING.md
Normal file
146
TESTING.md
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
# Testing
|
||||||
|
|
||||||
|
Automated tests cover the PHP API (`api/`, `handlers/`), shared libraries, and the admin SPA in `website/`. Legacy root PHP scripts and duplicate `api/*.php` files are **not** tested.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- **PHP 8.x** with extensions: `pdo_sqlite`, `openssl`, `json`
|
||||||
|
- **Composer**
|
||||||
|
- **Node.js 20+** and npm
|
||||||
|
|
||||||
|
### PHP: enable SQLite (Windows)
|
||||||
|
|
||||||
|
If `composer test` fails with `PDOException: could not find driver`, PDO has no SQLite driver loaded. Check:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
php -r "print_r(PDO::getAvailableDrivers());"
|
||||||
|
```
|
||||||
|
|
||||||
|
You should see `sqlite` in the list. If not, edit the `php.ini` shown by `php --ini` and uncomment:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
extension=pdo_sqlite
|
||||||
|
extension=sqlite3
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart the terminal, then run the check again. Winget’s PHP package uses a path like:
|
||||||
|
|
||||||
|
`%LOCALAPPDATA%\Microsoft\WinGet\Packages\PHP.PHP.8.4_*\php.ini`
|
||||||
|
|
||||||
|
## One-time setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
composer install
|
||||||
|
npm install
|
||||||
|
cd website && npm install && cd ..
|
||||||
|
npx playwright install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test database key
|
||||||
|
|
||||||
|
Tests use a fixed master key (not for production):
|
||||||
|
|
||||||
|
```text
|
||||||
|
QDB_MASTER_KEY=dGVzdC1tYXN0ZXIta2V5LXRoaXQtMzJieXRzIQ==
|
||||||
|
```
|
||||||
|
|
||||||
|
PHPUnit writes to `tests/var/uploads/`. Playwright and manual E2E seed `uploads/` via:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php tests/bin/seed-test-db.php
|
||||||
|
```
|
||||||
|
|
||||||
|
### Seeded accounts
|
||||||
|
|
||||||
|
| Username | Password | Role |
|
||||||
|
|-----------------|---------------|-------------|
|
||||||
|
| testadmin | testpass123 | admin |
|
||||||
|
| testsuper1 | testpass123 | supervisor |
|
||||||
|
| testcoach1 | testpass123 | coach |
|
||||||
|
| testmustchange | testpass123 | coach (must change password on first login) |
|
||||||
|
|
||||||
|
Sample questionnaire ID: `questionnaire_test_demo` (active, with question `q1`).
|
||||||
|
|
||||||
|
## Run tests
|
||||||
|
|
||||||
|
### All suites
|
||||||
|
|
||||||
|
From the repository root (PHPUnit, Vitest, then Playwright):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test
|
||||||
|
```
|
||||||
|
|
||||||
|
Alias: `npm run test:all`.
|
||||||
|
|
||||||
|
### PHP (unit + integration)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
composer test
|
||||||
|
```
|
||||||
|
|
||||||
|
Integration tests reset the database before **each** test method (isolated state; slightly slower).
|
||||||
|
|
||||||
|
### Website unit (Vitest)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd website && npm run test:unit
|
||||||
|
```
|
||||||
|
|
||||||
|
### End-to-end (Playwright)
|
||||||
|
|
||||||
|
Starts `php -S 127.0.0.1:8080 dev-router.php`, seeds the DB, then runs HTTP smoke tests and website UI tests. Detailed API behaviour is covered by PHPUnit; Playwright API tests are smoke + password-change flow only.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
### Continuous integration (Gitea Actions)
|
||||||
|
|
||||||
|
Workflow: [`.gitea/workflows/tests.yml`](.gitea/workflows/tests.yml) — same three steps on push and pull request (`CI=true` for E2E so the dev server is always started fresh).
|
||||||
|
|
||||||
|
**Prerequisites on your Gitea instance:**
|
||||||
|
|
||||||
|
1. **Enable Actions** in `app.ini`:
|
||||||
|
```ini
|
||||||
|
[actions]
|
||||||
|
ENABLED = true
|
||||||
|
```
|
||||||
|
2. **Register an [act_runner](https://gitea.com/gitea/act_runner)** on a Linux host (needs Docker or host tools for Playwright system deps). Use a label that matches the workflow, e.g. `ubuntu-latest`:
|
||||||
|
```bash
|
||||||
|
act_runner register --instance https://your-gitea.example --token <token>
|
||||||
|
act_runner daemon
|
||||||
|
```
|
||||||
|
3. **Enable Actions** on this repository (Settings → Actions).
|
||||||
|
4. **Third-party actions** (`actions/checkout`, `setup-php`, `setup-node`) are fetched from GitHub by default (`DEFAULT_ACTIONS_URL = github`). On air-gapped instances, mirror those actions or switch the workflow to shell `apt`/`composer` installs.
|
||||||
|
|
||||||
|
Gitea also reads `.github/workflows/` for migrated repos; this project uses **`.gitea/workflows/`** only to avoid duplicate runs.
|
||||||
|
|
||||||
|
Interactive UI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:e2e:ui
|
||||||
|
```
|
||||||
|
|
||||||
|
## Manual development server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Optional: seed test data into uploads/
|
||||||
|
php tests/bin/seed-test-db.php
|
||||||
|
|
||||||
|
$env:QDB_MASTER_KEY="dGVzdC1tYXN0ZXIta2V5LXRoaXQtMzJieXRzIQ=="
|
||||||
|
php -S 127.0.0.1:8080 dev-router.php
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://127.0.0.1:8080/website/](http://127.0.0.1:8080/website/).
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```text
|
||||||
|
.gitea/workflows/ Gitea Actions CI (tests.yml)
|
||||||
|
tests/ PHPUnit (Unit/, Integration/, Support/)
|
||||||
|
tests/fixtures/ schema.sql copy for tests
|
||||||
|
website/tests/ Vitest unit tests
|
||||||
|
e2e/api/ Playwright HTTP smoke tests (Android API)
|
||||||
|
e2e/website/ Playwright browser tests (hash routes)
|
||||||
|
```
|
||||||
72
api/dispatch.php
Normal file
72
api/dispatch.php
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch a single API request. Used by index.php and PHPUnit (multiple calls per process).
|
||||||
|
*/
|
||||||
|
function qdb_dispatch_api_request(): void
|
||||||
|
{
|
||||||
|
require_once __DIR__ . '/../common.php';
|
||||||
|
require_once __DIR__ . '/../db_init.php';
|
||||||
|
require_once __DIR__ . '/../lib/response.php';
|
||||||
|
require_once __DIR__ . '/../lib/validate.php';
|
||||||
|
|
||||||
|
if (!headers_sent()) {
|
||||||
|
header('Access-Control-Allow-Origin: *');
|
||||||
|
header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS');
|
||||||
|
header('Access-Control-Allow-Headers: Content-Type, Authorization');
|
||||||
|
header('Content-Type: application/json; charset=UTF-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||||
|
if (!defined('QDB_TESTING') || !QDB_TESTING) {
|
||||||
|
http_response_code(204);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
throw new QdbHttpResponse([], 204);
|
||||||
|
}
|
||||||
|
|
||||||
|
$requestUri = $_SERVER['REQUEST_URI'] ?? '';
|
||||||
|
$path = parse_url($requestUri, PHP_URL_PATH);
|
||||||
|
$base = dirname($_SERVER['SCRIPT_NAME']);
|
||||||
|
$route = trim(substr($path, strlen($base)), '/');
|
||||||
|
$route = preg_replace('/\.php$/', '', $route);
|
||||||
|
$route = strtolower($route);
|
||||||
|
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
|
||||||
|
$routes = [
|
||||||
|
'questionnaires' => __DIR__ . '/../handlers/questionnaires.php',
|
||||||
|
'questions' => __DIR__ . '/../handlers/questions.php',
|
||||||
|
'answer_options' => __DIR__ . '/../handlers/answer_options.php',
|
||||||
|
'translations' => __DIR__ . '/../handlers/translations.php',
|
||||||
|
'users' => __DIR__ . '/../handlers/users.php',
|
||||||
|
'assignments' => __DIR__ . '/../handlers/assignments.php',
|
||||||
|
'clients' => __DIR__ . '/../handlers/clients.php',
|
||||||
|
'results' => __DIR__ . '/../handlers/results.php',
|
||||||
|
'export' => __DIR__ . '/../handlers/export.php',
|
||||||
|
'app_questionnaires' => __DIR__ . '/../handlers/app_questionnaires.php',
|
||||||
|
'logout' => __DIR__ . '/../handlers/logout.php',
|
||||||
|
'auth/login' => __DIR__ . '/../handlers/auth.php',
|
||||||
|
'auth/change-password' => __DIR__ . '/../handlers/auth.php',
|
||||||
|
'backup' => __DIR__ . '/../handlers/backup.php',
|
||||||
|
'download' => __DIR__ . '/../handlers/download.php',
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!isset($routes[$route])) {
|
||||||
|
json_error('NOT_FOUND', "Unknown endpoint: $route", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$handlerFile = $routes[$route];
|
||||||
|
if (!file_exists($handlerFile)) {
|
||||||
|
json_error('NOT_FOUND', "Handler not found for: $route", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
require $handlerFile;
|
||||||
|
} catch (QdbHttpResponse $e) {
|
||||||
|
throw $e;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
error_log("Unhandled error on $method $route: " . $e->getMessage());
|
||||||
|
json_error('SERVER_ERROR', 'Internal server error', 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,67 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* Front-controller: single entry point for all /api/ requests.
|
* Front-controller: single entry point for all /api/ requests.
|
||||||
* Handles CORS, JSON content type, global error catching, and dispatch.
|
|
||||||
*/
|
*/
|
||||||
require_once __DIR__ . '/../common.php';
|
require_once __DIR__ . '/dispatch.php';
|
||||||
require_once __DIR__ . '/../db_init.php';
|
qdb_dispatch_api_request();
|
||||||
require_once __DIR__ . '/../lib/response.php';
|
|
||||||
require_once __DIR__ . '/../lib/validate.php';
|
|
||||||
|
|
||||||
// CORS
|
|
||||||
header('Access-Control-Allow-Origin: *');
|
|
||||||
header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS');
|
|
||||||
header('Access-Control-Allow-Headers: Content-Type, Authorization');
|
|
||||||
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
|
||||||
http_response_code(204);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
header('Content-Type: application/json; charset=UTF-8');
|
|
||||||
|
|
||||||
// Parse route: strip /api/ prefix and .php suffix, normalize
|
|
||||||
$requestUri = $_SERVER['REQUEST_URI'] ?? '';
|
|
||||||
$path = parse_url($requestUri, PHP_URL_PATH);
|
|
||||||
$base = dirname($_SERVER['SCRIPT_NAME']);
|
|
||||||
$route = trim(substr($path, strlen($base)), '/');
|
|
||||||
$route = preg_replace('/\.php$/', '', $route);
|
|
||||||
$route = strtolower($route);
|
|
||||||
|
|
||||||
$method = $_SERVER['REQUEST_METHOD'];
|
|
||||||
|
|
||||||
// Dispatch table: route -> handler file
|
|
||||||
$routes = [
|
|
||||||
'questionnaires' => __DIR__ . '/../handlers/questionnaires.php',
|
|
||||||
'questions' => __DIR__ . '/../handlers/questions.php',
|
|
||||||
'answer_options' => __DIR__ . '/../handlers/answer_options.php',
|
|
||||||
'translations' => __DIR__ . '/../handlers/translations.php',
|
|
||||||
'users' => __DIR__ . '/../handlers/users.php',
|
|
||||||
'assignments' => __DIR__ . '/../handlers/assignments.php',
|
|
||||||
'clients' => __DIR__ . '/../handlers/clients.php',
|
|
||||||
'results' => __DIR__ . '/../handlers/results.php',
|
|
||||||
'export' => __DIR__ . '/../handlers/export.php',
|
|
||||||
'app_questionnaires' => __DIR__ . '/../handlers/app_questionnaires.php',
|
|
||||||
'logout' => __DIR__ . '/../handlers/logout.php',
|
|
||||||
'auth/login' => __DIR__ . '/../handlers/auth.php',
|
|
||||||
'auth/change-password' => __DIR__ . '/../handlers/auth.php',
|
|
||||||
'backup' => __DIR__ . '/../handlers/backup.php',
|
|
||||||
'download' => __DIR__ . '/../handlers/download.php',
|
|
||||||
];
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!isset($routes[$route])) {
|
|
||||||
json_error('NOT_FOUND', "Unknown endpoint: $route", 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$handlerFile = $routes[$route];
|
|
||||||
if (!file_exists($handlerFile)) {
|
|
||||||
json_error('NOT_FOUND', "Handler not found for: $route", 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
require $handlerFile;
|
|
||||||
|
|
||||||
} catch (Throwable $e) {
|
|
||||||
error_log("Unhandled error on $method $route: " . $e->getMessage());
|
|
||||||
json_error('SERVER_ERROR', 'Internal server error', 500);
|
|
||||||
}
|
|
||||||
|
|||||||
31
common.php
31
common.php
@ -141,26 +141,28 @@ function get_bearer_token(): ?string {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function qdb_auth_abort(array $body, int $status): never {
|
||||||
|
if (defined('QDB_TESTING') && QDB_TESTING) {
|
||||||
|
require_once __DIR__ . '/lib/QdbHttpResponse.php';
|
||||||
|
throw new QdbHttpResponse($body, $status);
|
||||||
|
}
|
||||||
|
http_response_code($status);
|
||||||
|
header('Content-Type: application/json; charset=UTF-8');
|
||||||
|
echo json_encode($body);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
function require_valid_token(): array {
|
function require_valid_token(): array {
|
||||||
$token = get_bearer_token();
|
$token = get_bearer_token();
|
||||||
if (!$token) {
|
if (!$token) {
|
||||||
http_response_code(401);
|
qdb_auth_abort(["error" => "Missing Bearer token"], 401);
|
||||||
header('Content-Type: application/json; charset=UTF-8');
|
|
||||||
echo json_encode(["error" => "Missing Bearer token"]);
|
|
||||||
exit;
|
|
||||||
}
|
}
|
||||||
$rec = token_get_record($token);
|
$rec = token_get_record($token);
|
||||||
if (!$rec) {
|
if (!$rec) {
|
||||||
http_response_code(403);
|
qdb_auth_abort(["error" => "Invalid or expired token"], 403);
|
||||||
header('Content-Type: application/json; charset=UTF-8');
|
|
||||||
echo json_encode(["error" => "Invalid or expired token"]);
|
|
||||||
exit;
|
|
||||||
}
|
}
|
||||||
if (!empty($rec['temp'])) {
|
if (!empty($rec['temp'])) {
|
||||||
http_response_code(403);
|
qdb_auth_abort(["error" => "Password change required before access"], 403);
|
||||||
header('Content-Type: application/json; charset=UTF-8');
|
|
||||||
echo json_encode(["error" => "Password change required before access"]);
|
|
||||||
exit;
|
|
||||||
}
|
}
|
||||||
$rec['_token'] = $token;
|
$rec['_token'] = $token;
|
||||||
return $rec;
|
return $rec;
|
||||||
@ -169,10 +171,7 @@ function require_valid_token(): array {
|
|||||||
function require_role(array $allowed, array $tokenRecord): void {
|
function require_role(array $allowed, array $tokenRecord): void {
|
||||||
$role = $tokenRecord['role'] ?? '';
|
$role = $tokenRecord['role'] ?? '';
|
||||||
if (!in_array($role, $allowed, true)) {
|
if (!in_array($role, $allowed, true)) {
|
||||||
http_response_code(403);
|
qdb_auth_abort(["error" => "Insufficient permissions"], 403);
|
||||||
header('Content-Type: application/json; charset=UTF-8');
|
|
||||||
echo json_encode(["error" => "Insufficient permissions"]);
|
|
||||||
exit;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
15
composer.json
Normal file
15
composer.json
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "nat-as/questionnaire-server",
|
||||||
|
"description": "Questionnaire management API and admin website",
|
||||||
|
"require-dev": {
|
||||||
|
"phpunit/phpunit": "^11"
|
||||||
|
},
|
||||||
|
"autoload-dev": {
|
||||||
|
"psr-4": {
|
||||||
|
"Tests\\": "tests/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test": "phpunit -c tests/phpunit.xml"
|
||||||
|
}
|
||||||
|
}
|
||||||
12
db_init.php
12
db_init.php
@ -3,9 +3,15 @@
|
|||||||
// Shared helpers for opening, creating, and saving the encrypted SQLite database.
|
// Shared helpers for opening, creating, and saving the encrypted SQLite database.
|
||||||
require_once __DIR__ . '/common.php';
|
require_once __DIR__ . '/common.php';
|
||||||
|
|
||||||
define('QDB_PATH', __DIR__ . '/uploads/questionnaire_database');
|
if (!defined('QDB_PATH')) {
|
||||||
define('QDB_LOCK', __DIR__ . '/uploads/.qdb_lock');
|
define('QDB_PATH', __DIR__ . '/uploads/questionnaire_database');
|
||||||
define('QDB_SCHEMA', __DIR__ . '/schema.sql');
|
}
|
||||||
|
if (!defined('QDB_LOCK')) {
|
||||||
|
define('QDB_LOCK', __DIR__ . '/uploads/.qdb_lock');
|
||||||
|
}
|
||||||
|
if (!defined('QDB_SCHEMA')) {
|
||||||
|
define('QDB_SCHEMA', __DIR__ . '/schema.sql');
|
||||||
|
}
|
||||||
define('QDB_VERSION', 3);
|
define('QDB_VERSION', 3);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
77
e2e/api/app-questionnaires.spec.ts
Normal file
77
e2e/api/app-questionnaires.spec.ts
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const API = '/api';
|
||||||
|
|
||||||
|
const PASSWORD = 'testpass123';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async function login(request: import('@playwright/test').APIRequestContext, username: string) {
|
||||||
|
|
||||||
|
const res = await request.post(`${API}/auth/login`, {
|
||||||
|
|
||||||
|
data: { username, password: PASSWORD },
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
const json = await res.json();
|
||||||
|
|
||||||
|
expect(json.ok).toBe(true);
|
||||||
|
|
||||||
|
return json.data.token as string;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
test.describe('Android API – app_questionnaires (HTTP smoke)', () => {
|
||||||
|
|
||||||
|
test('list active questionnaires over HTTP', async ({ request }) => {
|
||||||
|
|
||||||
|
const token = await login(request, 'testcoach1');
|
||||||
|
|
||||||
|
const res = await request.get(`${API}/app_questionnaires`, {
|
||||||
|
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
|
||||||
|
const json = await res.json();
|
||||||
|
|
||||||
|
expect(json.ok).toBe(true);
|
||||||
|
|
||||||
|
expect(json.data.some((q: { id: string }) => q.id === 'questionnaire_test_demo')).toBe(true);
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
test('missing bearer returns 401', async ({ request }) => {
|
||||||
|
|
||||||
|
const res = await request.get(`${API}/app_questionnaires`);
|
||||||
|
|
||||||
|
expect(res.status()).toBe(401);
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
test('password change flow', async ({ request }) => {
|
||||||
|
|
||||||
|
const loginRes = await request.post(`${API}/auth/login`, {
|
||||||
|
|
||||||
|
data: { username: 'testmustchange', password: PASSWORD },
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
const loginJson = await loginRes.json();
|
||||||
|
|
||||||
|
expect(loginJson.data.mustChangePassword).toBe(true);
|
||||||
|
|
||||||
|
const tempToken = loginJson.data.token as string;
|
||||||
|
|
||||||
|
let fullToken: string | undefined;
|
||||||
25
e2e/api/auth.spec.ts
Normal file
25
e2e/api/auth.spec.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
const API = '/api';
|
||||||
|
|
||||||
|
test.describe('Android API – auth', () => {
|
||||||
|
test('login returns token', async ({ request }) => {
|
||||||
|
const res = await request.post(`${API}/auth/login`, {
|
||||||
|
data: { username: 'testadmin', password: 'testpass123' },
|
||||||
|
});
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
const json = await res.json();
|
||||||
|
expect(json.ok).toBe(true);
|
||||||
|
expect(json.data.token).toBeTruthy();
|
||||||
|
expect(json.data.role).toBe('admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bad password returns error', async ({ request }) => {
|
||||||
|
const res = await request.post(`${API}/auth/login`, {
|
||||||
|
data: { username: 'testadmin', password: 'wrong' },
|
||||||
|
});
|
||||||
|
expect(res.status()).toBe(401);
|
||||||
|
const json = await res.json();
|
||||||
|
expect(json.ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
16
e2e/global-setup.js
Normal file
16
e2e/global-setup.js
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { execSync } from 'child_process';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
|
||||||
|
export default async function globalSetup() {
|
||||||
|
execSync('php tests/bin/seed-test-db.php', {
|
||||||
|
cwd: root,
|
||||||
|
stdio: 'inherit',
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
QDB_MASTER_KEY: 'dGVzdC1tYXN0ZXIta2V5LXRoaXQtMzJieXRzIQ==',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
16
e2e/website/helpers.ts
Normal file
16
e2e/website/helpers.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { Page, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
export const TEST_PASSWORD = 'testpass123';
|
||||||
|
|
||||||
|
export async function loginAs(page: Page, username: string, password = TEST_PASSWORD) {
|
||||||
|
await page.goto('/website/#/login');
|
||||||
|
await page.locator('#username').fill(username);
|
||||||
|
await page.locator('#password').fill(password);
|
||||||
|
await page.locator('#loginForm button[type="submit"]').click();
|
||||||
|
await expect(page.locator('h1')).toContainText('Questionnaires', { timeout: 15000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout(page: Page) {
|
||||||
|
await page.locator('#logoutBtn').click();
|
||||||
|
await expect(page.locator('#loginForm')).toBeVisible();
|
||||||
|
}
|
||||||
21
e2e/website/login.spec.ts
Normal file
21
e2e/website/login.spec.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('Website – login', () => {
|
||||||
|
test('shows login form', async ({ page }) => {
|
||||||
|
await page.goto('/website/#/login');
|
||||||
|
await expect(page.locator('#loginForm')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bad password shows error', async ({ page }) => {
|
||||||
|
await page.goto('/website/#/login');
|
||||||
|
await page.locator('#username').fill('testadmin');
|
||||||
|
await page.locator('#password').fill('wrong-password');
|
||||||
|
await page.locator('#loginForm button[type="submit"]').click();
|
||||||
|
await expect(page.locator('#loginError')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('protected route redirects to login', async ({ page }) => {
|
||||||
|
await page.goto('/website/#/users');
|
||||||
|
await expect(page.locator('#loginForm')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
64
e2e/website/routes.spec.ts
Normal file
64
e2e/website/routes.spec.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { loginAs, logout } from './helpers';
|
||||||
|
|
||||||
|
test.describe('Website – routes (admin)', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await loginAs(page, 'testadmin');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dashboard lists questionnaires', async ({ page }) => {
|
||||||
|
await expect(page.locator('h1')).toHaveText('Questionnaires');
|
||||||
|
await expect(page.locator('.q-card, .empty-state')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('export page', async ({ page }) => {
|
||||||
|
await page.goto('/website/#/export');
|
||||||
|
await expect(page.locator('h1')).toContainText('Export');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('users page for admin', async ({ page }) => {
|
||||||
|
await page.goto('/website/#/users');
|
||||||
|
await expect(page.locator('h1')).toContainText('Users');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clients page', async ({ page }) => {
|
||||||
|
await page.goto('/website/#/clients');
|
||||||
|
await expect(page.locator('h1')).toContainText('Clients');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('assignments page', async ({ page }) => {
|
||||||
|
await page.goto('/website/#/assignments');
|
||||||
|
await expect(page.locator('h1')).toContainText('Assign');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('editor for seeded questionnaire', async ({ page }) => {
|
||||||
|
await page.goto('/website/#/questionnaire/questionnaire_test_demo');
|
||||||
|
await expect(page.locator('#editorTitle')).not.toHaveText('Loading...');
|
||||||
|
await expect(page.locator('#editorContent')).toContainText('Questions');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('results page', async ({ page }) => {
|
||||||
|
await page.goto('/website/#/questionnaire/questionnaire_test_demo/results');
|
||||||
|
await expect(page.locator('h1')).toContainText('Results');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('logout clears session', async ({ page }) => {
|
||||||
|
await logout(page);
|
||||||
|
await page.goto('/website/#/');
|
||||||
|
await expect(page.locator('#loginForm')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Website – role nav', () => {
|
||||||
|
test('coach cannot see admin nav items', async ({ page }) => {
|
||||||
|
await loginAs(page, 'testcoach1');
|
||||||
|
const usersNav = page.locator('[data-nav-roles] a[href="#/users"]');
|
||||||
|
await expect(usersNav).toBeHidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('supervisor sees users nav', async ({ page }) => {
|
||||||
|
await loginAs(page, 'testsuper1');
|
||||||
|
const usersNav = page.locator('[data-nav-roles] a[href="#/users"]');
|
||||||
|
await expect(usersNav).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -64,6 +64,8 @@ case 'POST':
|
|||||||
'nextQuestionId' => $nextQ,
|
'nextQuestionId' => $nextQ,
|
||||||
'translations' => [],
|
'translations' => [],
|
||||||
]]);
|
]]);
|
||||||
|
} catch (QdbHttpResponse $e) {
|
||||||
|
throw $e;
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
qdb_discard($tmpDb, $lockFp);
|
qdb_discard($tmpDb, $lockFp);
|
||||||
error_log($e->getMessage());
|
error_log($e->getMessage());
|
||||||
@ -104,6 +106,8 @@ case 'PUT':
|
|||||||
'orderIndex' => $order,
|
'orderIndex' => $order,
|
||||||
'nextQuestionId' => $nextQ,
|
'nextQuestionId' => $nextQ,
|
||||||
]]);
|
]]);
|
||||||
|
} catch (QdbHttpResponse $e) {
|
||||||
|
throw $e;
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
qdb_discard($tmpDb, $lockFp);
|
qdb_discard($tmpDb, $lockFp);
|
||||||
error_log($e->getMessage());
|
error_log($e->getMessage());
|
||||||
@ -130,6 +134,8 @@ case 'DELETE':
|
|||||||
$pdo->exec('PRAGMA foreign_keys = ON;');
|
$pdo->exec('PRAGMA foreign_keys = ON;');
|
||||||
qdb_save($tmpDb, $lockFp);
|
qdb_save($tmpDb, $lockFp);
|
||||||
json_success(['deleted' => true]);
|
json_success(['deleted' => true]);
|
||||||
|
} catch (QdbHttpResponse $e) {
|
||||||
|
throw $e;
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
if ($pdo->inTransaction()) {
|
if ($pdo->inTransaction()) {
|
||||||
$pdo->rollBack();
|
$pdo->rollBack();
|
||||||
@ -154,6 +160,8 @@ case 'PATCH':
|
|||||||
}
|
}
|
||||||
qdb_save($tmpDb, $lockFp);
|
qdb_save($tmpDb, $lockFp);
|
||||||
json_success(['reordered' => true]);
|
json_success(['reordered' => true]);
|
||||||
|
} catch (QdbHttpResponse $e) {
|
||||||
|
throw $e;
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
qdb_discard($tmpDb, $lockFp);
|
qdb_discard($tmpDb, $lockFp);
|
||||||
error_log($e->getMessage());
|
error_log($e->getMessage());
|
||||||
|
|||||||
@ -145,6 +145,8 @@ if ($method === 'POST') {
|
|||||||
qdb_save($tmpDb, $lockFp);
|
qdb_save($tmpDb, $lockFp);
|
||||||
|
|
||||||
json_success(['submitted' => true, 'sumPoints' => $sumPoints]);
|
json_success(['submitted' => true, 'sumPoints' => $sumPoints]);
|
||||||
|
} catch (QdbHttpResponse $e) {
|
||||||
|
throw $e;
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
if (isset($pdo) && $pdo->inTransaction()) $pdo->rollBack();
|
if (isset($pdo) && $pdo->inTransaction()) $pdo->rollBack();
|
||||||
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
|
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
|
||||||
|
|||||||
@ -44,6 +44,8 @@ case 'GET':
|
|||||||
|
|
||||||
qdb_discard($tmpDb, $lockFp);
|
qdb_discard($tmpDb, $lockFp);
|
||||||
json_success(['coaches' => $coaches, 'clients' => $clients]);
|
json_success(['coaches' => $coaches, 'clients' => $clients]);
|
||||||
|
} catch (QdbHttpResponse $e) {
|
||||||
|
throw $e;
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
|
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
|
||||||
error_log($e->getMessage());
|
error_log($e->getMessage());
|
||||||
@ -92,6 +94,8 @@ case 'POST':
|
|||||||
qdb_save($tmpDb, $lockFp);
|
qdb_save($tmpDb, $lockFp);
|
||||||
|
|
||||||
json_success(['assigned' => $count]);
|
json_success(['assigned' => $count]);
|
||||||
|
} catch (QdbHttpResponse $e) {
|
||||||
|
throw $e;
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
if (isset($pdo) && $pdo->inTransaction()) $pdo->rollBack();
|
if (isset($pdo) && $pdo->inTransaction()) $pdo->rollBack();
|
||||||
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
|
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
|
||||||
|
|||||||
@ -56,6 +56,8 @@ case 'auth/login':
|
|||||||
'user' => $username,
|
'user' => $username,
|
||||||
'role' => $user['role'],
|
'role' => $user['role'],
|
||||||
]);
|
]);
|
||||||
|
} catch (QdbHttpResponse $e) {
|
||||||
|
throw $e;
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
|
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
|
||||||
error_log($e->getMessage());
|
error_log($e->getMessage());
|
||||||
@ -138,6 +140,8 @@ case 'auth/change-password':
|
|||||||
'user' => $username,
|
'user' => $username,
|
||||||
'role' => $user['role'],
|
'role' => $user['role'],
|
||||||
]);
|
]);
|
||||||
|
} catch (QdbHttpResponse $e) {
|
||||||
|
throw $e;
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
|
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
|
||||||
error_log($e->getMessage());
|
error_log($e->getMessage());
|
||||||
|
|||||||
@ -26,6 +26,8 @@ case 'GET':
|
|||||||
|
|
||||||
qdb_discard($tmpDb, $lockFp);
|
qdb_discard($tmpDb, $lockFp);
|
||||||
json_success(['clients' => $clients]);
|
json_success(['clients' => $clients]);
|
||||||
|
} catch (QdbHttpResponse $e) {
|
||||||
|
throw $e;
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
|
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
|
||||||
error_log($e->getMessage());
|
error_log($e->getMessage());
|
||||||
@ -70,6 +72,8 @@ case 'POST':
|
|||||||
|
|
||||||
qdb_save($tmpDb, $lockFp);
|
qdb_save($tmpDb, $lockFp);
|
||||||
json_success(['clientCode' => $clientCode, 'coachID' => $coachID]);
|
json_success(['clientCode' => $clientCode, 'coachID' => $coachID]);
|
||||||
|
} catch (QdbHttpResponse $e) {
|
||||||
|
throw $e;
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
|
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
|
||||||
error_log($e->getMessage());
|
error_log($e->getMessage());
|
||||||
@ -110,6 +114,8 @@ case 'DELETE':
|
|||||||
|
|
||||||
qdb_save($tmpDb, $lockFp);
|
qdb_save($tmpDb, $lockFp);
|
||||||
json_success([]);
|
json_success([]);
|
||||||
|
} catch (QdbHttpResponse $e) {
|
||||||
|
throw $e;
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
if (isset($pdo) && $pdo->inTransaction()) $pdo->rollBack();
|
if (isset($pdo) && $pdo->inTransaction()) $pdo->rollBack();
|
||||||
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
|
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
|
||||||
|
|||||||
@ -20,7 +20,9 @@ if ($encMaster === false) {
|
|||||||
try {
|
try {
|
||||||
$masterKey = get_master_key_bytes();
|
$masterKey = get_master_key_bytes();
|
||||||
$plain = aes256_cbc_decrypt_bytes($encMaster, $masterKey);
|
$plain = aes256_cbc_decrypt_bytes($encMaster, $masterKey);
|
||||||
} catch (Throwable $e) {
|
} catch (QdbHttpResponse $e) {
|
||||||
|
throw $e;
|
||||||
|
} catch (Throwable $e) {
|
||||||
error_log($e->getMessage());
|
error_log($e->getMessage());
|
||||||
json_error('SERVER_ERROR', 'Decryption failed', 500);
|
json_error('SERVER_ERROR', 'Decryption failed', 500);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -54,6 +54,8 @@ case 'POST':
|
|||||||
'state' => $state, 'orderIndex' => $order, 'showPoints' => $showPts,
|
'state' => $state, 'orderIndex' => $order, 'showPoints' => $showPts,
|
||||||
'conditionJson' => $condJson, 'questionCount' => 0,
|
'conditionJson' => $condJson, 'questionCount' => 0,
|
||||||
]]);
|
]]);
|
||||||
|
} catch (QdbHttpResponse $e) {
|
||||||
|
throw $e;
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
qdb_discard($tmpDb, $lockFp);
|
qdb_discard($tmpDb, $lockFp);
|
||||||
error_log($e->getMessage());
|
error_log($e->getMessage());
|
||||||
@ -95,6 +97,8 @@ case 'PUT':
|
|||||||
'questionnaireID' => $id, 'name' => $name, 'version' => $version, 'state' => $state,
|
'questionnaireID' => $id, 'name' => $name, 'version' => $version, 'state' => $state,
|
||||||
'orderIndex' => $order, 'showPoints' => $showPts, 'conditionJson' => $condJson,
|
'orderIndex' => $order, 'showPoints' => $showPts, 'conditionJson' => $condJson,
|
||||||
]]);
|
]]);
|
||||||
|
} catch (QdbHttpResponse $e) {
|
||||||
|
throw $e;
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
qdb_discard($tmpDb, $lockFp);
|
qdb_discard($tmpDb, $lockFp);
|
||||||
error_log($e->getMessage());
|
error_log($e->getMessage());
|
||||||
@ -138,6 +142,8 @@ case 'DELETE':
|
|||||||
$pdo->exec('PRAGMA foreign_keys = ON;');
|
$pdo->exec('PRAGMA foreign_keys = ON;');
|
||||||
qdb_save($tmpDb, $lockFp);
|
qdb_save($tmpDb, $lockFp);
|
||||||
json_success([]);
|
json_success([]);
|
||||||
|
} catch (QdbHttpResponse $e) {
|
||||||
|
throw $e;
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
if ($pdo->inTransaction()) {
|
if ($pdo->inTransaction()) {
|
||||||
$pdo->rollBack();
|
$pdo->rollBack();
|
||||||
|
|||||||
@ -75,6 +75,8 @@ case 'POST':
|
|||||||
'type' => $type, 'orderIndex' => $order, 'isRequired' => $req,
|
'type' => $type, 'orderIndex' => $order, 'isRequired' => $req,
|
||||||
'configJson' => $config, 'answerOptions' => [], 'translations' => [],
|
'configJson' => $config, 'answerOptions' => [], 'translations' => [],
|
||||||
]]);
|
]]);
|
||||||
|
} catch (QdbHttpResponse $e) {
|
||||||
|
throw $e;
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
qdb_discard($tmpDb, $lockFp);
|
qdb_discard($tmpDb, $lockFp);
|
||||||
error_log($e->getMessage());
|
error_log($e->getMessage());
|
||||||
@ -111,6 +113,8 @@ case 'PUT':
|
|||||||
'defaultText' => $text, 'type' => $type, 'orderIndex' => $order, 'isRequired' => $req,
|
'defaultText' => $text, 'type' => $type, 'orderIndex' => $order, 'isRequired' => $req,
|
||||||
'configJson' => $config,
|
'configJson' => $config,
|
||||||
]]);
|
]]);
|
||||||
|
} catch (QdbHttpResponse $e) {
|
||||||
|
throw $e;
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
qdb_discard($tmpDb, $lockFp);
|
qdb_discard($tmpDb, $lockFp);
|
||||||
error_log($e->getMessage());
|
error_log($e->getMessage());
|
||||||
@ -142,6 +146,8 @@ case 'DELETE':
|
|||||||
$pdo->exec("PRAGMA foreign_keys = ON;");
|
$pdo->exec("PRAGMA foreign_keys = ON;");
|
||||||
qdb_save($tmpDb, $lockFp);
|
qdb_save($tmpDb, $lockFp);
|
||||||
json_success(['deleted' => true]);
|
json_success(['deleted' => true]);
|
||||||
|
} catch (QdbHttpResponse $e) {
|
||||||
|
throw $e;
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
if ($pdo->inTransaction()) {
|
if ($pdo->inTransaction()) {
|
||||||
$pdo->rollBack();
|
$pdo->rollBack();
|
||||||
@ -166,6 +172,8 @@ case 'PATCH':
|
|||||||
}
|
}
|
||||||
qdb_save($tmpDb, $lockFp);
|
qdb_save($tmpDb, $lockFp);
|
||||||
json_success(['reordered' => true]);
|
json_success(['reordered' => true]);
|
||||||
|
} catch (QdbHttpResponse $e) {
|
||||||
|
throw $e;
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
qdb_discard($tmpDb, $lockFp);
|
qdb_discard($tmpDb, $lockFp);
|
||||||
error_log($e->getMessage());
|
error_log($e->getMessage());
|
||||||
|
|||||||
@ -137,6 +137,8 @@ case 'PUT':
|
|||||||
->execute([':lc' => $lang, ':n' => $name]);
|
->execute([':lc' => $lang, ':n' => $name]);
|
||||||
qdb_save($tmpDb, $lockFp);
|
qdb_save($tmpDb, $lockFp);
|
||||||
json_success([]);
|
json_success([]);
|
||||||
|
} catch (QdbHttpResponse $e) {
|
||||||
|
throw $e;
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
qdb_discard($tmpDb, $lockFp);
|
qdb_discard($tmpDb, $lockFp);
|
||||||
error_log($e->getMessage());
|
error_log($e->getMessage());
|
||||||
@ -172,6 +174,8 @@ case 'PUT':
|
|||||||
}
|
}
|
||||||
qdb_save($tmpDb, $lockFp);
|
qdb_save($tmpDb, $lockFp);
|
||||||
json_success([]);
|
json_success([]);
|
||||||
|
} catch (QdbHttpResponse $e) {
|
||||||
|
throw $e;
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
qdb_discard($tmpDb, $lockFp);
|
qdb_discard($tmpDb, $lockFp);
|
||||||
error_log($e->getMessage());
|
error_log($e->getMessage());
|
||||||
@ -197,6 +201,8 @@ case 'DELETE':
|
|||||||
$pdo->prepare("DELETE FROM string_translation WHERE languageCode = :lc")->execute([':lc' => $lang]);
|
$pdo->prepare("DELETE FROM string_translation WHERE languageCode = :lc")->execute([':lc' => $lang]);
|
||||||
qdb_save($tmpDb, $lockFp);
|
qdb_save($tmpDb, $lockFp);
|
||||||
json_success([]);
|
json_success([]);
|
||||||
|
} catch (QdbHttpResponse $e) {
|
||||||
|
throw $e;
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
qdb_discard($tmpDb, $lockFp);
|
qdb_discard($tmpDb, $lockFp);
|
||||||
error_log($e->getMessage());
|
error_log($e->getMessage());
|
||||||
@ -225,6 +231,8 @@ case 'DELETE':
|
|||||||
}
|
}
|
||||||
qdb_save($tmpDb, $lockFp);
|
qdb_save($tmpDb, $lockFp);
|
||||||
json_success([]);
|
json_success([]);
|
||||||
|
} catch (QdbHttpResponse $e) {
|
||||||
|
throw $e;
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
qdb_discard($tmpDb, $lockFp);
|
qdb_discard($tmpDb, $lockFp);
|
||||||
error_log($e->getMessage());
|
error_log($e->getMessage());
|
||||||
|
|||||||
@ -53,6 +53,8 @@ case 'GET':
|
|||||||
'supervisors' => $supervisors,
|
'supervisors' => $supervisors,
|
||||||
'callerRole' => $callerRole,
|
'callerRole' => $callerRole,
|
||||||
]);
|
]);
|
||||||
|
} catch (QdbHttpResponse $e) {
|
||||||
|
throw $e;
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
|
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
|
||||||
error_log($e->getMessage());
|
error_log($e->getMessage());
|
||||||
@ -161,6 +163,8 @@ case 'POST':
|
|||||||
'mustChangePassword' => $mustChange,
|
'mustChangePassword' => $mustChange,
|
||||||
'createdAt' => $now,
|
'createdAt' => $now,
|
||||||
]);
|
]);
|
||||||
|
} catch (QdbHttpResponse $e) {
|
||||||
|
throw $e;
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
if (isset($pdo) && $pdo->inTransaction()) $pdo->rollBack();
|
if (isset($pdo) && $pdo->inTransaction()) $pdo->rollBack();
|
||||||
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
|
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
|
||||||
@ -222,6 +226,8 @@ case 'DELETE':
|
|||||||
qdb_save($tmpDb, $lockFp);
|
qdb_save($tmpDb, $lockFp);
|
||||||
|
|
||||||
json_success([]);
|
json_success([]);
|
||||||
|
} catch (QdbHttpResponse $e) {
|
||||||
|
throw $e;
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
if (isset($pdo) && $pdo->inTransaction()) $pdo->rollBack();
|
if (isset($pdo) && $pdo->inTransaction()) $pdo->rollBack();
|
||||||
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
|
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
|
||||||
|
|||||||
14
lib/QdbHttpResponse.php
Normal file
14
lib/QdbHttpResponse.php
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown instead of exit() when QDB_TESTING is enabled (PHPUnit integration tests).
|
||||||
|
*/
|
||||||
|
final class QdbHttpResponse extends Exception
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly array $body,
|
||||||
|
public readonly int $status,
|
||||||
|
) {
|
||||||
|
parent::__construct(json_encode($body, JSON_THROW_ON_ERROR), $status);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,20 +1,33 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
function json_success(mixed $data, int $status = 200): never {
|
require_once __DIR__ . '/QdbHttpResponse.php';
|
||||||
|
|
||||||
|
function qdb_test_abort(array $body, int $status): never {
|
||||||
|
if (defined('QDB_TESTING') && QDB_TESTING) {
|
||||||
|
throw new QdbHttpResponse($body, $status);
|
||||||
|
}
|
||||||
http_response_code($status);
|
http_response_code($status);
|
||||||
header('Content-Type: application/json; charset=UTF-8');
|
header('Content-Type: application/json; charset=UTF-8');
|
||||||
echo json_encode(["ok" => true, "data" => $data]);
|
echo json_encode($body);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function json_success(mixed $data, int $status = 200): never {
|
||||||
|
qdb_test_abort(["ok" => true, "data" => $data], $status);
|
||||||
|
}
|
||||||
|
|
||||||
function json_error(string $code, string $message, int $status = 400): never {
|
function json_error(string $code, string $message, int $status = 400): never {
|
||||||
http_response_code($status);
|
qdb_test_abort(["ok" => false, "error" => ["code" => $code, "message" => $message]], $status);
|
||||||
header('Content-Type: application/json; charset=UTF-8');
|
|
||||||
echo json_encode(["ok" => false, "error" => ["code" => $code, "message" => $message]]);
|
|
||||||
exit;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function read_json_body(): array {
|
function read_json_body(): array {
|
||||||
|
if (isset($GLOBALS['__TEST_JSON_BODY'])) {
|
||||||
|
$data = json_decode($GLOBALS['__TEST_JSON_BODY'], true);
|
||||||
|
if (!is_array($data)) {
|
||||||
|
json_error('INVALID_BODY', 'Request body must be valid JSON', 400);
|
||||||
|
}
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
$raw = file_get_contents('php://input');
|
$raw = file_get_contents('php://input');
|
||||||
$data = json_decode($raw, true);
|
$data = json_decode($raw, true);
|
||||||
if (!is_array($data)) {
|
if (!is_array($data)) {
|
||||||
|
|||||||
15
package.json
Normal file
15
package.json
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "nat-as-server",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"test": "composer test && npm run test:unit:website && npm run test:e2e",
|
||||||
|
"test:all": "npm run test",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"test:e2e:ui": "playwright test --ui",
|
||||||
|
"test:unit:website": "npm run test:unit --prefix website"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.50.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
29
playwright.config.ts
Normal file
29
playwright.config.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
const baseURL = 'http://127.0.0.1:8080';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './e2e',
|
||||||
|
fullyParallel: false,
|
||||||
|
workers: 1,
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: 0,
|
||||||
|
reporter: 'list',
|
||||||
|
use: {
|
||||||
|
baseURL,
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{ name: 'api', testMatch: /api\/.*\.spec\.ts/ },
|
||||||
|
{ name: 'website', testMatch: /website\/.*\.spec\.ts/, use: { ...devices['Desktop Chrome'] } },
|
||||||
|
],
|
||||||
|
globalSetup: './e2e/global-setup.js',
|
||||||
|
webServer: {
|
||||||
|
command: 'php -S 127.0.0.1:8080 dev-router.php',
|
||||||
|
url: baseURL + '/website/',
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
env: {
|
||||||
|
QDB_MASTER_KEY: 'dGVzdC1tYXN0ZXIta2V5LXRoaXQtMzJieXRzIQ==',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
4
test-results/.last-run.json
Normal file
4
test-results/.last-run.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"status": "passed",
|
||||||
|
"failedTests": []
|
||||||
|
}
|
||||||
78
tests/Integration/AppQuestionnairesTest.php
Normal file
78
tests/Integration/AppQuestionnairesTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
39
tests/Integration/AuthTest.php
Normal file
39
tests/Integration/AuthTest.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
49
tests/Integration/ClientsTest.php
Normal file
49
tests/Integration/ClientsTest.php
Normal 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);
|
||||||
25
tests/Integration/LogoutTest.php
Normal file
25
tests/Integration/LogoutTest.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
25
tests/Integration/QuestionnairesTest.php
Normal file
25
tests/Integration/QuestionnairesTest.php
Normal 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'] ?? []);
|
||||||
|
}
|
||||||
|
}
|
||||||
48
tests/Integration/UsersTest.php
Normal file
48
tests/Integration/UsersTest.php
Normal 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');
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
126
tests/Support/ApiTestCase.php
Normal file
126
tests/Support/ApiTestCase.php
Normal 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) {
|
||||||
|
|
||||||
186
tests/Support/DatabaseFixture.php
Normal file
186
tests/Support/DatabaseFixture.php
Normal 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
51
tests/Unit/CommonTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
41
tests/Unit/ResponseTest.php
Normal file
41
tests/Unit/ResponseTest.php
Normal 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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
23
tests/bin/seed-test-db.php
Normal file
23
tests/bin/seed-test-db.php
Normal 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
38
tests/bootstrap.php
Normal 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
18
tests/phpunit.xml
Normal 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>
|
||||||
1
uploads/.gitkeep
Normal file
1
uploads/.gitkeep
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
16
website/js/api-envelope.js
Normal file
16
website/js/api-envelope.js
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Unwrap the unified response envelope:
|
||||||
|
* {"ok": true, "data": ...} -> returns { success: true, ...data }
|
||||||
|
* {"ok": false, "error": ...} -> returns { success: false, error: message }
|
||||||
|
* Also supports legacy format for backward compat.
|
||||||
|
*/
|
||||||
|
export function unwrap(json) {
|
||||||
|
if (typeof json.ok === 'boolean') {
|
||||||
|
if (json.ok) {
|
||||||
|
const d = json.data || {};
|
||||||
|
return { success: true, ...(Array.isArray(d) ? { items: d } : d) };
|
||||||
|
}
|
||||||
|
return { success: false, error: json.error?.message || 'Unknown error' };
|
||||||
|
}
|
||||||
|
return json;
|
||||||
|
}
|
||||||
@ -1,3 +1,5 @@
|
|||||||
|
import { unwrap } from './api-envelope.js';
|
||||||
|
|
||||||
const API_BASE = '../api';
|
const API_BASE = '../api';
|
||||||
|
|
||||||
function getToken() {
|
function getToken() {
|
||||||
@ -32,23 +34,6 @@ async function apiFetch(endpoint, options = {}) {
|
|||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Unwrap the unified response envelope:
|
|
||||||
* {"ok": true, "data": ...} -> returns { success: true, ...data }
|
|
||||||
* {"ok": false, "error": ...} -> returns { success: false, error: message }
|
|
||||||
* Also supports legacy format for backward compat.
|
|
||||||
*/
|
|
||||||
function unwrap(json) {
|
|
||||||
if (typeof json.ok === 'boolean') {
|
|
||||||
if (json.ok) {
|
|
||||||
const d = json.data || {};
|
|
||||||
return { success: true, ...(Array.isArray(d) ? { items: d } : d) };
|
|
||||||
}
|
|
||||||
return { success: false, error: json.error?.message || 'Unknown error' };
|
|
||||||
}
|
|
||||||
return json;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function apiGet(endpoint) {
|
export async function apiGet(endpoint) {
|
||||||
const res = await apiFetch(endpoint);
|
const res = await apiFetch(endpoint);
|
||||||
return unwrap(await res.json());
|
return unwrap(await res.json());
|
||||||
|
|||||||
@ -7,21 +7,16 @@ import { exportPage } from './pages/export.js';
|
|||||||
import { usersPage } from './pages/users.js';
|
import { usersPage } from './pages/users.js';
|
||||||
import { assignmentsPage } from './pages/assignments.js';
|
import { assignmentsPage } from './pages/assignments.js';
|
||||||
import { clientsPage } from './pages/clients.js';
|
import { clientsPage } from './pages/clients.js';
|
||||||
|
import {
|
||||||
|
isLoggedIn,
|
||||||
|
getRole,
|
||||||
|
getUser,
|
||||||
|
canEdit,
|
||||||
|
authGuard as createAuthGuard,
|
||||||
|
roleGuard as createRoleGuard,
|
||||||
|
} from './auth-state.js';
|
||||||
|
|
||||||
// Auth state
|
export { isLoggedIn, getRole, getUser, canEdit };
|
||||||
export function isLoggedIn() {
|
|
||||||
return !!localStorage.getItem('qdb_token');
|
|
||||||
}
|
|
||||||
export function getRole() {
|
|
||||||
return localStorage.getItem('qdb_role') || '';
|
|
||||||
}
|
|
||||||
export function getUser() {
|
|
||||||
return localStorage.getItem('qdb_user') || '';
|
|
||||||
}
|
|
||||||
export function canEdit() {
|
|
||||||
const r = getRole();
|
|
||||||
return r === 'admin' || r === 'supervisor';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function showToast(message, type = 'info') {
|
export function showToast(message, type = 'info') {
|
||||||
const container = document.getElementById('toastContainer');
|
const container = document.getElementById('toastContainer');
|
||||||
@ -37,10 +32,7 @@ export function showToast(message, type = 'info') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function authGuard(handler) {
|
function authGuard(handler) {
|
||||||
return (params) => {
|
return createAuthGuard(navigate, handler);
|
||||||
if (!isLoggedIn()) { navigate('#/login'); return; }
|
|
||||||
return handler(params);
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateNav() {
|
function updateNav() {
|
||||||
@ -70,11 +62,7 @@ function updateNav() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function roleGuard(roles, handler) {
|
function roleGuard(roles, handler) {
|
||||||
return (params) => {
|
return createRoleGuard(navigate, roles, handler);
|
||||||
if (!isLoggedIn()) { navigate('#/login'); return; }
|
|
||||||
if (!roles.includes(getRole())) { navigate('#/'); return; }
|
|
||||||
return handler(params);
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Routes
|
// Routes
|
||||||
|
|||||||
31
website/js/auth-state.js
Normal file
31
website/js/auth-state.js
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
export function isLoggedIn() {
|
||||||
|
return !!localStorage.getItem('qdb_token');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRole() {
|
||||||
|
return localStorage.getItem('qdb_role') || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUser() {
|
||||||
|
return localStorage.getItem('qdb_user') || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canEdit() {
|
||||||
|
const r = getRole();
|
||||||
|
return r === 'admin' || r === 'supervisor';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function authGuard(navigate, handler) {
|
||||||
|
return (params) => {
|
||||||
|
if (!isLoggedIn()) { navigate('#/login'); return; }
|
||||||
|
return handler(params);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function roleGuard(navigate, roles, handler) {
|
||||||
|
return (params) => {
|
||||||
|
if (!isLoggedIn()) { navigate('#/login'); return; }
|
||||||
|
if (!roles.includes(getRole())) { navigate('#/'); return; }
|
||||||
|
return handler(params);
|
||||||
|
};
|
||||||
|
}
|
||||||
203
website/js/editor-utils.js
Normal file
203
website/js/editor-utils.js
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
export const LAYOUT_TYPES = [
|
||||||
|
{ value: 'radio_question', label: 'Radio (Single Choice)' },
|
||||||
|
{ value: 'multi_check_box_question', label: 'Checkbox (Multiple Choice)' },
|
||||||
|
{ value: 'glass_scale_question', label: 'Glass Scale' },
|
||||||
|
{ value: 'value_spinner', label: 'Value Spinner' },
|
||||||
|
{ value: 'date_spinner', label: 'Date Spinner' },
|
||||||
|
{ value: 'string_spinner', label: 'String Spinner' },
|
||||||
|
{ value: 'client_coach_code_question', label: 'Client / Coach Code' },
|
||||||
|
{ value: 'client_not_signed', label: 'Client Not Signed' },
|
||||||
|
{ value: 'last_page', label: 'Last Page' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const OPTION_TYPES = new Set(['radio_question', 'multi_check_box_question']);
|
||||||
|
|
||||||
|
export function layoutSelectHTML(selected = 'radio_question', id = '') {
|
||||||
|
return `<select ${id ? `id="${id}"` : ''} class="type-select">
|
||||||
|
${LAYOUT_TYPES.map(t =>
|
||||||
|
`<option value="${t.value}" ${selected === t.value ? 'selected' : ''}>${t.label}</option>`
|
||||||
|
).join('')}
|
||||||
|
</select>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function layoutLabel(value) {
|
||||||
|
const t = LAYOUT_TYPES.find(t => t.value === value);
|
||||||
|
return t ? t.label : value || 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseConfig(q) {
|
||||||
|
try { return JSON.parse(q.configJson || '{}'); } catch { return {}; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function questionLocalIds(questions) {
|
||||||
|
return questions.map(q => {
|
||||||
|
const parts = q.questionID.split('__');
|
||||||
|
return { questionID: q.questionID, localId: parts[parts.length - 1], defaultText: q.defaultText };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function esc(s) {
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.textContent = s ?? '';
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function configFormHTML(layout, config, prefix) {
|
||||||
|
let html = '';
|
||||||
|
switch (layout) {
|
||||||
|
case 'radio_question':
|
||||||
|
html = `
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Extra text key (optional)</label>
|
||||||
|
<input type="text" id="${prefix}_textKey" value="${esc(config.textKey || '')}" placeholder="e.g. resilience_reflection_prompt">
|
||||||
|
</div>`;
|
||||||
|
break;
|
||||||
|
case 'multi_check_box_question':
|
||||||
|
html = `
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Min selections</label>
|
||||||
|
<input type="number" id="${prefix}_minSelection" value="${config.minSelection || 0}" min="0">
|
||||||
|
</div>`;
|
||||||
|
break;
|
||||||
|
case 'glass_scale_question':
|
||||||
|
html = `
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Symptom keys (one per line)</label>
|
||||||
|
<textarea id="${prefix}_symptoms" rows="5" style="font-family:monospace;font-size:.85rem">${(config.symptoms || []).join('\n')}</textarea>
|
||||||
|
</div>`;
|
||||||
|
break;
|
||||||
|
case 'value_spinner':
|
||||||
|
html = `
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Min</label>
|
||||||
|
<input type="number" id="${prefix}_rangeMin" value="${config.range?.min ?? 0}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Max</label>
|
||||||
|
<input type="number" id="${prefix}_rangeMax" value="${config.range?.max ?? 100}">
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
break;
|
||||||
|
case 'date_spinner': {
|
||||||
|
const notBefore = config.constraints?.notBefore || '';
|
||||||
|
const notAfter = config.constraints?.notAfter || '';
|
||||||
|
html = `
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Not before (question key)</label>
|
||||||
|
<input type="text" id="${prefix}_notBefore" value="${esc(notBefore)}" placeholder="e.g. departure_country">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Not after (question key)</label>
|
||||||
|
<input type="text" id="${prefix}_notAfter" value="${esc(notAfter)}" placeholder="e.g. since_in_germany">
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'string_spinner':
|
||||||
|
html = `
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Static options (one per line)</label>
|
||||||
|
<textarea id="${prefix}_stringOptions" rows="4" style="font-family:monospace;font-size:.85rem">${(config.options || []).join('\n')}</textarea>
|
||||||
|
</div>`;
|
||||||
|
break;
|
||||||
|
case 'client_coach_code_question':
|
||||||
|
html = `
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Hint 1 key</label>
|
||||||
|
<input type="text" id="${prefix}_hint1" value="${esc(config.hint1 || '')}" placeholder="client_code">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Hint 2 key</label>
|
||||||
|
<input type="text" id="${prefix}_hint2" value="${esc(config.hint2 || '')}" placeholder="coach_code">
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
break;
|
||||||
|
case 'client_not_signed':
|
||||||
|
html = `
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Text key 1</label>
|
||||||
|
<input type="text" id="${prefix}_textKey1" value="${esc(config.textKey1 || '')}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Text key 2</label>
|
||||||
|
<input type="text" id="${prefix}_textKey2" value="${esc(config.textKey2 || '')}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Hint key</label>
|
||||||
|
<input type="text" id="${prefix}_hint" value="${esc(config.hint || '')}">
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
break;
|
||||||
|
case 'last_page':
|
||||||
|
html = `
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Text key</label>
|
||||||
|
<input type="text" id="${prefix}_textKey" value="${esc(config.textKey || '')}" placeholder="finish_data_entry">
|
||||||
|
</div>`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return html ? `<div class="config-section">${html}</div>` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readConfigFromForm(layout, prefix) {
|
||||||
|
const config = {};
|
||||||
|
const val = id => document.getElementById(`${prefix}_${id}`)?.value?.trim() || '';
|
||||||
|
|
||||||
|
switch (layout) {
|
||||||
|
case 'radio_question':
|
||||||
|
if (val('textKey')) config.textKey = val('textKey');
|
||||||
|
break;
|
||||||
|
case 'multi_check_box_question': {
|
||||||
|
const ms = parseInt(val('minSelection') || '0', 10);
|
||||||
|
if (ms > 0) config.minSelection = ms;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'glass_scale_question': {
|
||||||
|
const raw = val('symptoms');
|
||||||
|
const syms = raw.split('\n').map(s => s.trim()).filter(Boolean);
|
||||||
|
if (syms.length) config.symptoms = syms;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'value_spinner':
|
||||||
|
config.range = {
|
||||||
|
min: parseInt(val('rangeMin') || '0', 10),
|
||||||
|
max: parseInt(val('rangeMax') || '100', 10),
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
case 'date_spinner': {
|
||||||
|
const nb = val('notBefore');
|
||||||
|
const na = val('notAfter');
|
||||||
|
if (nb || na) {
|
||||||
|
config.constraints = {};
|
||||||
|
if (nb) config.constraints.notBefore = nb;
|
||||||
|
if (na) config.constraints.notAfter = na;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'string_spinner': {
|
||||||
|
const raw = val('stringOptions');
|
||||||
|
const opts = raw.split('\n').map(s => s.trim()).filter(Boolean);
|
||||||
|
if (opts.length) config.options = opts;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'client_coach_code_question':
|
||||||
|
if (val('hint1')) config.hint1 = val('hint1');
|
||||||
|
if (val('hint2')) config.hint2 = val('hint2');
|
||||||
|
break;
|
||||||
|
case 'client_not_signed':
|
||||||
|
if (val('textKey1')) config.textKey1 = val('textKey1');
|
||||||
|
if (val('textKey2')) config.textKey2 = val('textKey2');
|
||||||
|
if (val('hint')) config.hint = val('hint');
|
||||||
|
break;
|
||||||
|
case 'last_page':
|
||||||
|
if (val('textKey')) config.textKey = val('textKey');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return JSON.stringify(config);
|
||||||
|
}
|
||||||
@ -1,27 +1,23 @@
|
|||||||
import { apiGet, apiPost, apiPut, apiPatch, apiDelete } from '../api.js';
|
import { apiGet, apiPost, apiPut, apiPatch, apiDelete } from '../api.js';
|
||||||
import { canEdit, showToast } from '../app.js';
|
import { canEdit, showToast } from '../app.js';
|
||||||
import { navigate } from '../router.js';
|
import { navigate } from '../router.js';
|
||||||
|
import {
|
||||||
const LAYOUT_TYPES = [
|
OPTION_TYPES,
|
||||||
{ value: 'radio_question', label: 'Radio (Single Choice)' },
|
layoutSelectHTML,
|
||||||
{ value: 'multi_check_box_question', label: 'Checkbox (Multiple Choice)' },
|
layoutLabel,
|
||||||
{ value: 'glass_scale_question', label: 'Glass Scale' },
|
parseConfig,
|
||||||
{ value: 'value_spinner', label: 'Value Spinner' },
|
questionLocalIds,
|
||||||
{ value: 'date_spinner', label: 'Date Spinner' },
|
configFormHTML,
|
||||||
{ value: 'string_spinner', label: 'String Spinner' },
|
readConfigFromForm,
|
||||||
{ value: 'client_coach_code_question', label: 'Client / Coach Code' },
|
esc,
|
||||||
{ value: 'client_not_signed', label: 'Client Not Signed' },
|
} from '../editor-utils.js';
|
||||||
{ value: 'last_page', label: 'Last Page' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const OPTION_TYPES = new Set(['radio_question', 'multi_check_box_question']);
|
|
||||||
|
|
||||||
let questionnaire = null;
|
let questionnaire = null;
|
||||||
let questions = [];
|
let questions = [];
|
||||||
let isNew = false;
|
let isNew = false;
|
||||||
let activeTab = 'questions';
|
let activeTab = 'questions';
|
||||||
|
|
||||||
// ── Entry point ──────────────────────────────────────────────────────────
|
// ── Entry point ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function editorPage(params) {
|
export async function editorPage(params) {
|
||||||
const app = document.getElementById('app');
|
const app = document.getElementById('app');
|
||||||
@ -66,195 +62,7 @@ export async function editorPage(params) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Layout helpers ───────────────────────────────────────────────────────
|
// ── Main editor render (helpers in editor-utils.js) ─────────────────────
|
||||||
|
|
||||||
function layoutSelectHTML(selected = 'radio_question', id = '') {
|
|
||||||
return `<select ${id ? `id="${id}"` : ''} class="type-select">
|
|
||||||
${LAYOUT_TYPES.map(t =>
|
|
||||||
`<option value="${t.value}" ${selected === t.value ? 'selected' : ''}>${t.label}</option>`
|
|
||||||
).join('')}
|
|
||||||
</select>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function layoutLabel(value) {
|
|
||||||
const t = LAYOUT_TYPES.find(t => t.value === value);
|
|
||||||
return t ? t.label : value || 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseConfig(q) {
|
|
||||||
try { return JSON.parse(q.configJson || '{}'); } catch { return {}; }
|
|
||||||
}
|
|
||||||
|
|
||||||
function questionLocalIds() {
|
|
||||||
return questions.map(q => {
|
|
||||||
const parts = q.questionID.split('__');
|
|
||||||
return { questionID: q.questionID, localId: parts[parts.length - 1], defaultText: q.defaultText };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Config form HTML for each layout type ────────────────────────────────
|
|
||||||
|
|
||||||
function configFormHTML(layout, config, prefix) {
|
|
||||||
let html = '';
|
|
||||||
switch (layout) {
|
|
||||||
case 'radio_question':
|
|
||||||
html = `
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Extra text key (optional)</label>
|
|
||||||
<input type="text" id="${prefix}_textKey" value="${esc(config.textKey || '')}" placeholder="e.g. resilience_reflection_prompt">
|
|
||||||
</div>`;
|
|
||||||
break;
|
|
||||||
case 'multi_check_box_question':
|
|
||||||
html = `
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Min selections</label>
|
|
||||||
<input type="number" id="${prefix}_minSelection" value="${config.minSelection || 0}" min="0">
|
|
||||||
</div>`;
|
|
||||||
break;
|
|
||||||
case 'glass_scale_question':
|
|
||||||
html = `
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Symptom keys (one per line)</label>
|
|
||||||
<textarea id="${prefix}_symptoms" rows="5" style="font-family:monospace;font-size:.85rem">${(config.symptoms || []).join('\n')}</textarea>
|
|
||||||
</div>`;
|
|
||||||
break;
|
|
||||||
case 'value_spinner':
|
|
||||||
html = `
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Min</label>
|
|
||||||
<input type="number" id="${prefix}_rangeMin" value="${config.range?.min ?? 0}">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Max</label>
|
|
||||||
<input type="number" id="${prefix}_rangeMax" value="${config.range?.max ?? 100}">
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
break;
|
|
||||||
case 'date_spinner': {
|
|
||||||
const notBefore = config.constraints?.notBefore || '';
|
|
||||||
const notAfter = config.constraints?.notAfter || '';
|
|
||||||
html = `
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Not before (question key)</label>
|
|
||||||
<input type="text" id="${prefix}_notBefore" value="${esc(notBefore)}" placeholder="e.g. departure_country">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Not after (question key)</label>
|
|
||||||
<input type="text" id="${prefix}_notAfter" value="${esc(notAfter)}" placeholder="e.g. since_in_germany">
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'string_spinner':
|
|
||||||
html = `
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Static options (one per line)</label>
|
|
||||||
<textarea id="${prefix}_stringOptions" rows="4" style="font-family:monospace;font-size:.85rem">${(config.options || []).join('\n')}</textarea>
|
|
||||||
</div>`;
|
|
||||||
break;
|
|
||||||
case 'client_coach_code_question':
|
|
||||||
html = `
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Hint 1 key</label>
|
|
||||||
<input type="text" id="${prefix}_hint1" value="${esc(config.hint1 || '')}" placeholder="client_code">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Hint 2 key</label>
|
|
||||||
<input type="text" id="${prefix}_hint2" value="${esc(config.hint2 || '')}" placeholder="coach_code">
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
break;
|
|
||||||
case 'client_not_signed':
|
|
||||||
html = `
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Text key 1</label>
|
|
||||||
<input type="text" id="${prefix}_textKey1" value="${esc(config.textKey1 || '')}">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Text key 2</label>
|
|
||||||
<input type="text" id="${prefix}_textKey2" value="${esc(config.textKey2 || '')}">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Hint key</label>
|
|
||||||
<input type="text" id="${prefix}_hint" value="${esc(config.hint || '')}">
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
break;
|
|
||||||
case 'last_page':
|
|
||||||
html = `
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Text key</label>
|
|
||||||
<input type="text" id="${prefix}_textKey" value="${esc(config.textKey || '')}" placeholder="finish_data_entry">
|
|
||||||
</div>`;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
return html ? `<div class="config-section">${html}</div>` : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function readConfigFromForm(layout, prefix) {
|
|
||||||
const config = {};
|
|
||||||
const val = id => document.getElementById(`${prefix}_${id}`)?.value?.trim() || '';
|
|
||||||
|
|
||||||
switch (layout) {
|
|
||||||
case 'radio_question':
|
|
||||||
if (val('textKey')) config.textKey = val('textKey');
|
|
||||||
break;
|
|
||||||
case 'multi_check_box_question': {
|
|
||||||
const ms = parseInt(val('minSelection') || '0', 10);
|
|
||||||
if (ms > 0) config.minSelection = ms;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'glass_scale_question': {
|
|
||||||
const raw = val('symptoms');
|
|
||||||
const syms = raw.split('\n').map(s => s.trim()).filter(Boolean);
|
|
||||||
if (syms.length) config.symptoms = syms;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'value_spinner':
|
|
||||||
config.range = {
|
|
||||||
min: parseInt(val('rangeMin') || '0', 10),
|
|
||||||
max: parseInt(val('rangeMax') || '100', 10),
|
|
||||||
};
|
|
||||||
break;
|
|
||||||
case 'date_spinner': {
|
|
||||||
const nb = val('notBefore');
|
|
||||||
const na = val('notAfter');
|
|
||||||
if (nb || na) {
|
|
||||||
config.constraints = {};
|
|
||||||
if (nb) config.constraints.notBefore = nb;
|
|
||||||
if (na) config.constraints.notAfter = na;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'string_spinner': {
|
|
||||||
const raw = val('stringOptions');
|
|
||||||
const opts = raw.split('\n').map(s => s.trim()).filter(Boolean);
|
|
||||||
if (opts.length) config.options = opts;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'client_coach_code_question':
|
|
||||||
if (val('hint1')) config.hint1 = val('hint1');
|
|
||||||
if (val('hint2')) config.hint2 = val('hint2');
|
|
||||||
break;
|
|
||||||
case 'client_not_signed':
|
|
||||||
if (val('textKey1')) config.textKey1 = val('textKey1');
|
|
||||||
if (val('textKey2')) config.textKey2 = val('textKey2');
|
|
||||||
if (val('hint')) config.hint = val('hint');
|
|
||||||
break;
|
|
||||||
case 'last_page':
|
|
||||||
if (val('textKey')) config.textKey = val('textKey');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
return JSON.stringify(config);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Main editor render ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function renderEditor() {
|
function renderEditor() {
|
||||||
const container = document.getElementById('editorContent');
|
const container = document.getElementById('editorContent');
|
||||||
@ -367,7 +175,7 @@ function formatJson(str) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Save questionnaire meta ──────────────────────────────────────────────
|
// ── Save questionnaire meta ──────────────────────────────────────────────
|
||||||
|
|
||||||
async function saveMeta() {
|
async function saveMeta() {
|
||||||
const name = document.getElementById('metaName').value.trim();
|
const name = document.getElementById('metaName').value.trim();
|
||||||
@ -413,7 +221,7 @@ async function saveMeta() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Add question form ────────────────────────────────────────────────────
|
// ── Add question form ────────────────────────────────────────────────────
|
||||||
|
|
||||||
function showAddQuestionForm() {
|
function showAddQuestionForm() {
|
||||||
const wrapper = document.getElementById('addQuestionFormWrapper');
|
const wrapper = document.getElementById('addQuestionFormWrapper');
|
||||||
@ -457,7 +265,7 @@ function showAddQuestionForm() {
|
|||||||
<div class="form-group" style="width:160px;margin-bottom:0">
|
<div class="form-group" style="width:160px;margin-bottom:0">
|
||||||
<select id="aq_opt_next">
|
<select id="aq_opt_next">
|
||||||
<option value="">(next in order)</option>
|
<option value="">(next in order)</option>
|
||||||
${questionLocalIds().map(q => `<option value="${esc(q.localId)}">${esc(q.localId)} - ${esc(q.defaultText)}</option>`).join('')}
|
${questionLocalIds(questions).map(q => `<option value="${esc(q.localId)}">${esc(q.localId)} - ${esc(q.defaultText)}</option>`).join('')}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-sm" id="aq_opt_add" style="flex-shrink:0;white-space:nowrap">+ Add</button>
|
<button class="btn btn-sm" id="aq_opt_add" style="flex-shrink:0;white-space:nowrap">+ Add</button>
|
||||||
@ -588,7 +396,7 @@ function hideAddQuestionForm() {
|
|||||||
if (wrapper) { wrapper.style.display = 'none'; wrapper.innerHTML = ''; }
|
if (wrapper) { wrapper.style.display = 'none'; wrapper.innerHTML = ''; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Questions list ───────────────────────────────────────────────────────
|
// ── Questions list ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
function renderQuestions() {
|
function renderQuestions() {
|
||||||
const list = document.getElementById('questionList');
|
const list = document.getElementById('questionList');
|
||||||
@ -628,7 +436,7 @@ function renderQuestions() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Question body (expanded) ─────────────────────────────────────────────
|
// ── Question body (expanded) ─────────────────────────────────────────────
|
||||||
|
|
||||||
function renderQuestionBody(q) {
|
function renderQuestionBody(q) {
|
||||||
const body = document.getElementById(`qbody-${q.questionID}`);
|
const body = document.getElementById(`qbody-${q.questionID}`);
|
||||||
@ -734,7 +542,7 @@ function optionItemHTML(q, o, editable) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Add option form ──────────────────────────────────────────────────────
|
// ── Add option form ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
function showAddOptionForm(q) {
|
function showAddOptionForm(q) {
|
||||||
const wrapper = document.getElementById(`addOptForm-${q.questionID}`);
|
const wrapper = document.getElementById(`addOptForm-${q.questionID}`);
|
||||||
@ -743,7 +551,7 @@ function showAddOptionForm(q) {
|
|||||||
toggleBtn.style.display = 'none';
|
toggleBtn.style.display = 'none';
|
||||||
wrapper.style.display = '';
|
wrapper.style.display = '';
|
||||||
|
|
||||||
const qIds = questionLocalIds();
|
const qIds = questionLocalIds(questions);
|
||||||
|
|
||||||
wrapper.innerHTML = `
|
wrapper.innerHTML = `
|
||||||
<div class="inline-form-card" style="margin-top:8px">
|
<div class="inline-form-card" style="margin-top:8px">
|
||||||
@ -813,12 +621,12 @@ function hideAddOptionForm(q) {
|
|||||||
if (toggleBtn) toggleBtn.style.display = '';
|
if (toggleBtn) toggleBtn.style.display = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Edit option form ─────────────────────────────────────────────────────
|
// ── Edit option form ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
function showEditOptionForm(q, opt) {
|
function showEditOptionForm(q, opt) {
|
||||||
const li = document.querySelector(`#opts-${q.questionID} .option-item[data-id="${opt.answerOptionID}"]`);
|
const li = document.querySelector(`#opts-${q.questionID} .option-item[data-id="${opt.answerOptionID}"]`);
|
||||||
if (!li) return;
|
if (!li) return;
|
||||||
const qIds = questionLocalIds();
|
const qIds = questionLocalIds(questions);
|
||||||
|
|
||||||
li.innerHTML = `
|
li.innerHTML = `
|
||||||
<div class="inline-form-card" style="width:100%;padding:8px">
|
<div class="inline-form-card" style="width:100%;padding:8px">
|
||||||
@ -861,7 +669,7 @@ function showEditOptionForm(q, opt) {
|
|||||||
document.getElementById(`eo_cancel_${opt.answerOptionID}`).addEventListener('click', () => renderQuestionBody(q));
|
document.getElementById(`eo_cancel_${opt.answerOptionID}`).addEventListener('click', () => renderQuestionBody(q));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Question CRUD ────────────────────────────────────────────────────────
|
// ── Question CRUD ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function updateQuestion(questionID, fields) {
|
async function updateQuestion(questionID, fields) {
|
||||||
try {
|
try {
|
||||||
@ -892,7 +700,7 @@ async function deleteQuestion(q) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Answer option CRUD ───────────────────────────────────────────────────
|
// ── Answer option CRUD ───────────────────────────────────────────────────
|
||||||
|
|
||||||
async function updateOption(q, answerOptionID, fields) {
|
async function updateOption(q, answerOptionID, fields) {
|
||||||
try {
|
try {
|
||||||
@ -920,7 +728,7 @@ async function deleteOption(q, answerOptionID) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Translations tab ─────────────────────────────────────────────────────
|
// ── Translations tab ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
let transData = null;
|
let transData = null;
|
||||||
|
|
||||||
@ -1169,7 +977,7 @@ function updateTransStats(entries, langCodes) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Drag & drop (questions) ──────────────────────────────────────────────
|
// ── Drag & drop (questions) ──────────────────────────────────────────────
|
||||||
|
|
||||||
function initDragReorder() {
|
function initDragReorder() {
|
||||||
const list = document.getElementById('questionList');
|
const list = document.getElementById('questionList');
|
||||||
@ -1264,9 +1072,3 @@ function getDragAfterElement(container, y, selector) {
|
|||||||
return closest;
|
return closest;
|
||||||
}, { offset: Number.NEGATIVE_INFINITY }).element;
|
}, { offset: Number.NEGATIVE_INFINITY }).element;
|
||||||
}
|
}
|
||||||
|
|
||||||
function esc(s) {
|
|
||||||
const d = document.createElement('div');
|
|
||||||
d.textContent = s ?? '';
|
|
||||||
return d.innerHTML;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,17 +1,37 @@
|
|||||||
const routes = [];
|
const routes = [];
|
||||||
let currentCleanup = null;
|
let currentCleanup = null;
|
||||||
|
|
||||||
export function addRoute(pattern, handler) {
|
/** @returns {{ regex: RegExp, paramNames: string[] }} */
|
||||||
let regex;
|
export function compileRoute(pattern) {
|
||||||
const paramNames = [];
|
const paramNames = [];
|
||||||
if (pattern instanceof RegExp) {
|
|
||||||
regex = pattern;
|
|
||||||
} else {
|
|
||||||
const parts = pattern.replace(/\/:([^/]+)/g, (_, name) => {
|
const parts = pattern.replace(/\/:([^/]+)/g, (_, name) => {
|
||||||
paramNames.push(name);
|
paramNames.push(name);
|
||||||
return '/([^/]+)';
|
return '/([^/]+)';
|
||||||
});
|
});
|
||||||
regex = new RegExp('^' + parts + '$');
|
return { regex: new RegExp('^' + parts + '$'), paramNames };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Match a path against a string route pattern; returns params or null. */
|
||||||
|
export function matchRoute(pattern, path) {
|
||||||
|
const { regex, paramNames } = compileRoute(pattern);
|
||||||
|
const match = path.match(regex);
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const params = {};
|
||||||
|
paramNames.forEach((name, i) => {
|
||||||
|
params[name] = decodeURIComponent(match[i + 1]);
|
||||||
|
});
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addRoute(pattern, handler) {
|
||||||
|
let regex;
|
||||||
|
let paramNames = [];
|
||||||
|
if (pattern instanceof RegExp) {
|
||||||
|
regex = pattern;
|
||||||
|
} else {
|
||||||
|
({ regex, paramNames } = compileRoute(pattern));
|
||||||
}
|
}
|
||||||
routes.push({ regex, paramNames, handler });
|
routes.push({ regex, paramNames, handler });
|
||||||
}
|
}
|
||||||
|
|||||||
13
website/package.json
Normal file
13
website/package.json
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "nat-as-website",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"test:unit": "vitest run",
|
||||||
|
"test:unit:watch": "vitest"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"jsdom": "^25.0.1",
|
||||||
|
"vitest": "^3.0.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
28
website/tests/unit/api-envelope.test.js
Normal file
28
website/tests/unit/api-envelope.test.js
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { unwrap } from '../../js/api-envelope.js';
|
||||||
|
|
||||||
|
describe('unwrap', () => {
|
||||||
|
it('unwraps ok:true object data', () => {
|
||||||
|
const r = unwrap({ ok: true, data: { token: 'abc', role: 'admin' } });
|
||||||
|
expect(r.success).toBe(true);
|
||||||
|
expect(r.token).toBe('abc');
|
||||||
|
expect(r.role).toBe('admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unwraps ok:true array data as items', () => {
|
||||||
|
const r = unwrap({ ok: true, data: [{ id: 1 }] });
|
||||||
|
expect(r.success).toBe(true);
|
||||||
|
expect(r.items).toEqual([{ id: 1 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unwraps ok:false errors', () => {
|
||||||
|
const r = unwrap({ ok: false, error: { code: 'X', message: 'Failed' } });
|
||||||
|
expect(r.success).toBe(false);
|
||||||
|
expect(r.error).toBe('Failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes through legacy body', () => {
|
||||||
|
const legacy = { success: true, questionnaires: [] };
|
||||||
|
expect(unwrap(legacy)).toBe(legacy);
|
||||||
|
});
|
||||||
|
});
|
||||||
41
website/tests/unit/auth-state.test.js
Normal file
41
website/tests/unit/auth-state.test.js
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { canEdit, isLoggedIn, getRole, authGuard, roleGuard } from '../../js/auth-state.js';
|
||||||
|
|
||||||
|
describe('auth-state', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('canEdit allows admin and supervisor only', () => {
|
||||||
|
localStorage.setItem('qdb_role', 'admin');
|
||||||
|
expect(canEdit()).toBe(true);
|
||||||
|
localStorage.setItem('qdb_role', 'supervisor');
|
||||||
|
expect(canEdit()).toBe(true);
|
||||||
|
localStorage.setItem('qdb_role', 'coach');
|
||||||
|
expect(canEdit()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isLoggedIn checks token', () => {
|
||||||
|
expect(isLoggedIn()).toBe(false);
|
||||||
|
localStorage.setItem('qdb_token', 't');
|
||||||
|
expect(isLoggedIn()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('authGuard redirects when logged out', () => {
|
||||||
|
const navigate = vi.fn();
|
||||||
|
const handler = vi.fn();
|
||||||
|
const guarded = authGuard(navigate, handler);
|
||||||
|
guarded({});
|
||||||
|
expect(navigate).toHaveBeenCalledWith('#/login');
|
||||||
|
expect(handler).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('roleGuard redirects coach from admin routes', () => {
|
||||||
|
localStorage.setItem('qdb_token', 't');
|
||||||
|
localStorage.setItem('qdb_role', 'coach');
|
||||||
|
const navigate = vi.fn();
|
||||||
|
const handler = vi.fn();
|
||||||
|
roleGuard(navigate, ['admin', 'supervisor'], handler)({});
|
||||||
|
expect(navigate).toHaveBeenCalledWith('#/');
|
||||||
|
});
|
||||||
|
});
|
||||||
39
website/tests/unit/editor-utils.test.js
Normal file
39
website/tests/unit/editor-utils.test.js
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
parseConfig,
|
||||||
|
questionLocalIds,
|
||||||
|
layoutLabel,
|
||||||
|
readConfigFromForm,
|
||||||
|
} from '../../js/editor-utils.js';
|
||||||
|
|
||||||
|
describe('editor-utils', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parseConfig parses JSON', () => {
|
||||||
|
expect(parseConfig({ configJson: '{"textKey":"a"}' })).toEqual({ textKey: 'a' });
|
||||||
|
expect(parseConfig({ configJson: 'not-json' })).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('questionLocalIds extracts short ids', () => {
|
||||||
|
const ids = questionLocalIds([
|
||||||
|
{ questionID: 'qn__q1', defaultText: 'Q1' },
|
||||||
|
{ questionID: 'qn__q2', defaultText: 'Q2' },
|
||||||
|
]);
|
||||||
|
expect(ids.map(i => i.localId)).toEqual(['q1', 'q2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('layoutLabel resolves known layouts', () => {
|
||||||
|
expect(layoutLabel('radio_question')).toContain('Radio');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('readConfigFromForm round-trips value_spinner', () => {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<input id="pfx_rangeMin" value="1" />
|
||||||
|
<input id="pfx_rangeMax" value="10" />
|
||||||
|
`;
|
||||||
|
const json = readConfigFromForm('value_spinner', 'pfx');
|
||||||
|
expect(JSON.parse(json)).toEqual({ range: { min: 1, max: 10 } });
|
||||||
|
});
|
||||||
|
});
|
||||||
33
website/tests/unit/router.test.js
Normal file
33
website/tests/unit/router.test.js
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
import { navigate, currentHash, matchRoute } from '../../js/router.js';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
describe('router', () => {
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
|
||||||
|
window.location.hash = '';
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
it('currentHash defaults to / when empty', () => {
|
||||||
|
|
||||||
|
window.location.hash = '';
|
||||||
|
|
||||||
|
expect(currentHash()).toBe('/');
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
it('currentHash strips leading hash', () => {
|
||||||
|
|
||||||
|
window.location.hash = '#/dashboard';
|
||||||
|
|
||||||
|
expect(currentHash()).toBe('/dashboard');
|
||||||
|
|
||||||
|
});
|
||||||
8
website/vitest.config.js
Normal file
8
website/vitest.config.js
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
include: ['tests/unit/**/*.test.js'],
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user