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/.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
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
|
||||
/**
|
||||
* 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();
|
||||
|
||||
31
common.php
31
common.php
@ -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
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"
|
||||
}
|
||||
}
|
||||
@ -3,9 +3,15 @@
|
||||
// Shared helpers for opening, creating, and saving the encrypted SQLite database.
|
||||
require_once __DIR__ . '/common.php';
|
||||
|
||||
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);
|
||||
|
||||
/**
|
||||
|
||||
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,
|
||||
'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());
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -20,6 +20,8 @@ if ($encMaster === false) {
|
||||
try {
|
||||
$masterKey = get_master_key_bytes();
|
||||
$plain = aes256_cbc_decrypt_bytes($encMaster, $masterKey);
|
||||
} catch (QdbHttpResponse $e) {
|
||||
throw $e;
|
||||
} catch (Throwable $e) {
|
||||
error_log($e->getMessage());
|
||||
json_error('SERVER_ERROR', 'Decryption failed', 500);
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -137,6 +137,8 @@ case 'PUT':
|
||||
->execute([':lc' => $lang, ':n' => $name]);
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
json_success([]);
|
||||
} catch (QdbHttpResponse $e) {
|
||||
throw $e;
|
||||
} catch (Throwable $e) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
error_log($e->getMessage());
|
||||
@ -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,6 +201,8 @@ case 'DELETE':
|
||||
$pdo->prepare("DELETE FROM string_translation WHERE languageCode = :lc")->execute([':lc' => $lang]);
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
json_success([]);
|
||||
} catch (QdbHttpResponse $e) {
|
||||
throw $e;
|
||||
} catch (Throwable $e) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
error_log($e->getMessage());
|
||||
@ -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());
|
||||
|
||||
@ -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
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
|
||||
|
||||
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
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';
|
||||
|
||||
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());
|
||||
|
||||
@ -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
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 { 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;
|
||||
}
|
||||
|
||||
@ -1,17 +1,37 @@
|
||||
const routes = [];
|
||||
let currentCleanup = null;
|
||||
|
||||
export function addRoute(pattern, handler) {
|
||||
let regex;
|
||||
/** @returns {{ regex: RegExp, paramNames: string[] }} */
|
||||
export function compileRoute(pattern) {
|
||||
const paramNames = [];
|
||||
if (pattern instanceof RegExp) {
|
||||
regex = pattern;
|
||||
} else {
|
||||
const parts = pattern.replace(/\/:([^/]+)/g, (_, name) => {
|
||||
paramNames.push(name);
|
||||
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 });
|
||||
}
|
||||
|
||||
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