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