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

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