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.
This commit is contained in:
parent
0b61643ec5
commit
b8e6d2537d
20 changed files with 1331 additions and 33 deletions
58
AGENT.md
58
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. `<?= htmlspecialchars($var) ?>` for UGC. `<?= $content ?>` 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:** `<?= htmlspecialchars($var) ?>` for UGC. `<?= $content ?>` 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 `<head>` 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
|
||||
|
|
|
|||
32
Containerfile.test
Normal file
32
Containerfile.test
Normal file
|
|
@ -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"]
|
||||
24
compose.test.yaml
Normal file
24
compose.test.yaml
Normal file
|
|
@ -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
|
||||
2
tests/.gitignore
vendored
Normal file
2
tests/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
vendor/
|
||||
composer.lock
|
||||
46
tests/Browser/ContentTest.php
Normal file
46
tests/Browser/ContentTest.php
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
it('about page shows organization history', function () {
|
||||
$this->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('<main');
|
||||
});
|
||||
|
||||
it('brochures page loads with intro text', function () {
|
||||
$this->visit(BASE_URL . '/brosjyrer')
|
||||
->assertSee('Brosjyrer');
|
||||
});
|
||||
|
||||
it('articles section loads', function () {
|
||||
$this->visit(BASE_URL . '/artikler')
|
||||
->assertSourceHas('<main');
|
||||
});
|
||||
|
||||
it('patient info article loads', function () {
|
||||
$this->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');
|
||||
});
|
||||
26
tests/Browser/FaqTest.php
Normal file
26
tests/Browser/FaqTest.php
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
it('FAQ listing page loads', function () {
|
||||
$this->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('<main');
|
||||
});
|
||||
|
||||
it('individual FAQ article shows content', function () {
|
||||
$this->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('<main');
|
||||
});
|
||||
21
tests/Browser/HomepageTest.php
Normal file
21
tests/Browser/HomepageTest.php
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
it('loads the Norwegian homepage', function () {
|
||||
$this->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');
|
||||
});
|
||||
11
tests/Browser/LanguageTest.php
Normal file
11
tests/Browser/LanguageTest.php
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<?php
|
||||
|
||||
it('serves the Norwegian homepage by default', function () {
|
||||
$this->visit(BASE_URL . '/')
|
||||
->assertSourceHas('lang="no"');
|
||||
});
|
||||
|
||||
it('english news section is accessible', function () {
|
||||
$this->visit(BASE_URL . '/en/nyheter')
|
||||
->assertSourceHas('<main');
|
||||
});
|
||||
31
tests/Browser/NavigationTest.php
Normal file
31
tests/Browser/NavigationTest.php
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
it('navigates to the news section', function () {
|
||||
$this->visit(BASE_URL . '/nyheter')
|
||||
->assertSee('Nyheter');
|
||||
});
|
||||
|
||||
it('navigates to the FAQ section', function () {
|
||||
$this->visit(BASE_URL . '/faq')
|
||||
->assertSourceHas('<main');
|
||||
});
|
||||
|
||||
it('navigates to the about page', function () {
|
||||
$this->visit(BASE_URL . '/om-oss')
|
||||
->assertSourceHas('<main');
|
||||
});
|
||||
|
||||
it('navigates to the petition page', function () {
|
||||
$this->visit(BASE_URL . '/underskriftskampanje')
|
||||
->assertSourceHas('<main');
|
||||
});
|
||||
|
||||
it('navigates to the contact page', function () {
|
||||
$this->visit(BASE_URL . '/kontakt')
|
||||
->assertSourceHas('<main');
|
||||
});
|
||||
|
||||
it('navigates to the privacy page', function () {
|
||||
$this->visit(BASE_URL . '/personvern')
|
||||
->assertSourceHas('<main');
|
||||
});
|
||||
31
tests/Browser/NewsTest.php
Normal file
31
tests/Browser/NewsTest.php
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
it('news listing page loads', function () {
|
||||
$this->visit(BASE_URL . '/nyheter')
|
||||
->assertSourceHas('<main');
|
||||
});
|
||||
|
||||
it('news listing shows article titles', function () {
|
||||
$this->visit(BASE_URL . '/nyheter')
|
||||
->assertSee('Banebrytende');
|
||||
});
|
||||
|
||||
it('individual news article loads', function () {
|
||||
$this->visit(BASE_URL . '/nyheter/2025-09-26-banebrytende-studie')
|
||||
->assertSourceHas('<main');
|
||||
});
|
||||
|
||||
it('individual news article shows content', function () {
|
||||
$this->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('<main');
|
||||
});
|
||||
|
||||
it('returns 404 for non-existent news article', function () {
|
||||
$this->visit(BASE_URL . '/nyheter/finnes-ikke')
|
||||
->assertSourceHas('404');
|
||||
});
|
||||
19
tests/Browser/PetitionTest.php
Normal file
19
tests/Browser/PetitionTest.php
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
it('loads the petition page with a form', function () {
|
||||
$this->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"]');
|
||||
});
|
||||
10
tests/Pest.php
Normal file
10
tests/Pest.php
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?php
|
||||
|
||||
// Base URL for browser tests — http://app when running in compose.test.yaml
|
||||
define('BASE_URL', rtrim((string) getenv('APP_URL') ?: 'http://app', '/'));
|
||||
|
||||
// Path to custom/ source — /app/custom in container (mounted via compose.test.yaml)
|
||||
define('CUSTOM_DIR', __DIR__ . '/../custom');
|
||||
|
||||
// Group all Unit tests
|
||||
uses()->group('unit')->in('Unit');
|
||||
98
tests/README.md
Normal file
98
tests/README.md
Normal file
|
|
@ -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
|
||||
<?php
|
||||
class Context {}
|
||||
class Hook { const TEMPLATE_VARS = 'template_vars'; }
|
||||
class Hooks { public static function add(string $hook, callable $fn): void {} }
|
||||
|
||||
require_once CUSTOM_DIR . '/plugins/page/my-plugin.php';
|
||||
|
||||
it('does something', function () {
|
||||
expect(myFunction('input'))->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.
|
||||
105
tests/Unit/NewsletterSignupTest.php
Normal file
105
tests/Unit/NewsletterSignupTest.php
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
<?php
|
||||
|
||||
// newsletter-signup.php calls session_start() at load time — pre-initialise
|
||||
// $_SESSION so the guard fires correctly without emitting headers.
|
||||
$_SESSION = [];
|
||||
$_SERVER['REMOTE_ADDR'] ??= '127.0.0.1';
|
||||
$_SERVER['HTTP_HOST'] ??= 'localhost';
|
||||
|
||||
require_once CUSTOM_DIR . '/plugins/page/newsletter-signup.php';
|
||||
|
||||
// --- newsletterT ---
|
||||
|
||||
it('returns the fallback value when no global context is present', function () {
|
||||
unset($GLOBALS['ctx']);
|
||||
expect(newsletterT('name_label'))->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' => '<b>Name & "Label"</b>']
|
||||
: $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");
|
||||
}
|
||||
});
|
||||
113
tests/Unit/PetitionCliTest.php
Normal file
113
tests/Unit/PetitionCliTest.php
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
<?php
|
||||
|
||||
// petition-cli.php has a CLI-only guard; tests always run in CLI so it passes.
|
||||
// It defines BASE_DIR / DATA_DIR / PETITIONS_DIR / SMTP_LOG / IGNORE_LIST pointing
|
||||
// to real custom/data/ paths, but the functions tested here are pure-logic and
|
||||
// do not touch those constants.
|
||||
require_once CUSTOM_DIR . '/petition-cli.php';
|
||||
|
||||
// --- isIgnored ---
|
||||
|
||||
it('returns false when the ignore list is empty', function () {
|
||||
expect(isIgnored([], 'failed', 'my-petition', 'user@example.com'))->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/');
|
||||
});
|
||||
60
tests/Unit/PetitionFormTest.php
Normal file
60
tests/Unit/PetitionFormTest.php
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
<?php
|
||||
|
||||
// Stubs for framework classes used by petition-form.php
|
||||
class Context {}
|
||||
class Hook { const TEMPLATE_VARS = 'template_vars'; }
|
||||
class Hooks { public static function add(string $hook, callable $fn): void {} }
|
||||
|
||||
// petition-form.php calls session_start() at load time — stub $_SESSION and $_SERVER first
|
||||
$_SESSION = [];
|
||||
$_SERVER['REMOTE_ADDR'] ??= '127.0.0.1';
|
||||
$_SERVER['HTTP_HOST'] ??= 'localhost';
|
||||
|
||||
require_once CUSTOM_DIR . '/plugins/page/petition-form.php';
|
||||
|
||||
// --- petitionSanitizeCSV ---
|
||||
|
||||
it('leaves safe values unchanged', function () {
|
||||
expect(petitionSanitizeCSV('John Doe'))->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');
|
||||
});
|
||||
366
tests/Unit/PetitionFormTestCsv.php
Normal file
366
tests/Unit/PetitionFormTestCsv.php
Normal file
|
|
@ -0,0 +1,366 @@
|
|||
<?php
|
||||
|
||||
// PetitionFormTest.php (loads first) already required petition-form.php and
|
||||
// defined the Context stub. This file only adds CSV-function tests.
|
||||
|
||||
const PETITION_CSV_HEADER = [
|
||||
'timestamp', 'email', 'firstname', 'surname',
|
||||
'region', 'display', 'status', 'token', 'token_created', 'ip_hash',
|
||||
];
|
||||
|
||||
/**
|
||||
* Write rows to a temp CSV and return its path. Caller must unlink().
|
||||
*/
|
||||
function petitionTestCsv(array $rows): string
|
||||
{
|
||||
$path = tempnam(sys_get_temp_dir(), 'petition_form_test_');
|
||||
$fp = fopen($path, 'w');
|
||||
foreach ($rows as $row) {
|
||||
fputcsv($fp, $row, ',', '"', '');
|
||||
}
|
||||
fclose($fp);
|
||||
return $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a minimal signature data array for petitionAppendSignature.
|
||||
*/
|
||||
function sigData(string $email, string $status = 'pending', string $token = 'tok'): array
|
||||
{
|
||||
return [
|
||||
'timestamp' => 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);
|
||||
});
|
||||
162
tests/Unit/PetitionFormTestPure.php
Normal file
162
tests/Unit/PetitionFormTestPure.php
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
<?php
|
||||
|
||||
// PetitionFormTest.php (which loads first alphabetically) already required
|
||||
// petition-form.php and defined Context / Hook / Hooks stubs.
|
||||
// This file only adds tests — no re-requires or re-declarations needed.
|
||||
|
||||
/**
|
||||
* Build a minimal Context mock that supports get('translations') and get('currentLang').
|
||||
*/
|
||||
function mockContext(array $translations = [], string $lang = 'no'): object
|
||||
{
|
||||
return new class($translations, $lang) extends Context {
|
||||
public function __construct(
|
||||
private array $translations,
|
||||
private string $lang
|
||||
) {}
|
||||
|
||||
public function get(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return match ($key) {
|
||||
'translations' => $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' => '<b>Kari</b>', 'surname' => '<script>', 'region' => 'oslo', 'display' => 'full'];
|
||||
$result = petitionFormatSignature($sig, $ctx);
|
||||
expect($result)->toContain('<b>');
|
||||
expect($result)->not->toContain('<b>');
|
||||
});
|
||||
|
||||
it('falls back to the region key when no translation exists', function () {
|
||||
$ctx = mockContext(['petition.from_region' => 'fra']);
|
||||
$sig = ['firstname' => 'Ola', 'surname' => 'Nilsen', 'region' => 'trondelag', 'display' => 'semi'];
|
||||
expect(petitionFormatSignature($sig, $ctx))->toContain('trondelag');
|
||||
});
|
||||
134
tests/Unit/PetitionMapTest.php
Normal file
134
tests/Unit/PetitionMapTest.php
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
<?php
|
||||
|
||||
// petition-map.php defines cache-path constants and functions.
|
||||
// petitionMapBuildData() takes an explicit $csvPath so it can be tested
|
||||
// with temp files; the cache constants are not touched by these tests.
|
||||
//
|
||||
// Context is already stubbed in PetitionFormTest.php (loaded first alphabetically),
|
||||
// so we do not redeclare it here.
|
||||
require_once CUSTOM_DIR . '/plugins/page/petition-map.php';
|
||||
|
||||
// CSV header row shared across tests
|
||||
const PETITION_MAP_CSV_HEADER = ['timestamp', 'email', 'firstname', 'surname', 'region', 'display', 'status', 'token', 'token_created', 'ip_hash'];
|
||||
|
||||
/**
|
||||
* Write rows to a temp file and return its path.
|
||||
* The caller is responsible for unlinking it.
|
||||
*/
|
||||
function mapTestCsv(array $rows): string
|
||||
{
|
||||
$path = tempnam(sys_get_temp_dir(), 'map_test_');
|
||||
$fp = fopen($path, 'w');
|
||||
foreach ($rows as $row) {
|
||||
fputcsv($fp, $row, ',', '"', '');
|
||||
}
|
||||
fclose($fp);
|
||||
return $path;
|
||||
}
|
||||
|
||||
// --- petitionMapBuildData: missing / empty file ---
|
||||
|
||||
it('returns empty data when CSV does not exist', function () {
|
||||
$result = petitionMapBuildData('/tmp/nonexistent-petition-map-test.csv');
|
||||
expect($result['total'])->toBe(0);
|
||||
expect($result)->toHaveKey('generated');
|
||||
});
|
||||
|
||||
it('returns zero total for a CSV with only pending signatures', function () {
|
||||
$path = mapTestCsv([
|
||||
PETITION_MAP_CSV_HEADER,
|
||||
[time(), 'a@b.com', 'Ola', 'Hansen', 'oslo', 'semi', 'pending', 'tok1', time(), 'hash1'],
|
||||
]);
|
||||
expect(petitionMapBuildData($path)['total'])->toBe(0);
|
||||
unlink($path);
|
||||
});
|
||||
|
||||
it('returns zero total for a CSV with only the header', function () {
|
||||
$path = mapTestCsv([PETITION_MAP_CSV_HEADER]);
|
||||
expect(petitionMapBuildData($path)['total'])->toBe(0);
|
||||
unlink($path);
|
||||
});
|
||||
|
||||
// --- petitionMapBuildData: counting confirmed signatures ---
|
||||
|
||||
it('counts confirmed signatures and groups them by region', function () {
|
||||
$path = mapTestCsv([
|
||||
PETITION_MAP_CSV_HEADER,
|
||||
[time(), 'a@b.com', 'Kari', 'Hansen', 'oslo', 'semi', 'confirmed', 't1', time(), 'h1'],
|
||||
[time(), 'b@b.com', 'Ola', 'Nilsen', 'oslo', 'semi', 'confirmed', 't2', time(), 'h2'],
|
||||
[time(), 'c@b.com', 'Per', 'Olsen', 'bergen', 'semi', 'confirmed', 't3', time(), 'h3'],
|
||||
]);
|
||||
$result = petitionMapBuildData($path);
|
||||
expect($result['total'])->toBe(3);
|
||||
expect($result['regions']['oslo']['count'])->toBe(2);
|
||||
expect($result['regions']['bergen']['count'])->toBe(1);
|
||||
unlink($path);
|
||||
});
|
||||
|
||||
it('ignores non-confirmed rows (pending, deleted, etc.)', function () {
|
||||
$path = mapTestCsv([
|
||||
PETITION_MAP_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'],
|
||||
]);
|
||||
$result = petitionMapBuildData($path);
|
||||
expect($result['total'])->toBe(1);
|
||||
unlink($path);
|
||||
});
|
||||
|
||||
it('skips confirmed entries that have no region', function () {
|
||||
$path = mapTestCsv([
|
||||
PETITION_MAP_CSV_HEADER,
|
||||
[time(), 'a@b.com', 'Kari', 'Hansen', '', 'semi', 'confirmed', 't1', time(), 'h1'],
|
||||
]);
|
||||
expect(petitionMapBuildData($path)['total'])->toBe(0);
|
||||
unlink($path);
|
||||
});
|
||||
|
||||
// --- petitionMapBuildData: name handling ---
|
||||
|
||||
it('uses only the first word of the firstname', function () {
|
||||
$path = mapTestCsv([
|
||||
PETITION_MAP_CSV_HEADER,
|
||||
[time(), 'a@b.com', 'Kari Marte', 'Hansen', 'oslo', 'semi', 'confirmed', 't1', time(), 'h1'],
|
||||
]);
|
||||
$signer = petitionMapBuildData($path)['regions']['oslo']['signers'][0];
|
||||
expect($signer['n'])->toBe('Kari');
|
||||
unlink($path);
|
||||
});
|
||||
|
||||
it('sets name to null and anonymous flag true for anonymous display', function () {
|
||||
$path = mapTestCsv([
|
||||
PETITION_MAP_CSV_HEADER,
|
||||
[time(), 'a@b.com', 'Kari', 'Hansen', 'oslo', 'anonymous', 'confirmed', 't1', time(), 'h1'],
|
||||
]);
|
||||
$signer = petitionMapBuildData($path)['regions']['oslo']['signers'][0];
|
||||
expect($signer['n'])->toBeNull();
|
||||
expect($signer['a'])->toBeTrue();
|
||||
unlink($path);
|
||||
});
|
||||
|
||||
it('sets anonymous flag false for non-anonymous display', function () {
|
||||
$path = mapTestCsv([
|
||||
PETITION_MAP_CSV_HEADER,
|
||||
[time(), 'a@b.com', 'Lars', 'Berg', 'oslo', 'semi', 'confirmed', 't1', time(), 'h1'],
|
||||
]);
|
||||
$signer = petitionMapBuildData($path)['regions']['oslo']['signers'][0];
|
||||
expect($signer['a'])->toBeFalse();
|
||||
unlink($path);
|
||||
});
|
||||
|
||||
it('does not include emails, surnames, tokens, or ip_hashes in the output', function () {
|
||||
$path = mapTestCsv([
|
||||
PETITION_MAP_CSV_HEADER,
|
||||
[time(), 'secret@b.com', 'Lars', 'SecretSurname', 'oslo', 'semi', 'confirmed', 'secret-token', time(), 'secret-hash'],
|
||||
]);
|
||||
$result = petitionMapBuildData($path);
|
||||
$json = json_encode($result);
|
||||
expect($json)->not->toContain('secret@b.com');
|
||||
expect($json)->not->toContain('SecretSurname');
|
||||
expect($json)->not->toContain('secret-token');
|
||||
expect($json)->not->toContain('secret-hash');
|
||||
unlink($path);
|
||||
});
|
||||
15
tests/composer.json
Normal file
15
tests/composer.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"name": "stopplidelsen/tests",
|
||||
"description": "Test suite for stopplidelsen.no",
|
||||
"require-dev": {
|
||||
"pestphp/pest": "^4.0",
|
||||
"pestphp/pest-plugin-browser": "^4.0"
|
||||
},
|
||||
"config": {
|
||||
"allow-plugins": {
|
||||
"pestphp/pest-plugin": true
|
||||
}
|
||||
},
|
||||
"minimum-stability": "dev",
|
||||
"prefer-stable": true
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue