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:
Ruben 2026-03-17 16:43:39 +01:00
parent 0b61643ec5
commit b8e6d2537d
20 changed files with 1331 additions and 33 deletions

2
tests/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
vendor/
composer.lock

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

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

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

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

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

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

View 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('&lt;b&gt;Name &amp; &quot;Label&quot;&lt;/b&gt;');
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");
}
});

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

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

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

View 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('&lt;b&gt;');
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');
});

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