From b8e6d2537d47b2876c7fb7ddce5edf4cc89a73df Mon Sep 17 00:00:00 2001 From: Ruben Date: Tue, 17 Mar 2026 16:43:39 +0100 Subject: [PATCH] Add browser and unit test suite with Pest + Playwright Add comprehensive test coverage for core site features (content, navigation, language, FAQ, news, petition, newsletter) using Pest browser tests and unit tests for custom plugins. Includes test infrastructure (Containerfile.test, compose.test.yaml), test documentation, and test files covering petition form logic, CSV handling, translation, date formatting, rate limiting, and map data building. --- AGENT.md | 58 ++--- Containerfile.test | 32 +++ compose.test.yaml | 24 ++ tests/.gitignore | 2 + tests/Browser/ContentTest.php | 46 ++++ tests/Browser/FaqTest.php | 26 ++ tests/Browser/HomepageTest.php | 21 ++ tests/Browser/LanguageTest.php | 11 + tests/Browser/NavigationTest.php | 31 +++ tests/Browser/NewsTest.php | 31 +++ tests/Browser/PetitionTest.php | 19 ++ tests/Pest.php | 10 + tests/README.md | 98 ++++++++ tests/Unit/NewsletterSignupTest.php | 105 ++++++++ tests/Unit/PetitionCliTest.php | 113 +++++++++ tests/Unit/PetitionFormTest.php | 60 +++++ tests/Unit/PetitionFormTestCsv.php | 366 ++++++++++++++++++++++++++++ tests/Unit/PetitionFormTestPure.php | 162 ++++++++++++ tests/Unit/PetitionMapTest.php | 134 ++++++++++ tests/composer.json | 15 ++ 20 files changed, 1331 insertions(+), 33 deletions(-) create mode 100644 Containerfile.test create mode 100644 compose.test.yaml create mode 100644 tests/.gitignore create mode 100644 tests/Browser/ContentTest.php create mode 100644 tests/Browser/FaqTest.php create mode 100644 tests/Browser/HomepageTest.php create mode 100644 tests/Browser/LanguageTest.php create mode 100644 tests/Browser/NavigationTest.php create mode 100644 tests/Browser/NewsTest.php create mode 100644 tests/Browser/PetitionTest.php create mode 100644 tests/Pest.php create mode 100644 tests/README.md create mode 100644 tests/Unit/NewsletterSignupTest.php create mode 100644 tests/Unit/PetitionCliTest.php create mode 100644 tests/Unit/PetitionFormTest.php create mode 100644 tests/Unit/PetitionFormTestCsv.php create mode 100644 tests/Unit/PetitionFormTestPure.php create mode 100644 tests/Unit/PetitionMapTest.php create mode 100644 tests/composer.json diff --git a/AGENT.md b/AGENT.md index bc6dbbd..5e9d53b 100644 --- a/AGENT.md +++ b/AGENT.md @@ -1,6 +1,6 @@ # Stopp lidelsen - Site built on FolderWeb -Advocacy site for medical cannabis patients in Norway. Built on FolderWeb (minimal file-based CMS). +Advocacy site for medical cannabis patients in Norway. Built on FolderWeb (minimal file-based CMS). Decade-scale maintainability, no volatile dependencies, only essential code. ## Edit instructions All edits to this AGENT.md file should be done in a compressed shortform for LLM consumption. @@ -9,24 +9,21 @@ All edits to this AGENT.md file should be done in a compressed shortform for LLM - `app/` is a **symlink** to the FolderWeb framework (`folderweb/app`). **NEVER modify files in `app/`.** - Only modify files in `custom/`, `content/`, and project root. -- Framework docs: read `app/docs/` when needed for understanding routing, hooks, templates, etc. -- Site-specific docs: read `docs/` in this repo for guidance on this site's features and customizations. +- Framework docs: `../folderweb/docs/04-development/` (architecture, context API, hooks/plugins, rendering, templates, etc.) +- Site-specific docs: `docs/` in this repo. -## Philosophy -Minimal PHP for modern conveniences. Decade-scale maintainability. No volatile dependencies. Only essential code. +## Standards -## Core Constraints -**Stack:** HTML5, PHP 8.4+, CSS. no frameworks, no build tools, no package managers. +**Stack:** HTML5, PHP 8.4+, CSS. No frameworks, no build tools, no package managers. -**Frontend:** Classless semantic HTML5. Modern CSS (nesting, `oklch()`, grid, `clamp()`, logical props). Responsive via fluid typography + flexible layouts. Comments only for major sections. +**PHP:** Arrow functions, null coalescing, match. Type hints where practical. Single-purpose functions, avoid side effects. + +**CSS:** Variables, native nesting, grid. `clamp()` over `@media`. Relative units. `oklch()`, `light-dark()`, logical props. Margin-top only (reset: `* { margin-bottom: 0 }`). + +**HTML/Templates:** Classless semantic HTML5. `` for UGC. `` for pre-rendered HTML. **Security:** Path traversal protection, document root restriction, strict MIME types, escape all UGC, CSV injection prevention. -## Code Style -**PHP:** Arrow functions, null coalescing, match. Type hints where practical. Single-purpose functions. -**CSS:** Variables, native nesting, grid. `clamp()` over `@media`. Relative units. Margin-top only (reset: `* { margin-bottom: 0 }`). -**Templates:** `` for UGC. `` for pre-rendered HTML. - ## Project Structure ``` @@ -43,35 +40,30 @@ app/ -> symlink # FolderWeb framework (DO NOT EDIT) docs/ # Site-specific LLM documentation ``` -## Key Features - -**Content:** Folders = URLs. `metadata.ini` controls titles, slugs, menu order, templates, feeds. -**Languages:** Norwegian (default), English available. Language plugin via `[en]` sections in metadata. -**Templates:** base.php wraps all pages. List templates: list.php, list-card-grid.php, list-faq.php, list-grid.php. Selected via `page_template` in metadata. -**Atom feeds:** Enabled per-section via `feed = true` in metadata. URL: `/{section}/feed.xml`. Feed link auto-added to `` when `$feedUrl` is set. -**Petition system:** GDPR-compliant, CSV-based, double opt-in email confirmation, file locking, rate limiting. See `docs/petition-system.md`. -**Newsletter:** Listmonk integration via public API. Hero and small themes. See `docs/newsletter-plugin.md`. +Content: folders = URLs, numbered file prefixes control order, `cover.jpg` for list item images, `metadata.ini` controls titles/slugs/menu order/templates/feeds. ## Knowledge Base -Read these docs on-demand when working on related areas: +Read on-demand: -| Topic | File | Read When | -|---|---|---| -| Templates & variables | `docs/templates.md` | Modifying templates, understanding available variables | -| Content & metadata | `docs/content-system.md` | Adding/modifying content, metadata fields, feeds | -| Petition system | `docs/petition-system.md` | Modifying petition form, CSV format, email flow | -| Newsletter plugin | `docs/newsletter-plugin.md` | Modifying newsletter signup, Listmonk integration | -| Framework internals | `app/docs/` | Understanding routing, hooks, plugins, rendering pipeline | +| Topic | File | +|---|---| +| Templates & variables | `docs/templates.md` | +| Content & metadata | `docs/content-system.md` | +| Petition system | `docs/petition-system.md` | +| Newsletter plugin | `docs/newsletter-plugin.md` | +| Framework internals | `../folderweb/docs/04-development/` | +| Test suite | `tests/README.md` | **Required before modifying `custom/` or `content/`** — workflow, how to run, how to write tests | + +Site features: Norwegian (default) + English via `[en]` metadata sections. Atom feeds via `feed = true` in metadata. Petition: GDPR, CSV, double opt-in, rate limiting. Newsletter: Listmonk API, hero/small themes. ## Development Environment Host has no PHP. Always use `podman compose` with `compose.yaml`. **Dev server:** `localhost:4040` -**Testing:** `curl localhost:4040/path` to fetch pages; `curl -X POST -d "field=value" localhost:4040/path` for forms -**Container isolation:** ALL test data (CSV, temp files) MUST stay inside container filesystem, never in mounted volumes -**Test data paths:** Use `/tmp/` or `/var/test/` inside container -**Running tests:** `podman exec stopplidelsen.no php /path/to/test.php` or `podman-compose run --rm custom php /tmp/test-script.php` +**Verification:** `curl localhost:4040/path`; `curl -X POST -d "field=value" localhost:4040/path` for forms +**Container isolation:** ALL test data MUST stay inside container filesystem, never in mounted volumes. Use `/tmp/` or `/var/test/`. +**Running scripts:** `podman exec stopplidelsen.no php /path/to/test.php` **Syntax checks:** `podman exec stopplidelsen.no php -l /var/www/custom/file.php` **Cleanup:** `podman-compose down && podman-compose up -d` between test runs diff --git a/Containerfile.test b/Containerfile.test new file mode 100644 index 0000000..bd86862 --- /dev/null +++ b/Containerfile.test @@ -0,0 +1,32 @@ +FROM php:8.4-cli-bookworm + +# System dependencies + PHP sockets extension (required by pest-plugin-browser) +RUN apt-get update && apt-get install -y \ + git unzip curl \ + && rm -rf /var/lib/apt/lists/* \ + && docker-php-ext-install sockets pcntl + +# Node.js 22 LTS (required by pest-plugin-browser / Playwright) +RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ + && apt-get install -y nodejs \ + && rm -rf /var/lib/apt/lists/* + +# Composer +RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer + +# WORKDIR matches where composer.json lives — Pest finds vendor/autoload.php here +# Tests are mounted at /app/tests via compose.test.yaml +WORKDIR /app + +# Install PHP test dependencies +COPY tests/composer.json composer.json +RUN composer install --no-interaction --no-progress + +# Install Playwright npm package (skip post-install browser download — we do it explicitly below) +RUN PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 npm install playwright + +# Install Playwright browsers (Chromium) with OS-level dependencies +ENV PLAYWRIGHT_BROWSERS_PATH=/opt/playwright +RUN ./node_modules/.bin/playwright install --with-deps chromium + +ENTRYPOINT ["/app/vendor/bin/pest"] diff --git a/compose.test.yaml b/compose.test.yaml new file mode 100644 index 0000000..ff6d176 --- /dev/null +++ b/compose.test.yaml @@ -0,0 +1,24 @@ +services: + app: + build: . + container_name: stopplidelsen.test.app + working_dir: /var/www/html/ + volumes: + - ./app:/var/www/app:z + - ./content:/var/www/html:z + - ./custom:/var/www/custom:z + command: > + bash -c "chown -R www-data:www-data /var/www/custom/data /var/www/custom/assets && apache2-foreground" + + test: + build: + context: . + dockerfile: Containerfile.test + container_name: stopplidelsen.test.runner + volumes: + - ./tests:/app/tests:z + - ./custom:/app/custom:z + depends_on: + - app + environment: + APP_URL: http://app.dns.podman diff --git a/tests/.gitignore b/tests/.gitignore new file mode 100644 index 0000000..d1502b0 --- /dev/null +++ b/tests/.gitignore @@ -0,0 +1,2 @@ +vendor/ +composer.lock diff --git a/tests/Browser/ContentTest.php b/tests/Browser/ContentTest.php new file mode 100644 index 0000000..592949d --- /dev/null +++ b/tests/Browser/ContentTest.php @@ -0,0 +1,46 @@ +visit(BASE_URL . '/om-oss') + ->assertSee('Vår historie'); +}); + +it('about page mentions the organization name', function () { + $this->visit(BASE_URL . '/om-oss') + ->assertSourceHas('Stopp lidelsen'); +}); + +it('privacy page loads with correct heading', function () { + $this->visit(BASE_URL . '/personvern') + ->assertSee('Personvernerklæring'); +}); + +it('contact page loads with content', function () { + $this->visit(BASE_URL . '/kontakt') + ->assertSourceHas('visit(BASE_URL . '/brosjyrer') + ->assertSee('Brosjyrer'); +}); + +it('articles section loads', function () { + $this->visit(BASE_URL . '/artikler') + ->assertSourceHas('visit(BASE_URL . '/artikler/pasientinfo') + ->assertSee('pasienter'); +}); + +it('homepage shows goals section heading', function () { + $this->visit(BASE_URL . '/') + ->assertSee('oppnå'); +}); + +it('homepage shows intro text about the organization', function () { + $this->visit(BASE_URL . '/') + ->assertSee('frivillig'); +}); diff --git a/tests/Browser/FaqTest.php b/tests/Browser/FaqTest.php new file mode 100644 index 0000000..9639511 --- /dev/null +++ b/tests/Browser/FaqTest.php @@ -0,0 +1,26 @@ +visit(BASE_URL . '/faq') + ->assertSee('Ofte stilte spørsmål'); +}); + +it('FAQ listing shows question items', function () { + $this->visit(BASE_URL . '/faq') + ->assertSee('medisinsk cannabis'); +}); + +it('individual FAQ article loads', function () { + $this->visit(BASE_URL . '/faq/Hva-er-MC') + ->assertSourceHas('visit(BASE_URL . '/faq/Hva-er-MC') + ->assertSee('medisinsk cannabis'); +}); + +it('FAQ article about driving license loads', function () { + $this->visit(BASE_URL . '/faq/forerkort') + ->assertSourceHas('visit(BASE_URL . '/') + ->assertSourceHas('Stopp lidelsen'); +}); + +it('shows the news section', function () { + $this->visit(BASE_URL . '/') + ->assertSee('Nyheter'); +}); + +it('shows the petition section', function () { + $this->visit(BASE_URL . '/') + ->assertSee('underskrift'); +}); + +it('shows the newsletter section', function () { + $this->visit(BASE_URL . '/') + ->assertSee('nyhetsbrev'); +}); diff --git a/tests/Browser/LanguageTest.php b/tests/Browser/LanguageTest.php new file mode 100644 index 0000000..8a4d058 --- /dev/null +++ b/tests/Browser/LanguageTest.php @@ -0,0 +1,11 @@ +visit(BASE_URL . '/') + ->assertSourceHas('lang="no"'); +}); + +it('english news section is accessible', function () { + $this->visit(BASE_URL . '/en/nyheter') + ->assertSourceHas('visit(BASE_URL . '/nyheter') + ->assertSee('Nyheter'); +}); + +it('navigates to the FAQ section', function () { + $this->visit(BASE_URL . '/faq') + ->assertSourceHas('visit(BASE_URL . '/om-oss') + ->assertSourceHas('visit(BASE_URL . '/underskriftskampanje') + ->assertSourceHas('visit(BASE_URL . '/kontakt') + ->assertSourceHas('visit(BASE_URL . '/personvern') + ->assertSourceHas('visit(BASE_URL . '/nyheter') + ->assertSourceHas('visit(BASE_URL . '/nyheter') + ->assertSee('Banebrytende'); +}); + +it('individual news article loads', function () { + $this->visit(BASE_URL . '/nyheter/2025-09-26-banebrytende-studie') + ->assertSourceHas('visit(BASE_URL . '/nyheter/2025-09-26-banebrytende-studie') + ->assertSee('medisinsk cannabis'); +}); + +it('news article about podcast loads', function () { + $this->visit(BASE_URL . '/nyheter/2026-03-08-podcast-paradigmepodden') + ->assertSourceHas('visit(BASE_URL . '/nyheter/finnes-ikke') + ->assertSourceHas('404'); +}); diff --git a/tests/Browser/PetitionTest.php b/tests/Browser/PetitionTest.php new file mode 100644 index 0000000..a55d57d --- /dev/null +++ b/tests/Browser/PetitionTest.php @@ -0,0 +1,19 @@ +visit(BASE_URL . '/underskriftskampanje/medisinsk-cannabis-pa-resept/') + ->assertPresent('input[name="firstname"]'); +}); + +it('shows required form fields', function () { + $this->visit(BASE_URL . '/underskriftskampanje/medisinsk-cannabis-pa-resept/') + ->assertPresent('input[name="firstname"]') + ->assertPresent('input[name="email"]') + ->assertPresent('button[type="submit"]'); +}); + +it('does not submit empty form', function () { + $this->visit(BASE_URL . '/underskriftskampanje/medisinsk-cannabis-pa-resept/') + ->press('button[type="submit"]') + ->assertPresent('input[name="firstname"]'); +}); diff --git a/tests/Pest.php b/tests/Pest.php new file mode 100644 index 0000000..b23bcd4 --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,10 @@ +group('unit')->in('Unit'); diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..aea1d04 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,98 @@ +# Test Suite + +Browser tests using [Pest](https://pestphp.com/) + [pest-plugin-browser](https://github.com/pestphp/pest-plugin-browser) (Playwright/Chromium). + +## Run tests + +```bash +podman compose -f compose.test.yaml run --rm test +``` + +The `app` service (the site) starts automatically via `depends_on`. Tests run against `http://app.dns.podman` inside the Podman network. + +## Structure + +``` +tests/ + composer.json # pestphp/pest ^4, pestphp/pest-plugin-browser ^4 + Pest.php # defines BASE_URL and CUSTOM_DIR constants; groups unit tests + Browser/ # browser tests (require running app + Playwright) + HomepageTest.php + NavigationTest.php + LanguageTest.php + PetitionTest.php + Screenshots/ # auto-saved on failure (gitignored) + Unit/ # unit tests (pure PHP, no browser needed) + PetitionFormTest.php +``` + +## Writing unit tests + +Place tests in `tests/Unit/`. The constant `CUSTOM_DIR` points to `custom/` on the host. + +Since `custom/` plugins are written as framework plugins (they call `Hooks::add()` and reference `Context`), stub those classes before requiring the file: + +```php +toBe('expected'); +}); +``` + +Only test functions from `custom/` — `app/` has its own test suite. + +## Writing browser tests + +All browser tests use `$this` for the Playwright page object via pest-plugin-browser. + +```php +it('describes behavior', function () { + $this->visit(BASE_URL . '/path') + ->assertSee('text') // visible text + ->assertSourceHas('html snippet') // raw HTML/source + ->assertPresent('css selector') // element exists in DOM + ->click('css selector') + ->press('input[type="submit"]') // click button/submit + ->assertSee('expected result'); +}); +``` + +Place new test files in `tests/Browser/`. No registration needed — Pest autodiscovers `*Test.php` files. + +**Reference:** [pestphp.com/docs/browser-testing](https://pestphp.com/docs/browser-testing) — full list of interactions (`type()`, `select()`, `check()`, `drag()`, …) and assertions (`assertUrlIs()`, `assertAttribute()`, `assertNoJavaScriptErrors()`, …). + +## Infrastructure + +- **Containerfile.test**: `php:8.4-cli-bookworm` + Node 22 + Composer + Playwright Chromium at `/opt/playwright` +- **compose.test.yaml**: `app` service = the site; `test` service = runner; mounts `./tests` as volume so edits apply without rebuild +- **Pest.php**: `BASE_URL` defaults to `http://app` if `APP_URL` not set + +## Rebuild image + +Only needed after changing `Containerfile.test` or `composer.json`: + +```bash +podman compose -f compose.test.yaml build test +``` + +## Agent workflow + +When modifying or adding code in `custom/` or `content/`: + +1. Run the existing test suite first to establish a baseline. +2. Make your changes. +3. Run tests again. All previously passing tests must still pass. +4. If your change adds new behavior, add a test for it in `tests/Browser/`. +5. Do not mark work done until the test suite passes (excluding pre-existing known failures listed below). + +Failed tests save screenshots to `tests/Browser/Screenshots/` — read them to diagnose rendering issues. + +## Known failures + +None. diff --git a/tests/Unit/NewsletterSignupTest.php b/tests/Unit/NewsletterSignupTest.php new file mode 100644 index 0000000..e3cc0b2 --- /dev/null +++ b/tests/Unit/NewsletterSignupTest.php @@ -0,0 +1,105 @@ +toBe('Navn'); + expect(newsletterT('submit_button'))->toBe('Meld deg på'); + expect(newsletterT('success_message'))->toBe('Sjekk innboksen din!'); +}); + +it('returns the key itself for an unknown key when no context is present', function () { + unset($GLOBALS['ctx']); + expect(newsletterT('nonexistent_key'))->toBe('nonexistent_key'); +}); + +it('returns a translation from context when available', function () { + $GLOBALS['ctx'] = new class { + public function get(string $key, mixed $default = null): mixed + { + return $key === 'translations' + ? ['newsletter.name_label' => 'Name'] + : $default; + } + }; + expect(newsletterT('name_label'))->toBe('Name'); + unset($GLOBALS['ctx']); +}); + +it('falls back to the key when context has no matching translation', function () { + $GLOBALS['ctx'] = new class { + public function get(string $key, mixed $default = null): mixed + { + return $key === 'translations' ? [] : $default; + } + }; + expect(newsletterT('name_label'))->toBe('name_label'); + unset($GLOBALS['ctx']); +}); + +// --- newsletterCheckRateLimit --- + +it('returns true when no previous submission is recorded in the session', function () { + unset($_SESSION['newsletter_last_submit']); + expect(newsletterCheckRateLimit())->toBeTrue(); +}); + +it('returns false immediately after a submission', function () { + $_SESSION['newsletter_last_submit'] = time(); + expect(newsletterCheckRateLimit())->toBeFalse(); +}); + +it('returns false when 29 seconds have elapsed', function () { + $_SESSION['newsletter_last_submit'] = time() - 29; + expect(newsletterCheckRateLimit())->toBeFalse(); +}); + +it('returns true when exactly 30 seconds have elapsed', function () { + $_SESSION['newsletter_last_submit'] = time() - 30; + expect(newsletterCheckRateLimit())->toBeTrue(); +}); + +it('returns true when more than 30 seconds have elapsed', function () { + $_SESSION['newsletter_last_submit'] = time() - 60; + expect(newsletterCheckRateLimit())->toBeTrue(); +}); + +// --- newsletterGetTranslations --- + +it('returns all expected translation keys', function () { + unset($GLOBALS['ctx']); + expect(newsletterGetTranslations())->toHaveKeys([ + 'nameLabel', 'namePlaceholder', 'emailLabel', 'emailPlaceholder', + 'notice', 'submitButton', 'successMessage', 'successConfirm', 'errorMessage', + ]); +}); + +it('HTML-escapes special characters in translation values', function () { + $GLOBALS['ctx'] = new class { + public function get(string $key, mixed $default = null): mixed + { + return $key === 'translations' + ? ['newsletter.name_label' => 'Name & "Label"'] + : $default; + } + }; + $translations = newsletterGetTranslations(); + expect($translations['nameLabel'])->toBe('<b>Name & "Label"</b>'); + unset($GLOBALS['ctx']); +}); + +it('returns string values for all keys', function () { + unset($GLOBALS['ctx']); + foreach (newsletterGetTranslations() as $key => $value) { + expect($value)->toBeString("Key '{$key}' should be a string"); + } +}); diff --git a/tests/Unit/PetitionCliTest.php b/tests/Unit/PetitionCliTest.php new file mode 100644 index 0000000..f1aaa57 --- /dev/null +++ b/tests/Unit/PetitionCliTest.php @@ -0,0 +1,113 @@ +toBeFalse(); +}); + +it('returns true when the exact key is present', function () { + $ignoreList = [ + 'failed|my-petition|user@example.com' => [ + 'timestamp' => 1700000000, + 'type' => 'failed', + 'petition_id' => 'my-petition', + 'email' => 'user@example.com', + 'reason' => 'bounced', + ], + ]; + expect(isIgnored($ignoreList, 'failed', 'my-petition', 'user@example.com'))->toBeTrue(); +}); + +it('matches the email case-insensitively', function () { + $ignoreList = [ + 'failed|my-petition|user@example.com' => [ + 'timestamp' => 0, 'type' => 'failed', 'petition_id' => 'my-petition', + 'email' => 'user@example.com', 'reason' => '', + ], + ]; + expect(isIgnored($ignoreList, 'failed', 'my-petition', 'User@EXAMPLE.COM'))->toBeTrue(); +}); + +it('returns false when the type does not match', function () { + $ignoreList = [ + 'failed|my-petition|user@example.com' => [ + 'timestamp' => 0, 'type' => 'failed', 'petition_id' => 'my-petition', + 'email' => 'user@example.com', 'reason' => '', + ], + ]; + expect(isIgnored($ignoreList, 'unconfirmed', 'my-petition', 'user@example.com'))->toBeFalse(); +}); + +it('returns false when the petition_id does not match', function () { + $ignoreList = [ + 'failed|my-petition|user@example.com' => [ + 'timestamp' => 0, 'type' => 'failed', 'petition_id' => 'my-petition', + 'email' => 'user@example.com', 'reason' => '', + ], + ]; + expect(isIgnored($ignoreList, 'failed', 'other-petition', 'user@example.com'))->toBeFalse(); +}); + +it('returns false when the email does not match', function () { + $ignoreList = [ + 'failed|my-petition|user@example.com' => [ + 'timestamp' => 0, 'type' => 'failed', 'petition_id' => 'my-petition', + 'email' => 'user@example.com', 'reason' => '', + ], + ]; + expect(isIgnored($ignoreList, 'failed', 'my-petition', 'other@example.com'))->toBeFalse(); +}); + +// --- formatDate --- + +it('formats a unix timestamp as Y-m-d H:i', function () { + $result = formatDate(0); + expect($result)->toMatch('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/'); +}); + +it('produces the expected date string for a known timestamp', function () { + // 2024-03-15 10:05:00 UTC — pin to UTC so test is locale-independent + $original = date_default_timezone_get(); + date_default_timezone_set('UTC'); + $ts = mktime(10, 5, 0, 3, 15, 2024); + expect(formatDate($ts))->toBe('2024-03-15 10:05'); + date_default_timezone_set($original); +}); + +// --- buildConfirmationEmail --- + +it('returns an array with subject and body keys', function () { + $sig = ['petition_id' => 'test', 'firstname' => 'Kari', 'token' => 'abc']; + $result = buildConfirmationEmail($sig, 'Test Campaign'); + expect($result)->toHaveKeys(['subject', 'body']); +}); + +it('includes the recipient first name in the body', function () { + $sig = ['petition_id' => 'test', 'firstname' => 'Ingrid', 'token' => 'tok1']; + expect(buildConfirmationEmail($sig, 'X')['body'])->toContain('Ingrid'); +}); + +it('includes the petition title in the body', function () { + $sig = ['petition_id' => 'test', 'firstname' => 'Ola', 'token' => 'tok2']; + expect(buildConfirmationEmail($sig, 'Medisinsk Cannabis')['body'])->toContain('Medisinsk Cannabis'); +}); + +it('includes the confirmation URL with the correct token', function () { + $sig = ['petition_id' => 'my-petition', 'firstname' => 'Per', 'token' => 'tok999']; + $body = buildConfirmationEmail($sig, 'X')['body']; + expect($body)->toContain('?confirm=tok999'); + expect($body)->toContain('/underskriftskampanje/my-petition/'); +}); + +it('uses the petition_id in the confirmation URL', function () { + $sig = ['petition_id' => 'helse-kampanje', 'firstname' => 'Liv', 'token' => 'x']; + $body = buildConfirmationEmail($sig, 'X')['body']; + expect($body)->toContain('/underskriftskampanje/helse-kampanje/'); +}); diff --git a/tests/Unit/PetitionFormTest.php b/tests/Unit/PetitionFormTest.php new file mode 100644 index 0000000..1c17a83 --- /dev/null +++ b/tests/Unit/PetitionFormTest.php @@ -0,0 +1,60 @@ +toBe('John Doe'); + expect(petitionSanitizeCSV('john@example.com'))->toBe('john@example.com'); + expect(petitionSanitizeCSV(''))->toBe(''); +}); + +it('prefixes formula-starting characters to prevent CSV injection', function () { + expect(petitionSanitizeCSV('=SUM(A1)'))->toBe("'=SUM(A1)"); + expect(petitionSanitizeCSV('+evil'))->toBe("'+evil"); + expect(petitionSanitizeCSV('-evil'))->toBe("'-evil"); + expect(petitionSanitizeCSV('@evil'))->toBe("'@evil"); +}); + +it('prefixes tab and newline characters', function () { + expect(petitionSanitizeCSV("\t data"))->toBe("'\t data"); + expect(petitionSanitizeCSV("\r data"))->toBe("'\r data"); + expect(petitionSanitizeCSV("\n data"))->toBe("'\n data"); +}); + +it('only checks the first character', function () { + expect(petitionSanitizeCSV('safe=still safe'))->toBe('safe=still safe'); +}); + +// --- petitionGetCsvPath --- + +it('builds the correct CSV path for a valid petition ID', function () { + $path = petitionGetCsvPath('my-petition'); + expect($path)->toEndWith('/data/petitions/my-petition.csv'); +}); + +it('strips non-alphanumeric characters from petition ID', function () { + $path = petitionGetCsvPath('my petition!'); + expect($path)->toEndWith('/data/petitions/mypetition.csv'); +}); + +it('throws for an empty petition ID', function () { + petitionGetCsvPath(''); +})->throws(Exception::class); + +it('strips path traversal characters rather than throwing', function () { + // Dots and slashes are stripped by the regex, leaving a safe ID + $path = petitionGetCsvPath('../evil'); + expect($path)->toEndWith('/data/petitions/evil.csv'); +}); diff --git a/tests/Unit/PetitionFormTestCsv.php b/tests/Unit/PetitionFormTestCsv.php new file mode 100644 index 0000000..ee03ab4 --- /dev/null +++ b/tests/Unit/PetitionFormTestCsv.php @@ -0,0 +1,366 @@ + time(), + 'email' => $email, + 'firstname' => 'Test', + 'surname' => 'User', + 'region' => 'oslo', + 'display' => 'semi', + 'status' => $status, + 'token' => $token, + 'token_created' => time(), + 'ip_hash' => 'hash', + ]; +} + +// --- petitionEmailExists --- + +it('returns false when the CSV does not exist', function () { + expect(petitionEmailExists('/tmp/nonexistent-petition-form-test.csv', 'a@b.com'))->toBeFalse(); +}); + +it('returns false when the email is not in the CSV', function () { + $path = petitionTestCsv([ + PETITION_CSV_HEADER, + [time(), 'other@b.com', 'Ola', 'Hansen', 'oslo', 'semi', 'confirmed', 't1', time(), 'h1'], + ]); + expect(petitionEmailExists($path, 'nothere@b.com'))->toBeFalse(); + unlink($path); +}); + +it('returns true when the email exists', function () { + $path = petitionTestCsv([ + PETITION_CSV_HEADER, + [time(), 'kari@b.com', 'Kari', 'Hansen', 'oslo', 'semi', 'confirmed', 't1', time(), 'h1'], + ]); + expect(petitionEmailExists($path, 'kari@b.com'))->toBeTrue(); + unlink($path); +}); + +it('matches the email case-insensitively', function () { + $path = petitionTestCsv([ + PETITION_CSV_HEADER, + [time(), 'kari@b.com', 'Kari', 'Hansen', 'oslo', 'semi', 'confirmed', 't1', time(), 'h1'], + ]); + expect(petitionEmailExists($path, 'KARI@B.COM'))->toBeTrue(); + unlink($path); +}); + +// --- petitionGetConfirmedSignatures --- + +it('returns an empty array when the CSV does not exist', function () { + expect(petitionGetConfirmedSignatures('/tmp/nonexistent-petition-form-test2.csv'))->toBe([]); +}); + +it('returns only confirmed signatures', function () { + $path = petitionTestCsv([ + PETITION_CSV_HEADER, + [time(), 'a@b.com', 'Kari', 'Hansen', 'oslo', 'semi', 'confirmed', 't1', time(), 'h1'], + [time(), 'b@b.com', 'Ola', 'Nilsen', 'oslo', 'semi', 'pending', 't2', time(), 'h2'], + [time(), 'c@b.com', 'Per', 'Olsen', 'oslo', 'semi', 'deleted', 't3', time(), 'h3'], + ]); + $sigs = petitionGetConfirmedSignatures($path); + expect(count($sigs))->toBe(1); + expect($sigs[0]['firstname'])->toBe('Kari'); + unlink($path); +}); + +it('sorts confirmed signatures newest-first', function () { + $older = time() - 100; + $newer = time(); + $path = petitionTestCsv([ + PETITION_CSV_HEADER, + [$older, 'a@b.com', 'Older', 'Hansen', 'oslo', 'semi', 'confirmed', 't1', $older, 'h1'], + [$newer, 'b@b.com', 'Newer', 'Nilsen', 'oslo', 'semi', 'confirmed', 't2', $newer, 'h2'], + ]); + $sigs = petitionGetConfirmedSignatures($path); + expect($sigs[0]['firstname'])->toBe('Newer'); + expect($sigs[1]['firstname'])->toBe('Older'); + unlink($path); +}); + +it('does not include email or token in the returned signature data', function () { + $path = petitionTestCsv([ + PETITION_CSV_HEADER, + [time(), 'secret@b.com', 'Lars', 'Berg', 'oslo', 'semi', 'confirmed', 'secret-tok', time(), 'h1'], + ]); + $sig = petitionGetConfirmedSignatures($path)[0]; + expect($sig)->not->toHaveKey('email'); + expect($sig)->not->toHaveKey('token'); + unlink($path); +}); + +// --- petitionGetSignatureByToken --- + +it('returns null when the CSV does not exist', function () { + expect(petitionGetSignatureByToken('/tmp/nonexistent.csv', 'tok'))->toBeNull(); +}); + +it('returns null for an unknown token', function () { + $path = petitionTestCsv([ + PETITION_CSV_HEADER, + [time(), 'a@b.com', 'Kari', 'Hansen', 'oslo', 'semi', 'pending', 'real-tok', time(), 'h1'], + ]); + expect(petitionGetSignatureByToken($path, 'wrong-tok'))->toBeNull(); + unlink($path); +}); + +it('returns email, firstname, and surname for a matching token', function () { + $path = petitionTestCsv([ + PETITION_CSV_HEADER, + [time(), 'kari@b.com', 'Kari', 'Hansen', 'oslo', 'semi', 'pending', 'my-tok', time(), 'h1'], + ]); + $sig = petitionGetSignatureByToken($path, 'my-tok'); + expect($sig)->not->toBeNull(); + expect($sig['email'])->toBe('kari@b.com'); + expect($sig['firstname'])->toBe('Kari'); + expect($sig['surname'])->toBe('Hansen'); + unlink($path); +}); + +// --- petitionGetPendingSignatureByEmail --- + +it('returns null when the CSV does not exist for pending lookup', function () { + expect(petitionGetPendingSignatureByEmail('/tmp/nonexistent.csv', 'a@b.com'))->toBeNull(); +}); + +it('returns null when the email is not present', function () { + $path = petitionTestCsv([ + PETITION_CSV_HEADER, + [time(), 'other@b.com', 'Ola', 'Hansen', 'oslo', 'semi', 'pending', 't1', time(), 'h1'], + ]); + expect(petitionGetPendingSignatureByEmail($path, 'missing@b.com'))->toBeNull(); + unlink($path); +}); + +it('returns the signature when the email matches', function () { + $path = petitionTestCsv([ + PETITION_CSV_HEADER, + [time(), 'kari@b.com', 'Kari', 'Hansen', 'oslo', 'semi', 'pending', 'tok1', time(), 'h1'], + ]); + $sig = petitionGetPendingSignatureByEmail($path, 'kari@b.com'); + expect($sig)->not->toBeNull(); + expect($sig['email'])->toBe('kari@b.com'); + expect($sig['status'])->toBe('pending'); + unlink($path); +}); + +it('matches the email case-insensitively for pending lookup', function () { + $path = petitionTestCsv([ + PETITION_CSV_HEADER, + [time(), 'kari@b.com', 'Kari', 'Hansen', 'oslo', 'semi', 'pending', 'tok1', time(), 'h1'], + ]); + expect(petitionGetPendingSignatureByEmail($path, 'KARI@B.COM'))->not->toBeNull(); + unlink($path); +}); + +// --- petitionAppendSignature --- + +it('creates a new CSV with a header and appends the first signature', function () { + $path = tempnam(sys_get_temp_dir(), 'petition_append_test_'); + unlink($path); // petitionAppendSignature creates it + + $result = petitionAppendSignature($path, sigData('new@b.com')); + expect($result)->toBeTrue(); + expect(file_exists($path))->toBeTrue(); + + $rows = array_filter(explode("\n", file_get_contents($path))); + expect(count($rows))->toBe(2); // header + 1 row + unlink($path); +}); + +it('appends a signature to an existing CSV', function () { + $path = petitionTestCsv([ + PETITION_CSV_HEADER, + [time(), 'first@b.com', 'First', 'User', 'oslo', 'semi', 'pending', 't1', time(), 'h1'], + ]); + petitionAppendSignature($path, sigData('second@b.com', 'pending', 't2')); + + $fp = fopen($path, 'r'); + fgetcsv($fp, null, ',', '"', ''); // skip header + $rows = []; + while (($row = fgetcsv($fp, null, ',', '"', '')) !== false) $rows[] = $row; + fclose($path); + + expect(count($rows))->toBe(2); + unlink($path); +}); + +it('rejects a duplicate email and returns false', function () { + $path = petitionTestCsv([ + PETITION_CSV_HEADER, + [time(), 'existing@b.com', 'Kari', 'Hansen', 'oslo', 'semi', 'pending', 't1', time(), 'h1'], + ]); + $result = petitionAppendSignature($path, sigData('existing@b.com', 'pending', 't2')); + expect($result)->toBeFalse(); + unlink($path); +}); + +it('rejects a duplicate email case-insensitively', function () { + $path = petitionTestCsv([ + PETITION_CSV_HEADER, + [time(), 'kari@b.com', 'Kari', 'Hansen', 'oslo', 'semi', 'pending', 't1', time(), 'h1'], + ]); + $result = petitionAppendSignature($path, sigData('KARI@B.COM', 'pending', 't2')); + expect($result)->toBeFalse(); + unlink($path); +}); + +// --- petitionConfirmSignature --- + +it('returns error when the CSV does not exist', function () { + expect(petitionConfirmSignature('/tmp/nonexistent.csv', 'tok'))->toBe('error'); +}); + +it('returns error for an unknown token', function () { + $path = petitionTestCsv([ + PETITION_CSV_HEADER, + [time(), 'a@b.com', 'Kari', 'Hansen', 'oslo', 'semi', 'pending', 'real-tok', time(), 'h1'], + ]); + expect(petitionConfirmSignature($path, 'wrong-tok'))->toBe('error'); + unlink($path); +}); + +it('returns success and sets status to confirmed for a valid pending token', function () { + $path = petitionTestCsv([ + PETITION_CSV_HEADER, + [time(), 'a@b.com', 'Kari', 'Hansen', 'oslo', 'semi', 'pending', 'valid-tok', time(), 'h1'], + ]); + expect(petitionConfirmSignature($path, 'valid-tok'))->toBe('success'); + + // Verify the status was actually updated in the file + $fp = fopen($path, 'r'); + fgetcsv($fp, null, ',', '"', ''); // header + $row = fgetcsv($fp, null, ',', '"', ''); + fclose($fp); + expect($row[6])->toBe('confirmed'); + unlink($path); +}); + +it('returns already when the token is already confirmed', function () { + $path = petitionTestCsv([ + PETITION_CSV_HEADER, + [time(), 'a@b.com', 'Kari', 'Hansen', 'oslo', 'semi', 'confirmed', 'valid-tok', time(), 'h1'], + ]); + expect(petitionConfirmSignature($path, 'valid-tok'))->toBe('already'); + unlink($path); +}); + +it('returns expired when the token is older than 30 days', function () { + $oldTime = time() - (31 * 86400); + $path = petitionTestCsv([ + PETITION_CSV_HEADER, + [$oldTime, 'a@b.com', 'Kari', 'Hansen', 'oslo', 'semi', 'pending', 'old-tok', $oldTime, 'h1'], + ]); + expect(petitionConfirmSignature($path, 'old-tok'))->toBe('expired'); + unlink($path); +}); + +// --- petitionDeleteSignature --- + +it('returns error when the CSV does not exist for delete', function () { + expect(petitionDeleteSignature('/tmp/nonexistent.csv', 'tok'))->toBe('error'); +}); + +it('returns error when the token is not found for delete', function () { + $path = petitionTestCsv([ + PETITION_CSV_HEADER, + [time(), 'a@b.com', 'Kari', 'Hansen', 'oslo', 'semi', 'confirmed', 'real-tok', time(), 'h1'], + ]); + expect(petitionDeleteSignature($path, 'wrong-tok'))->toBe('error'); + unlink($path); +}); + +it('returns success and removes the row for a matching token', function () { + $path = petitionTestCsv([ + PETITION_CSV_HEADER, + [time(), 'a@b.com', 'Kari', 'Hansen', 'oslo', 'semi', 'confirmed', 'del-tok', time(), 'h1'], + [time(), 'b@b.com', 'Other', 'Nilsen', 'oslo', 'semi', 'confirmed', 'keep-tok', time(), 'h2'], + ]); + expect(petitionDeleteSignature($path, 'del-tok'))->toBe('success'); + + $fp = fopen($path, 'r'); + fgetcsv($fp, null, ',', '"', ''); // header + $rows = []; + while (($row = fgetcsv($fp, null, ',', '"', '')) !== false) $rows[] = $row; + fclose($fp); + + expect(count($rows))->toBe(1); + expect($rows[0][7])->toBe('keep-tok'); + unlink($path); +}); + +// --- petitionUpdateSignatureToken --- + +it('returns false when the CSV does not exist for token update', function () { + expect(petitionUpdateSignatureToken('/tmp/nonexistent.csv', 'a@b.com', 'new-tok'))->toBeFalse(); +}); + +it('returns false when the email has no pending entry', function () { + $path = petitionTestCsv([ + PETITION_CSV_HEADER, + [time(), 'a@b.com', 'Kari', 'Hansen', 'oslo', 'semi', 'confirmed', 'old-tok', time(), 'h1'], + ]); + expect(petitionUpdateSignatureToken($path, 'a@b.com', 'new-tok'))->toBeFalse(); + unlink($path); +}); + +it('returns true and updates the token for a pending entry', function () { + $path = petitionTestCsv([ + PETITION_CSV_HEADER, + [time(), 'a@b.com', 'Kari', 'Hansen', 'oslo', 'semi', 'pending', 'old-tok', time(), 'h1'], + ]); + expect(petitionUpdateSignatureToken($path, 'a@b.com', 'new-tok'))->toBeTrue(); + + $fp = fopen($path, 'r'); + fgetcsv($fp, null, ',', '"', ''); // header + $row = fgetcsv($fp, null, ',', '"', ''); + fclose($fp); + expect($row[7])->toBe('new-tok'); + unlink($path); +}); + +it('only updates pending entries, not confirmed ones', function () { + $path = petitionTestCsv([ + PETITION_CSV_HEADER, + [time(), 'a@b.com', 'Kari', 'Hansen', 'oslo', 'semi', 'confirmed', 'conf-tok', time(), 'h1'], + ]); + $result = petitionUpdateSignatureToken($path, 'a@b.com', 'new-tok'); + expect($result)->toBeFalse(); + + // Confirmed token should be unchanged + $fp = fopen($path, 'r'); + fgetcsv($fp, null, ',', '"', ''); + $row = fgetcsv($fp, null, ',', '"', ''); + fclose($fp); + expect($row[7])->toBe('conf-tok'); + unlink($path); +}); diff --git a/tests/Unit/PetitionFormTestPure.php b/tests/Unit/PetitionFormTestPure.php new file mode 100644 index 0000000..be0b4e6 --- /dev/null +++ b/tests/Unit/PetitionFormTestPure.php @@ -0,0 +1,162 @@ + $this->translations, + 'currentLang' => $this->lang, + default => $default, + }; + } + }; +} + +// --- petitionT --- + +it('returns the key when no translation is found', function () { + $ctx = mockContext([]); + expect(petitionT($ctx, 'petition', 'missing_key'))->toBe('missing_key'); +}); + +it('returns the translated string when found', function () { + $ctx = mockContext(['petition.greeting' => 'Hei!']); + expect(petitionT($ctx, 'petition', 'greeting'))->toBe('Hei!'); +}); + +it('applies placeholder replacements', function () { + $ctx = mockContext(['petition.email_greeting' => 'Hei {name}!']); + expect(petitionT($ctx, 'petition', 'email_greeting', ['name' => 'Kari']))->toBe('Hei Kari!'); +}); + +it('applies multiple replacements in a single string', function () { + $ctx = mockContext(['petition.thanks' => 'Takk {name} for {title}']); + $result = petitionT($ctx, 'petition', 'thanks', ['name' => 'Ola', 'title' => 'kampanjen']); + expect($result)->toBe('Takk Ola for kampanjen'); +}); + +it('uses the section as part of the translation key', function () { + $ctx = mockContext(['regions.oslo' => 'Oslo', 'petition.oslo' => 'Wrong']); + expect(petitionT($ctx, 'regions', 'oslo'))->toBe('Oslo'); +}); + +// --- petitionGetIdFromPath --- + +it('returns the basename of a path as the petition ID', function () { + expect(petitionGetIdFromPath('/content/underskriftskampanje/my-petition'))->toBe('my-petition'); +}); + +it('strips a leading numeric prefix from the folder name', function () { + expect(petitionGetIdFromPath('/content/01-my-petition'))->toBe('my-petition'); + expect(petitionGetIdFromPath('/content/001-my-petition'))->toBe('my-petition'); +}); + +it('leaves non-prefixed names intact', function () { + expect(petitionGetIdFromPath('/content/medisinsk-cannabis'))->toBe('medisinsk-cannabis'); +}); + +it('returns null when the basename is empty', function () { + expect(petitionGetIdFromPath('/'))->toBeNull(); +}); + +// --- petitionFormatDate --- + +it('formats a date in Norwegian by default', function () { + date_default_timezone_set('UTC'); + $ctx = mockContext([], 'no'); + // 2024-03-15 10:05:00 UTC + $ts = mktime(10, 5, 0, 3, 15, 2024); + expect(petitionFormatDate($ts, $ctx))->toBe('15. mars 2024, 10:05'); +}); + +it('formats a date in English when lang is en', function () { + date_default_timezone_set('UTC'); + $ctx = mockContext([], 'en'); + $ts = mktime(10, 5, 0, 3, 15, 2024); + expect(petitionFormatDate($ts, $ctx))->toBe('March 15, 2024, 10:05'); +}); + +it('uses Norwegian month names for an unknown language', function () { + date_default_timezone_set('UTC'); + $ctx = mockContext([], 'fr'); + $ts = mktime(0, 0, 0, 1, 1, 2024); + $result = petitionFormatDate($ts, $ctx); + expect($result)->toContain('januar'); +}); + +it('uses the correct Norwegian month names', function () { + date_default_timezone_set('UTC'); + $ctx = mockContext([], 'no'); + $months = ['januar','februar','mars','april','mai','juni', + 'juli','august','september','oktober','november','desember']; + foreach ($months as $i => $name) { + $ts = mktime(0, 0, 0, $i + 1, 1, 2024); + expect(petitionFormatDate($ts, $ctx))->toContain($name); + } +}); + +// --- petitionFormatSignature --- + +it('shows only first name and region for semi display', function () { + $ctx = mockContext([ + 'petition.from_region' => 'fra', + 'regions.oslo' => 'Oslo', + ]); + $sig = ['firstname' => 'Kari', 'surname' => 'Hansen', 'region' => 'oslo', 'display' => 'semi']; + $result = petitionFormatSignature($sig, $ctx); + expect($result)->toBe('Kari fra Oslo'); + expect($result)->not->toContain('Hansen'); +}); + +it('shows full name and region for full display', function () { + $ctx = mockContext([ + 'petition.from_region' => 'fra', + 'regions.oslo' => 'Oslo', + ]); + $sig = ['firstname' => 'Kari', 'surname' => 'Hansen', 'region' => 'oslo', 'display' => 'full']; + expect(petitionFormatSignature($sig, $ctx))->toBe('Kari Hansen fra Oslo'); +}); + +it('shows anonymous label for anonymous display', function () { + $ctx = mockContext([ + 'petition.from_region' => 'fra', + 'petition.anonymous_name' => 'Anonym', + 'regions.oslo' => 'Oslo', + ]); + $sig = ['firstname' => 'Kari', 'surname' => 'Hansen', 'region' => 'oslo', 'display' => 'anonymous']; + $result = petitionFormatSignature($sig, $ctx); + expect($result)->toContain('Anonym'); + expect($result)->not->toContain('Kari'); + expect($result)->not->toContain('Hansen'); +}); + +it('HTML-escapes names in full display mode', function () { + $ctx = mockContext([ + 'petition.from_region' => 'fra', + 'regions.oslo' => 'Oslo', + ]); + $sig = ['firstname' => 'Kari', 'surname' => '