Compare commits

1 Commits

Author SHA1 Message Date
06fc134299 just testing the environment
Some checks failed
Tests / test (push) Has been cancelled
2026-05-20 07:10:08 +02:00
56 changed files with 1890 additions and 361 deletions

View 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
View File

@ -8,6 +8,11 @@ valid_tokens.txt
uploads/*
!uploads/.gitkeep
# Test runtime artifacts
tests/var/*
!tests/var/uploads/.gitkeep
tests/.phpunit.cache/
# Database lock files (just in case)
uploads/.qdb_lock
uploads/*.lock

146
TESTING.md Normal file
View 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. Wingets 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
View 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);
}
}

View File

@ -1,67 +1,6 @@
<?php
/**
* 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__ . '/../db_init.php';
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);
}
require_once __DIR__ . '/dispatch.php';
qdb_dispatch_api_request();

View File

@ -141,26 +141,28 @@ function get_bearer_token(): ?string {
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 {
$token = get_bearer_token();
if (!$token) {
http_response_code(401);
header('Content-Type: application/json; charset=UTF-8');
echo json_encode(["error" => "Missing Bearer token"]);
exit;
qdb_auth_abort(["error" => "Missing Bearer token"], 401);
}
$rec = token_get_record($token);
if (!$rec) {
http_response_code(403);
header('Content-Type: application/json; charset=UTF-8');
echo json_encode(["error" => "Invalid or expired token"]);
exit;
qdb_auth_abort(["error" => "Invalid or expired token"], 403);
}
if (!empty($rec['temp'])) {
http_response_code(403);
header('Content-Type: application/json; charset=UTF-8');
echo json_encode(["error" => "Password change required before access"]);
exit;
qdb_auth_abort(["error" => "Password change required before access"], 403);
}
$rec['_token'] = $token;
return $rec;
@ -169,10 +171,7 @@ function require_valid_token(): array {
function require_role(array $allowed, array $tokenRecord): void {
$role = $tokenRecord['role'] ?? '';
if (!in_array($role, $allowed, true)) {
http_response_code(403);
header('Content-Type: application/json; charset=UTF-8');
echo json_encode(["error" => "Insufficient permissions"]);
exit;
qdb_auth_abort(["error" => "Insufficient permissions"], 403);
}
}

15
composer.json Normal file
View 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"
}
}

View File

@ -3,9 +3,15 @@
// Shared helpers for opening, creating, and saving the encrypted SQLite database.
require_once __DIR__ . '/common.php';
define('QDB_PATH', __DIR__ . '/uploads/questionnaire_database');
define('QDB_LOCK', __DIR__ . '/uploads/.qdb_lock');
define('QDB_SCHEMA', __DIR__ . '/schema.sql');
if (!defined('QDB_PATH')) {
define('QDB_PATH', __DIR__ . '/uploads/questionnaire_database');
}
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);
/**

View 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
View 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
View 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
View 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
View 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();
});
});

View 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();
});
});

View File

@ -64,6 +64,8 @@ case 'POST':
'nextQuestionId' => $nextQ,
'translations' => [],
]]);
} catch (QdbHttpResponse $e) {
throw $e;
} catch (Throwable $e) {
qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage());
@ -104,6 +106,8 @@ case 'PUT':
'orderIndex' => $order,
'nextQuestionId' => $nextQ,
]]);
} catch (QdbHttpResponse $e) {
throw $e;
} catch (Throwable $e) {
qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage());
@ -130,6 +134,8 @@ case 'DELETE':
$pdo->exec('PRAGMA foreign_keys = ON;');
qdb_save($tmpDb, $lockFp);
json_success(['deleted' => true]);
} catch (QdbHttpResponse $e) {
throw $e;
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
@ -154,6 +160,8 @@ case 'PATCH':
}
qdb_save($tmpDb, $lockFp);
json_success(['reordered' => true]);
} catch (QdbHttpResponse $e) {
throw $e;
} catch (Throwable $e) {
qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage());

View File

@ -145,6 +145,8 @@ if ($method === 'POST') {
qdb_save($tmpDb, $lockFp);
json_success(['submitted' => true, 'sumPoints' => $sumPoints]);
} catch (QdbHttpResponse $e) {
throw $e;
} catch (Throwable $e) {
if (isset($pdo) && $pdo->inTransaction()) $pdo->rollBack();
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);

View File

@ -44,6 +44,8 @@ case 'GET':
qdb_discard($tmpDb, $lockFp);
json_success(['coaches' => $coaches, 'clients' => $clients]);
} catch (QdbHttpResponse $e) {
throw $e;
} catch (Throwable $e) {
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage());
@ -92,6 +94,8 @@ case 'POST':
qdb_save($tmpDb, $lockFp);
json_success(['assigned' => $count]);
} catch (QdbHttpResponse $e) {
throw $e;
} catch (Throwable $e) {
if (isset($pdo) && $pdo->inTransaction()) $pdo->rollBack();
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);

View File

@ -56,6 +56,8 @@ case 'auth/login':
'user' => $username,
'role' => $user['role'],
]);
} catch (QdbHttpResponse $e) {
throw $e;
} catch (Throwable $e) {
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage());
@ -138,6 +140,8 @@ case 'auth/change-password':
'user' => $username,
'role' => $user['role'],
]);
} catch (QdbHttpResponse $e) {
throw $e;
} catch (Throwable $e) {
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage());

View File

@ -26,6 +26,8 @@ case 'GET':
qdb_discard($tmpDb, $lockFp);
json_success(['clients' => $clients]);
} catch (QdbHttpResponse $e) {
throw $e;
} catch (Throwable $e) {
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage());
@ -70,6 +72,8 @@ case 'POST':
qdb_save($tmpDb, $lockFp);
json_success(['clientCode' => $clientCode, 'coachID' => $coachID]);
} catch (QdbHttpResponse $e) {
throw $e;
} catch (Throwable $e) {
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage());
@ -110,6 +114,8 @@ case 'DELETE':
qdb_save($tmpDb, $lockFp);
json_success([]);
} catch (QdbHttpResponse $e) {
throw $e;
} catch (Throwable $e) {
if (isset($pdo) && $pdo->inTransaction()) $pdo->rollBack();
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);

View File

@ -20,7 +20,9 @@ if ($encMaster === false) {
try {
$masterKey = get_master_key_bytes();
$plain = aes256_cbc_decrypt_bytes($encMaster, $masterKey);
} catch (Throwable $e) {
} catch (QdbHttpResponse $e) {
throw $e;
} catch (Throwable $e) {
error_log($e->getMessage());
json_error('SERVER_ERROR', 'Decryption failed', 500);
}

View File

@ -54,6 +54,8 @@ case 'POST':
'state' => $state, 'orderIndex' => $order, 'showPoints' => $showPts,
'conditionJson' => $condJson, 'questionCount' => 0,
]]);
} catch (QdbHttpResponse $e) {
throw $e;
} catch (Throwable $e) {
qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage());
@ -95,6 +97,8 @@ case 'PUT':
'questionnaireID' => $id, 'name' => $name, 'version' => $version, 'state' => $state,
'orderIndex' => $order, 'showPoints' => $showPts, 'conditionJson' => $condJson,
]]);
} catch (QdbHttpResponse $e) {
throw $e;
} catch (Throwable $e) {
qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage());
@ -138,6 +142,8 @@ case 'DELETE':
$pdo->exec('PRAGMA foreign_keys = ON;');
qdb_save($tmpDb, $lockFp);
json_success([]);
} catch (QdbHttpResponse $e) {
throw $e;
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();

View File

@ -75,6 +75,8 @@ case 'POST':
'type' => $type, 'orderIndex' => $order, 'isRequired' => $req,
'configJson' => $config, 'answerOptions' => [], 'translations' => [],
]]);
} catch (QdbHttpResponse $e) {
throw $e;
} catch (Throwable $e) {
qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage());
@ -111,6 +113,8 @@ case 'PUT':
'defaultText' => $text, 'type' => $type, 'orderIndex' => $order, 'isRequired' => $req,
'configJson' => $config,
]]);
} catch (QdbHttpResponse $e) {
throw $e;
} catch (Throwable $e) {
qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage());
@ -142,6 +146,8 @@ case 'DELETE':
$pdo->exec("PRAGMA foreign_keys = ON;");
qdb_save($tmpDb, $lockFp);
json_success(['deleted' => true]);
} catch (QdbHttpResponse $e) {
throw $e;
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
@ -166,6 +172,8 @@ case 'PATCH':
}
qdb_save($tmpDb, $lockFp);
json_success(['reordered' => true]);
} catch (QdbHttpResponse $e) {
throw $e;
} catch (Throwable $e) {
qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage());

View File

@ -137,7 +137,9 @@ case 'PUT':
->execute([':lc' => $lang, ':n' => $name]);
qdb_save($tmpDb, $lockFp);
json_success([]);
} catch (Throwable $e) {
} catch (QdbHttpResponse $e) {
throw $e;
} catch (Throwable $e) {
qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage());
json_error('SERVER_ERROR', 'Server error', 500);
@ -172,6 +174,8 @@ case 'PUT':
}
qdb_save($tmpDb, $lockFp);
json_success([]);
} catch (QdbHttpResponse $e) {
throw $e;
} catch (Throwable $e) {
qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage());
@ -197,7 +201,9 @@ case 'DELETE':
$pdo->prepare("DELETE FROM string_translation WHERE languageCode = :lc")->execute([':lc' => $lang]);
qdb_save($tmpDb, $lockFp);
json_success([]);
} catch (Throwable $e) {
} catch (QdbHttpResponse $e) {
throw $e;
} catch (Throwable $e) {
qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage());
json_error('SERVER_ERROR', 'Server error', 500);
@ -225,6 +231,8 @@ case 'DELETE':
}
qdb_save($tmpDb, $lockFp);
json_success([]);
} catch (QdbHttpResponse $e) {
throw $e;
} catch (Throwable $e) {
qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage());

View File

@ -53,6 +53,8 @@ case 'GET':
'supervisors' => $supervisors,
'callerRole' => $callerRole,
]);
} catch (QdbHttpResponse $e) {
throw $e;
} catch (Throwable $e) {
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
error_log($e->getMessage());
@ -161,6 +163,8 @@ case 'POST':
'mustChangePassword' => $mustChange,
'createdAt' => $now,
]);
} catch (QdbHttpResponse $e) {
throw $e;
} catch (Throwable $e) {
if (isset($pdo) && $pdo->inTransaction()) $pdo->rollBack();
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);
@ -222,6 +226,8 @@ case 'DELETE':
qdb_save($tmpDb, $lockFp);
json_success([]);
} catch (QdbHttpResponse $e) {
throw $e;
} catch (Throwable $e) {
if (isset($pdo) && $pdo->inTransaction()) $pdo->rollBack();
if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp);

14
lib/QdbHttpResponse.php Normal file
View 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);
}
}

View File

@ -1,20 +1,33 @@
<?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);
header('Content-Type: application/json; charset=UTF-8');
echo json_encode(["ok" => true, "data" => $data]);
echo json_encode($body);
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 {
http_response_code($status);
header('Content-Type: application/json; charset=UTF-8');
echo json_encode(["ok" => false, "error" => ["code" => $code, "message" => $message]]);
exit;
qdb_test_abort(["ok" => false, "error" => ["code" => $code, "message" => $message]], $status);
}
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');
$data = json_decode($raw, true);
if (!is_array($data)) {

15
package.json Normal file
View 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
View 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==',
},
},
});

View File

@ -0,0 +1,4 @@
{
"status": "passed",
"failedTests": []
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

38
tests/bootstrap.php Normal file
View File

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

18
tests/phpunit.xml Normal file
View File

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

1
uploads/.gitkeep Normal file
View File

@ -0,0 +1 @@

View 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;
}

View File

@ -1,3 +1,5 @@
import { unwrap } from './api-envelope.js';
const API_BASE = '../api';
function getToken() {
@ -32,23 +34,6 @@ async function apiFetch(endpoint, options = {}) {
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) {
const res = await apiFetch(endpoint);
return unwrap(await res.json());

View File

@ -7,21 +7,16 @@ import { exportPage } from './pages/export.js';
import { usersPage } from './pages/users.js';
import { assignmentsPage } from './pages/assignments.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 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 { isLoggedIn, getRole, getUser, canEdit };
export function showToast(message, type = 'info') {
const container = document.getElementById('toastContainer');
@ -37,10 +32,7 @@ export function showToast(message, type = 'info') {
}
function authGuard(handler) {
return (params) => {
if (!isLoggedIn()) { navigate('#/login'); return; }
return handler(params);
};
return createAuthGuard(navigate, handler);
}
function updateNav() {
@ -70,11 +62,7 @@ function updateNav() {
}
function roleGuard(roles, handler) {
return (params) => {
if (!isLoggedIn()) { navigate('#/login'); return; }
if (!roles.includes(getRole())) { navigate('#/'); return; }
return handler(params);
};
return createRoleGuard(navigate, roles, handler);
}
// Routes

31
website/js/auth-state.js Normal file
View 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
View 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);
}

View File

@ -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 { navigate } from '../router.js';
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' },
];
const OPTION_TYPES = new Set(['radio_question', 'multi_check_box_question']);
import {
OPTION_TYPES,
layoutSelectHTML,
layoutLabel,
parseConfig,
questionLocalIds,
configFormHTML,
readConfigFromForm,
esc,
} from '../editor-utils.js';
let questionnaire = null;
let questions = [];
let isNew = false;
let activeTab = 'questions';
// ── Entry point ──────────────────────────────────────────────────────────
// ── Entry point ──────────────────────────────────────────────────────────
export async function editorPage(params) {
const app = document.getElementById('app');
@ -66,195 +62,7 @@ export async function editorPage(params) {
}
}
// ── Layout helpers ───────────────────────────────────────────────────────
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 ───────────────────────────────────────────────────
// ── Main editor render (helpers in editor-utils.js) ─────────────────────
function renderEditor() {
const container = document.getElementById('editorContent');
@ -367,7 +175,7 @@ function formatJson(str) {
}
}
// ── Save questionnaire meta ──────────────────────────────────────────────
// ── Save questionnaire meta ──────────────────────────────────────────────
async function saveMeta() {
const name = document.getElementById('metaName').value.trim();
@ -413,7 +221,7 @@ async function saveMeta() {
}
}
// ── Add question form ────────────────────────────────────────────────────
// ── Add question form ────────────────────────────────────────────────────
function showAddQuestionForm() {
const wrapper = document.getElementById('addQuestionFormWrapper');
@ -457,7 +265,7 @@ function showAddQuestionForm() {
<div class="form-group" style="width:160px;margin-bottom:0">
<select id="aq_opt_next">
<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>
</div>
<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 = ''; }
}
// ── Questions list ───────────────────────────────────────────────────────
// ── Questions list ───────────────────────────────────────────────────────
function renderQuestions() {
const list = document.getElementById('questionList');
@ -628,7 +436,7 @@ function renderQuestions() {
});
}
// ── Question body (expanded) ─────────────────────────────────────────────
// ── Question body (expanded) ─────────────────────────────────────────────
function renderQuestionBody(q) {
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) {
const wrapper = document.getElementById(`addOptForm-${q.questionID}`);
@ -743,7 +551,7 @@ function showAddOptionForm(q) {
toggleBtn.style.display = 'none';
wrapper.style.display = '';
const qIds = questionLocalIds();
const qIds = questionLocalIds(questions);
wrapper.innerHTML = `
<div class="inline-form-card" style="margin-top:8px">
@ -813,12 +621,12 @@ function hideAddOptionForm(q) {
if (toggleBtn) toggleBtn.style.display = '';
}
// ── Edit option form ─────────────────────────────────────────────────────
// ── Edit option form ─────────────────────────────────────────────────────
function showEditOptionForm(q, opt) {
const li = document.querySelector(`#opts-${q.questionID} .option-item[data-id="${opt.answerOptionID}"]`);
if (!li) return;
const qIds = questionLocalIds();
const qIds = questionLocalIds(questions);
li.innerHTML = `
<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));
}
// ── Question CRUD ────────────────────────────────────────────────────────
// ── Question CRUD ────────────────────────────────────────────────────────
async function updateQuestion(questionID, fields) {
try {
@ -892,7 +700,7 @@ async function deleteQuestion(q) {
}
}
// ── Answer option CRUD ───────────────────────────────────────────────────
// ── Answer option CRUD ───────────────────────────────────────────────────
async function updateOption(q, answerOptionID, fields) {
try {
@ -920,7 +728,7 @@ async function deleteOption(q, answerOptionID) {
}
}
// ── Translations tab ─────────────────────────────────────────────────────
// ── Translations tab ─────────────────────────────────────────────────────
let transData = null;
@ -1169,7 +977,7 @@ function updateTransStats(entries, langCodes) {
`;
}
// ── Drag & drop (questions) ──────────────────────────────────────────────
// ── Drag & drop (questions) ──────────────────────────────────────────────
function initDragReorder() {
const list = document.getElementById('questionList');
@ -1264,9 +1072,3 @@ function getDragAfterElement(container, y, selector) {
return closest;
}, { offset: Number.NEGATIVE_INFINITY }).element;
}
function esc(s) {
const d = document.createElement('div');
d.textContent = s ?? '';
return d.innerHTML;
}

View File

@ -1,17 +1,37 @@
const routes = [];
let currentCleanup = null;
/** @returns {{ regex: RegExp, paramNames: string[] }} */
export function compileRoute(pattern) {
const paramNames = [];
const parts = pattern.replace(/\/:([^/]+)/g, (_, name) => {
paramNames.push(name);
return '/([^/]+)';
});
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;
const paramNames = [];
let paramNames = [];
if (pattern instanceof RegExp) {
regex = pattern;
} else {
const parts = pattern.replace(/\/:([^/]+)/g, (_, name) => {
paramNames.push(name);
return '/([^/]+)';
});
regex = new RegExp('^' + parts + '$');
({ regex, paramNames } = compileRoute(pattern));
}
routes.push({ regex, paramNames, handler });
}

13
website/package.json Normal file
View 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"
}
}

View 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);
});
});

View 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('#/');
});
});

View 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 } });
});
});

View 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
View File

@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom',
include: ['tests/unit/**/*.test.js'],
},
});