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