From 06fc134299e7f85aef647736d9e40fddf22d5bab Mon Sep 17 00:00:00 2001 From: "tom.hempel" Date: Wed, 20 May 2026 07:10:08 +0200 Subject: [PATCH] just testing the environment --- .gitea/workflows/tests.yml | 44 ++++ .gitignore | 5 + TESTING.md | 146 ++++++++++++ api/dispatch.php | 72 ++++++ api/index.php | 65 +---- common.php | 31 ++- composer.json | 15 ++ db_init.php | 12 +- e2e/api/app-questionnaires.spec.ts | 77 ++++++ e2e/api/auth.spec.ts | 25 ++ e2e/global-setup.js | 16 ++ e2e/website/helpers.ts | 16 ++ e2e/website/login.spec.ts | 21 ++ e2e/website/routes.spec.ts | 64 +++++ handlers/answer_options.php | 8 + handlers/app_questionnaires.php | 2 + handlers/assignments.php | 4 + handlers/auth.php | 4 + handlers/clients.php | 6 + handlers/download.php | 4 +- handlers/questionnaires.php | 6 + handlers/questions.php | 8 + handlers/translations.php | 12 +- handlers/users.php | 6 + lib/QdbHttpResponse.php | 14 ++ lib/response.php | 25 +- package.json | 15 ++ playwright.config.ts | 29 +++ test-results/.last-run.json | 4 + tests/Integration/AppQuestionnairesTest.php | 78 ++++++ tests/Integration/AuthTest.php | 39 +++ tests/Integration/ClientsTest.php | 49 ++++ tests/Integration/LogoutTest.php | 25 ++ tests/Integration/QuestionnairesTest.php | 25 ++ tests/Integration/UsersTest.php | 48 ++++ tests/Support/ApiTestCase.php | 126 ++++++++++ tests/Support/DatabaseFixture.php | 186 +++++++++++++++ tests/Unit/CommonTest.php | 51 ++++ tests/Unit/ResponseTest.php | 41 ++++ tests/bin/seed-test-db.php | 23 ++ tests/bootstrap.php | 38 +++ tests/phpunit.xml | 18 ++ uploads/.gitkeep | 1 + website/js/api-envelope.js | 16 ++ website/js/api.js | 19 +- website/js/app.js | 34 +-- website/js/auth-state.js | 31 +++ website/js/editor-utils.js | 203 ++++++++++++++++ website/js/pages/editor.js | 250 ++------------------ website/js/router.js | 32 ++- website/package.json | 13 + website/tests/unit/api-envelope.test.js | 28 +++ website/tests/unit/auth-state.test.js | 41 ++++ website/tests/unit/editor-utils.test.js | 39 +++ website/tests/unit/router.test.js | 33 +++ website/vitest.config.js | 8 + 56 files changed, 1890 insertions(+), 361 deletions(-) create mode 100644 .gitea/workflows/tests.yml create mode 100644 TESTING.md create mode 100644 api/dispatch.php create mode 100644 composer.json create mode 100644 e2e/api/app-questionnaires.spec.ts create mode 100644 e2e/api/auth.spec.ts create mode 100644 e2e/global-setup.js create mode 100644 e2e/website/helpers.ts create mode 100644 e2e/website/login.spec.ts create mode 100644 e2e/website/routes.spec.ts create mode 100644 lib/QdbHttpResponse.php create mode 100644 package.json create mode 100644 playwright.config.ts create mode 100644 test-results/.last-run.json create mode 100644 tests/Integration/AppQuestionnairesTest.php create mode 100644 tests/Integration/AuthTest.php create mode 100644 tests/Integration/ClientsTest.php create mode 100644 tests/Integration/LogoutTest.php create mode 100644 tests/Integration/QuestionnairesTest.php create mode 100644 tests/Integration/UsersTest.php create mode 100644 tests/Support/ApiTestCase.php create mode 100644 tests/Support/DatabaseFixture.php create mode 100644 tests/Unit/CommonTest.php create mode 100644 tests/Unit/ResponseTest.php create mode 100644 tests/bin/seed-test-db.php create mode 100644 tests/bootstrap.php create mode 100644 tests/phpunit.xml create mode 100644 uploads/.gitkeep create mode 100644 website/js/api-envelope.js create mode 100644 website/js/auth-state.js create mode 100644 website/js/editor-utils.js create mode 100644 website/package.json create mode 100644 website/tests/unit/api-envelope.test.js create mode 100644 website/tests/unit/auth-state.test.js create mode 100644 website/tests/unit/editor-utils.test.js create mode 100644 website/tests/unit/router.test.js create mode 100644 website/vitest.config.js diff --git a/.gitea/workflows/tests.yml b/.gitea/workflows/tests.yml new file mode 100644 index 0000000..05ee3f7 --- /dev/null +++ b/.gitea/workflows/tests.yml @@ -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== diff --git a/.gitignore b/.gitignore index 2ca2fb4..d98a820 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..445357d --- /dev/null +++ b/TESTING.md @@ -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 + 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) +``` diff --git a/api/dispatch.php b/api/dispatch.php new file mode 100644 index 0000000..520d88a --- /dev/null +++ b/api/dispatch.php @@ -0,0 +1,72 @@ + __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); + } +} diff --git a/api/index.php b/api/index.php index 37852ee..ec55d26 100644 --- a/api/index.php +++ b/api/index.php @@ -1,67 +1,6 @@ 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(); diff --git a/common.php b/common.php index d0a243d..dcf74b9 100644 --- a/common.php +++ b/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); } } diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..e07a837 --- /dev/null +++ b/composer.json @@ -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" + } +} diff --git a/db_init.php b/db_init.php index 54a4266..e08b7b6 100644 --- a/db_init.php +++ b/db_init.php @@ -3,9 +3,15 @@ // Shared helpers for opening, creating, and saving the encrypted SQLite database. require_once __DIR__ . '/common.php'; -define('QDB_PATH', __DIR__ . '/uploads/questionnaire_database'); -define('QDB_LOCK', __DIR__ . '/uploads/.qdb_lock'); -define('QDB_SCHEMA', __DIR__ . '/schema.sql'); +if (!defined('QDB_PATH')) { + define('QDB_PATH', __DIR__ . '/uploads/questionnaire_database'); +} +if (!defined('QDB_LOCK')) { + define('QDB_LOCK', __DIR__ . '/uploads/.qdb_lock'); +} +if (!defined('QDB_SCHEMA')) { + define('QDB_SCHEMA', __DIR__ . '/schema.sql'); +} define('QDB_VERSION', 3); /** diff --git a/e2e/api/app-questionnaires.spec.ts b/e2e/api/app-questionnaires.spec.ts new file mode 100644 index 0000000..53142af --- /dev/null +++ b/e2e/api/app-questionnaires.spec.ts @@ -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; + + try { + const blocked = await request.get(`${API}/app_questionnaires`, { + headers: { Authorization: `Bearer ${tempToken}` }, + }); + expect(blocked.status()).toBe(403); + + const changeRes = await request.post(`${API}/auth/change-password`, { + headers: { Authorization: `Bearer ${tempToken}` }, + data: { + username: 'testmustchange', + old_password: PASSWORD, + new_password: 'newpass123', + }, + }); + const changeJson = await changeRes.json(); + expect(changeJson.ok).toBe(true); + fullToken = changeJson.data.token as string; + + const ok = await request.get(`${API}/app_questionnaires`, { + headers: { Authorization: `Bearer ${fullToken}` }, + }); + expect(ok.ok()).toBeTruthy(); + } finally { + if (fullToken) { + await request.post(`${API}/auth/change-password`, { + headers: { Authorization: `Bearer ${fullToken}` }, + data: { + username: 'testmustchange', + old_password: 'newpass123', + new_password: PASSWORD, + }, + }); + } + } + }); +}); + \ No newline at end of file diff --git a/e2e/api/auth.spec.ts b/e2e/api/auth.spec.ts new file mode 100644 index 0000000..03d0dcf --- /dev/null +++ b/e2e/api/auth.spec.ts @@ -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); + }); +}); diff --git a/e2e/global-setup.js b/e2e/global-setup.js new file mode 100644 index 0000000..12db92b --- /dev/null +++ b/e2e/global-setup.js @@ -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==', + }, + }); +} diff --git a/e2e/website/helpers.ts b/e2e/website/helpers.ts new file mode 100644 index 0000000..46f516a --- /dev/null +++ b/e2e/website/helpers.ts @@ -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(); +} diff --git a/e2e/website/login.spec.ts b/e2e/website/login.spec.ts new file mode 100644 index 0000000..6c09319 --- /dev/null +++ b/e2e/website/login.spec.ts @@ -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(); + }); +}); diff --git a/e2e/website/routes.spec.ts b/e2e/website/routes.spec.ts new file mode 100644 index 0000000..bf32310 --- /dev/null +++ b/e2e/website/routes.spec.ts @@ -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(); + }); +}); diff --git a/handlers/answer_options.php b/handlers/answer_options.php index f211e08..e601c1b 100644 --- a/handlers/answer_options.php +++ b/handlers/answer_options.php @@ -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()); diff --git a/handlers/app_questionnaires.php b/handlers/app_questionnaires.php index 9dbc26b..bcc817e 100644 --- a/handlers/app_questionnaires.php +++ b/handlers/app_questionnaires.php @@ -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); diff --git a/handlers/assignments.php b/handlers/assignments.php index 40149fa..d21c821 100644 --- a/handlers/assignments.php +++ b/handlers/assignments.php @@ -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); diff --git a/handlers/auth.php b/handlers/auth.php index 8be9726..fedee75 100644 --- a/handlers/auth.php +++ b/handlers/auth.php @@ -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()); diff --git a/handlers/clients.php b/handlers/clients.php index 6d19187..bb5f06f 100644 --- a/handlers/clients.php +++ b/handlers/clients.php @@ -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); diff --git a/handlers/download.php b/handlers/download.php index 1c89ee3..688812c 100644 --- a/handlers/download.php +++ b/handlers/download.php @@ -20,7 +20,9 @@ if ($encMaster === false) { try { $masterKey = get_master_key_bytes(); $plain = aes256_cbc_decrypt_bytes($encMaster, $masterKey); -} catch (Throwable $e) { +} catch (QdbHttpResponse $e) { + throw $e; + } catch (Throwable $e) { error_log($e->getMessage()); json_error('SERVER_ERROR', 'Decryption failed', 500); } diff --git a/handlers/questionnaires.php b/handlers/questionnaires.php index 48ad455..9e403fd 100644 --- a/handlers/questionnaires.php +++ b/handlers/questionnaires.php @@ -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(); diff --git a/handlers/questions.php b/handlers/questions.php index 8d36491..73ecaa2 100644 --- a/handlers/questions.php +++ b/handlers/questions.php @@ -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()); diff --git a/handlers/translations.php b/handlers/translations.php index 2228d04..092cc3c 100644 --- a/handlers/translations.php +++ b/handlers/translations.php @@ -137,7 +137,9 @@ case 'PUT': ->execute([':lc' => $lang, ':n' => $name]); qdb_save($tmpDb, $lockFp); json_success([]); - } catch (Throwable $e) { + } catch (QdbHttpResponse $e) { + throw $e; + } catch (Throwable $e) { qdb_discard($tmpDb, $lockFp); error_log($e->getMessage()); json_error('SERVER_ERROR', 'Server error', 500); @@ -172,6 +174,8 @@ case 'PUT': } qdb_save($tmpDb, $lockFp); json_success([]); + } catch (QdbHttpResponse $e) { + throw $e; } catch (Throwable $e) { qdb_discard($tmpDb, $lockFp); error_log($e->getMessage()); @@ -197,7 +201,9 @@ case 'DELETE': $pdo->prepare("DELETE FROM string_translation WHERE languageCode = :lc")->execute([':lc' => $lang]); qdb_save($tmpDb, $lockFp); json_success([]); - } catch (Throwable $e) { + } catch (QdbHttpResponse $e) { + throw $e; + } catch (Throwable $e) { qdb_discard($tmpDb, $lockFp); error_log($e->getMessage()); json_error('SERVER_ERROR', 'Server error', 500); @@ -225,6 +231,8 @@ case 'DELETE': } qdb_save($tmpDb, $lockFp); json_success([]); + } catch (QdbHttpResponse $e) { + throw $e; } catch (Throwable $e) { qdb_discard($tmpDb, $lockFp); error_log($e->getMessage()); diff --git a/handlers/users.php b/handlers/users.php index ba1ed29..8143b12 100644 --- a/handlers/users.php +++ b/handlers/users.php @@ -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); diff --git a/lib/QdbHttpResponse.php b/lib/QdbHttpResponse.php new file mode 100644 index 0000000..e583597 --- /dev/null +++ b/lib/QdbHttpResponse.php @@ -0,0 +1,14 @@ + 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)) { diff --git a/package.json b/package.json new file mode 100644 index 0000000..27978b1 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..da205cc --- /dev/null +++ b/playwright.config.ts @@ -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==', + }, + }, +}); diff --git a/test-results/.last-run.json b/test-results/.last-run.json new file mode 100644 index 0000000..cbcc1fb --- /dev/null +++ b/test-results/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "passed", + "failedTests": [] +} \ No newline at end of file diff --git a/tests/Integration/AppQuestionnairesTest.php b/tests/Integration/AppQuestionnairesTest.php new file mode 100644 index 0000000..3fa093e --- /dev/null +++ b/tests/Integration/AppQuestionnairesTest.php @@ -0,0 +1,78 @@ +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); + } +} diff --git a/tests/Integration/AuthTest.php b/tests/Integration/AuthTest.php new file mode 100644 index 0000000..1d69a38 --- /dev/null +++ b/tests/Integration/AuthTest.php @@ -0,0 +1,39 @@ +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'); + } +} diff --git a/tests/Integration/ClientsTest.php b/tests/Integration/ClientsTest.php new file mode 100644 index 0000000..eeab72a --- /dev/null +++ b/tests/Integration/ClientsTest.php @@ -0,0 +1,49 @@ +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); + } + + public function testSupervisorSeesOnlyTheirCoachesClients(): void + { + $this->loginAs(DatabaseFixture::SUPERVISOR1_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_COACH1B, $codes); + $this->assertNotContains(DatabaseFixture::CLIENT_COACH2, $codes); + } + + public function testSupervisorCannotAssignClientToOtherSupervisorsCoach(): void + { + $this->loginAs(DatabaseFixture::SUPERVISOR1_USERNAME); + $res = $this->api('POST', 'clients', [ + 'clientCode' => 'CLIENT-NEW', + 'coachID' => DatabaseFixture::COACH2_ID, + ]); + $this->assertError($res, 'NOT_FOUND'); + } +} + \ No newline at end of file diff --git a/tests/Integration/LogoutTest.php b/tests/Integration/LogoutTest.php new file mode 100644 index 0000000..6053e4e --- /dev/null +++ b/tests/Integration/LogoutTest.php @@ -0,0 +1,25 @@ +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'); + } +} diff --git a/tests/Integration/QuestionnairesTest.php b/tests/Integration/QuestionnairesTest.php new file mode 100644 index 0000000..59bc9b1 --- /dev/null +++ b/tests/Integration/QuestionnairesTest.php @@ -0,0 +1,25 @@ +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'] ?? []); + } +} diff --git a/tests/Integration/UsersTest.php b/tests/Integration/UsersTest.php new file mode 100644 index 0000000..213362d --- /dev/null +++ b/tests/Integration/UsersTest.php @@ -0,0 +1,48 @@ +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'); + } + + public function testAdminSeesAllUsers(): void + { + $this->loginAs(DatabaseFixture::ADMIN_USERNAME); + $res = $this->api('GET', 'users'); + $this->assertOk($res); + $usernames = array_column($res['body']['data']['users'], 'username'); + $this->assertContains(DatabaseFixture::COACH1_USERNAME, $usernames); + $this->assertContains(DatabaseFixture::COACH2_USERNAME, $usernames); + $this->assertContains(DatabaseFixture::SUPERVISOR1_USERNAME, $usernames); + $this->assertNotEmpty($res['body']['data']['supervisors']); + } + + public function testSupervisorSeesOnlyTheirCoaches(): void + { + $this->loginAs(DatabaseFixture::SUPERVISOR1_USERNAME); + $res = $this->api('GET', 'users'); + $this->assertOk($res); + $usernames = array_column($res['body']['data']['users'], 'username'); + $this->assertContains(DatabaseFixture::COACH1_USERNAME, $usernames); + $this->assertNotContains(DatabaseFixture::COACH2_USERNAME, $usernames); + $this->assertSame([], $res['body']['data']['supervisors']); + } +} + \ No newline at end of file diff --git a/tests/Support/ApiTestCase.php b/tests/Support/ApiTestCase.php new file mode 100644 index 0000000..149611f --- /dev/null +++ b/tests/Support/ApiTestCase.php @@ -0,0 +1,126 @@ +reset(); + $this->bearerToken = null; + unset($GLOBALS['__TEST_JSON_BODY']); + $_GET = []; + $_POST = []; + } + + /** + * @return array{status: int, body: array, 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) { + $status = $e->status; + $body = $e->body; + } + + $raw = (string) ob_get_clean(); + + if ($raw !== '' && $body === []) { + $decoded = json_decode($raw, true); + if (is_array($decoded)) { + $body = $decoded; + } + } + + if ($body === [] && $raw !== '') { + $body = ['_raw' => $raw]; + } + + if (!isset($body['ok']) && isset($body['error'])) { + // legacy auth envelope — body kept as-is + } elseif ($status === 200 && !isset($body['ok']) && $raw === '') { + $status = http_response_code() ?: 200; + } + + return ['status' => $status, 'body' => $body, 'raw' => $raw]; + } + + /** + * @return array{status: int, body: array, raw: string} + */ + protected function loginAs(string $username, string $password = DatabaseFixture::PASSWORD): array + { + $res = $this->api('POST', 'auth/login', [ + 'username' => $username, + 'password' => $password, + ]); + + if (($res['body']['ok'] ?? false) === true) { + $this->bearerToken = $res['body']['data']['token'] ?? null; + } + + return $res; + } + + protected function assertOk(array $response): void + { + $this->assertTrue($response['body']['ok'] ?? false, $response['raw'] ?: json_encode($response['body'])); + } + + protected function assertError(array $response, ?string $code = null): void + { + $this->assertFalse($response['body']['ok'] ?? true); + if ($code !== null) { + $this->assertSame($code, $response['body']['error']['code'] ?? null); + } + } + + protected function assertLegacyAuthError(array $response, int $status, string $message): void + { + $this->assertSame($status, $response['status']); + $this->assertSame($message, $response['body']['error'] ?? null); + } +} + \ No newline at end of file diff --git a/tests/Support/DatabaseFixture.php b/tests/Support/DatabaseFixture.php new file mode 100644 index 0000000..78f9f78 --- /dev/null +++ b/tests/Support/DatabaseFixture.php @@ -0,0 +1,186 @@ +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, '']); + } +} diff --git a/tests/Unit/CommonTest.php b/tests/Unit/CommonTest.php new file mode 100644 index 0000000..1cddee0 --- /dev/null +++ b/tests/Unit/CommonTest.php @@ -0,0 +1,51 @@ +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); + } +} diff --git a/tests/Unit/ResponseTest.php b/tests/Unit/ResponseTest.php new file mode 100644 index 0000000..dba4641 --- /dev/null +++ b/tests/Unit/ResponseTest.php @@ -0,0 +1,41 @@ + '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']); + } + } +} diff --git a/tests/bin/seed-test-db.php b/tests/bin/seed-test-db.php new file mode 100644 index 0000000..8e95804 --- /dev/null +++ b/tests/bin/seed-test-db.php @@ -0,0 +1,23 @@ +reset(); + +echo "Test database seeded in uploads/\n"; diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..a67cb1c --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,38 @@ + + + + + Unit + + + Integration + + + + + + diff --git a/uploads/.gitkeep b/uploads/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/uploads/.gitkeep @@ -0,0 +1 @@ + diff --git a/website/js/api-envelope.js b/website/js/api-envelope.js new file mode 100644 index 0000000..8b29b29 --- /dev/null +++ b/website/js/api-envelope.js @@ -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; +} diff --git a/website/js/api.js b/website/js/api.js index 88bd13a..fe8bc7f 100644 --- a/website/js/api.js +++ b/website/js/api.js @@ -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()); diff --git a/website/js/app.js b/website/js/app.js index 107dc92..d498a2c 100644 --- a/website/js/app.js +++ b/website/js/app.js @@ -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 diff --git a/website/js/auth-state.js b/website/js/auth-state.js new file mode 100644 index 0000000..57a1649 --- /dev/null +++ b/website/js/auth-state.js @@ -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); + }; +} diff --git a/website/js/editor-utils.js b/website/js/editor-utils.js new file mode 100644 index 0000000..ca9447e --- /dev/null +++ b/website/js/editor-utils.js @@ -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 ``; +} + +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 = ` +
+ + +
`; + break; + case 'multi_check_box_question': + html = ` +
+ + +
`; + break; + case 'glass_scale_question': + html = ` +
+ + +
`; + break; + case 'value_spinner': + html = ` +
+
+ + +
+
+ + +
+
`; + break; + case 'date_spinner': { + const notBefore = config.constraints?.notBefore || ''; + const notAfter = config.constraints?.notAfter || ''; + html = ` +
+
+ + +
+
+ + +
+
`; + break; + } + case 'string_spinner': + html = ` +
+ + +
`; + break; + case 'client_coach_code_question': + html = ` +
+
+ + +
+
+ + +
+
`; + break; + case 'client_not_signed': + html = ` +
+
+ + +
+
+ + +
+
+
+
+ + +
+
`; + break; + case 'last_page': + html = ` +
+ + +
`; + break; + } + return html ? `
${html}
` : ''; +} + +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); +} diff --git a/website/js/pages/editor.js b/website/js/pages/editor.js index 539261c..739b8e4 100644 --- a/website/js/pages/editor.js +++ b/website/js/pages/editor.js @@ -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 ``; -} - -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 = ` -
- - -
`; - break; - case 'multi_check_box_question': - html = ` -
- - -
`; - break; - case 'glass_scale_question': - html = ` -
- - -
`; - break; - case 'value_spinner': - html = ` -
-
- - -
-
- - -
-
`; - break; - case 'date_spinner': { - const notBefore = config.constraints?.notBefore || ''; - const notAfter = config.constraints?.notAfter || ''; - html = ` -
-
- - -
-
- - -
-
`; - break; - } - case 'string_spinner': - html = ` -
- - -
`; - break; - case 'client_coach_code_question': - html = ` -
-
- - -
-
- - -
-
`; - break; - case 'client_not_signed': - html = ` -
-
- - -
-
- - -
-
-
-
- - -
-
`; - break; - case 'last_page': - html = ` -
- - -
`; - break; - } - return html ? `
${html}
` : ''; -} - -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() {
@@ -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 = `
@@ -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 = `
@@ -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; -} diff --git a/website/js/router.js b/website/js/router.js index f5ce3b7..7588efe 100644 --- a/website/js/router.js +++ b/website/js/router.js @@ -1,17 +1,37 @@ const routes = []; let currentCleanup = null; +/** @returns {{ regex: RegExp, paramNames: string[] }} */ +export function compileRoute(pattern) { + const paramNames = []; + const parts = pattern.replace(/\/:([^/]+)/g, (_, name) => { + paramNames.push(name); + return '/([^/]+)'; + }); + return { regex: new RegExp('^' + parts + '$'), paramNames }; +} + +/** Match a path against a string route pattern; returns params or null. */ +export function matchRoute(pattern, path) { + const { regex, paramNames } = compileRoute(pattern); + const match = path.match(regex); + if (!match) { + return null; + } + const params = {}; + paramNames.forEach((name, i) => { + params[name] = decodeURIComponent(match[i + 1]); + }); + return params; +} + export function addRoute(pattern, handler) { let regex; - const paramNames = []; + let paramNames = []; if (pattern instanceof RegExp) { regex = pattern; } else { - const parts = pattern.replace(/\/:([^/]+)/g, (_, name) => { - paramNames.push(name); - return '/([^/]+)'; - }); - regex = new RegExp('^' + parts + '$'); + ({ regex, paramNames } = compileRoute(pattern)); } routes.push({ regex, paramNames, handler }); } diff --git a/website/package.json b/website/package.json new file mode 100644 index 0000000..382df24 --- /dev/null +++ b/website/package.json @@ -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" + } +} diff --git a/website/tests/unit/api-envelope.test.js b/website/tests/unit/api-envelope.test.js new file mode 100644 index 0000000..4cb5f06 --- /dev/null +++ b/website/tests/unit/api-envelope.test.js @@ -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); + }); +}); diff --git a/website/tests/unit/auth-state.test.js b/website/tests/unit/auth-state.test.js new file mode 100644 index 0000000..251e6ac --- /dev/null +++ b/website/tests/unit/auth-state.test.js @@ -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('#/'); + }); +}); diff --git a/website/tests/unit/editor-utils.test.js b/website/tests/unit/editor-utils.test.js new file mode 100644 index 0000000..7fdd36a --- /dev/null +++ b/website/tests/unit/editor-utils.test.js @@ -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 = ` + + + `; + const json = readConfigFromForm('value_spinner', 'pfx'); + expect(JSON.parse(json)).toEqual({ range: { min: 1, max: 10 } }); + }); +}); diff --git a/website/tests/unit/router.test.js b/website/tests/unit/router.test.js new file mode 100644 index 0000000..e4c9c5d --- /dev/null +++ b/website/tests/unit/router.test.js @@ -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'); + }); + + it('navigate sets window hash', () => { + navigate('#/login'); + expect(window.location.hash).toBe('#/login'); + }); + + it('matchRoute extracts questionnaire id', () => { + const params = matchRoute('/questionnaire/:id', '/questionnaire/qn-123'); + expect(params).toEqual({ id: 'qn-123' }); + }); + + it('matchRoute returns null when path does not match', () => { + expect(matchRoute('/questionnaire/:id', '/users')).toBeNull(); + }); +}); + \ No newline at end of file diff --git a/website/vitest.config.js b/website/vitest.config.js new file mode 100644 index 0000000..fb13a3a --- /dev/null +++ b/website/vitest.config.js @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'jsdom', + include: ['tests/unit/**/*.test.js'], + }, +});